diff --git a/__tests__/api/conversation-file-upload.test.ts b/__tests__/api/conversation-file-upload.test.ts new file mode 100644 index 000000000..5ff8d0817 --- /dev/null +++ b/__tests__/api/conversation-file-upload.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; +import { + __resetActiveStoreForTests, + setActiveSelection, + setRegisteredBackends, +} from "#/api/backend-registry/active-store"; +import type { Backend } from "#/api/backend-registry/types"; +import { uploadFilesToConversation } from "#/api/conversation-file-upload.api"; + +const fileUploadMock = vi.fn(); + +vi.mock("@openhands/typescript-client/workspace/remote-workspace", () => ({ + RemoteWorkspace: vi.fn(function RemoteWorkspaceMock() { + return { fileUpload: fileUploadMock }; + }), +})); + +const batchGetCloudConversations = vi.fn(); + +vi.mock("#/api/cloud/conversation-service.api", () => ({ + batchGetCloudConversations: (...args: unknown[]) => + batchGetCloudConversations(...args), +})); + +const cloudBackend: Backend = { + id: "cloud-1", + name: "Cloud", + host: "https://app.all-hands.dev", + apiKey: "cloud-token", + kind: "cloud", +}; + +function makeFile(name: string) { + return new File(["content"], name, { type: "text/plain" }); +} + +describe("uploadFilesToConversation", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + __resetActiveStoreForTests(); + fileUploadMock.mockResolvedValue(undefined); + batchGetCloudConversations.mockReset(); + }); + + it("uploads local conversations through the bundled agent-server host", async () => { + setRegisteredBackends([ + { + id: "local-1", + name: "Local", + host: "http://127.0.0.1:18000", + apiKey: "local-key", + kind: "local", + }, + ]); + setActiveSelection({ backendId: "local-1" }); + + const result = await uploadFilesToConversation("conv-1", [ + makeFile("a.txt"), + ]); + + expect(fileUploadMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "a.txt" }), + "/workspace/project/a.txt", + ); + expect(result.uploaded_files).toEqual(["a.txt"]); + expect(batchGetCloudConversations).not.toHaveBeenCalled(); + }); + + it("uploads cloud conversations against the provisioned runtime URL", async () => { + setRegisteredBackends([cloudBackend]); + setActiveSelection({ backendId: cloudBackend.id }); + batchGetCloudConversations.mockResolvedValue([ + { + id: "1717df59-63ee-43bf-b32a-83428d3efdc8", + conversation_url: + "http://runtime.example.dev/api/conversations/1717df59-63ee-43bf-b32a-83428d3efdc8", + session_api_key: "runtime-session-key", + workspace: { working_dir: "/workspace/project" }, + }, + ]); + + const conversationId = "1717df59-63ee-43bf-b32a-83428d3efdc8"; + const result = await uploadFilesToConversation(conversationId, [ + makeFile("notes.md"), + ]); + + expect(batchGetCloudConversations).toHaveBeenCalledWith([conversationId]); + expect(RemoteWorkspace).toHaveBeenCalledWith( + expect.objectContaining({ + host: "http://runtime.example.dev", + apiKey: "runtime-session-key", + }), + ); + expect(fileUploadMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "notes.md" }), + "/workspace/project/notes.md", + ); + expect(result.uploaded_files).toEqual(["notes.md"]); + }); +}); diff --git a/__tests__/api/conversation-service.test.ts b/__tests__/api/conversation-service.test.ts index 608abea7c..246325538 100644 --- a/__tests__/api/conversation-service.test.ts +++ b/__tests__/api/conversation-service.test.ts @@ -38,11 +38,11 @@ describe("ConversationService", () => { ); expect(fileUploadMock).toHaveBeenCalledWith( expect.objectContaining({ name: "a.txt" }), - "/workspace/a.txt", + "/workspace/project/a.txt", ); expect(fileUploadMock).toHaveBeenCalledWith( expect.objectContaining({ name: "b.txt" }), - "/workspace/b.txt", + "/workspace/project/b.txt", ); expect(result).toEqual({ uploaded_files: ["a.txt", "b.txt"], @@ -59,7 +59,7 @@ describe("ConversationService", () => { expect(fileUploadMock).toHaveBeenCalledWith( expect.objectContaining({ name: "../../evil.txt" }), - "/workspace/evil.txt", + "/workspace/project/evil.txt", ); expect(result).toEqual({ uploaded_files: ["evil.txt"], @@ -67,6 +67,21 @@ describe("ConversationService", () => { }); }); + it("uploads into the active conversation workspace when set", async () => { + ConversationService.setCurrentConversation({ + id: "conv-1", + workspace: { working_dir: "/workspace/project/my-app" }, + } as never); + fileUploadMock.mockResolvedValue(undefined); + + await ConversationService.uploadFiles("conv-1", [makeFile("doc.txt")]); + + expect(fileUploadMock).toHaveBeenCalledWith( + expect.objectContaining({ name: "doc.txt" }), + "/workspace/project/my-app/doc.txt", + ); + }); + it("uses the current conversation session key and reports per-file failures", async () => { ConversationService.setCurrentConversation({ id: "conv-1", diff --git a/__tests__/api/workspace-upload-path.test.ts b/__tests__/api/workspace-upload-path.test.ts new file mode 100644 index 000000000..004af6556 --- /dev/null +++ b/__tests__/api/workspace-upload-path.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildWorkspaceUploadPath, + getSafeUploadFileName, + resolveConversationUploadWorkingDir, + toAbsoluteWorkspacePath, +} from "#/api/workspace-upload-path"; + +vi.mock("#/api/conversation-service/agent-server-conversation-service.api", () => ({ + default: { + resolveConversationWorkingDir: vi.fn( + async (id: string) => `/workspace/project/${id.replace(/-/g, "")}`, + ), + }, +})); + +describe("workspace-upload-path", () => { + it("normalizes relative working dirs to absolute paths", () => { + expect(toAbsoluteWorkspacePath("workspace/project")).toBe( + "/workspace/project", + ); + expect(buildWorkspaceUploadPath("a.txt", "workspace/project")).toBe( + "/workspace/project/a.txt", + ); + }); + + it("strips path segments from file names", () => { + expect(getSafeUploadFileName("../../evil.txt")).toBe("evil.txt"); + expect(buildWorkspaceUploadPath("../../evil.txt", "/workspace/project")).toBe( + "/workspace/project/evil.txt", + ); + }); + + it("prefers the active conversation workspace when ids match", async () => { + const dir = await resolveConversationUploadWorkingDir("conv-uuid", { + id: "conv-uuid", + workspace: { working_dir: "/workspace/project/custom" }, + } as never); + + expect(dir).toBe("/workspace/project/custom"); + }); + + it("resolves per-conversation dirs for UUID ids", async () => { + const dir = await resolveConversationUploadWorkingDir( + "550e8400-e29b-41d4-a716-446655440000", + null, + ); + + expect(dir).toBe( + "/workspace/project/550e8400e29b41d4a716446655440000", + ); + }); +}); diff --git a/__tests__/components/chat/chat-add-file-button.test.tsx b/__tests__/components/chat/chat-add-file-button.test.tsx index b09ee5a3c..6dc767bae 100644 --- a/__tests__/components/chat/chat-add-file-button.test.tsx +++ b/__tests__/components/chat/chat-add-file-button.test.tsx @@ -4,28 +4,58 @@ import { describe, expect, it, vi } from "vitest"; import { ChatAddFileButton } from "#/components/features/chat/chat-add-file-button"; import { I18nKey } from "#/i18n/declaration"; +vi.mock("#/hooks/query/use-active-conversation", () => ({ + useActiveConversation: () => ({ data: undefined }), +})); + +vi.mock("#/hooks/use-conversation-id", () => ({ + useOptionalConversationId: () => ({ conversationId: undefined }), +})); + +vi.mock("#/hooks/use-conversation-name-context-menu", () => ({ + useConversationNameContextMenu: () => ({ + handleShowAgentTools: vi.fn(), + handleShowSkills: vi.fn(), + handleShowHooks: vi.fn(), + systemModalVisible: false, + setSystemModalVisible: vi.fn(), + skillsModalVisible: false, + setSkillsModalVisible: vi.fn(), + hooksModalVisible: false, + setHooksModalVisible: vi.fn(), + systemMessage: null, + shouldShowAgentTools: true, + shouldShowHooks: false, + }), +})); + +vi.mock("#/hooks/use-user-providers", () => ({ + useUserProviders: () => ({ providers: [] }), +})); + describe("ChatAddFileButton", () => { - it("uses the translated aria-label key instead of a hardcoded string", () => { + it("uses the translated aria-label for the plus menu trigger", () => { render(); - const button = screen.getByTestId("paperclip-icon"); - expect(button).toHaveAttribute("aria-label", I18nKey.CHAT_INTERFACE$ADD_FILE); - expect(button).not.toHaveAttribute("aria-label", "Add file"); + const button = screen.getByTestId("chat-plus-button"); + expect(button).toHaveAttribute("aria-label", I18nKey.CHAT_INTERFACE$PLUS_MENU); + expect(button).toHaveAttribute("aria-haspopup", "menu"); }); - it("invokes handleFileIconClick when clicked", async () => { + it("opens the tools menu and invokes handleFileIconClick from the footer item", async () => { const user = userEvent.setup(); const handleFileIconClick = vi.fn(); render(); - await user.click( - screen.getByLabelText(I18nKey.CHAT_INTERFACE$ADD_FILE), - ); + await user.click(screen.getByTestId("chat-plus-button")); + expect(screen.getByTestId("tools-context-menu")).toBeInTheDocument(); + + await user.click(screen.getByTestId("add-files-and-images-button")); expect(handleFileIconClick).toHaveBeenCalledTimes(1); }); - it("disables the button and suppresses clicks when disabled", async () => { + it("does not open the menu or invoke the file handler when disabled", async () => { const user = userEvent.setup(); const handleFileIconClick = vi.fn(); @@ -36,14 +66,11 @@ describe("ChatAddFileButton", () => { />, ); - const button = screen.getByTestId("paperclip-icon"); + const button = screen.getByTestId("chat-plus-button"); expect(button).toBeDisabled(); - expect(button).toHaveAttribute( - "aria-label", - I18nKey.CHAT_INTERFACE$ADD_FILE, - ); await user.click(button); + expect(screen.queryByTestId("tools-context-menu")).not.toBeInTheDocument(); expect(handleFileIconClick).not.toHaveBeenCalled(); }); }); diff --git a/__tests__/components/chat/chat-interface.test.tsx b/__tests__/components/chat/chat-interface.test.tsx index a630ae81b..593f15a3b 100644 --- a/__tests__/components/chat/chat-interface.test.tsx +++ b/__tests__/components/chat/chat-interface.test.tsx @@ -31,6 +31,7 @@ import type { MessageEvent } from "#/types/agent-server/core"; import { useEventStore } from "#/stores/use-event-store"; import { useAgentState } from "#/hooks/use-agent-state"; import { useLoadOlderEvents } from "#/hooks/use-load-older-events"; +import { useTaskPolling } from "#/hooks/query/use-task-polling"; import { AgentState } from "#/types/agent-state"; import { useConversationStore } from "#/stores/conversation-store"; import { act } from "@testing-library/react"; @@ -68,6 +69,10 @@ vi.mock("#/hooks/use-load-older-events", () => ({ useLoadOlderEvents: vi.fn(), })); +vi.mock("#/hooks/query/use-task-polling", () => ({ + useTaskPolling: vi.fn(), +})); + vi.mock("#/hooks/use-agent-state", () => ({ useAgentState: vi.fn(() => ({ curAgentState: AgentState.AWAITING_USER_INPUT, @@ -107,6 +112,21 @@ beforeEach(() => { vi.mocked(useOptionalConversationId).mockReturnValue({ conversationId: "test-conversation-id", }); + vi.mocked(useTaskPolling).mockReturnValue({ + isTask: false, + taskId: null, + conversationId: "test-conversation-id", + task: undefined, + taskStatus: undefined, + taskDetail: undefined, + taskError: null, + isLoadingTask: false, + repositoryInfo: { + selectedRepository: undefined, + selectedBranch: undefined, + gitProvider: undefined, + }, + }); // Default: pagination disabled (hasMore=false) so unrelated tests don't // accidentally trigger loadOlder via the on-mount auto-trigger effect. // Tests that exercise pagination override this. @@ -130,6 +150,22 @@ describe("ChatInterface - Chat Suggestions", () => { }, }); + vi.mocked(useTaskPolling).mockReturnValue({ + isTask: false, + taskId: null, + conversationId: "test-conversation-id", + task: undefined, + taskStatus: undefined, + taskDetail: undefined, + taskError: null, + isLoadingTask: false, + repositoryInfo: { + selectedRepository: undefined, + selectedBranch: undefined, + gitProvider: undefined, + }, + }); + useOptimisticUserMessageStore.setState({ pendingMessages: [], }); @@ -187,6 +223,58 @@ describe("ChatInterface - Chat Suggestions", () => { // Check if ChatSuggestions is not rendered with optimistic user message expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); }); + + test("should hide chat suggestions while a cloud start task is provisioning", () => { + vi.mocked(useTaskPolling).mockReturnValue({ + isTask: true, + taskId: "abc", + conversationId: null, + task: undefined, + taskStatus: "WORKING", + taskDetail: undefined, + taskError: null, + isLoadingTask: false, + repositoryInfo: { + selectedRepository: undefined, + selectedBranch: undefined, + gitProvider: undefined, + }, + }); + + renderWithQueryClient( + , + queryClient, + "/task-abc", + ); + + expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); + }); + + test("should hide chat suggestions on a task route even when the task is READY", () => { + vi.mocked(useTaskPolling).mockReturnValue({ + isTask: true, + taskId: "abc", + conversationId: null, + task: undefined, + taskStatus: "READY", + taskDetail: undefined, + taskError: null, + isLoadingTask: false, + repositoryInfo: { + selectedRepository: undefined, + selectedBranch: undefined, + gitProvider: undefined, + }, + }); + + renderWithQueryClient( + , + queryClient, + "/task-abc", + ); + + expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument(); + }); }); describe("ChatInterface - Empty state", () => { diff --git a/__tests__/components/chat/message-display-continuity.test.tsx b/__tests__/components/chat/message-display-continuity.test.tsx index 9e3690b8e..cb6bb621d 100644 --- a/__tests__/components/chat/message-display-continuity.test.tsx +++ b/__tests__/components/chat/message-display-continuity.test.tsx @@ -15,6 +15,7 @@ import { useConversationWebSocket } from "#/contexts/conversation-websocket-cont import { useConfig } from "#/hooks/query/use-config"; import { useUnifiedUploadFiles } from "#/hooks/mutation/use-unified-upload-files"; import { useEventStore } from "#/stores/use-event-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { useAgentState } from "#/hooks/use-agent-state"; import { AgentState } from "#/types/agent-state"; @@ -107,6 +108,8 @@ describe("ChatInterface – message display continuity (spec 3.1)", () => { conversationId: "test-conversation-id", }); + useOptimisticUserMessageStore.setState({ pendingMessages: [] }); + (useConfig as unknown as ReturnType).mockReturnValue({ data: { app_mode: "local" }, }); @@ -188,6 +191,35 @@ describe("ChatInterface – message display continuity (spec 3.1)", () => { expect(screen.getByTestId("chat-messages-skeleton")).toBeInTheDocument(); }); + it("hides skeleton when a pending user message is visible during history load", () => { + vi.mocked(useConversationWebSocket).mockReturnValue({ + isLoadingHistory: true, + connectionState: "OPEN", + sendMessage: vi.fn(), + reconnect: vi.fn(), + }); + + useEventStore.setState({ + events: [], + eventIds: new Set(), + uiEvents: [], + }); + + useOptimisticUserMessageStore.getState().enqueuePendingMessage({ + conversationId: "test-conversation-id", + text: "hello from home", + }); + + renderWithQueryClient(, queryClient); + + expect( + screen.queryByTestId("chat-messages-skeleton"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("user-message")).toHaveTextContent( + "hello from home", + ); + }); + it("shows messages when loading is already false on mount (edge case)", () => { // Simulate: component re-mounts when WebSocket has already finished loading vi.mocked(useConversationWebSocket).mockReturnValue({ diff --git a/__tests__/components/features/chat/components/chat-input-actions.test.tsx b/__tests__/components/features/chat/components/chat-input-actions.test.tsx index 04bafd7ef..f4e062ee1 100644 --- a/__tests__/components/features/chat/components/chat-input-actions.test.tsx +++ b/__tests__/components/features/chat/components/chat-input-actions.test.tsx @@ -20,10 +20,6 @@ vi.mock("#/components/features/controls/agent-status", () => ({ AgentStatus: () =>
, })); -vi.mock("#/components/features/controls/tools", () => ({ - Tools: () =>
, -})); - vi.mock("#/components/features/chat/change-agent-button", () => ({ ChangeAgentButton: () =>
, })); diff --git a/__tests__/components/features/chat/utils/chat-input.utils.test.ts b/__tests__/components/features/chat/utils/chat-input.utils.test.ts new file mode 100644 index 000000000..fc102cfb8 --- /dev/null +++ b/__tests__/components/features/chat/utils/chat-input.utils.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { + getClipboardFiles, + isPastedClipboardImage, + normalizePastedFile, + partitionImagesForUpload, +} from "#/components/features/chat/utils/chat-input.utils"; + +function createFileList(files: File[]): FileList { + const list = { + length: files.length, + item: (index: number) => files[index] ?? null, + } as FileList & Record; + for (let i = 0; i < files.length; i += 1) { + list[i] = files[i]; + } + return list; +} + +function createMockDataTransfer({ + files = [], + items = [], +}: { + files?: File[]; + items?: Array<{ kind: string; type: string; file: File | null }>; +}): DataTransfer { + const fileItems = items.map((entry) => ({ + kind: entry.kind, + type: entry.type, + getAsFile: () => entry.file, + })); + + return { + files: createFileList(files), + items: fileItems as unknown as DataTransferItemList, + getData: () => "", + } as unknown as DataTransfer; +} + +describe("normalizePastedFile", () => { + it("returns the file unchanged when it already has a name", () => { + const file = new File(["x"], "photo.png", { type: "image/png" }); + expect(normalizePastedFile(file)).toBe(file); + }); + + it("assigns a generated name for unnamed clipboard images", () => { + const file = new File(["x"], "", { type: "image/png" }); + const normalized = normalizePastedFile(file); + expect(normalized.name).toMatch(/^pasted-image-\d+\.png$/); + expect(normalized.type).toBe("image/png"); + }); +}); + +describe("isPastedClipboardImage", () => { + it("returns true for normalized clipboard screenshot names", () => { + const file = new File(["x"], "pasted-image-1710000000000.png", { + type: "image/png", + }); + expect(isPastedClipboardImage(file)).toBe(true); + }); + + it("returns false for images picked from the file dialog", () => { + const file = new File(["x"], "photo.png", { type: "image/png" }); + expect(isPastedClipboardImage(file)).toBe(false); + }); +}); + +describe("partitionImagesForUpload", () => { + it("splits marked images into the file-upload bucket", () => { + const embed = new File(["a"], "embed.png", { type: "image/png" }); + const upload = new File(["b"], "upload.png", { type: "image/png" }); + + const result = partitionImagesForUpload([embed, upload], ["upload.png"]); + + expect(result.imagesToEmbed).toEqual([embed]); + expect(result.imagesAsFiles).toEqual([upload]); + }); +}); + +describe("getClipboardFiles", () => { + it("reads from clipboardData.files when present", () => { + const file = new File(["x"], "doc.txt", { type: "text/plain" }); + const clipboard = createMockDataTransfer({ files: [file] }); + + expect(getClipboardFiles(clipboard)).toEqual([file]); + }); + + it("falls back to clipboard items for screenshot-style image paste", () => { + const image = new File(["pixels"], "", { type: "image/png" }); + const clipboard = createMockDataTransfer({ + items: [{ kind: "file", type: "image/png", file: image }], + }); + + const result = getClipboardFiles(clipboard); + expect(result).toHaveLength(1); + expect(result[0].type).toBe("image/png"); + expect(result[0].name).toMatch(/^pasted-image-\d+\.png$/); + }); + + it("ignores non-file clipboard items", () => { + const clipboard = createMockDataTransfer({ + items: [{ kind: "string", type: "text/plain", file: null }], + }); + + expect(getClipboardFiles(clipboard)).toEqual([]); + }); +}); diff --git a/__tests__/components/features/home/home-chat-launcher.test.tsx b/__tests__/components/features/home/home-chat-launcher.test.tsx index 45d115021..63c3e0f21 100644 --- a/__tests__/components/features/home/home-chat-launcher.test.tsx +++ b/__tests__/components/features/home/home-chat-launcher.test.tsx @@ -10,8 +10,33 @@ import WorkspacesService from "#/api/workspaces-service/workspaces-service.api"; const mockNavigate = vi.fn(); const mockUseActiveBackend = vi.fn(); +const sendMessageWithAttachments = vi.fn(); +const mockClearAllFiles = vi.fn(); +const enqueueHomeTaskPendingMessage = vi.fn(); const mockDisplayErrorToast = vi.fn(); +let mockImages: File[] = []; +let mockFiles: File[] = []; + +vi.mock("#/utils/send-message-with-attachments", () => ({ + sendMessageWithAttachments: (...args: unknown[]) => + sendMessageWithAttachments(...args), +})); + +vi.mock("#/utils/enqueue-home-task-pending-message", () => ({ + enqueueHomeTaskPendingMessage: (...args: unknown[]) => + enqueueHomeTaskPendingMessage(...args), +})); + +vi.mock("#/stores/conversation-store", () => ({ + useConversationStore: () => ({ + images: mockImages, + files: mockFiles, + imagesMarkedUploadAsFile: [], + clearAllFiles: mockClearAllFiles, + }), +})); + vi.mock("#/utils/custom-toast-handlers", async (importOriginal) => { const actual = await importOriginal(); @@ -210,7 +235,17 @@ describe("HomeChatLauncher", () => { beforeEach(() => { vi.restoreAllMocks(); vi.clearAllMocks(); + mockImages = []; + mockFiles = []; mockUseActiveBackend.mockReturnValue(localBackend); + enqueueHomeTaskPendingMessage.mockResolvedValue(undefined); + sendMessageWithAttachments.mockResolvedValue({ + text: "hello world", + content: "hello world", + imageUrls: ["data:image/png;base64,abc"], + fileUrls: [], + timestamp: "2020-01-01T00:00:00.000Z", + }); vi.spyOn(WorkspacesService, "listWorkspaces").mockResolvedValue({ workspaces: [], workspaceParents: [], @@ -332,6 +367,35 @@ describe("HomeChatLauncher", () => { ); }); + it("does not pass query to createConversation when attachments are present", async () => { + mockImages = [new File(["x"], "shot.png", { type: "image/png" })]; + const createSpy = vi + .spyOn(AgentServerConversationService, "createConversation") + .mockResolvedValue(makeConversationResponse()); + + renderLauncher(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("stub-chat-submit")); + + await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); + expect(createSpy).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + null, + undefined, + undefined, + undefined, + ); + await waitFor(() => + expect(sendMessageWithAttachments).toHaveBeenCalledTimes(1), + ); + expect(mockClearAllFiles).toHaveBeenCalled(); + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith("/conversations/conv-abc"), + ); + }); + it("surfaces a toast and skips navigation when conversation creation fails", async () => { vi.spyOn( AgentServerConversationService, @@ -345,4 +409,77 @@ describe("HomeChatLauncher", () => { await waitFor(() => expect(mockDisplayErrorToast).toHaveBeenCalled()); expect(mockNavigate).not.toHaveBeenCalled(); }); + + it("enqueues an optimistic pending message when cloud returns a start task", async () => { + mockUseActiveBackend.mockReturnValue(cloudBackend); + const createSpy = vi + .spyOn(AgentServerConversationService, "createConversation") + .mockResolvedValue( + makeConversationResponse({ + id: "start-task-1", + app_conversation_id: null, + }), + ); + + renderLauncher(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("stub-chat-submit")); + + await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(enqueueHomeTaskPendingMessage).toHaveBeenCalledWith({ + conversationId: "task-start-task-1", + text: "hello world", + images: [], + imagesMarkedUploadAsFile: [], + }), + ); + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith( + "/conversations/task-start-task-1", + ), + ); + }); + + it("defers attachments and enqueues an optimistic pending message for cloud start tasks", async () => { + mockUseActiveBackend.mockReturnValue(cloudBackend); + mockImages = [new File(["x"], "shot.png", { type: "image/png" })]; + const createSpy = vi + .spyOn(AgentServerConversationService, "createConversation") + .mockResolvedValue( + makeConversationResponse({ + id: "start-task-2", + app_conversation_id: null, + }), + ); + + renderLauncher(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("stub-chat-submit")); + + await waitFor(() => expect(createSpy).toHaveBeenCalledTimes(1)); + expect(createSpy).toHaveBeenCalledWith( + undefined, + undefined, + undefined, + null, + undefined, + undefined, + undefined, + ); + expect(sendMessageWithAttachments).not.toHaveBeenCalled(); + await waitFor(() => + expect(enqueueHomeTaskPendingMessage).toHaveBeenCalledWith({ + conversationId: "task-start-task-2", + text: "hello world", + images: mockImages, + imagesMarkedUploadAsFile: [], + }), + ); + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith( + "/conversations/task-start-task-2", + ), + ); + }); }); diff --git a/__tests__/hooks/query/use-task-polling.test.tsx b/__tests__/hooks/query/use-task-polling.test.tsx index 5c8ef2586..eab273ca8 100644 --- a/__tests__/hooks/query/use-task-polling.test.tsx +++ b/__tests__/hooks/query/use-task-polling.test.tsx @@ -6,11 +6,13 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser import type { AppConversationStartTask } from "#/api/conversation-service/agent-server-conversation-service.types"; import { NavigationProvider } from "#/context/navigation-context"; import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { consumePendingTaskDraft, getConversationState, setPendingTaskDraft, } from "#/utils/conversation-local-storage"; +import { resetPendingTaskMessageLinkState } from "#/utils/pending-task-message-link"; vi.mock( "#/api/conversation-service/agent-server-conversation-service.api", @@ -63,6 +65,8 @@ describe("useTaskPolling", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + resetPendingTaskMessageLinkState(); + useOptimisticUserMessageStore.setState({ pendingMessages: [] }); }); afterEach(() => { @@ -95,4 +99,58 @@ describe("useTaskPolling", () => { "123", ); }); + + it("reassigns optimistic pending messages on the real conversation route", async () => { + vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue( + readyTask, + ); + useOptimisticUserMessageStore.getState().enqueuePendingMessage({ + conversationId: "task-123", + text: "hello from home", + }); + + const createWrapperForConversation = (conversationId: string) => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); + }; + }; + + renderHook(() => useTaskPolling(), { + wrapper: createWrapperForConversation("task-123"), + }); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", { + replace: true, + }); + }); + + renderHook(() => useTaskPolling(), { + wrapper: createWrapperForConversation("conversation-1"), + }); + + await waitFor(() => { + const pending = useOptimisticUserMessageStore.getState().pendingMessages; + expect(pending).toHaveLength(1); + expect(pending[0].conversationId).toBe("conversation-1"); + expect(pending[0].text).toBe("hello from home"); + }); + }); }); diff --git a/__tests__/hooks/use-load-older-events.test.tsx b/__tests__/hooks/use-load-older-events.test.tsx index b8dfd90f2..87df5d58c 100644 --- a/__tests__/hooks/use-load-older-events.test.tsx +++ b/__tests__/hooks/use-load-older-events.test.tsx @@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useLoadOlderEvents } from "#/hooks/use-load-older-events"; import EventService from "#/api/event-service/event-service.api"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; +import { useConversationHistory } from "#/hooks/query/use-conversation-history"; import { useEventStore } from "#/stores/use-event-store"; import { INITIAL_HISTORY_PAGE_SIZE } from "#/hooks/query/use-conversation-history"; import type { Conversation } from "#/api/open-hands.types"; @@ -14,6 +15,13 @@ import type { EventSearchPage } from "#/api/event-service/event-service.types"; vi.mock("#/api/event-service/event-service.api"); vi.mock("#/hooks/query/use-user-conversation"); +vi.mock("#/hooks/query/use-conversation-history", () => ({ + INITIAL_HISTORY_PAGE_SIZE: 50, + useConversationHistory: vi.fn(() => ({ + data: undefined, + isFetched: false, + })), +})); function makeConversation(): Conversation { // Cast: `useUserConversation` actually returns an `AppConversation` whose @@ -80,6 +88,11 @@ describe("useLoadOlderEvents", () => { error: null, refetch: vi.fn(), } as any); + + vi.mocked(useConversationHistory).mockReturnValue({ + data: undefined, + isFetched: false, + } as ReturnType); }); afterEach(() => { @@ -284,7 +297,7 @@ describe("useLoadOlderEvents", () => { expect(result.current.hasMore).toBe(true); }); - it("stops paginating and throws when the oldest loaded event is missing a timestamp", async () => { + it("stops paginating silently when the oldest loaded event is missing a timestamp", async () => { act(() => { useEventStore .getState() @@ -296,22 +309,66 @@ describe("useLoadOlderEvents", () => { wrapper, }); - let thrown: unknown; await act(async () => { - try { - await result.current.loadOlder(); - } catch (error) { - thrown = error; - } + await result.current.loadOlder(); }); expect(spy).not.toHaveBeenCalled(); - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toContain( - "oldest loaded event has no timestamp", - ); await waitFor(() => { expect(result.current.hasMore).toBe(false); }); }); + + it("does not paginate on start-task placeholder conversation ids", async () => { + act(() => { + useEventStore + .getState() + .addEvent(makeEvent("evt-recent", "2024-06-01T00:00:00Z")); + }); + + const spy = vi.spyOn(EventService, "searchEvents"); + const { result } = renderHook(() => useLoadOlderEvents("task-abc"), { + wrapper, + }); + + expect(result.current.hasMore).toBe(false); + + await act(async () => { + await result.current.loadOlder(); + }); + + expect(spy).not.toHaveBeenCalled(); + }); + + it("mirrors initial REST history hasMore=false so short chats do not backfill", async () => { + vi.mocked(useConversationHistory).mockReturnValue({ + data: { + events: [makeEvent("evt-only", "2024-06-01T00:00:00Z")], + hasMore: false, + nextPageId: null, + }, + isFetched: true, + } as ReturnType); + + act(() => { + useEventStore + .getState() + .addEvent(makeEvent("evt-only", "2024-06-01T00:00:00Z")); + }); + + const spy = vi.spyOn(EventService, "searchEvents"); + const { result } = renderHook(() => useLoadOlderEvents("conv-1"), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.hasMore).toBe(false); + }); + + await act(async () => { + await result.current.loadOlder(); + }); + + expect(spy).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/stores/conversation-store.test.ts b/__tests__/stores/conversation-store.test.ts index 462db9aaf..953f839cc 100644 --- a/__tests__/stores/conversation-store.test.ts +++ b/__tests__/stores/conversation-store.test.ts @@ -33,7 +33,8 @@ describe("conversation store", () => { planContent: null, subConversationTaskId: null, shouldHideSuggestions: false, - uploadImagesAsFiles: false, + imagesMarkedUploadAsFile: [], + pastedImageNames: [], }); }); @@ -48,23 +49,57 @@ describe("conversation store", () => { }); }); - describe("uploadImagesAsFiles", () => { - it("defaults to false and is updated by setUploadImagesAsFiles", () => { - expect(useConversationStore.getState().uploadImagesAsFiles).toBe(false); + describe("imagesMarkedUploadAsFile", () => { + it("toggles per-image upload-as-file marks by file name", () => { + expect(useConversationStore.getState().imagesMarkedUploadAsFile).toEqual( + [], + ); - useConversationStore.getState().setUploadImagesAsFiles(true); - expect(useConversationStore.getState().uploadImagesAsFiles).toBe(true); + useConversationStore.getState().toggleImageUploadAsFile("paste.png"); + expect(useConversationStore.getState().imagesMarkedUploadAsFile).toEqual( + ["paste.png"], + ); - useConversationStore.getState().setUploadImagesAsFiles(false); - expect(useConversationStore.getState().uploadImagesAsFiles).toBe(false); + useConversationStore.getState().toggleImageUploadAsFile("paste.png"); + expect(useConversationStore.getState().imagesMarkedUploadAsFile).toEqual( + [], + ); }); - it("is reset to false by clearAllFiles", () => { - useConversationStore.getState().setUploadImagesAsFiles(true); - expect(useConversationStore.getState().uploadImagesAsFiles).toBe(true); + it("clears marks when an image is removed", () => { + const image = new File(["x"], "paste.png", { type: "image/png" }); + useConversationStore.getState().addImages([image]); + useConversationStore.getState().toggleImageUploadAsFile("paste.png"); + useConversationStore.getState().removeImage(0); + expect(useConversationStore.getState().imagesMarkedUploadAsFile).toEqual( + [], + ); + }); + + it("is reset by clearAllFiles", () => { + useConversationStore.getState().toggleImageUploadAsFile("paste.png"); useConversationStore.getState().clearAllFiles(); - expect(useConversationStore.getState().uploadImagesAsFiles).toBe(false); + expect(useConversationStore.getState().imagesMarkedUploadAsFile).toEqual( + [], + ); + }); + }); + + describe("pastedImageNames", () => { + it("tracks attached image names for the upload-as-file control", () => { + useConversationStore.getState().markImagesAsPasted(["shot.png"]); + expect(useConversationStore.getState().pastedImageNames).toEqual([ + "shot.png", + ]); + }); + + it("clears pasted names when the image is removed", () => { + const image = new File(["x"], "shot.png", { type: "image/png" }); + useConversationStore.getState().addImages([image]); + useConversationStore.getState().markImagesAsPasted(["shot.png"]); + useConversationStore.getState().removeImage(0); + expect(useConversationStore.getState().pastedImageNames).toEqual([]); }); }); diff --git a/__tests__/stores/optimistic-user-message-store.test.ts b/__tests__/stores/optimistic-user-message-store.test.ts index 26f555da5..fe23f9330 100644 --- a/__tests__/stores/optimistic-user-message-store.test.ts +++ b/__tests__/stores/optimistic-user-message-store.test.ts @@ -272,4 +272,24 @@ describe("optimistic-user-message-store", () => { useOptimisticUserMessageStore.getState().pendingMessages, ).toHaveLength(0); }); + + it("reassignPendingMessages moves entries from a task id to the real conversation id", () => { + const store = useOptimisticUserMessageStore.getState(); + store.enqueuePendingMessage({ + conversationId: "task-abc", + text: "hello", + }); + store.enqueuePendingMessage({ + conversationId: "other-convo", + text: "untouched", + }); + + store.reassignPendingMessages("task-abc", "real-convo"); + + const pending = useOptimisticUserMessageStore.getState().pendingMessages; + expect(pending.map((m) => [m.conversationId, m.text])).toEqual([ + ["real-convo", "hello"], + ["other-convo", "untouched"], + ]); + }); }); diff --git a/__tests__/stores/pending-task-attachments-store.test.ts b/__tests__/stores/pending-task-attachments-store.test.ts new file mode 100644 index 000000000..80b9c9af9 --- /dev/null +++ b/__tests__/stores/pending-task-attachments-store.test.ts @@ -0,0 +1,28 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + consumePendingTaskAttachments, + setPendingTaskAttachments, + usePendingTaskAttachmentsStore, +} from "#/stores/pending-task-attachments-store"; + +describe("pending-task-attachments-store", () => { + beforeEach(() => { + usePendingTaskAttachmentsStore.setState({ byTaskId: {} }); + }); + + it("stores and consumes attachments by task id", () => { + const image = new File(["x"], "shot.png", { type: "image/png" }); + + setPendingTaskAttachments("task-uuid", { + content: "see this", + images: [image], + files: [], + imagesMarkedUploadAsFile: [], + }); + + const consumed = consumePendingTaskAttachments("task-uuid"); + expect(consumed?.content).toBe("see this"); + expect(consumed?.images).toHaveLength(1); + expect(consumePendingTaskAttachments("task-uuid")).toBeNull(); + }); +}); diff --git a/__tests__/utils/flush-pending-task-attachments.test.ts b/__tests__/utils/flush-pending-task-attachments.test.ts new file mode 100644 index 000000000..26c4d6ca1 --- /dev/null +++ b/__tests__/utils/flush-pending-task-attachments.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + setPendingTaskAttachments, + usePendingTaskAttachmentsStore, +} from "#/stores/pending-task-attachments-store"; +import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments"; + +const sendMessageWithAttachments = vi.fn(); + +vi.mock("#/utils/send-message-with-attachments", () => ({ + sendMessageWithAttachments: (...args: unknown[]) => + sendMessageWithAttachments(...args), +})); + +vi.mock("#/i18n", () => ({ + default: { getFixedT: () => (key: string) => key }, + OPENHANDS_I18N_NAMESPACE: "openhands", + waitForI18n: vi.fn().mockResolvedValue(undefined), +})); + +describe("flushPendingTaskAttachments", () => { + beforeEach(() => { + vi.clearAllMocks(); + usePendingTaskAttachmentsStore.setState({ byTaskId: {} }); + + sendMessageWithAttachments.mockResolvedValue({ + text: "hello", + content: "hello", + imageUrls: ["data:image/png;base64,abc"], + fileUrls: [], + timestamp: "2020-01-01T00:00:00.000Z", + }); + }); + + it("sends queued attachments to the real conversation id", async () => { + const image = new File(["x"], "shot.png", { type: "image/png" }); + setPendingTaskAttachments("start-task-id", { + content: "hello", + images: [image], + files: [], + imagesMarkedUploadAsFile: [], + }); + + await flushPendingTaskAttachments( + "start-task-id", + "550e8400-e29b-41d4-a716-446655440000", + ); + + expect(sendMessageWithAttachments).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: "550e8400-e29b-41d4-a716-446655440000", + content: "hello", + }), + ); + }); +}); diff --git a/__tests__/utils/pending-task-message-link.test.ts b/__tests__/utils/pending-task-message-link.test.ts new file mode 100644 index 000000000..8195d8159 --- /dev/null +++ b/__tests__/utils/pending-task-message-link.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { + linkPendingTaskMessages, + matchesPendingConversationId, + resetPendingTaskMessageLinkState, + schedulePendingTaskMessageReassign, + consumeScheduledPendingTaskMessageReassign, + clearPendingTaskMessageLink, +} from "#/utils/pending-task-message-link"; + +describe("pending-task-message-link", () => { + beforeEach(() => { + resetPendingTaskMessageLinkState(); + }); + + it("matches pending messages linked from a task placeholder route", () => { + linkPendingTaskMessages("conv-real", "task-abc"); + + expect( + matchesPendingConversationId("conv-real", "task-abc"), + ).toBe(true); + expect( + matchesPendingConversationId("conv-real", "conv-real"), + ).toBe(true); + expect( + matchesPendingConversationId("conv-real", "task-other"), + ).toBe(false); + }); + + it("schedules and consumes a pending reassign once for the target conversation", () => { + schedulePendingTaskMessageReassign("task-abc", "conv-real"); + + expect(consumeScheduledPendingTaskMessageReassign("conv-other")).toBeNull(); + expect(consumeScheduledPendingTaskMessageReassign("conv-real")).toEqual({ + fromConversationId: "task-abc", + toConversationId: "conv-real", + }); + expect(consumeScheduledPendingTaskMessageReassign("conv-real")).toBeNull(); + }); + + it("clears linked task ids for a real conversation", () => { + linkPendingTaskMessages("conv-real", "task-abc"); + clearPendingTaskMessageLink("conv-real"); + + expect( + matchesPendingConversationId("conv-real", "task-abc"), + ).toBe(false); + }); +}); diff --git a/src/api/conversation-file-upload.api.ts b/src/api/conversation-file-upload.api.ts new file mode 100644 index 000000000..b83f0ab15 --- /dev/null +++ b/src/api/conversation-file-upload.api.ts @@ -0,0 +1,163 @@ +import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; +import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { getActiveBackend } from "#/api/backend-registry/active-store"; +import { batchGetCloudConversations } from "#/api/cloud/conversation-service.api"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; +import type { FileUploadSuccessResponse } from "#/api/open-hands.types"; +import { + buildWorkspaceUploadPath, + getSafeUploadFileName, + resolveConversationUploadWorkingDir, +} from "#/api/workspace-upload-path"; + +const FILE_UPLOAD_CONCURRENCY = 5; + +export interface ConversationRuntimeContext { + conversationUrl: string | null; + sessionApiKey: string | null; +} + +/** + * Resolve the sandbox runtime URL + session key needed for file upload and + * send-event calls. Cloud conversations only exist on the provisioned runtime, + * not on the bundled local agent-server. + */ +export async function resolveConversationRuntime( + conversationId: string, + currentConversation?: AppConversation | null, +): Promise { + if ( + currentConversation?.id === conversationId && + currentConversation.conversation_url?.trim() && + currentConversation.session_api_key?.trim() + ) { + return { + conversationUrl: currentConversation.conversation_url.trim(), + sessionApiKey: currentConversation.session_api_key.trim(), + }; + } + + if (getActiveBackend().backend.kind === "cloud") { + const [conversation] = await batchGetCloudConversations([conversationId]); + return { + conversationUrl: conversation?.conversation_url?.trim() ?? null, + sessionApiKey: conversation?.session_api_key?.trim() ?? null, + }; + } + + return { conversationUrl: null, sessionApiKey: null }; +} + +function requireCloudRuntime( + runtime: ConversationRuntimeContext, +): ConversationRuntimeContext & { + conversationUrl: string; + sessionApiKey: string; +} { + if (!runtime.conversationUrl || !runtime.sessionApiKey) { + throw new Error( + "Conversation sandbox is still starting. Wait for it to finish, then try again.", + ); + } + return { + conversationUrl: runtime.conversationUrl, + sessionApiKey: runtime.sessionApiKey, + }; +} + +/** + * Upload attachments into the conversation workspace. Local conversations use + * the bundled agent-server; cloud conversations target the provisioned runtime. + */ +export async function uploadFilesToConversation( + conversationId: string, + files: File[], + currentConversation?: AppConversation | null, +): Promise { + const workingDir = await resolveConversationUploadWorkingDir( + conversationId, + currentConversation, + ); + const runtime = await resolveConversationRuntime( + conversationId, + currentConversation, + ); + const isCloud = getActiveBackend().backend.kind === "cloud"; + + const sessionApiKey = + currentConversation?.id === conversationId + ? (currentConversation.session_api_key ?? runtime.sessionApiKey) + : runtime.sessionApiKey; + const conversationUrl = + currentConversation?.id === conversationId + ? (currentConversation.conversation_url ?? runtime.conversationUrl) + : runtime.conversationUrl; + + if (isCloud) { + const cloudRuntime = requireCloudRuntime({ + conversationUrl, + sessionApiKey, + }); + return uploadFilesToRuntime({ + files, + workingDir, + conversationUrl: cloudRuntime.conversationUrl, + sessionApiKey: cloudRuntime.sessionApiKey, + }); + } + + return uploadFilesToRuntime({ + files, + workingDir, + conversationUrl, + sessionApiKey, + }); +} + +async function uploadFilesToRuntime(options: { + files: File[]; + workingDir: string; + conversationUrl: string | null; + sessionApiKey: string | null; +}): Promise { + const { files, workingDir, conversationUrl, sessionApiKey } = options; + const workspace = new RemoteWorkspace( + getAgentServerClientOptions({ + conversationUrl, + sessionApiKey, + workingDir, + }), + ); + + const uploadFile = async (file: File) => { + try { + const safeName = getSafeUploadFileName(file.name); + const uploadPath = buildWorkspaceUploadPath(file.name, workingDir); + await workspace.fileUpload(file, uploadPath); + return { uploadedFile: safeName, skippedFile: null }; + } catch (error) { + return { + uploadedFile: null, + skippedFile: { + name: file.name, + reason: error instanceof Error ? error.message : "Upload failed", + }, + }; + } + }; + + const results: Awaited>[] = []; + for (let index = 0; index < files.length; index += FILE_UPLOAD_CONCURRENCY) { + const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY); + results.push(...(await Promise.all(batch.map(uploadFile)))); + } + + return { + uploaded_files: results.flatMap((result) => + result.uploadedFile ? [result.uploadedFile] : [], + ), + skipped_files: results.flatMap((result) => + result.skippedFile ? [result.skippedFile] : [], + ), + }; +} diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts index 5fe8a66e1..fc1655128 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -10,6 +10,7 @@ import { } from "@openhands/typescript-client/clients"; import { v4 as uuidv4 } from "uuid"; import { Provider } from "#/types/settings"; +import type { ConversationRuntimeContext } from "#/api/conversation-file-upload.api"; import { buildHttpBaseUrl } from "#/utils/websocket-url"; import { buildConversationWorkingDir, @@ -288,14 +289,45 @@ class AgentServerConversationService { static async sendMessage( conversationId: string, message: SendMessageRequest, + runtime?: ConversationRuntimeContext | null, ): Promise { - await new ConversationClient(getAgentServerClientOptions()).sendEvent( - conversationId, - message, - { - run: true, - }, - ); + const active = getActiveBackend().backend; + let conversationUrl = runtime?.conversationUrl ?? null; + let sessionApiKey = runtime?.sessionApiKey ?? null; + + if (active.kind === "cloud") { + if (!conversationUrl || !sessionApiKey) { + const [conversation] = await batchGetCloudConversations([ + conversationId, + ]); + conversationUrl = conversation?.conversation_url?.trim() ?? null; + sessionApiKey = conversation?.session_api_key?.trim() ?? null; + } + + if (!conversationUrl || !sessionApiKey) { + throw new Error( + "Conversation sandbox is still starting. Wait for it to finish, then try again.", + ); + } + + await callCloudProxy({ + backend: active, + method: "POST", + hostOverride: buildHttpBaseUrl(conversationUrl), + path: `/api/conversations/${conversationId}/events`, + body: { ...message, run: true }, + authMode: "session-api-key", + sessionApiKey, + }); + + return message; + } + + await new ConversationClient( + getAgentServerClientOptions({ conversationUrl, sessionApiKey }), + ).sendEvent(conversationId, message, { + run: true, + }); return message; } diff --git a/src/api/conversation-service/conversation-service.api.ts b/src/api/conversation-service/conversation-service.api.ts index 44e34025e..508267f28 100644 --- a/src/api/conversation-service/conversation-service.api.ts +++ b/src/api/conversation-service/conversation-service.api.ts @@ -1,6 +1,6 @@ import { VSCodeClient } from "@openhands/typescript-client/clients"; import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; +import { uploadFilesToConversation } from "#/api/conversation-file-upload.api"; import { GetVSCodeUrlResponse, GetTrajectoryResponse, @@ -13,19 +13,6 @@ import { } from "../agent-server-client-options"; import { AppConversation } from "./agent-server-conversation-service.types"; -const FILE_UPLOAD_CONCURRENCY = 5; - -function getSafeUploadFileName(fileName: string): string { - const parts = fileName.split(/[\\/]+/).filter(Boolean); - const safeName = parts[parts.length - 1]; - - if (!safeName || safeName === "." || safeName === "..") { - throw new Error("Invalid file name"); - } - - return safeName; -} - class ConversationService { private static currentConversation: AppConversation | null = null; @@ -72,47 +59,14 @@ class ConversationService { } static async uploadFiles( - _conversationId: string, + conversationId: string, files: File[], ): Promise { - const workspace = new RemoteWorkspace( - getAgentServerClientOptions(this.getClientOverrides()), + return uploadFilesToConversation( + conversationId, + files, + this.currentConversation, ); - const uploadFile = async (file: File) => { - try { - const safeName = getSafeUploadFileName(file.name); - await workspace.fileUpload(file, `/workspace/${safeName}`); - return { uploadedFile: safeName, skippedFile: null }; - } catch (error) { - return { - uploadedFile: null, - skippedFile: { - name: file.name, - reason: error instanceof Error ? error.message : "Upload failed", - }, - }; - } - }; - - const results: Awaited>[] = []; - for ( - let index = 0; - index < files.length; - index += FILE_UPLOAD_CONCURRENCY - ) { - const batch = files.slice(index, index + FILE_UPLOAD_CONCURRENCY); - - results.push(...(await Promise.all(batch.map(uploadFile)))); - } - - return { - uploaded_files: results.flatMap((result) => - result.uploadedFile ? [result.uploadedFile] : [], - ), - skipped_files: results.flatMap((result) => - result.skippedFile ? [result.skippedFile] : [], - ), - }; } } diff --git a/src/api/workspace-upload-path.ts b/src/api/workspace-upload-path.ts new file mode 100644 index 000000000..a8026890c --- /dev/null +++ b/src/api/workspace-upload-path.ts @@ -0,0 +1,57 @@ +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import { getAgentServerWorkingDir } from "#/api/agent-server-config"; +import { getStoredConversationMetadata } from "#/api/conversation-metadata-store"; +import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types"; + +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export function getSafeUploadFileName(fileName: string): string { + const parts = fileName.split(/[\\/]+/).filter(Boolean); + const safeName = parts[parts.length - 1]; + + if (!safeName || safeName === "." || safeName === "..") { + throw new Error("Invalid file name"); + } + + return safeName; +} + +/** Normalize agent-server working_dir values to absolute sandbox paths. */ +export function toAbsoluteWorkspacePath(path: string): string { + return path.startsWith("/") ? path : `/${path}`; +} + +export function buildWorkspaceUploadPath( + fileName: string, + workingDir: string, +): string { + const safeName = getSafeUploadFileName(fileName); + const base = toAbsoluteWorkspacePath(workingDir.replace(/\/+$/, "")); + return `${base}/${safeName}`; +} + +export async function resolveConversationUploadWorkingDir( + conversationId: string, + currentConversation?: AppConversation | null, +): Promise { + if ( + currentConversation?.id === conversationId && + currentConversation.workspace?.working_dir?.trim() + ) { + return currentConversation.workspace.working_dir.trim(); + } + + const stored = getStoredConversationMetadata(conversationId); + if (stored?.selected_workspace?.trim()) { + return stored.selected_workspace.trim(); + } + + if (UUID_PATTERN.test(conversationId)) { + return AgentServerConversationService.resolveConversationWorkingDir( + conversationId, + ); + } + + return getAgentServerWorkingDir(); +} diff --git a/src/components/features/chat/chat-add-file-button.tsx b/src/components/features/chat/chat-add-file-button.tsx index aebcfee30..e1ad9a47a 100644 --- a/src/components/features/chat/chat-add-file-button.tsx +++ b/src/components/features/chat/chat-add-file-button.tsx @@ -1,7 +1,15 @@ -import { Plus } from "lucide-react"; +import React from "react"; +import { Paperclip, Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; +import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { useActiveConversation } from "#/hooks/query/use-active-conversation"; +import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu"; +import { ToolsContextMenu } from "#/components/features/controls/tools-context-menu"; +import { SystemMessageModal } from "#/components/features/conversation-panel/system-message-modal"; +import { SkillsModal } from "#/components/features/conversation-panel/skills-modal"; +import { HooksModal } from "#/components/features/conversation-panel/hooks-modal"; export interface ChatAddFileButtonProps { handleFileIconClick: () => void; @@ -13,24 +21,94 @@ export function ChatAddFileButton({ disabled = false, }: ChatAddFileButtonProps) { const { t } = useTranslation("openhands"); + const { conversationId } = useOptionalConversationId(); + const { data: conversation } = useActiveConversation(); + const [menuOpen, setMenuOpen] = React.useState(false); + + const { + handleShowAgentTools, + handleShowSkills, + handleShowHooks, + systemModalVisible, + setSystemModalVisible, + skillsModalVisible, + setSkillsModalVisible, + hooksModalVisible, + setHooksModalVisible, + systemMessage, + shouldShowAgentTools, + shouldShowHooks, + } = useConversationNameContextMenu({ + conversationId: conversationId ?? undefined, + executionStatus: conversation?.execution_status, + showOptions: true, + onContextMenuToggle: setMenuOpen, + }); + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (disabled) return; + setMenuOpen((open) => !open); + }; return ( - + + {menuOpen && ( + setMenuOpen(false)} + onShowSkills={handleShowSkills} + onShowHooks={handleShowHooks} + onShowAgentTools={handleShowAgentTools} + shouldShowAgentTools={shouldShowAgentTools} + shouldShowHooks={shouldShowHooks} + footerAction={{ + testId: "add-files-and-images-button", + icon: ( + + ), + label: t(I18nKey.CHAT_INTERFACE$ADD_FILES_AND_IMAGES), + onClick: handleFileIconClick, + }} + /> + )} + + setSystemModalVisible(false)} + systemMessage={systemMessage || null} + /> + {skillsModalVisible && ( + setSkillsModalVisible(false)} /> + )} + {hooksModalVisible && ( + setHooksModalVisible(false)} /> )} - aria-label={t(I18nKey.CHAT_INTERFACE$ADD_FILE)} - data-testid="paperclip-icon" - onClick={handleFileIconClick} - disabled={disabled} - > - - - - +
); } diff --git a/src/components/features/chat/chat-interface.tsx b/src/components/features/chat/chat-interface.tsx index b0aba068d..764c21773 100644 --- a/src/components/features/chat/chat-interface.tsx +++ b/src/components/features/chat/chat-interface.tsx @@ -34,6 +34,7 @@ import { validateFiles } from "#/utils/file-validation"; import { useConversationStore } from "#/stores/conversation-store"; import ConfirmationModeEnabled from "./confirmation-mode-enabled"; import { useTaskPolling } from "#/hooks/query/use-task-polling"; +import { matchesPendingConversationId } from "#/utils/pending-task-message-link"; import { useConversationWebSocket } from "#/contexts/conversation-websocket-context"; import ChatStatusIndicator from "./chat-status-indicator"; import { getStatusColor, getStatusText } from "#/utils/utils"; @@ -57,6 +58,9 @@ export function ChatInterface() { const { errorMessage, removeErrorMessage, setErrorMessage } = useErrorMessageStore(); const { isTask, taskStatus, taskDetail } = useTaskPolling(); + // Hide empty-state chrome for the entire `/conversations/task-{uuid}` route, + // including the brief READY window before redirect completes. + const isProvisioningTask = isTask; const conversationWebSocket = useConversationWebSocket(); const { send } = useSendMessage(); const { @@ -165,7 +169,9 @@ export function ChatInterface() { } | null>(null); const maybeLoadOlder = React.useCallback( (target: HTMLElement) => { - if (isLoadingOlderEvents || !hasMoreOlderEvents) return; + if (isProvisioningTask || isLoadingOlderEvents || !hasMoreOlderEvents) { + return; + } const atTop = target.scrollTop <= SCROLL_TOP_THRESHOLD_PX; const noOverflow = @@ -185,7 +191,14 @@ export function ChatInterface() { setErrorMessage(message); }); }, - [hasMoreOlderEvents, isLoadingOlderEvents, loadOlder, setErrorMessage, t], + [ + hasMoreOlderEvents, + isLoadingOlderEvents, + isProvisioningTask, + loadOlder, + setErrorMessage, + t, + ], ); const handleWheelForPagination = React.useCallback( @@ -199,14 +212,28 @@ export function ChatInterface() { [maybeLoadOlder], ); - const hasPendingUserMessages = pendingMessages.length > 0; + const hasPendingUserMessages = React.useMemo( + () => + conversationId + ? pendingMessages.some((message) => + matchesPendingConversationId( + conversationId, + message.conversationId, + ), + ) + : false, + [pendingMessages, conversationId], + ); // Show V1 messages immediately if events exist in store (e.g., remount), - // or once loading completes. This replaces the old transition-observation - // pattern (useState + useEffect watching loading→loaded) which always showed - // skeleton on remount because local state initialized to false. + // if the user already has a locally-tracked pending bubble (home-page cloud + // submit while history/WS catch up), or once loading completes. This + // replaces the old transition-observation pattern (useState + useEffect + // watching loading→loaded) which always showed skeleton on remount because + // local state initialized to false. const showConversationMessages = allConversationEvents.length > 0 || + hasPendingUserMessages || !conversationWebSocket?.isLoadingHistory; const isReturningToConversation = !!conversationId; @@ -417,6 +444,8 @@ export function ChatInterface() { !userEventsExist && !hasModelEntries && !isChatLoading && + !isProvisioningTask && + totalEvents === 0 && !isArchivedConversation && ( setMessageToSend(message)} diff --git a/src/components/features/chat/components/chat-input-actions.tsx b/src/components/features/chat/components/chat-input-actions.tsx index 1a63ba505..bfd12d85f 100644 --- a/src/components/features/chat/components/chat-input-actions.tsx +++ b/src/components/features/chat/components/chat-input-actions.tsx @@ -1,9 +1,8 @@ import React from "react"; import ReactDOM from "react-dom"; import { useTranslation } from "react-i18next"; -import { Cpu, Wrench } from "lucide-react"; +import { Cpu } from "lucide-react"; import { AgentStatus } from "#/components/features/controls/agent-status"; -import { Tools } from "../../controls/tools"; import { ChangeAgentButton } from "../change-agent-button"; import { ChatInputModel } from "./chat-input-model"; import { SwitchProfileButton } from "../switch-profile-button"; @@ -21,17 +20,12 @@ import { usePauseConversation } from "#/hooks/mutation/use-pause-conversation"; import { useResumeConversation } from "#/hooks/mutation/use-resume-conversation"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useConversationNameContextMenu } from "#/hooks/use-conversation-name-context-menu"; import { useConversationStore } from "#/stores/conversation-store"; import { useAgentState } from "#/hooks/use-agent-state"; import { AgentState } from "#/types/agent-state"; import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status"; import { useHandlePlanClick } from "#/hooks/use-handle-plan-click"; import { I18nKey } from "#/i18n/declaration"; -import { SystemMessageModal } from "../../conversation-panel/system-message-modal"; -import { SkillsModal } from "../../conversation-panel/skills-modal"; -import { HooksModal } from "../../conversation-panel/hooks-modal"; -import { ToolsContextMenu } from "../../controls/tools-context-menu"; import { ToolsContextMenuIconText } from "../../controls/tools-context-menu-icon-text"; import { ContextMenuListItem } from "../../context-menu/context-menu-list-item"; import { ContextMenu } from "#/ui/context-menu"; @@ -61,8 +55,6 @@ export function ChatInputActions({ const unifiedPauseMutation = useUnifiedPauseConversation(); const pauseConversationMutation = usePauseConversation(); const resumeConversationMutation = useResumeConversation(); - // Optional because the chat input also renders on the home page (no - // conversation route yet). Conversation-scoped actions below guard on this. const { conversationId } = useOptionalConversationId(); const { data: conversation } = useActiveConversation(); const { backend } = useActiveBackend(); @@ -78,7 +70,6 @@ export function ChatInputActions({ const actionsRowRef = React.useRef(null); const rightSectionRef = React.useRef(null); const addFileRef = React.useRef(null); - const toolsRef = React.useRef(null); const codeRef = React.useRef(null); const modelRef = React.useRef(null); const overflowTriggerRef = React.useRef(null); @@ -87,41 +78,19 @@ export function ChatInputActions({ ); const [rightSectionWidth, setRightSectionWidth] = React.useState(0); const [addFileWidth, setAddFileWidth] = React.useState(32); - const [toolsWidth, setToolsWidth] = React.useState(100); const [codeWidth, setCodeWidth] = React.useState(96); const [modelWidth, setModelWidth] = React.useState(120); const [isOverflowOpen, setIsOverflowOpen] = React.useState(false); const [activeSubmenu, setActiveSubmenu] = React.useState< - "tools" | "agent" | "model" | null + "agent" | "model" | null >(null); const [overflowPortalStyle, setOverflowPortalStyle] = React.useState(); - const { - handleShowAgentTools, - handleShowSkills, - handleShowHooks, - systemModalVisible, - setSystemModalVisible, - skillsModalVisible, - setSkillsModalVisible, - hooksModalVisible, - setHooksModalVisible, - systemMessage, - shouldShowAgentTools, - shouldShowHooks, - } = useConversationNameContextMenu({ - conversationId: conversationId ?? undefined, - executionStatus: conversation?.execution_status, - showOptions: true, - onContextMenuToggle: setIsOverflowOpen, - }); - React.useEffect(() => { const rowEl = actionsRowRef.current; const rightEl = rightSectionRef.current; const addEl = addFileRef.current; - const toolsEl = toolsRef.current; const codeEl = codeRef.current; const modelEl = modelRef.current; @@ -129,7 +98,6 @@ export function ChatInputActions({ !rowEl || !rightEl || !addEl || - !toolsEl || !modelEl || (isCloud && !codeEl) || typeof ResizeObserver === "undefined" @@ -141,13 +109,11 @@ export function ChatInputActions({ const nextRowWidth = rowEl.getBoundingClientRect().width; const nextRightWidth = rightEl.getBoundingClientRect().width; const nextAddWidth = addEl.getBoundingClientRect().width; - const nextToolsWidth = toolsEl.getBoundingClientRect().width; const nextModelWidth = modelEl.getBoundingClientRect().width; if (nextRowWidth > 0) setActionsRowWidth(nextRowWidth); if (nextRightWidth > 0) setRightSectionWidth(nextRightWidth); if (nextAddWidth > 0) setAddFileWidth(nextAddWidth); - if (nextToolsWidth > 0) setToolsWidth(nextToolsWidth); if (nextModelWidth > 0) setModelWidth(nextModelWidth); if (codeEl) { @@ -163,7 +129,6 @@ export function ChatInputActions({ observer.observe(rowEl); observer.observe(rightEl); observer.observe(addEl); - observer.observe(toolsEl); observer.observe(modelEl); if (codeEl) { observer.observe(codeEl); @@ -176,13 +141,11 @@ export function ChatInputActions({ const handlePauseAgent = () => { if (!conversationId) return; - // Pause the conversation (agent execution) pauseConversationMutation.mutate({ conversationId }); }; const handleResumeAgentClick = () => { if (!conversationId) return; - // Resume the conversation (agent execution) resumeConversationMutation.mutate({ conversationId }); }; @@ -197,16 +160,10 @@ export function ChatInputActions({ (availableWidth: number) => { let remaining = availableWidth; const next = { - showToolsInline: false, showCodeInline: false, showModelInline: false, }; - if (remaining >= toolsWidth) { - next.showToolsInline = true; - remaining -= toolsWidth + INLINE_GAP; - } - if (isCloud && remaining >= codeWidth) { next.showCodeInline = true; remaining -= codeWidth + INLINE_GAP; @@ -218,7 +175,7 @@ export function ChatInputActions({ return next; }, - [toolsWidth, isCloud, codeWidth, modelWidth], + [isCloud, codeWidth, modelWidth], ); const leftBaseWidth = @@ -226,7 +183,6 @@ export function ChatInputActions({ const fitWithoutOverflow = fitOptionalItems(leftBaseWidth); const allOptionalFit = - fitWithoutOverflow.showToolsInline && (!isCloud || fitWithoutOverflow.showCodeInline) && fitWithoutOverflow.showModelInline; @@ -234,17 +190,13 @@ export function ChatInputActions({ ? fitWithoutOverflow : fitOptionalItems(leftBaseWidth - OVERFLOW_BUTTON_WIDTH - INLINE_GAP); - const showToolsInline = fitWithOverflow.showToolsInline; const showCodeInline = !isCloud ? false : fitWithOverflow.showCodeInline; const showModelInline = fitWithOverflow.showModelInline; const showAddFileInline = true; const showAgentStatusInline = actionsRowWidth >= 360; const hasOverflowItems = - !showAddFileInline || - !showToolsInline || - (isCloud && !showCodeInline) || - !showModelInline; + !showAddFileInline || (isCloud && !showCodeInline) || !showModelInline; React.useEffect(() => { if (!hasOverflowItems) { @@ -305,42 +257,6 @@ export function ChatInputActions({ alignment="left" className="!static !top-auto !bottom-auto !left-auto !right-auto !mt-0 overflow-visible min-w-[200px]" > - {!showToolsInline && ( -
- - setActiveSubmenu((current) => - current === "tools" ? null : "tools", - ) - } - > - } - text={t(I18nKey.MICROAGENTS_MODAL$TOOLS)} - rightIcon={} - /> - -
- -
-
- )} {isCloud && !showCodeInline && (
-
- -
{isCloud && (
@@ -531,7 +444,6 @@ export function ChatInputActions({ typeof document !== "undefined" && overflowPortalStyle && ReactDOM.createPortal( - // portal position computed from DOM bounding rect at runtime
{overflowMenu}
, document.body, )} @@ -559,18 +471,6 @@ export function ChatInputActions({ /> )}
- - setSystemModalVisible(false)} - systemMessage={systemMessage || null} - /> - {skillsModalVisible && ( - setSkillsModalVisible(false)} /> - )} - {hooksModalVisible && ( - setHooksModalVisible(false)} /> - )}
); } diff --git a/src/components/features/chat/custom-chat-input.tsx b/src/components/features/chat/custom-chat-input.tsx index d0e41e28d..5ec0da8af 100644 --- a/src/components/features/chat/custom-chat-input.tsx +++ b/src/components/features/chat/custom-chat-input.tsx @@ -18,7 +18,10 @@ export interface CustomChatInputProps { onSubmit: (message: string) => void; onFocus?: () => void; onBlur?: () => void; - onFilesPaste?: (files: File[]) => void; + onFilesPaste?: ( + files: File[], + options?: import("#/hooks/chat/use-chat-attachment-upload").ChatAttachmentUploadOptions, + ) => void; className?: React.HTMLAttributes["className"]; buttonClassName?: React.HTMLAttributes["className"]; } @@ -40,6 +43,8 @@ export function CustomChatInput({ clearAllFiles, setShouldHideSuggestions, setSubmittedMessage, + images, + files, } = useConversationStore(); // Note: we intentionally do NOT disable the input when the conversation is @@ -81,8 +86,9 @@ export function CustomChatInput({ const syncCanSubmit = React.useCallback(() => { const text = chatInputRef.current?.innerText ?? ""; - setCanSubmit(text.trim().length > 0); - }, [chatInputRef]); + const hasAttachments = images.length > 0 || files.length > 0; + setCanSubmit(text.trim().length > 0 || hasAttachments); + }, [chatInputRef, images, files]); const { fileInputRef, @@ -153,7 +159,7 @@ export function CustomChatInput({ ); useEffect(() => { syncCanSubmit(); - }, [syncCanSubmit]); + }, [syncCanSubmit, images.length, files.length]); return (
{/* Hidden file input */} diff --git a/src/components/features/chat/interactive-chat-box.tsx b/src/components/features/chat/interactive-chat-box.tsx index b530066f4..b65cd686c 100644 --- a/src/components/features/chat/interactive-chat-box.tsx +++ b/src/components/features/chat/interactive-chat-box.tsx @@ -1,17 +1,15 @@ -import { isFileImage } from "#/utils/is-file-image"; -import { displayErrorToast } from "#/utils/custom-toast-handlers"; -import { validateFiles } from "#/utils/file-validation"; import { CustomChatInput } from "./custom-chat-input"; import { useBtwInterceptor } from "#/hooks/chat/use-btw-interceptor"; import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; +import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; import { AgentState } from "#/types/agent-state"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useOptionalConversationId } from "#/hooks/use-conversation-id"; import { GitControlBar } from "./git-control-bar"; import { useConversationStore } from "#/stores/conversation-store"; import { useAgentState } from "#/hooks/use-agent-state"; -import { processFiles, processImages } from "#/utils/file-processing"; import { useSubConversationTaskPolling } from "#/hooks/query/use-sub-conversation-task-polling"; +import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils"; import { isTaskPolling } from "#/utils/utils"; interface InteractiveChatBoxProps { @@ -26,135 +24,29 @@ export function InteractiveChatBox({ const { images, files, - uploadImagesAsFiles, - addImages, - addFiles, + imagesMarkedUploadAsFile, clearAllFiles, - addFileLoading, - removeFileLoading, - addImageLoading, - removeImageLoading, subConversationTaskId, } = useConversationStore(); const { curAgentState } = useAgentState(); const { data: conversation } = useActiveConversation(); - // URL-based id is available the instant we land on the conversation route; - // `conversation?.id` is `undefined` until the first fetch resolves, which - // would let an early `/model NAME` (first action) fall through unhandled. const { conversationId: routeConversationId } = useOptionalConversationId(); const conversationId = routeConversationId ?? conversation?.id ?? null; - // Poll sub-conversation task to check if it's loading const { taskStatus: subConversationTaskStatus } = useSubConversationTaskPolling( subConversationTaskId, conversation?.id || null, ); - // Helper function to validate and filter files - const validateAndFilterFiles = (selectedFiles: File[]) => { - const validation = validateFiles(selectedFiles, [...images, ...files]); - - if (!validation.isValid) { - displayErrorToast(`Error: ${validation.errorMessage}`); - return null; - } - - const validFiles = selectedFiles.filter((f) => !isFileImage(f)); - const validImages = selectedFiles.filter((f) => isFileImage(f)); - - return { validFiles, validImages }; - }; - - // Helper function to show loading indicators for files - const showLoadingIndicators = (validFiles: File[], validImages: File[]) => { - validFiles.forEach((file) => addFileLoading(file.name)); - validImages.forEach((image) => addImageLoading(image.name)); - }; - - // Helper function to handle successful file processing results - const handleSuccessfulFiles = (fileResults: { successful: File[] }) => { - if (fileResults.successful.length > 0) { - addFiles(fileResults.successful); - fileResults.successful.forEach((file) => removeFileLoading(file.name)); - } - }; - - // Helper function to handle successful image processing results - const handleSuccessfulImages = (imageResults: { successful: File[] }) => { - if (imageResults.successful.length > 0) { - addImages(imageResults.successful); - imageResults.successful.forEach((image) => - removeImageLoading(image.name), - ); - } - }; - - // Helper function to handle failed file processing results - const handleFailedFiles = ( - fileResults: { failed: { file: File; error: Error }[] }, - imageResults: { failed: { file: File; error: Error }[] }, - ) => { - fileResults.failed.forEach(({ file, error }) => { - removeFileLoading(file.name); - displayErrorToast( - `Failed to process file ${file.name}: ${error.message}`, - ); - }); - - imageResults.failed.forEach(({ file, error }) => { - removeImageLoading(file.name); - displayErrorToast( - `Failed to process image ${file.name}: ${error.message}`, - ); - }); - }; - - // Helper function to clear loading states on error - const clearLoadingStates = (validFiles: File[], validImages: File[]) => { - validFiles.forEach((file) => removeFileLoading(file.name)); - validImages.forEach((image) => removeImageLoading(image.name)); - }; - - const handleUpload = async (selectedFiles: File[]) => { - // Step 1: Validate and filter files - const result = validateAndFilterFiles(selectedFiles); - if (!result) return; - - const { validFiles, validImages } = result; - - // Step 2: Show loading indicators immediately - showLoadingIndicators(validFiles, validImages); - - // Step 3: Process files using REAL FileReader - try { - const [fileResults, imageResults] = await Promise.all([ - processFiles(validFiles), - processImages(validImages), - ]); - - // Step 4: Handle successful results - handleSuccessfulFiles(fileResults); - handleSuccessfulImages(imageResults); - - // Step 5: Handle failed results - handleFailedFiles(fileResults, imageResults); - } catch { - // Clear loading states and show error - clearLoadingStates(validFiles, validImages); - displayErrorToast("An unexpected error occurred while processing files"); - } - }; + const { handleUpload } = useChatAttachmentUpload(); const handleAfterModel = useBtwInterceptor(conversationId, (message) => { - // When the user opts in via the "upload as file" checkbox, route - // the attached images through the normal file-upload path instead - // of embedding them in the message sent to the LLM. - if (uploadImagesAsFiles) { - onSubmit(message, [], [...files, ...images]); - } else { - onSubmit(message, images, files); - } + const { imagesToEmbed, imagesAsFiles } = partitionImagesForUpload( + images, + imagesMarkedUploadAsFile, + ); + onSubmit(message, imagesToEmbed, [...files, ...imagesAsFiles]); clearAllFiles(); }); const handleSubmit = useModelInterceptor(conversationId, handleAfterModel); @@ -163,8 +55,6 @@ export function InteractiveChatBox({ handleSubmit(suggestion); }; - // Allow users to submit messages during LOADING state - they will be - // queued server-side and delivered when the conversation becomes ready const isDisabled = disabled || curAgentState === AgentState.AWAITING_USER_CONFIRMATION || diff --git a/src/components/features/chat/pasted-image-upload-as-file-button.tsx b/src/components/features/chat/pasted-image-upload-as-file-button.tsx new file mode 100644 index 000000000..1ad77260d --- /dev/null +++ b/src/components/features/chat/pasted-image-upload-as-file-button.tsx @@ -0,0 +1,51 @@ +import { Check, FilePlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; +import { I18nKey } from "#/i18n/declaration"; +import { cn } from "#/utils/utils"; + +interface PastedImageUploadAsFileButtonProps { + active: boolean; + onToggle: () => void; +} + +export function PastedImageUploadAsFileButton({ + active, + onToggle, +}: PastedImageUploadAsFileButtonProps) { + const { t } = useTranslation("openhands"); + const uploadLabel = t(I18nKey.CHAT_INTERFACE$UPLOAD_IMAGES_AS_FILES); + const doNotUploadLabel = t(I18nKey.CHAT_INTERFACE$DO_NOT_UPLOAD_AS_FILE); + const label = active ? doNotUploadLabel : uploadLabel; + + return ( +
+ + + +
+ ); +} diff --git a/src/components/features/chat/pending-user-messages.tsx b/src/components/features/chat/pending-user-messages.tsx index 004f6eb4b..9efeb51b5 100644 --- a/src/components/features/chat/pending-user-messages.tsx +++ b/src/components/features/chat/pending-user-messages.tsx @@ -3,6 +3,8 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message- import { useSendMessage } from "#/hooks/use-send-message"; import { createChatMessage } from "#/services/chat-service"; import { useOptionalConversationId } from "#/hooks/use-conversation-id"; +import { matchesPendingConversationId } from "#/utils/pending-task-message-link"; +import { ImageCarousel } from "#/components/features/images/image-carousel"; import { ChatMessage } from "./chat-message"; /** @@ -33,8 +35,11 @@ export function PendingUserMessages() { const visibleMessages = React.useMemo( () => conversationId - ? pendingMessages.filter( - (message) => message.conversationId === conversationId, + ? pendingMessages.filter((message) => + matchesPendingConversationId( + conversationId, + message.conversationId, + ), ) : [], [pendingMessages, conversationId], @@ -84,7 +89,11 @@ export function PendingUserMessages() { ? () => handleRetry(message.id) : undefined } - /> + > + {message.imageUrls.length > 0 && ( + + )} + ))} ); diff --git a/src/components/features/chat/remove-file-button.tsx b/src/components/features/chat/remove-file-button.tsx index bd5fc14d7..cf5c8d796 100644 --- a/src/components/features/chat/remove-file-button.tsx +++ b/src/components/features/chat/remove-file-button.tsx @@ -13,7 +13,7 @@ export function RemoveFileButton({ onClick }: RemoveFileButtonProps) { type="button" onClick={onClick} className={cn( - "flex w-4 h-4 rounded-full items-center justify-center bg-[var(--oh-surface)] hover:bg-[var(--oh-muted)] cursor-pointer absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200", + "z-10 flex w-4 h-4 rounded-full items-center justify-center bg-[var(--oh-surface)] hover:bg-[var(--oh-muted)] cursor-pointer absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200", isMobile && "opacity-100", )} > diff --git a/src/components/features/chat/upload-as-file-checkbox.tsx b/src/components/features/chat/upload-as-file-checkbox.tsx deleted file mode 100644 index 6f9e9c752..000000000 --- a/src/components/features/chat/upload-as-file-checkbox.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useTranslation } from "react-i18next"; -import { I18nKey } from "#/i18n/declaration"; -import { useConversationStore } from "#/stores/conversation-store"; - -export function UploadAsFileCheckbox() { - const { t } = useTranslation("openhands"); - const { uploadImagesAsFiles, setUploadImagesAsFiles } = - useConversationStore(); - - return ( - - ); -} diff --git a/src/components/features/chat/uploaded-files.tsx b/src/components/features/chat/uploaded-files.tsx index 2540eb0c2..ebfd2ff7b 100644 --- a/src/components/features/chat/uploaded-files.tsx +++ b/src/components/features/chat/uploaded-files.tsx @@ -1,6 +1,5 @@ import { UploadedFile } from "./uploaded-file"; import { UploadedImage } from "./uploaded-image"; -import { UploadAsFileCheckbox } from "./upload-as-file-checkbox"; import { useConversationStore } from "#/stores/conversation-store"; export function UploadedFiles() { @@ -9,12 +8,12 @@ export function UploadedFiles() { files, loadingFiles, loadingImages, + imagesMarkedUploadAsFile, removeFile, removeImage, + toggleImageUploadAsFile, } = useConversationStore(); - const hasImages = images.length > 0 || loadingImages.length > 0; - const handleRemoveFile = (index: number) => { removeFile(index); }; @@ -34,8 +33,8 @@ export function UploadedFiles() { } return ( -
-
+
+
{/* Regular files */} {files.map((file, index) => ( handleRemoveImage(index)} isLoading={loadingImages.includes(image.name)} + showUploadAsFileToggle + uploadAsFileActive={imagesMarkedUploadAsFile.includes(image.name)} + onToggleUploadAsFile={() => toggleImageUploadAsFile(image.name)} /> ))} @@ -80,12 +82,13 @@ export function UploadedFiles() { image={tempImage} onRemove={() => {}} // No remove action during loading isLoading + showUploadAsFileToggle + uploadAsFileActive={imagesMarkedUploadAsFile.includes(imageName)} + onToggleUploadAsFile={() => toggleImageUploadAsFile(imageName)} /> ); })}
- - {hasImages && }
); } diff --git a/src/components/features/chat/uploaded-image.tsx b/src/components/features/chat/uploaded-image.tsx index 0f27a90c2..8601a5342 100644 --- a/src/components/features/chat/uploaded-image.tsx +++ b/src/components/features/chat/uploaded-image.tsx @@ -1,17 +1,24 @@ import React from "react"; import { LoaderCircle } from "lucide-react"; +import { PastedImageUploadAsFileButton } from "./pasted-image-upload-as-file-button"; import { RemoveFileButton } from "./remove-file-button"; interface UploadedImageProps { image: File; onRemove: () => void; isLoading?: boolean; + showUploadAsFileToggle?: boolean; + uploadAsFileActive?: boolean; + onToggleUploadAsFile?: () => void; } export function UploadedImage({ image, onRemove, isLoading = false, + showUploadAsFileToggle = false, + uploadAsFileActive = false, + onToggleUploadAsFile, }: UploadedImageProps) { const [imageUrl, setImageUrl] = React.useState(""); @@ -27,8 +34,7 @@ export function UploadedImage({ }, [image]); return ( -
- +
{isLoading ? ( ) : ( @@ -36,10 +42,17 @@ export function UploadedImage({ {image.name} ) )} + + {showUploadAsFileToggle && onToggleUploadAsFile && ( + + )}
); } diff --git a/src/components/features/chat/utils/chat-input.utils.ts b/src/components/features/chat/utils/chat-input.utils.ts index b08107259..a91dd00db 100644 --- a/src/components/features/chat/utils/chat-input.utils.ts +++ b/src/components/features/chat/utils/chat-input.utils.ts @@ -2,6 +2,85 @@ * Utility functions for chat input component */ /* eslint-disable no-param-reassign */ + +const CLIPBOARD_IMAGE_EXTENSIONS: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/webp": "webp", + "image/bmp": "bmp", +}; + +/** + * Screenshots and copied images are often exposed only via + * `clipboardData.items` (not `clipboardData.files`). Normalize unnamed + * clipboard files so validation and loading UI have stable labels. + */ +export function normalizePastedFile(file: File): File { + if (file.name.trim()) { + return file; + } + + const extension = + CLIPBOARD_IMAGE_EXTENSIONS[file.type] ?? + (file.type.startsWith("image/") ? "png" : "bin"); + + return new File([file], `pasted-image-${Date.now()}.${extension}`, { + type: file.type, + lastModified: file.lastModified, + }); +} + +/** Matches names assigned by {@link normalizePastedFile} for clipboard screenshots. */ +export const PASTED_CLIPBOARD_IMAGE_NAME = /^pasted-image-\d+\.[a-z0-9]+$/i; + +export function isPastedClipboardImage(file: File): boolean { + return PASTED_CLIPBOARD_IMAGE_NAME.test(file.name); +} + +export function partitionImagesForUpload( + images: File[], + markedUploadAsFileNames: readonly string[], +): { imagesToEmbed: File[]; imagesAsFiles: File[] } { + const marked = new Set(markedUploadAsFileNames); + const imagesToEmbed: File[] = []; + const imagesAsFiles: File[] = []; + + for (const image of images) { + if (marked.has(image.name)) { + imagesAsFiles.push(image); + } else { + imagesToEmbed.push(image); + } + } + + return { imagesToEmbed, imagesAsFiles }; +} + +/** + * Collect files from a paste event, including clipboard image items. + */ +export function getClipboardFiles(clipboardData: DataTransfer): File[] { + const fromFileList = Array.from(clipboardData.files); + if (fromFileList.length > 0) { + return fromFileList.map(normalizePastedFile); + } + + const fromItems: File[] = []; + for (let i = 0; i < clipboardData.items.length; i += 1) { + const item = clipboardData.items[i]; + if (item.kind !== "file") { + continue; + } + const file = item.getAsFile(); + if (file) { + fromItems.push(normalizePastedFile(file)); + } + } + + return fromItems; +} /** * Check if contentEditable element is truly empty */ diff --git a/src/components/features/controls/tools-context-menu.tsx b/src/components/features/controls/tools-context-menu.tsx index 0df9fcef1..c7cc218ec 100644 --- a/src/components/features/controls/tools-context-menu.tsx +++ b/src/components/features/controls/tools-context-menu.tsx @@ -26,6 +26,13 @@ interface ToolsContextMenuProps { onShowAgentTools: (event: React.MouseEvent) => void; shouldShowAgentTools?: boolean; shouldShowHooks?: boolean; + /** When set, renders a divider and this action as the last menu item. */ + footerAction?: { + testId: string; + icon: React.ReactNode; + label: string; + onClick: () => void; + }; } export function ToolsContextMenu({ @@ -35,6 +42,7 @@ export function ToolsContextMenu({ onShowAgentTools, shouldShowAgentTools = true, shouldShowHooks = false, + footerAction, }: ToolsContextMenuProps) { const { t } = useTranslation("openhands"); const { data: conversation } = useActiveConversation(); @@ -157,6 +165,26 @@ export function ToolsContextMenu({ /> )} + + {footerAction && ( + <> + + { + event.preventDefault(); + event.stopPropagation(); + footerAction.onClick(); + handleClose(); + }} + > + + + + )} ); } diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 29696e129..c70ce37ab 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -6,6 +6,11 @@ import { useActiveBackend } from "#/contexts/active-backend-context"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { useLocalWorkspaces } from "#/hooks/query/use-local-workspaces"; import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; +import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; +import { useConversationStore } from "#/stores/conversation-store"; +import { setPendingTaskAttachments } from "#/stores/pending-task-attachments-store"; +import { enqueueHomeTaskPendingMessage } from "#/utils/enqueue-home-task-pending-message"; +import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; import { useNavigation } from "#/context/navigation-context"; import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; import { Branch, GitRepository } from "#/types/git"; @@ -40,6 +45,9 @@ export function HomeChatLauncher() { const { mutate: createConversation, isPending } = useCreateConversation(); const isCreatingElsewhere = useIsCreatingConversation(); const isCreating = isPending || isCreatingElsewhere; + const { images, files, imagesMarkedUploadAsFile, clearAllFiles } = + useConversationStore(); + const { handleUpload } = useChatAttachmentUpload(); const { error: workspacesError } = useLocalWorkspaces({ enabled: isLocal }); const workspacesUnsupportedMessage = isLocal ? getWorkspacesUnsupportedMessage(workspacesError, t) @@ -51,13 +59,22 @@ export function HomeChatLauncher() { const handleSubmit = (message: string) => { const trimmed = message.trim(); - if (!trimmed || isCreating) return; + const hasAttachments = images.length > 0 || files.length > 0; + if ((!trimmed && !hasAttachments) || isCreating) return; + + const attachmentSnapshot = { + images: [...images], + files: [...files], + }; // Workspace/repo are optional — match the "Start from scratch" flow which // creates a conversation with no working dir and no repo. Build the // payload from whatever is selected. + // When attachments are present the first user message is sent afterward + // via sendMessageWithAttachments / flushPendingTaskAttachments. Passing + // query here would create a duplicate text-only initial_message. let variables: Parameters[0] = { - query: trimmed, + query: hasAttachments ? undefined : trimmed || undefined, }; if (isLocal && pendingWorkspace) { variables = { ...variables, workingDir: pendingWorkspace.path }; @@ -80,9 +97,71 @@ export function HomeChatLauncher() { ); createConversation(variables, { - onSuccess: (data) => { + onSuccess: async (data) => { toast.dismiss(toastId); - navigate(`/conversations/${data.conversation_id}`); + const targetConversationId = data.conversation_id; + const isTaskConversation = targetConversationId.startsWith("task-"); + + if (hasAttachments) { + // Cloud sandboxes provision asynchronously; uploads and the first + // message must target the runtime URL, not the bundled local server. + const shouldDeferAttachments = !isLocal || isTaskConversation; + + if (shouldDeferAttachments) { + const taskId = + data.task_id ?? + (isTaskConversation + ? targetConversationId.slice("task-".length) + : null); + + if (!taskId) { + displayErrorToast(null); + return; + } + + setPendingTaskAttachments(taskId, { + content: trimmed, + images: attachmentSnapshot.images, + files: attachmentSnapshot.files, + imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile], + }); + clearAllFiles(); + await enqueueHomeTaskPendingMessage({ + conversationId: targetConversationId, + text: trimmed, + images: attachmentSnapshot.images, + imagesMarkedUploadAsFile, + }); + navigate(`/conversations/${targetConversationId}`); + return; + } else { + try { + await sendMessageWithAttachments({ + conversationId: targetConversationId, + content: trimmed, + images: attachmentSnapshot.images, + files: attachmentSnapshot.files, + imagesMarkedUploadAsFile, + t, + }); + clearAllFiles(); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + return; + } + } + } + + if (isTaskConversation && trimmed) { + await enqueueHomeTaskPendingMessage({ + conversationId: targetConversationId, + text: trimmed, + images: [], + imagesMarkedUploadAsFile: [], + }); + } + + navigate(`/conversations/${targetConversationId}`); }, onError: (error) => { toast.dismiss(toastId); @@ -109,6 +188,7 @@ export function HomeChatLauncher() {
diff --git a/src/components/shared/buttons/styled-tooltip.tsx b/src/components/shared/buttons/styled-tooltip.tsx index 7a1a1e46e..a99f451fc 100644 --- a/src/components/shared/buttons/styled-tooltip.tsx +++ b/src/components/shared/buttons/styled-tooltip.tsx @@ -10,6 +10,7 @@ export interface StyledTooltipProps { showArrow?: boolean; closeDelay?: number; offset?: number; + shouldFlip?: boolean; } function getTooltipTriggerChild(children: ReactNode) { @@ -26,6 +27,7 @@ export function StyledTooltip({ placement = "right", showArrow = false, closeDelay = 100, + shouldFlip, offset = 7, }: StyledTooltipProps) { const disableAnimation = import.meta.env.MODE === "test"; @@ -36,6 +38,8 @@ export function StyledTooltip({ closeDelay={closeDelay} placement={placement} offset={offset} + shouldFlip={shouldFlip} + className={cn("bg-white text-black", tooltipClassName)} showArrow={showArrow} disableAnimation={disableAnimation} classNames={{ diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx index 7ca9c9c89..8e7f25be2 100644 --- a/src/contexts/conversation-websocket-context.tsx +++ b/src/contexts/conversation-websocket-context.tsx @@ -2,6 +2,7 @@ import React, { createContext, useContext, useEffect, + useLayoutEffect, useState, useCallback, useMemo, @@ -215,7 +216,7 @@ export function ConversationWebSocketProvider({ const isLoadingHistoryMain = !!conversationId && isPreloadingHistory; - useEffect(() => { + useLayoutEffect(() => { if (!preloadedHistory || preloadedHistory.events.length === 0) { return; } diff --git a/src/hooks/chat/use-chat-attachment-upload.ts b/src/hooks/chat/use-chat-attachment-upload.ts new file mode 100644 index 000000000..760b3a585 --- /dev/null +++ b/src/hooks/chat/use-chat-attachment-upload.ts @@ -0,0 +1,102 @@ +import { useCallback } from "react"; + +export type ChatAttachmentUploadOptions = { + fromPaste?: boolean; +}; +import { isFileImage } from "#/utils/is-file-image"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { validateFiles } from "#/utils/file-validation"; +import { processFiles, processImages } from "#/utils/file-processing"; +import { useConversationStore } from "#/stores/conversation-store"; + +/** + * Shared attachment pipeline for home and conversation chat inputs. + */ +export function useChatAttachmentUpload() { + const { + images, + files, + addImages, + addFiles, + addFileLoading, + removeFileLoading, + addImageLoading, + removeImageLoading, + markImagesAsPasted, + } = useConversationStore(); + + const handleUpload = useCallback( + async (selectedFiles: File[], _options?: ChatAttachmentUploadOptions) => { + const validation = validateFiles(selectedFiles, [...images, ...files]); + + if (!validation.isValid) { + displayErrorToast(`Error: ${validation.errorMessage}`); + return; + } + + const validFiles = selectedFiles.filter((f) => !isFileImage(f)); + const validImages = selectedFiles.filter((f) => isFileImage(f)); + + if (validImages.length > 0) { + markImagesAsPasted(validImages.map((image) => image.name)); + } + + validFiles.forEach((file) => addFileLoading(file.name)); + validImages.forEach((image) => addImageLoading(image.name)); + + try { + const [fileResults, imageResults] = await Promise.all([ + processFiles(validFiles), + processImages(validImages), + ]); + + if (fileResults.successful.length > 0) { + addFiles(fileResults.successful); + fileResults.successful.forEach((file) => + removeFileLoading(file.name), + ); + } + + if (imageResults.successful.length > 0) { + addImages(imageResults.successful); + imageResults.successful.forEach((image) => + removeImageLoading(image.name), + ); + } + + fileResults.failed.forEach(({ file, error }) => { + removeFileLoading(file.name); + displayErrorToast( + `Failed to process file ${file.name}: ${error.message}`, + ); + }); + + imageResults.failed.forEach(({ file, error }) => { + removeImageLoading(file.name); + displayErrorToast( + `Failed to process image ${file.name}: ${error.message}`, + ); + }); + } catch { + validFiles.forEach((file) => removeFileLoading(file.name)); + validImages.forEach((image) => removeImageLoading(image.name)); + displayErrorToast( + "An unexpected error occurred while processing files", + ); + } + }, + [ + images, + files, + addImages, + addFiles, + addFileLoading, + removeFileLoading, + addImageLoading, + removeImageLoading, + markImagesAsPasted, + ], + ); + + return { handleUpload }; +} diff --git a/src/hooks/chat/use-chat-input-events.ts b/src/hooks/chat/use-chat-input-events.ts index e6ae1bcc6..c57899faa 100644 --- a/src/hooks/chat/use-chat-input-events.ts +++ b/src/hooks/chat/use-chat-input-events.ts @@ -3,6 +3,7 @@ import { isMobileDevice } from "#/utils/utils"; import { ensureCursorVisible, clearEmptyContent, + getClipboardFiles, } from "#/components/features/chat/utils/chat-input.utils"; /** @@ -35,8 +36,7 @@ export const useChatInputEvents = ( (e: React.ClipboardEvent) => { e.preventDefault(); - // Check if there are files in the clipboard - const files = Array.from(e.clipboardData.files); + const files = getClipboardFiles(e.clipboardData); const hasFiles = files.length > 0; if (hasFiles) { diff --git a/src/hooks/chat/use-chat-submission.ts b/src/hooks/chat/use-chat-submission.ts index 9455bb10a..287e41055 100644 --- a/src/hooks/chat/use-chat-submission.ts +++ b/src/hooks/chat/use-chat-submission.ts @@ -3,6 +3,7 @@ import { clearTextContent, clearFileInput, } from "#/components/features/chat/utils/chat-input.utils"; +import { useConversationStore } from "#/stores/conversation-store"; /** * Hook for handling chat message submission @@ -18,8 +19,10 @@ export const useChatSubmission = ( const handleSubmit = useCallback(() => { const message = chatInputRef.current?.innerText || ""; const trimmedMessage = message.trim(); + const { images, files } = useConversationStore.getState(); + const hasAttachments = images.length > 0 || files.length > 0; - if (!trimmedMessage) { + if (!trimmedMessage && !hasAttachments) { return; } diff --git a/src/hooks/chat/use-file-handling.ts b/src/hooks/chat/use-file-handling.ts index 0e0c13481..86b6c7840 100644 --- a/src/hooks/chat/use-file-handling.ts +++ b/src/hooks/chat/use-file-handling.ts @@ -1,4 +1,5 @@ import React, { useRef, useCallback, useState, useEffect } from "react"; +import type { ChatAttachmentUploadOptions } from "#/hooks/chat/use-chat-attachment-upload"; interface UseFileHandlingReturn { fileInputRef: React.RefObject; @@ -15,7 +16,7 @@ interface UseFileHandlingReturn { * Hook for handling file operations (upload, drag & drop) */ export const useFileHandling = ( - onFilesPaste?: (files: File[]) => void, + onFilesPaste?: (files: File[], options?: ChatAttachmentUploadOptions) => void, ): UseFileHandlingReturn => { const fileInputRef = useRef(null); const chatContainerRef = useRef(null); @@ -23,9 +24,9 @@ export const useFileHandling = ( // Function to add files and notify parent const addFiles = useCallback( - (files: File[]) => { + (files: File[], options?: ChatAttachmentUploadOptions) => { if (onFilesPaste && files.length > 0) { - onFilesPaste(files); + onFilesPaste(files, options); } }, [onFilesPaste], @@ -36,7 +37,7 @@ export const useFileHandling = ( const handlePasteFiles = (event: CustomEvent) => { const files = event.detail.files as File[]; if (files && files.length > 0) { - addFiles(files); + addFiles(files, { fromPaste: true }); } }; diff --git a/src/hooks/mutation/use-conversation-upload-files.ts b/src/hooks/mutation/use-conversation-upload-files.ts index 7145797bf..4e6cf669d 100644 --- a/src/hooks/mutation/use-conversation-upload-files.ts +++ b/src/hooks/mutation/use-conversation-upload-files.ts @@ -1,11 +1,16 @@ import { useMutation } from "@tanstack/react-query"; import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; import { getAgentServerClientOptions } from "#/api/agent-server-client-options"; +import { + buildWorkspaceUploadPath, + getSafeUploadFileName, +} from "#/api/workspace-upload-path"; import { FileUploadSuccessResponse } from "#/api/open-hands.types"; interface UploadFilesVariables { conversationUrl: string | null | undefined; sessionApiKey: string | null | undefined; + workingDir: string; files: File[]; } @@ -21,22 +26,25 @@ export const useConversationUploadFiles = () => mutationFn: async ( variables: UploadFilesVariables, ): Promise => { - const { conversationUrl, sessionApiKey, files } = variables; + const { conversationUrl, sessionApiKey, workingDir, files } = variables; - // Upload all files in parallel const uploadPromises = files.map(async (file) => { try { - // Upload to /workspace/{filename} - const filePath = `/workspace/${file.name}`; + const safeName = getSafeUploadFileName(file.name); + const filePath = buildWorkspaceUploadPath(file.name, workingDir); await new RemoteWorkspace( - getAgentServerClientOptions({ conversationUrl, sessionApiKey }), + getAgentServerClientOptions({ + conversationUrl, + sessionApiKey, + workingDir, + }), ).fileUpload(file, filePath); - return { success: true as const, fileName: file.name, filePath }; + return { success: true as const, fileName: safeName, filePath }; } catch (error) { return { success: false as const, fileName: file.name, - filePath: `/workspace/${file.name}`, + filePath: buildWorkspaceUploadPath(file.name, workingDir), error: error instanceof Error ? error.message : "Unknown error", }; } diff --git a/src/hooks/mutation/use-unified-upload-files.ts b/src/hooks/mutation/use-unified-upload-files.ts index bb4e0cf61..6ceed4438 100644 --- a/src/hooks/mutation/use-unified-upload-files.ts +++ b/src/hooks/mutation/use-unified-upload-files.ts @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/react-query"; +import { uploadFilesToConversation } from "#/api/conversation-file-upload.api"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; -import { useConversationUploadFiles } from "./use-conversation-upload-files"; import { FileUploadSuccessResponse } from "#/api/open-hands.types"; interface UnifiedUploadFilesVariables { @@ -9,26 +9,18 @@ interface UnifiedUploadFilesVariables { } /** - * Uploads files for the active agent-server conversation. + * Uploads files for the active conversation (local agent-server or cloud runtime). */ export const useUnifiedUploadFiles = () => { const { data: conversation } = useActiveConversation(); - const conversationUpload = useConversationUploadFiles(); - return useMutation({ mutationKey: ["unified-upload-files"], mutationFn: async ( variables: UnifiedUploadFilesVariables, ): Promise => { - const { files } = variables; - - // Use conversation URL and session API key - return conversationUpload.mutateAsync({ - conversationUrl: conversation?.conversation_url, - sessionApiKey: conversation?.session_api_key, - files, - }); + const { conversationId, files } = variables; + return uploadFilesToConversation(conversationId, files, conversation); }, meta: { disableToast: true, diff --git a/src/hooks/query/use-task-polling.ts b/src/hooks/query/use-task-polling.ts index fa2a7e446..15c509996 100644 --- a/src/hooks/query/use-task-polling.ts +++ b/src/hooks/query/use-task-polling.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; import { useNavigation } from "#/context/navigation-context"; @@ -7,6 +7,14 @@ import { consumePendingTaskDraft, setConversationState, } from "#/utils/conversation-local-storage"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments"; +import { + clearPendingTaskMessageLink, + consumeScheduledPendingTaskMessageReassign, + linkPendingTaskMessages, + schedulePendingTaskMessageReassign, +} from "#/utils/pending-task-message-link"; /** * Hook that polls V1 conversation start tasks and navigates when ready. @@ -56,22 +64,70 @@ export const useTaskPolling = () => { retry: false, }); + const handledReadyTaskIdRef = useRef(null); + + // Reassign optimistic pending messages before paint on the real conversation + // route. Doing this in the ready handler before navigate leaves a frame where + // the URL still points at `task-{uuid}` but pending is keyed to the real id. + useLayoutEffect(() => { + if (!conversationId) { + return; + } + + const pendingReassign = + consumeScheduledPendingTaskMessageReassign(conversationId); + if (!pendingReassign) { + return; + } + + useOptimisticUserMessageStore + .getState() + .reassignPendingMessages( + pendingReassign.fromConversationId, + pendingReassign.toConversationId, + ); + clearPendingTaskMessageLink(pendingReassign.toConversationId); + }, [conversationId]); + // Navigate to conversation ID when task is ready useEffect(() => { const task = taskQuery.data; - if (task?.status === "READY" && task.app_conversation_id) { + if ( + !taskId || + task?.status !== "READY" || + !task.app_conversation_id || + handledReadyTaskIdRef.current === taskId + ) { + return; + } + + handledReadyTaskIdRef.current = taskId; + + void (async () => { + await flushPendingTaskAttachments(taskId, task.app_conversation_id!); + + const taskConversationId = `task-${taskId}`; + linkPendingTaskMessages(task.app_conversation_id!, taskConversationId); + schedulePendingTaskMessageReassign( + taskConversationId, + task.app_conversation_id!, + ); + const pendingDraft = consumePendingTaskDraft(taskId); if (pendingDraft) { - setConversationState(task.app_conversation_id, { + setConversationState(task.app_conversation_id!, { draftMessage: pendingDraft, }); } - // Replace the URL with the actual conversation ID navigate(`/conversations/${task.app_conversation_id}`, { replace: true }); - } + })(); }, [taskQuery.data, navigate, taskId]); + useEffect(() => { + handledReadyTaskIdRef.current = null; + }, [taskId]); + return { isTask, taskId, diff --git a/src/hooks/use-load-older-events.ts b/src/hooks/use-load-older-events.ts index 949637346..a67b06471 100644 --- a/src/hooks/use-load-older-events.ts +++ b/src/hooks/use-load-older-events.ts @@ -2,7 +2,11 @@ import React from "react"; import EventService from "#/api/event-service/event-service.api"; import { useUserConversation } from "#/hooks/query/use-user-conversation"; import { useEventStore } from "#/stores/use-event-store"; -import { INITIAL_HISTORY_PAGE_SIZE } from "#/hooks/query/use-conversation-history"; +import { + INITIAL_HISTORY_PAGE_SIZE, + useConversationHistory, +} from "#/hooks/query/use-conversation-history"; +import { isTaskConversationId } from "#/utils/conversation-local-storage"; import type { OpenHandsEvent } from "#/types/agent-server/core"; const getEventTimestamp = (event: OpenHandsEvent): string | undefined => @@ -36,7 +40,13 @@ interface UseLoadOlderEventsResult { export const useLoadOlderEvents = ( conversationId?: string | null, ): UseLoadOlderEventsResult => { + const isTaskConversation = + !!conversationId && isTaskConversationId(conversationId); + const realConversationId = isTaskConversation ? undefined : conversationId; + const { data: conversation } = useUserConversation(conversationId ?? null); + const { data: initialHistory, isFetched: isInitialHistoryFetched } = + useConversationHistory(realConversationId ?? undefined); const addEvents = useEventStore((state) => state.addEvents); const [isLoading, setIsLoading] = React.useState(false); @@ -45,14 +55,50 @@ export const useLoadOlderEvents = ( const hasMoreRef = React.useRef(true); React.useEffect(() => { - hasMoreRef.current = true; isLoadingRef.current = false; - setHasMore(true); setIsLoading(false); - }, [conversationId]); + + if (isTaskConversation) { + hasMoreRef.current = false; + setHasMore(false); + return; + } + + hasMoreRef.current = true; + setHasMore(true); + }, [conversationId, isTaskConversation]); + + // Mirror the initial REST page: if the tail fetch already returned + // everything, don't auto-trigger an older-events request on short chats. + React.useEffect(() => { + if (isTaskConversation || !isInitialHistoryFetched || !initialHistory) { + return; + } + if (!initialHistory.hasMore) { + hasMoreRef.current = false; + setHasMore(false); + } + }, [ + isTaskConversation, + isInitialHistoryFetched, + initialHistory?.hasMore, + realConversationId, + ]); const loadOlder = React.useCallback(async () => { - if (!conversationId || isLoadingRef.current || !hasMoreRef.current) { + if ( + !conversationId || + isTaskConversationId(conversationId) || + isLoadingRef.current || + !hasMoreRef.current + ) { + return; + } + + // Cloud/local metadata (runtime URL, session key) isn't available on + // start-task placeholder routes and may still be loading right after + // redirect from `/conversations/task-{uuid}`. + if (!conversation) { return; } @@ -65,11 +111,11 @@ export const useLoadOlderEvents = ( const oldestTimestamp = getEventTimestamp(oldest); if (!oldestTimestamp) { + // Nothing paginate-able — treat as exhausted rather than surfacing an + // error banner on brand-new conversations. hasMoreRef.current = false; setHasMore(false); - throw new Error( - "Unable to load older events because the oldest loaded event has no timestamp.", - ); + return; } isLoadingRef.current = true; @@ -111,6 +157,7 @@ export const useLoadOlderEvents = ( } }, [ conversationId, + conversation, conversation?.conversation_url, conversation?.session_api_key, addEvents, diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 3cd3122bc..fd41b3232 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -10234,7 +10234,7 @@ "ca": "Envia el missatge" }, "CHAT_INTERFACE$UPLOAD_IMAGES_AS_FILES": { - "en": "upload as file", + "en": "Upload as file", "ja": "ファイルとしてアップロード", "zh-CN": "作为文件上传", "zh-TW": "作為檔案上傳", @@ -10250,6 +10250,23 @@ "tr": "dosya olarak yükle", "uk": "завантажити як файл" }, + "CHAT_INTERFACE$DO_NOT_UPLOAD_AS_FILE": { + "en": "Do not upload as file", + "ja": "ファイルとしてアップロードしない", + "zh-CN": "不作为文件上传", + "zh-TW": "不作為檔案上傳", + "ko-KR": "파일로 업로드하지 않음", + "no": "ikke last opp som fil", + "ar": "عدم التحميل كملف", + "de": "Nicht als Datei hochladen", + "fr": "ne pas envoyer en tant que fichier", + "it": "non caricare come file", + "pt": "não enviar como arquivo", + "es": "no subir como archivo", + "ca": "no pujar com a fitxer", + "tr": "dosya olarak yükleme", + "uk": "не завантажувати як файл" + }, "CHAT_INTERFACE$TOOLTIP_UPLOAD_IMAGE": { "en": "Upload image", "zh-CN": "上传图片", @@ -10284,6 +10301,40 @@ "uk": "Додати файл", "ca": "Afegeix un fitxer" }, + "CHAT_INTERFACE$ADD_FILES_AND_IMAGES": { + "en": "Add Files and Images", + "ja": "ファイルと画像を追加", + "zh-CN": "添加文件和图片", + "zh-TW": "新增檔案與圖片", + "ko-KR": "파일 및 이미지 추가", + "no": "Legg til filer og bilder", + "it": "Aggiungi file e immagini", + "pt": "Adicionar arquivos e imagens", + "es": "Añadir archivos e imágenes", + "ar": "إضافة ملفات وصور", + "fr": "Ajouter des fichiers et des images", + "tr": "Dosya ve görsel ekle", + "de": "Dateien und Bilder hinzufügen", + "uk": "Додати файли та зображення", + "ca": "Afegeix fitxers i imatges" + }, + "CHAT_INTERFACE$PLUS_MENU": { + "en": "More actions", + "ja": "その他の操作", + "zh-CN": "更多操作", + "zh-TW": "更多操作", + "ko-KR": "추가 작업", + "no": "Flere handlinger", + "it": "Altre azioni", + "pt": "Mais ações", + "es": "Más acciones", + "ar": "المزيد من الإجراءات", + "fr": "Plus d'actions", + "tr": "Diğer işlemler", + "de": "Weitere Aktionen", + "uk": "Більше дій", + "ca": "Més accions" + }, "CHAT_INTERFACE$INITIAL_MESSAGE": { "en": "Hi! I'm OpenHands, an AI Software Engineer. What would you like to build with me today?", "zh-CN": "你好!我是 OpenHands,一名 AI 软件工程师。今天想和我一起编写什么程序呢?", diff --git a/src/stores/conversation-store.ts b/src/stores/conversation-store.ts index c7f379d8b..8e3b75ee6 100644 --- a/src/stores/conversation-store.ts +++ b/src/stores/conversation-store.ts @@ -25,7 +25,10 @@ interface ConversationState { selectedTab: ConversationTab | null; images: File[]; files: File[]; - uploadImagesAsFiles: boolean; // If true, attached images are sent through the file-upload path instead of being embedded in the LLM message + /** Image file names (e.g. pasted screenshots) to send via file upload instead of vision embed. */ + imagesMarkedUploadAsFile: string[]; + /** Image file names attached in chat (controls per-image upload-as-file UI). */ + pastedImageNames: string[]; loadingFiles: string[]; // File names currently being processed loadingImages: string[]; // Image names currently being processed messageToSend: IMessageToSend | null; @@ -45,7 +48,8 @@ interface ConversationActions { setShouldHideSuggestions: (shouldHideSuggestions: boolean) => void; addImages: (images: File[]) => void; addFiles: (files: File[]) => void; - setUploadImagesAsFiles: (uploadImagesAsFiles: boolean) => void; + toggleImageUploadAsFile: (fileName: string) => void; + markImagesAsPasted: (fileNames: string[]) => void; removeImage: (index: number) => void; removeFile: (index: number) => void; clearImages: () => void; @@ -108,7 +112,8 @@ export const useConversationStore = create()( selectedTab: "files" as ConversationTab, images: [], files: [], - uploadImagesAsFiles: false, + imagesMarkedUploadAsFile: [], + pastedImageNames: [], loadingFiles: [], loadingImages: [], messageToSend: null, @@ -147,15 +152,48 @@ export const useConversationStore = create()( "addFiles", ), - setUploadImagesAsFiles: (uploadImagesAsFiles) => - set({ uploadImagesAsFiles }, false, "setUploadImagesAsFiles"), + toggleImageUploadAsFile: (fileName) => + set( + (state) => { + const marked = new Set(state.imagesMarkedUploadAsFile); + if (marked.has(fileName)) { + marked.delete(fileName); + } else { + marked.add(fileName); + } + return { imagesMarkedUploadAsFile: [...marked] }; + }, + false, + "toggleImageUploadAsFile", + ), + + markImagesAsPasted: (fileNames) => + set( + (state) => { + const merged = new Set([...state.pastedImageNames, ...fileNames]); + return { pastedImageNames: [...merged] }; + }, + false, + "markImagesAsPasted", + ), removeImage: (index) => set( (state) => { + const removed = state.images[index]; const newImages = [...state.images]; newImages.splice(index, 1); - return { images: newImages }; + return { + images: newImages, + imagesMarkedUploadAsFile: removed + ? state.imagesMarkedUploadAsFile.filter( + (name) => name !== removed.name, + ) + : state.imagesMarkedUploadAsFile, + pastedImageNames: removed + ? state.pastedImageNames.filter((name) => name !== removed.name) + : state.pastedImageNames, + }; }, false, "removeImage", @@ -181,7 +219,8 @@ export const useConversationStore = create()( { images: [], files: [], - uploadImagesAsFiles: false, + imagesMarkedUploadAsFile: [], + pastedImageNames: [], loadingFiles: [], loadingImages: [], }, diff --git a/src/stores/optimistic-user-message-store.ts b/src/stores/optimistic-user-message-store.ts index a8412a083..69490d617 100644 --- a/src/stores/optimistic-user-message-store.ts +++ b/src/stores/optimistic-user-message-store.ts @@ -84,6 +84,14 @@ interface OptimisticUserMessageActions { ) => PendingUserMessage | null; /** Wipe all queued messages (e.g., when changing conversations). */ clearPendingMessages: () => void; + /** + * Move pending entries from a provisional task URL (`task-{uuid}`) to the + * real conversation id once cloud provisioning finishes. + */ + reassignPendingMessages: ( + fromConversationId: string, + toConversationId: string, + ) => void; } type OptimisticUserMessageStore = OptimisticUserMessageState & @@ -190,5 +198,14 @@ export const useOptimisticUserMessageStore = create( }, clearPendingMessages: () => set(() => ({ ...initialState })), + + reassignPendingMessages: (fromConversationId, toConversationId) => + set((state) => ({ + pendingMessages: state.pendingMessages.map((message) => + message.conversationId === fromConversationId + ? { ...message, conversationId: toConversationId } + : message, + ), + })), }), ); diff --git a/src/stores/pending-task-attachments-store.ts b/src/stores/pending-task-attachments-store.ts new file mode 100644 index 000000000..3fa0b618e --- /dev/null +++ b/src/stores/pending-task-attachments-store.ts @@ -0,0 +1,60 @@ +import { create } from "zustand"; + +export interface PendingTaskAttachments { + content: string; + images: File[]; + files: File[]; + imagesMarkedUploadAsFile: string[]; +} + +interface PendingTaskAttachmentsState { + byTaskId: Record; + setPendingTaskAttachments: ( + taskId: string, + payload: PendingTaskAttachments, + ) => void; + consumePendingTaskAttachments: ( + taskId: string, + ) => PendingTaskAttachments | null; +} + +export const usePendingTaskAttachmentsStore = + create()((set, get) => ({ + byTaskId: {}, + + setPendingTaskAttachments: (taskId, payload) => + set((state) => ({ + byTaskId: { ...state.byTaskId, [taskId]: payload }, + })), + + consumePendingTaskAttachments: (taskId) => { + const payload = get().byTaskId[taskId]; + if (!payload) { + return null; + } + + set((state) => { + const { [taskId]: _removed, ...rest } = state.byTaskId; + return { byTaskId: rest }; + }); + + return payload; + }, + })); + +export function setPendingTaskAttachments( + taskId: string, + payload: PendingTaskAttachments, +): void { + usePendingTaskAttachmentsStore + .getState() + .setPendingTaskAttachments(taskId, payload); +} + +export function consumePendingTaskAttachments( + taskId: string, +): PendingTaskAttachments | null { + return usePendingTaskAttachmentsStore + .getState() + .consumePendingTaskAttachments(taskId); +} diff --git a/src/utils/enqueue-home-task-pending-message.ts b/src/utils/enqueue-home-task-pending-message.ts new file mode 100644 index 000000000..e576d91ea --- /dev/null +++ b/src/utils/enqueue-home-task-pending-message.ts @@ -0,0 +1,30 @@ +import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; + +/** + * Shows the user's first message immediately on cloud start-task routes + * (`/conversations/task-{uuid}`) while the sandbox provisions. + */ +export async function enqueueHomeTaskPendingMessage(options: { + conversationId: string; + text: string; + images: File[]; + imagesMarkedUploadAsFile: string[]; +}): Promise { + const { imagesToEmbed } = partitionImagesForUpload( + options.images, + options.imagesMarkedUploadAsFile, + ); + const imageUrls = await Promise.all( + imagesToEmbed.map((image) => convertImageToBase64(image)), + ); + + useOptimisticUserMessageStore.getState().enqueuePendingMessage({ + conversationId: options.conversationId, + text: options.text, + content: options.text, + imageUrls, + fileUrls: [], + }); +} diff --git a/src/utils/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts new file mode 100644 index 000000000..f00357cd2 --- /dev/null +++ b/src/utils/flush-pending-task-attachments.ts @@ -0,0 +1,33 @@ +import i18n, { OPENHANDS_I18N_NAMESPACE, waitForI18n } from "#/i18n"; +import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; + +/** + * Sends attachments queued during cloud start-task provisioning once the + * real conversation UUID is available. + */ +export async function flushPendingTaskAttachments( + taskId: string, + conversationId: string, +): Promise { + const pending = consumePendingTaskAttachments(taskId); + if (!pending) { + return; + } + + try { + await waitForI18n(); + await sendMessageWithAttachments({ + conversationId, + content: pending.content, + images: pending.images, + files: pending.files, + imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile, + t: i18n.getFixedT(null, OPENHANDS_I18N_NAMESPACE), + }); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + throw error; + } +} diff --git a/src/utils/pending-task-message-link.ts b/src/utils/pending-task-message-link.ts new file mode 100644 index 000000000..62f4c7da0 --- /dev/null +++ b/src/utils/pending-task-message-link.ts @@ -0,0 +1,61 @@ +/** + * While a cloud start task redirects from `/conversations/task-{uuid}` to the + * real conversation id, optimistic pending messages stay keyed to the task id + * until a layout effect can reassign them. This map lets the chat UI keep + * matching those bubbles against the real conversation route. + */ +const taskSourceByRealConversationId = new Map(); + +let scheduledPendingReassign: { + fromConversationId: string; + toConversationId: string; +} | null = null; + +export function linkPendingTaskMessages( + realConversationId: string, + taskConversationId: string, +): void { + taskSourceByRealConversationId.set(realConversationId, taskConversationId); +} + +export function clearPendingTaskMessageLink(realConversationId: string): void { + taskSourceByRealConversationId.delete(realConversationId); +} + +export function schedulePendingTaskMessageReassign( + fromConversationId: string, + toConversationId: string, +): void { + scheduledPendingReassign = { fromConversationId, toConversationId }; +} + +export function consumeScheduledPendingTaskMessageReassign( + conversationId: string, +): { fromConversationId: string; toConversationId: string } | null { + if (scheduledPendingReassign?.toConversationId !== conversationId) { + return null; + } + + const value = scheduledPendingReassign; + scheduledPendingReassign = null; + return value; +} + +export function matchesPendingConversationId( + activeConversationId: string, + pendingConversationId: string, +): boolean { + if (pendingConversationId === activeConversationId) { + return true; + } + + const linkedTaskConversationId = + taskSourceByRealConversationId.get(activeConversationId); + return linkedTaskConversationId === pendingConversationId; +} + +/** Test helper */ +export function resetPendingTaskMessageLinkState(): void { + taskSourceByRealConversationId.clear(); + scheduledPendingReassign = null; +} diff --git a/src/utils/send-message-with-attachments.ts b/src/utils/send-message-with-attachments.ts new file mode 100644 index 000000000..b57dd97bf --- /dev/null +++ b/src/utils/send-message-with-attachments.ts @@ -0,0 +1,93 @@ +import type { TFunction } from "i18next"; +import { + resolveConversationRuntime, + uploadFilesToConversation, +} from "#/api/conversation-file-upload.api"; +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import type { SendMessageRequest } from "#/api/conversation-service/agent-server-conversation-service.types"; +import { convertImageToBase64 } from "#/utils/convert-image-to-base-64"; +import { displayErrorToast } from "#/utils/custom-toast-handlers"; +import { partitionImagesForUpload } from "#/components/features/chat/utils/chat-input.utils"; +import { validateFiles } from "#/utils/file-validation"; + +export interface SendMessageWithAttachmentsResult { + text: string; + content: string; + imageUrls: string[]; + fileUrls: string[]; + timestamp: string; +} + +export async function sendMessageWithAttachments(options: { + conversationId: string; + content: string; + images: File[]; + files: File[]; + imagesMarkedUploadAsFile: string[]; + t: TFunction; +}): Promise { + const { + conversationId, + content, + images, + files, + imagesMarkedUploadAsFile, + t, + } = options; + + const { imagesToEmbed, imagesAsFiles } = partitionImagesForUpload( + images, + imagesMarkedUploadAsFile, + ); + const filesToUpload = [...files, ...imagesAsFiles]; + + const validation = validateFiles([...imagesToEmbed, ...filesToUpload]); + if (!validation.isValid) { + throw new Error(validation.errorMessage ?? "Invalid attachments"); + } + + const imageUrls = await Promise.all( + imagesToEmbed.map((image) => convertImageToBase64(image)), + ); + + const runtime = await resolveConversationRuntime(conversationId); + + const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } = + filesToUpload.length > 0 + ? await uploadFilesToConversation(conversationId, filesToUpload) + : { skipped_files: [], uploaded_files: [] }; + + skippedFiles.forEach((file) => displayErrorToast(file.reason)); + + const filePrompt = `${t("CHAT_INTERFACE$AUGMENTED_PROMPT_FILES_TITLE")}: ${uploadedFiles.join("\n\n")}`; + const prompt = + uploadedFiles.length > 0 ? `${content}\n\n${filePrompt}` : content; + + const timestamp = new Date().toISOString(); + + const messageContent: SendMessageRequest = { + role: "user", + content: [{ type: "text", text: prompt }], + }; + + if (imageUrls.length > 0) { + messageContent.content.push({ + type: "image", + image_urls: imageUrls, + }); + } + + await AgentServerConversationService.sendMessage( + conversationId, + messageContent, + runtime, + ); + + return { + text: content, + content: prompt, + imageUrls, + fileUrls: uploadedFiles, + timestamp, + }; +}