From 6ed4e2796735c4aa1fbddc8c8c6aaff5b3100d95 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 10:30:10 -0700 Subject: [PATCH 01/19] feat(chat): merge tools menu into plus button with file upload footer Combine the chat tools dropdown with the + control and add an Add Files and Images action with a paperclip icon at the bottom. Co-authored-by: Cursor --- .../chat/chat-add-file-button.test.tsx | 55 ++++++--- .../components/chat-input-actions.test.tsx | 4 - .../features/chat/chat-add-file-button.tsx | 112 +++++++++++++++--- .../chat/components/chat-input-actions.tsx | 110 +---------------- .../features/controls/tools-context-menu.tsx | 30 +++++ src/i18n/translation.json | 34 ++++++ 6 files changed, 204 insertions(+), 141 deletions(-) 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/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/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/components/chat-input-actions.tsx b/src/components/features/chat/components/chat-input-actions.tsx index 0e93bf1f9..d584f38ab 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) { @@ -310,44 +262,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", - ) - } - className={contextMenuListItemClassName} - > - } - text={t(I18nKey.MICROAGENTS_MODAL$TOOLS)} - rightIcon={} - className={CONTEXT_MENU_ICON_TEXT_CLASSNAME} - /> - -
- -
-
- )} {isCloud && !showCodeInline && (
-
- -
{isCloud && (
@@ -539,7 +450,6 @@ export function ChatInputActions({ typeof document !== "undefined" && overflowPortalStyle && ReactDOM.createPortal( - // portal position computed from DOM bounding rect at runtime
{overflowMenu}
, document.body, )} @@ -567,18 +477,6 @@ export function ChatInputActions({ /> )}
- - setSystemModalVisible(false)} - systemMessage={systemMessage || null} - /> - {skillsModalVisible && ( - setSkillsModalVisible(false)} /> - )} - {hooksModalVisible && ( - setHooksModalVisible(false)} /> - )}
); } diff --git a/src/components/features/controls/tools-context-menu.tsx b/src/components/features/controls/tools-context-menu.tsx index 531ef3ec3..bffc27695 100644 --- a/src/components/features/controls/tools-context-menu.tsx +++ b/src/components/features/controls/tools-context-menu.tsx @@ -31,6 +31,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({ @@ -40,6 +47,7 @@ export function ToolsContextMenu({ onShowAgentTools, shouldShowAgentTools = true, shouldShowHooks = false, + footerAction, }: ToolsContextMenuProps) { const { t } = useTranslation("openhands"); const { data: conversation } = useActiveConversation(); @@ -171,6 +179,28 @@ export function ToolsContextMenu({ /> )} + + {footerAction && ( + <> + + { + event.preventDefault(); + event.stopPropagation(); + footerAction.onClick(); + handleClose(); + }} + className={contextMenuListItemClassName} + > + + + + )} ); } diff --git a/src/i18n/translation.json b/src/i18n/translation.json index 080acfa43..4a50d8230 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -10199,6 +10199,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 软件工程师。今天想和我一起编写什么程序呢?", From 1d2f75edc9f29e91641110ecd020e85b85b10c21 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 10:34:59 -0700 Subject: [PATCH 02/19] feat(chat): support clipboard image paste on home and conversation inputs Read pasted screenshots from clipboard items, wire the home launcher through the shared attachment upload flow, and send first messages with attachments after creating a conversation. Co-authored-by: Cursor --- .../chat/utils/chat-input.utils.test.ts | 79 ++++++++++++ .../features/chat/custom-chat-input.tsx | 9 +- .../features/chat/interactive-chat-box.tsx | 115 +----------------- .../features/chat/utils/chat-input.utils.ts | 53 ++++++++ .../features/home/home-chat-launcher.tsx | 49 +++++++- src/hooks/chat/use-chat-attachment-upload.ts | 92 ++++++++++++++ src/hooks/chat/use-chat-input-events.ts | 4 +- src/hooks/chat/use-chat-submission.ts | 5 +- src/utils/send-message-with-attachments.ts | 77 ++++++++++++ 9 files changed, 361 insertions(+), 122 deletions(-) create mode 100644 __tests__/components/features/chat/utils/chat-input.utils.test.ts create mode 100644 src/hooks/chat/use-chat-attachment-upload.ts create mode 100644 src/utils/send-message-with-attachments.ts 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..5075386c5 --- /dev/null +++ b/__tests__/components/features/chat/utils/chat-input.utils.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + getClipboardFiles, + normalizePastedFile, +} 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("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/src/components/features/chat/custom-chat-input.tsx b/src/components/features/chat/custom-chat-input.tsx index d0e41e28d..c1a6d9425 100644 --- a/src/components/features/chat/custom-chat-input.tsx +++ b/src/components/features/chat/custom-chat-input.tsx @@ -40,6 +40,8 @@ export function CustomChatInput({ clearAllFiles, setShouldHideSuggestions, setSubmittedMessage, + images, + files, } = useConversationStore(); // Note: we intentionally do NOT disable the input when the conversation is @@ -81,8 +83,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 +156,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 e1c5a2d5f..bf816e02d 100644 --- a/src/components/features/chat/interactive-chat-box.tsx +++ b/src/components/features/chat/interactive-chat-box.tsx @@ -1,16 +1,13 @@ -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 { isTaskPolling } from "#/utils/utils"; @@ -27,129 +24,23 @@ export function InteractiveChatBox({ images, files, uploadImagesAsFiles, - addImages, - addFiles, 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 { @@ -163,8 +54,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/utils/chat-input.utils.ts b/src/components/features/chat/utils/chat-input.utils.ts index 46b33a651..4b4274861 100644 --- a/src/components/features/chat/utils/chat-input.utils.ts +++ b/src/components/features/chat/utils/chat-input.utils.ts @@ -2,6 +2,59 @@ * 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, + }); +} + +/** + * 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/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 9b4f569ad..cb6369ecb 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -5,6 +5,10 @@ import { CustomChatInput } from "#/components/features/chat/custom-chat-input"; import { useActiveBackend } from "#/contexts/active-backend-context"; import { useCreateConversation } from "#/hooks/mutation/use-create-conversation"; import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; +import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; +import { useConversationStore } from "#/stores/conversation-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +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"; @@ -38,6 +42,12 @@ export function HomeChatLauncher() { const { mutate: createConversation, isPending } = useCreateConversation(); const isCreatingElsewhere = useIsCreatingConversation(); const isCreating = isPending || isCreatingElsewhere; + const { images, files, uploadImagesAsFiles, clearAllFiles } = + useConversationStore(); + const enqueuePendingMessage = useOptimisticUserMessageStore( + (state) => state.enqueuePendingMessage, + ); + const { handleUpload } = useChatAttachmentUpload(); const hasSelection = isLocal ? !!pendingWorkspace @@ -45,13 +55,19 @@ 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. let variables: Parameters[0] = { - query: trimmed, + query: hasAttachments ? undefined : trimmed, }; if (isLocal && pendingWorkspace) { variables = { ...variables, workingDir: pendingWorkspace.path }; @@ -74,8 +90,34 @@ export function HomeChatLauncher() { ); createConversation(variables, { - onSuccess: (data) => { + onSuccess: async (data) => { toast.dismiss(toastId); + + if (hasAttachments) { + try { + const sent = await sendMessageWithAttachments({ + conversationId: data.conversation_id, + content: trimmed, + images: attachmentSnapshot.images, + files: attachmentSnapshot.files, + uploadImagesAsFiles, + t, + }); + enqueuePendingMessage({ + conversationId: data.conversation_id, + text: sent.text, + content: sent.content, + imageUrls: sent.imageUrls, + fileUrls: sent.fileUrls, + timestamp: sent.timestamp, + }); + clearAllFiles(); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + return; + } + } + navigate(`/conversations/${data.conversation_id}`); }, onError: (error) => { @@ -103,6 +145,7 @@ export function HomeChatLauncher() {
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..c67b8ffd1 --- /dev/null +++ b/src/hooks/chat/use-chat-attachment-upload.ts @@ -0,0 +1,92 @@ +import { useCallback } from "react"; +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, + } = useConversationStore(); + + const handleUpload = useCallback( + async (selectedFiles: File[]) => { + 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)); + + 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, + ], + ); + + 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/utils/send-message-with-attachments.ts b/src/utils/send-message-with-attachments.ts new file mode 100644 index 000000000..f02b6578f --- /dev/null +++ b/src/utils/send-message-with-attachments.ts @@ -0,0 +1,77 @@ +import type { TFunction } from "i18next"; +import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api"; +import ConversationService from "#/api/conversation-service/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 { 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[]; + uploadImagesAsFiles: boolean; + t: TFunction; +}): Promise { + const { conversationId, content, images, files, uploadImagesAsFiles, t } = + options; + + const imagesToEmbed = uploadImagesAsFiles ? [] : images; + const filesToUpload = uploadImagesAsFiles ? [...files, ...images] : files; + + 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 { skipped_files: skippedFiles, uploaded_files: uploadedFiles } = + filesToUpload.length > 0 + ? await ConversationService.uploadFiles(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, + ); + + return { + text: content, + content: prompt, + imageUrls, + fileUrls: uploadedFiles, + timestamp, + }; +} From 90883c384e9be1c235241e0a6c7713b89731357c Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 10:44:29 -0700 Subject: [PATCH 03/19] feat(chat): per-pasted-image upload-as-file control on thumbnails Replace the global checkbox with a circular upload button on clipboard pasted images, track paste source in the store, and fix overlay stacking. Co-authored-by: Cursor --- .../chat/utils/chat-input.utils.test.ts | 28 +++++++++ __tests__/stores/conversation-store.test.ts | 59 +++++++++++++++---- .../features/chat/custom-chat-input.tsx | 5 +- .../features/chat/interactive-chat-box.tsx | 13 ++-- .../pasted-image-upload-as-file-button.tsx | 40 +++++++++++++ .../features/chat/remove-file-button.tsx | 2 +- .../features/chat/upload-as-file-checkbox.tsx | 26 -------- .../features/chat/uploaded-files.tsx | 14 +++-- .../features/chat/uploaded-image.tsx | 19 +++++- .../features/chat/utils/chat-input.utils.ts | 26 ++++++++ .../features/home/home-chat-launcher.tsx | 4 +- src/hooks/chat/use-chat-attachment-upload.ts | 12 +++- src/hooks/chat/use-file-handling.ts | 9 +-- src/i18n/translation.json | 2 +- src/stores/conversation-store.ts | 53 ++++++++++++++--- src/utils/send-message-with-attachments.ts | 20 +++++-- 16 files changed, 258 insertions(+), 74 deletions(-) create mode 100644 src/components/features/chat/pasted-image-upload-as-file-button.tsx delete mode 100644 src/components/features/chat/upload-as-file-checkbox.tsx diff --git a/__tests__/components/features/chat/utils/chat-input.utils.test.ts b/__tests__/components/features/chat/utils/chat-input.utils.test.ts index 5075386c5..fc102cfb8 100644 --- a/__tests__/components/features/chat/utils/chat-input.utils.test.ts +++ b/__tests__/components/features/chat/utils/chat-input.utils.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { getClipboardFiles, + isPastedClipboardImage, normalizePastedFile, + partitionImagesForUpload, } from "#/components/features/chat/utils/chat-input.utils"; function createFileList(files: File[]): FileList { @@ -49,6 +51,32 @@ describe("normalizePastedFile", () => { }); }); +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" }); diff --git a/__tests__/stores/conversation-store.test.ts b/__tests__/stores/conversation-store.test.ts index 462db9aaf..dc541322f 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 clipboard-pasted 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/src/components/features/chat/custom-chat-input.tsx b/src/components/features/chat/custom-chat-input.tsx index c1a6d9425..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"]; } diff --git a/src/components/features/chat/interactive-chat-box.tsx b/src/components/features/chat/interactive-chat-box.tsx index bf816e02d..1becbc875 100644 --- a/src/components/features/chat/interactive-chat-box.tsx +++ b/src/components/features/chat/interactive-chat-box.tsx @@ -9,6 +9,7 @@ import { GitControlBar } from "./git-control-bar"; import { useConversationStore } from "#/stores/conversation-store"; import { useAgentState } from "#/hooks/use-agent-state"; 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 { @@ -23,7 +24,7 @@ export function InteractiveChatBox({ const { images, files, - uploadImagesAsFiles, + imagesMarkedUploadAsFile, clearAllFiles, subConversationTaskId, } = useConversationStore(); @@ -41,11 +42,11 @@ export function InteractiveChatBox({ const { handleUpload } = useChatAttachmentUpload(); const handleAfterModel = useBtwInterceptor(conversationId, (message) => { - 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); 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..ac78c49ee --- /dev/null +++ b/src/components/features/chat/pasted-image-upload-as-file-button.tsx @@ -0,0 +1,40 @@ +import { Upload } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { ChatActionTooltip } from "#/components/features/chat/chat-action-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"); + return ( + + + + ); +} 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..47ad1ec9e 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,13 @@ export function UploadedFiles() { files, loadingFiles, loadingImages, + imagesMarkedUploadAsFile, + pastedImageNames, removeFile, removeImage, + toggleImageUploadAsFile, } = useConversationStore(); - const hasImages = images.length > 0 || loadingImages.length > 0; - const handleRemoveFile = (index: number) => { removeFile(index); }; @@ -67,6 +67,9 @@ export function UploadedFiles() { image={image} onRemove={() => handleRemoveImage(index)} isLoading={loadingImages.includes(image.name)} + showUploadAsFileToggle={pastedImageNames.includes(image.name)} + uploadAsFileActive={imagesMarkedUploadAsFile.includes(image.name)} + onToggleUploadAsFile={() => toggleImageUploadAsFile(image.name)} /> ))} @@ -80,12 +83,13 @@ export function UploadedFiles() { image={tempImage} onRemove={() => {}} // No remove action during loading isLoading + showUploadAsFileToggle={pastedImageNames.includes(imageName)} + 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 4b4274861..34e3f7f3f 100644 --- a/src/components/features/chat/utils/chat-input.utils.ts +++ b/src/components/features/chat/utils/chat-input.utils.ts @@ -32,6 +32,32 @@ export function normalizePastedFile(file: File): File { }); } +/** 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. */ diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index cb6369ecb..e6cb87945 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -42,7 +42,7 @@ export function HomeChatLauncher() { const { mutate: createConversation, isPending } = useCreateConversation(); const isCreatingElsewhere = useIsCreatingConversation(); const isCreating = isPending || isCreatingElsewhere; - const { images, files, uploadImagesAsFiles, clearAllFiles } = + const { images, files, imagesMarkedUploadAsFile, clearAllFiles } = useConversationStore(); const enqueuePendingMessage = useOptimisticUserMessageStore( (state) => state.enqueuePendingMessage, @@ -100,7 +100,7 @@ export function HomeChatLauncher() { content: trimmed, images: attachmentSnapshot.images, files: attachmentSnapshot.files, - uploadImagesAsFiles, + imagesMarkedUploadAsFile, t, }); enqueuePendingMessage({ diff --git a/src/hooks/chat/use-chat-attachment-upload.ts b/src/hooks/chat/use-chat-attachment-upload.ts index c67b8ffd1..02ce1b540 100644 --- a/src/hooks/chat/use-chat-attachment-upload.ts +++ b/src/hooks/chat/use-chat-attachment-upload.ts @@ -1,4 +1,8 @@ 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"; @@ -18,10 +22,11 @@ export function useChatAttachmentUpload() { removeFileLoading, addImageLoading, removeImageLoading, + markImagesAsPasted, } = useConversationStore(); const handleUpload = useCallback( - async (selectedFiles: File[]) => { + async (selectedFiles: File[], options?: ChatAttachmentUploadOptions) => { const validation = validateFiles(selectedFiles, [...images, ...files]); if (!validation.isValid) { @@ -32,6 +37,10 @@ export function useChatAttachmentUpload() { const validFiles = selectedFiles.filter((f) => !isFileImage(f)); const validImages = selectedFiles.filter((f) => isFileImage(f)); + if (options?.fromPaste && validImages.length > 0) { + markImagesAsPasted(validImages.map((image) => image.name)); + } + validFiles.forEach((file) => addFileLoading(file.name)); validImages.forEach((image) => addImageLoading(image.name)); @@ -85,6 +94,7 @@ export function useChatAttachmentUpload() { removeFileLoading, addImageLoading, removeImageLoading, + markImagesAsPasted, ], ); 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/i18n/translation.json b/src/i18n/translation.json index 4a50d8230..dc6c1eef3 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -10149,7 +10149,7 @@ "ca": "Envia el missatge" }, "CHAT_INTERFACE$UPLOAD_IMAGES_AS_FILES": { - "en": "upload as file", + "en": "Upload as file", "ja": "ファイルとしてアップロード", "zh-CN": "作为文件上传", "zh-TW": "作為檔案上傳", diff --git a/src/stores/conversation-store.ts b/src/stores/conversation-store.ts index c7f379d8b..ef04df830 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 via clipboard paste (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/utils/send-message-with-attachments.ts b/src/utils/send-message-with-attachments.ts index f02b6578f..048fd240e 100644 --- a/src/utils/send-message-with-attachments.ts +++ b/src/utils/send-message-with-attachments.ts @@ -4,6 +4,7 @@ import ConversationService from "#/api/conversation-service/conversation-service 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 { @@ -19,14 +20,23 @@ export async function sendMessageWithAttachments(options: { content: string; images: File[]; files: File[]; - uploadImagesAsFiles: boolean; + imagesMarkedUploadAsFile: string[]; t: TFunction; }): Promise { - const { conversationId, content, images, files, uploadImagesAsFiles, t } = - options; + const { + conversationId, + content, + images, + files, + imagesMarkedUploadAsFile, + t, + } = options; - const imagesToEmbed = uploadImagesAsFiles ? [] : images; - const filesToUpload = uploadImagesAsFiles ? [...files, ...images] : files; + const { imagesToEmbed, imagesAsFiles } = partitionImagesForUpload( + images, + imagesMarkedUploadAsFile, + ); + const filesToUpload = [...files, ...imagesAsFiles]; const validation = validateFiles([...imagesToEmbed, ...filesToUpload]); if (!validation.isValid) { From c716bc9a3477623e3243209bc70b6df9fe3cd81c Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 10:51:51 -0700 Subject: [PATCH 04/19] fix(chat): polish pasted-image upload toggle styling and tooltips Match the remove button size and corner inset, show a checkmark when active with hover feedback, and swap the tooltip to "Do not upload as file". Co-authored-by: Cursor --- .../pasted-image-upload-as-file-button.tsx | 58 ++++++++++++------- .../shared/buttons/styled-tooltip.tsx | 6 ++ src/i18n/translation.json | 17 ++++++ 3 files changed, 59 insertions(+), 22 deletions(-) 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 index ac78c49ee..96d360be4 100644 --- a/src/components/features/chat/pasted-image-upload-as-file-button.tsx +++ b/src/components/features/chat/pasted-image-upload-as-file-button.tsx @@ -1,6 +1,6 @@ -import { Upload } from "lucide-react"; +import { Check, Upload } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { ChatActionTooltip } from "#/components/features/chat/chat-action-tooltip"; +import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; import { I18nKey } from "#/i18n/declaration"; import { cn } from "#/utils/utils"; @@ -14,27 +14,41 @@ export function PastedImageUploadAsFileButton({ 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/shared/buttons/styled-tooltip.tsx b/src/components/shared/buttons/styled-tooltip.tsx index 58da15fbb..d7b1b6fb8 100644 --- a/src/components/shared/buttons/styled-tooltip.tsx +++ b/src/components/shared/buttons/styled-tooltip.tsx @@ -9,6 +9,8 @@ export interface StyledTooltipProps { placement?: TooltipProps["placement"]; showArrow?: boolean; closeDelay?: number; + offset?: number; + shouldFlip?: boolean; } export function StyledTooltip({ @@ -18,6 +20,8 @@ export function StyledTooltip({ placement = "right", showArrow = false, closeDelay = 100, + offset, + shouldFlip, }: StyledTooltipProps) { const disableAnimation = import.meta.env.MODE === "test"; @@ -26,6 +30,8 @@ export function StyledTooltip({ content={content} closeDelay={closeDelay} placement={placement} + offset={offset} + shouldFlip={shouldFlip} className={cn("bg-white text-black", tooltipClassName)} showArrow={showArrow} disableAnimation={disableAnimation} diff --git a/src/i18n/translation.json b/src/i18n/translation.json index dc6c1eef3..8899b17bd 100644 --- a/src/i18n/translation.json +++ b/src/i18n/translation.json @@ -10165,6 +10165,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": "上传图片", From e8f8459d5e1e98412d339bd55bb8120ab5bdefd6 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 10:52:29 -0700 Subject: [PATCH 05/19] fix(chat): add bottom padding below attachment thumbnails row Match the chat input container's top inset so pasted images and files are spaced evenly above and below the thumbnail strip. Co-authored-by: Cursor --- src/components/features/chat/uploaded-files.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/features/chat/uploaded-files.tsx b/src/components/features/chat/uploaded-files.tsx index 47ad1ec9e..fad4a935f 100644 --- a/src/components/features/chat/uploaded-files.tsx +++ b/src/components/features/chat/uploaded-files.tsx @@ -34,8 +34,8 @@ export function UploadedFiles() { } return ( -
-
+
+
{/* Regular files */} {files.map((file, index) => ( Date: Wed, 20 May 2026 10:57:02 -0700 Subject: [PATCH 06/19] fix(chat): use surface grey for upload-as-file toggle and nudge position Replace invalid primary tokens with oh-surface/oh-muted styling for both states and raise the button slightly from the thumbnail corner. Co-authored-by: Cursor --- .../features/chat/pasted-image-upload-as-file-button.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) 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 index 96d360be4..534ae714e 100644 --- a/src/components/features/chat/pasted-image-upload-as-file-button.tsx +++ b/src/components/features/chat/pasted-image-upload-as-file-button.tsx @@ -19,7 +19,7 @@ export function PastedImageUploadAsFileButton({ const label = active ? doNotUploadLabel : uploadLabel; return ( -
+
{active ? ( From 9a443088ca5b14613c72b60459e26f1d8fb0f545 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 11:05:13 -0700 Subject: [PATCH 07/19] fix(home): defer attachment sends until cloud start task is ready Cloud conversation creation can return a provisional `task-{uuid}` URL while the sandbox provisions. Sending messages or uploading files against that id caused 422 UUID parsing errors on the home `/conversations` input. - Queue attachments in memory keyed by start-task id when provisioning - Flush uploads and the user message once `useTaskPolling` sees READY - Always include typed text in the start request, even with attachments - Add store and flush helper tests Paste/drag on the home input were already wired; this fixes starting a conversation with attachments (or text + attachments) on cloud backends. Co-authored-by: Cursor --- .../pending-task-attachments-store.test.ts | 28 +++++++++ .../flush-pending-task-attachments.test.ts | 55 +++++++++++++++++ .../features/home/home-chat-launcher.tsx | 52 ++++++++++------ src/hooks/query/use-task-polling.ts | 29 +++++++-- src/stores/pending-task-attachments-store.ts | 60 +++++++++++++++++++ src/utils/flush-pending-task-attachments.ts | 42 +++++++++++++ 6 files changed, 244 insertions(+), 22 deletions(-) create mode 100644 __tests__/stores/pending-task-attachments-store.test.ts create mode 100644 __tests__/utils/flush-pending-task-attachments.test.ts create mode 100644 src/stores/pending-task-attachments-store.ts create mode 100644 src/utils/flush-pending-task-attachments.ts 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..ee9ab97fd --- /dev/null +++ b/__tests__/utils/flush-pending-task-attachments.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + setPendingTaskAttachments, + usePendingTaskAttachmentsStore, +} from "#/stores/pending-task-attachments-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-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), +})); + +describe("flushPendingTaskAttachments", () => { + beforeEach(() => { + vi.clearAllMocks(); + useOptimisticUserMessageStore.getState().clearPendingMessages(); + 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", + }), + ); + expect( + useOptimisticUserMessageStore.getState().pendingMessages, + ).toHaveLength(1); + }); +}); diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index e6cb87945..56d101fb0 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -8,6 +8,7 @@ import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; import { useConversationStore } from "#/stores/conversation-store"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { setPendingTaskAttachments } from "#/stores/pending-task-attachments-store"; import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; import { useNavigation } from "#/context/navigation-context"; import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; @@ -67,7 +68,7 @@ export function HomeChatLauncher() { // creates a conversation with no working dir and no repo. Build the // payload from whatever is selected. let variables: Parameters[0] = { - query: hasAttachments ? undefined : trimmed, + query: trimmed || undefined, }; if (isLocal && pendingWorkspace) { variables = { ...variables, workingDir: pendingWorkspace.path }; @@ -94,27 +95,44 @@ export function HomeChatLauncher() { toast.dismiss(toastId); if (hasAttachments) { - try { - const sent = await sendMessageWithAttachments({ - conversationId: data.conversation_id, + const isProvisioningTask = data.conversation_id.startsWith("task-"); + + if (isProvisioningTask) { + if (!data.task_id) { + displayErrorToast(null); + return; + } + + setPendingTaskAttachments(data.task_id, { content: trimmed, images: attachmentSnapshot.images, files: attachmentSnapshot.files, - imagesMarkedUploadAsFile, - t, - }); - enqueuePendingMessage({ - conversationId: data.conversation_id, - text: sent.text, - content: sent.content, - imageUrls: sent.imageUrls, - fileUrls: sent.fileUrls, - timestamp: sent.timestamp, + imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile], }); clearAllFiles(); - } catch (error) { - displayErrorToast(error instanceof Error ? error.message : null); - return; + } else { + try { + const sent = await sendMessageWithAttachments({ + conversationId: data.conversation_id, + content: trimmed, + images: attachmentSnapshot.images, + files: attachmentSnapshot.files, + imagesMarkedUploadAsFile, + t, + }); + enqueuePendingMessage({ + conversationId: data.conversation_id, + text: sent.text, + content: sent.content, + imageUrls: sent.imageUrls, + fileUrls: sent.fileUrls, + timestamp: sent.timestamp, + }); + clearAllFiles(); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + return; + } } } diff --git a/src/hooks/query/use-task-polling.ts b/src/hooks/query/use-task-polling.ts index fa2a7e446..061705ef7 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, 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,7 @@ import { consumePendingTaskDraft, setConversationState, } from "#/utils/conversation-local-storage"; +import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments"; /** * Hook that polls V1 conversation start tasks and navigates when ready. @@ -56,22 +57,40 @@ export const useTaskPolling = () => { retry: false, }); + const handledReadyTaskIdRef = useRef(null); + // 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 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/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/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts new file mode 100644 index 000000000..7e9b8fcac --- /dev/null +++ b/src/utils/flush-pending-task-attachments.ts @@ -0,0 +1,42 @@ +import i18n from "i18next"; +import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-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 { + const sent = await sendMessageWithAttachments({ + conversationId, + content: pending.content, + images: pending.images, + files: pending.files, + imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile, + t: i18n.getFixedT(null, "openhands"), + }); + + useOptimisticUserMessageStore.getState().enqueuePendingMessage({ + conversationId, + text: sent.text, + content: sent.content, + imageUrls: sent.imageUrls, + fileUrls: sent.fileUrls, + timestamp: sent.timestamp, + }); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + throw error; + } +} From 7ba6a267cedaa67dbf08948ddcd00b9c5d6e269c Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 11:07:08 -0700 Subject: [PATCH 08/19] Revert "fix(home): defer attachment sends until cloud start task is ready" This reverts commit 9a443088ca5b14613c72b60459e26f1d8fb0f545. --- .../pending-task-attachments-store.test.ts | 28 --------- .../flush-pending-task-attachments.test.ts | 55 ----------------- .../features/home/home-chat-launcher.tsx | 52 ++++++---------- src/hooks/query/use-task-polling.ts | 29 ++------- src/stores/pending-task-attachments-store.ts | 60 ------------------- src/utils/flush-pending-task-attachments.ts | 42 ------------- 6 files changed, 22 insertions(+), 244 deletions(-) delete mode 100644 __tests__/stores/pending-task-attachments-store.test.ts delete mode 100644 __tests__/utils/flush-pending-task-attachments.test.ts delete mode 100644 src/stores/pending-task-attachments-store.ts delete mode 100644 src/utils/flush-pending-task-attachments.ts diff --git a/__tests__/stores/pending-task-attachments-store.test.ts b/__tests__/stores/pending-task-attachments-store.test.ts deleted file mode 100644 index 80b9c9af9..000000000 --- a/__tests__/stores/pending-task-attachments-store.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index ee9ab97fd..000000000 --- a/__tests__/utils/flush-pending-task-attachments.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - setPendingTaskAttachments, - usePendingTaskAttachmentsStore, -} from "#/stores/pending-task-attachments-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-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), -})); - -describe("flushPendingTaskAttachments", () => { - beforeEach(() => { - vi.clearAllMocks(); - useOptimisticUserMessageStore.getState().clearPendingMessages(); - 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", - }), - ); - expect( - useOptimisticUserMessageStore.getState().pendingMessages, - ).toHaveLength(1); - }); -}); diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 56d101fb0..e6cb87945 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -8,7 +8,6 @@ import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; import { useConversationStore } from "#/stores/conversation-store"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; -import { setPendingTaskAttachments } from "#/stores/pending-task-attachments-store"; import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; import { useNavigation } from "#/context/navigation-context"; import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; @@ -68,7 +67,7 @@ export function HomeChatLauncher() { // creates a conversation with no working dir and no repo. Build the // payload from whatever is selected. let variables: Parameters[0] = { - query: trimmed || undefined, + query: hasAttachments ? undefined : trimmed, }; if (isLocal && pendingWorkspace) { variables = { ...variables, workingDir: pendingWorkspace.path }; @@ -95,44 +94,27 @@ export function HomeChatLauncher() { toast.dismiss(toastId); if (hasAttachments) { - const isProvisioningTask = data.conversation_id.startsWith("task-"); - - if (isProvisioningTask) { - if (!data.task_id) { - displayErrorToast(null); - return; - } - - setPendingTaskAttachments(data.task_id, { + try { + const sent = await sendMessageWithAttachments({ + conversationId: data.conversation_id, content: trimmed, images: attachmentSnapshot.images, files: attachmentSnapshot.files, - imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile], + imagesMarkedUploadAsFile, + t, + }); + enqueuePendingMessage({ + conversationId: data.conversation_id, + text: sent.text, + content: sent.content, + imageUrls: sent.imageUrls, + fileUrls: sent.fileUrls, + timestamp: sent.timestamp, }); clearAllFiles(); - } else { - try { - const sent = await sendMessageWithAttachments({ - conversationId: data.conversation_id, - content: trimmed, - images: attachmentSnapshot.images, - files: attachmentSnapshot.files, - imagesMarkedUploadAsFile, - t, - }); - enqueuePendingMessage({ - conversationId: data.conversation_id, - text: sent.text, - content: sent.content, - imageUrls: sent.imageUrls, - fileUrls: sent.fileUrls, - timestamp: sent.timestamp, - }); - clearAllFiles(); - } catch (error) { - displayErrorToast(error instanceof Error ? error.message : null); - return; - } + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + return; } } diff --git a/src/hooks/query/use-task-polling.ts b/src/hooks/query/use-task-polling.ts index 061705ef7..fa2a7e446 100644 --- a/src/hooks/query/use-task-polling.ts +++ b/src/hooks/query/use-task-polling.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect } 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,7 +7,6 @@ import { consumePendingTaskDraft, setConversationState, } from "#/utils/conversation-local-storage"; -import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments"; /** * Hook that polls V1 conversation start tasks and navigates when ready. @@ -57,40 +56,22 @@ export const useTaskPolling = () => { retry: false, }); - const handledReadyTaskIdRef = useRef(null); - // Navigate to conversation ID when task is ready useEffect(() => { const task = taskQuery.data; - 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!); - + if (task?.status === "READY" && 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/stores/pending-task-attachments-store.ts b/src/stores/pending-task-attachments-store.ts deleted file mode 100644 index 3fa0b618e..000000000 --- a/src/stores/pending-task-attachments-store.ts +++ /dev/null @@ -1,60 +0,0 @@ -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/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts deleted file mode 100644 index 7e9b8fcac..000000000 --- a/src/utils/flush-pending-task-attachments.ts +++ /dev/null @@ -1,42 +0,0 @@ -import i18n from "i18next"; -import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-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 { - const sent = await sendMessageWithAttachments({ - conversationId, - content: pending.content, - images: pending.images, - files: pending.files, - imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile, - t: i18n.getFixedT(null, "openhands"), - }); - - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId, - text: sent.text, - content: sent.content, - imageUrls: sent.imageUrls, - fileUrls: sent.fileUrls, - timestamp: sent.timestamp, - }); - } catch (error) { - displayErrorToast(error instanceof Error ? error.message : null); - throw error; - } -} From 47713d58279e38fe54feb6b9cd7e2daea59f51cb Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 11:09:42 -0700 Subject: [PATCH 09/19] Reapply "fix(home): defer attachment sends until cloud start task is ready" This reverts commit 7ba6a267cedaa67dbf08948ddcd00b9c5d6e269c. --- .../pending-task-attachments-store.test.ts | 28 +++++++++ .../flush-pending-task-attachments.test.ts | 55 +++++++++++++++++ .../features/home/home-chat-launcher.tsx | 52 ++++++++++------ src/hooks/query/use-task-polling.ts | 29 +++++++-- src/stores/pending-task-attachments-store.ts | 60 +++++++++++++++++++ src/utils/flush-pending-task-attachments.ts | 42 +++++++++++++ 6 files changed, 244 insertions(+), 22 deletions(-) create mode 100644 __tests__/stores/pending-task-attachments-store.test.ts create mode 100644 __tests__/utils/flush-pending-task-attachments.test.ts create mode 100644 src/stores/pending-task-attachments-store.ts create mode 100644 src/utils/flush-pending-task-attachments.ts 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..ee9ab97fd --- /dev/null +++ b/__tests__/utils/flush-pending-task-attachments.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + setPendingTaskAttachments, + usePendingTaskAttachmentsStore, +} from "#/stores/pending-task-attachments-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-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), +})); + +describe("flushPendingTaskAttachments", () => { + beforeEach(() => { + vi.clearAllMocks(); + useOptimisticUserMessageStore.getState().clearPendingMessages(); + 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", + }), + ); + expect( + useOptimisticUserMessageStore.getState().pendingMessages, + ).toHaveLength(1); + }); +}); diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index e6cb87945..56d101fb0 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -8,6 +8,7 @@ import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; import { useConversationStore } from "#/stores/conversation-store"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; +import { setPendingTaskAttachments } from "#/stores/pending-task-attachments-store"; import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; import { useNavigation } from "#/context/navigation-context"; import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"; @@ -67,7 +68,7 @@ export function HomeChatLauncher() { // creates a conversation with no working dir and no repo. Build the // payload from whatever is selected. let variables: Parameters[0] = { - query: hasAttachments ? undefined : trimmed, + query: trimmed || undefined, }; if (isLocal && pendingWorkspace) { variables = { ...variables, workingDir: pendingWorkspace.path }; @@ -94,27 +95,44 @@ export function HomeChatLauncher() { toast.dismiss(toastId); if (hasAttachments) { - try { - const sent = await sendMessageWithAttachments({ - conversationId: data.conversation_id, + const isProvisioningTask = data.conversation_id.startsWith("task-"); + + if (isProvisioningTask) { + if (!data.task_id) { + displayErrorToast(null); + return; + } + + setPendingTaskAttachments(data.task_id, { content: trimmed, images: attachmentSnapshot.images, files: attachmentSnapshot.files, - imagesMarkedUploadAsFile, - t, - }); - enqueuePendingMessage({ - conversationId: data.conversation_id, - text: sent.text, - content: sent.content, - imageUrls: sent.imageUrls, - fileUrls: sent.fileUrls, - timestamp: sent.timestamp, + imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile], }); clearAllFiles(); - } catch (error) { - displayErrorToast(error instanceof Error ? error.message : null); - return; + } else { + try { + const sent = await sendMessageWithAttachments({ + conversationId: data.conversation_id, + content: trimmed, + images: attachmentSnapshot.images, + files: attachmentSnapshot.files, + imagesMarkedUploadAsFile, + t, + }); + enqueuePendingMessage({ + conversationId: data.conversation_id, + text: sent.text, + content: sent.content, + imageUrls: sent.imageUrls, + fileUrls: sent.fileUrls, + timestamp: sent.timestamp, + }); + clearAllFiles(); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + return; + } } } diff --git a/src/hooks/query/use-task-polling.ts b/src/hooks/query/use-task-polling.ts index fa2a7e446..061705ef7 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, 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,7 @@ import { consumePendingTaskDraft, setConversationState, } from "#/utils/conversation-local-storage"; +import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments"; /** * Hook that polls V1 conversation start tasks and navigates when ready. @@ -56,22 +57,40 @@ export const useTaskPolling = () => { retry: false, }); + const handledReadyTaskIdRef = useRef(null); + // 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 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/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/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts new file mode 100644 index 000000000..7e9b8fcac --- /dev/null +++ b/src/utils/flush-pending-task-attachments.ts @@ -0,0 +1,42 @@ +import i18n from "i18next"; +import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-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 { + const sent = await sendMessageWithAttachments({ + conversationId, + content: pending.content, + images: pending.images, + files: pending.files, + imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile, + t: i18n.getFixedT(null, "openhands"), + }); + + useOptimisticUserMessageStore.getState().enqueuePendingMessage({ + conversationId, + text: sent.text, + content: sent.content, + imageUrls: sent.imageUrls, + fileUrls: sent.fileUrls, + timestamp: sent.timestamp, + }); + } catch (error) { + displayErrorToast(error instanceof Error ? error.message : null); + throw error; + } +} From b6154d04b084fd5809852e48c10a9c12123e1441 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 11:14:29 -0700 Subject: [PATCH 10/19] fix(chat): upload attachments into the conversation workspace File uploads targeted read-only /workspace in Docker dev stacks and cloud task flush used raw i18next before app init. Resolve the conversation working_dir for upload paths and use the initialized openhands i18n instance when flushing deferred attachments. Co-authored-by: Cursor --- __tests__/api/conversation-service.test.ts | 21 ++++++- __tests__/api/workspace-upload-path.test.ts | 53 +++++++++++++++++ .../flush-pending-task-attachments.test.ts | 6 ++ .../conversation-service.api.ts | 30 +++++----- src/api/workspace-upload-path.ts | 57 +++++++++++++++++++ .../mutation/use-conversation-upload-files.ts | 22 ++++--- .../mutation/use-unified-upload-files.ts | 13 ++++- src/utils/flush-pending-task-attachments.ts | 5 +- 8 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 __tests__/api/workspace-upload-path.test.ts create mode 100644 src/api/workspace-upload-path.ts 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__/utils/flush-pending-task-attachments.test.ts b/__tests__/utils/flush-pending-task-attachments.test.ts index ee9ab97fd..9a7c960a7 100644 --- a/__tests__/utils/flush-pending-task-attachments.test.ts +++ b/__tests__/utils/flush-pending-task-attachments.test.ts @@ -13,6 +13,12 @@ vi.mock("#/utils/send-message-with-attachments", () => ({ sendMessageWithAttachments(...args), })); +vi.mock("#/i18n", () => ({ + default: { getFixedT: () => (key: string) => key }, + OPENHANDS_I18N_NAMESPACE: "openhands", + waitForI18n: vi.fn().mockResolvedValue(undefined), +})); + describe("flushPendingTaskAttachments", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/api/conversation-service/conversation-service.api.ts b/src/api/conversation-service/conversation-service.api.ts index e26ad714a..e003f7787 100644 --- a/src/api/conversation-service/conversation-service.api.ts +++ b/src/api/conversation-service/conversation-service.api.ts @@ -2,6 +2,11 @@ import { VSCodeClient } from "@openhands/typescript-client/clients"; import { HttpClient } from "@openhands/typescript-client/client/http-client"; import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list"; import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; +import { + buildWorkspaceUploadPath, + getSafeUploadFileName, + resolveConversationUploadWorkingDir, +} from "#/api/workspace-upload-path"; import { GetVSCodeUrlResponse, GetTrajectoryResponse, @@ -16,17 +21,6 @@ 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; @@ -75,16 +69,24 @@ class ConversationService { } static async uploadFiles( - _conversationId: string, + conversationId: string, files: File[], ): Promise { + const workingDir = await resolveConversationUploadWorkingDir( + conversationId, + this.currentConversation, + ); const workspace = new RemoteWorkspace( - getAgentServerClientOptions(this.getClientOverrides()), + getAgentServerClientOptions({ + ...this.getClientOverrides(), + workingDir, + }), ); const uploadFile = async (file: File) => { try { const safeName = getSafeUploadFileName(file.name); - await workspace.fileUpload(file, `/workspace/${safeName}`); + const uploadPath = buildWorkspaceUploadPath(file.name, workingDir); + await workspace.fileUpload(file, uploadPath); return { uploadedFile: safeName, skippedFile: null }; } catch (error) { return { 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/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..469ea5032 100644 --- a/src/hooks/mutation/use-unified-upload-files.ts +++ b/src/hooks/mutation/use-unified-upload-files.ts @@ -1,4 +1,6 @@ import { useMutation } from "@tanstack/react-query"; +import { getAgentServerWorkingDir } from "#/api/agent-server-config"; +import { resolveConversationUploadWorkingDir } from "#/api/workspace-upload-path"; import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useConversationUploadFiles } from "./use-conversation-upload-files"; import { FileUploadSuccessResponse } from "#/api/open-hands.types"; @@ -21,12 +23,19 @@ export const useUnifiedUploadFiles = () => { mutationFn: async ( variables: UnifiedUploadFilesVariables, ): Promise => { - const { files } = variables; + const { conversationId, files } = variables; + + const workingDir = conversation?.workspace?.working_dir?.trim() + ? conversation.workspace.working_dir.trim() + : await resolveConversationUploadWorkingDir( + conversationId, + conversation, + ); - // Use conversation URL and session API key return conversationUpload.mutateAsync({ conversationUrl: conversation?.conversation_url, sessionApiKey: conversation?.session_api_key, + workingDir: workingDir || getAgentServerWorkingDir(), files, }); }, diff --git a/src/utils/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts index 7e9b8fcac..365c09cee 100644 --- a/src/utils/flush-pending-task-attachments.ts +++ b/src/utils/flush-pending-task-attachments.ts @@ -1,4 +1,4 @@ -import i18n from "i18next"; +import i18n, { OPENHANDS_I18N_NAMESPACE, waitForI18n } from "#/i18n"; import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store"; import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; @@ -18,13 +18,14 @@ export async function flushPendingTaskAttachments( } try { + await waitForI18n(); const sent = await sendMessageWithAttachments({ conversationId, content: pending.content, images: pending.images, files: pending.files, imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile, - t: i18n.getFixedT(null, "openhands"), + t: i18n.getFixedT(null, OPENHANDS_I18N_NAMESPACE), }); useOptimisticUserMessageStore.getState().enqueuePendingMessage({ From d0818d8ea941dfe068021627bc336bd012c99dea Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Wed, 20 May 2026 11:50:51 -0700 Subject: [PATCH 11/19] fix(cloud): route home attachments through the runtime sandbox Cloud file uploads and the first message were hitting the bundled local agent-server and 404ing. Defer home attachments until the start task is ready, upload via the provisioned runtime URL, and send events through the cloud proxy. Co-authored-by: Cursor --- .../api/conversation-file-upload.test.ts | 102 +++++++++++ src/api/conversation-file-upload.api.ts | 163 ++++++++++++++++++ .../agent-server-conversation-service.api.ts | 46 ++++- .../conversation-service.api.ts | 54 +----- .../features/home/home-chat-launcher.tsx | 21 ++- .../mutation/use-unified-upload-files.ts | 23 +-- src/utils/send-message-with-attachments.ts | 10 +- 7 files changed, 334 insertions(+), 85 deletions(-) create mode 100644 __tests__/api/conversation-file-upload.test.ts create mode 100644 src/api/conversation-file-upload.api.ts 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/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 fe463c99f..68295258a 100644 --- a/src/api/conversation-service/agent-server-conversation-service.api.ts +++ b/src/api/conversation-service/agent-server-conversation-service.api.ts @@ -8,6 +8,7 @@ import { import { HttpClient } from "@openhands/typescript-client/client/http-client"; 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, @@ -281,14 +282,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 e003f7787..f8c689e8a 100644 --- a/src/api/conversation-service/conversation-service.api.ts +++ b/src/api/conversation-service/conversation-service.api.ts @@ -1,12 +1,7 @@ import { VSCodeClient } from "@openhands/typescript-client/clients"; import { HttpClient } from "@openhands/typescript-client/client/http-client"; import { RemoteEventsList } from "@openhands/typescript-client/events/remote-events-list"; -import { RemoteWorkspace } from "@openhands/typescript-client/workspace/remote-workspace"; -import { - buildWorkspaceUploadPath, - getSafeUploadFileName, - resolveConversationUploadWorkingDir, -} from "#/api/workspace-upload-path"; +import { uploadFilesToConversation } from "#/api/conversation-file-upload.api"; import { GetVSCodeUrlResponse, GetTrajectoryResponse, @@ -19,8 +14,6 @@ import { } from "../agent-server-client-options"; import { AppConversation } from "./agent-server-conversation-service.types"; -const FILE_UPLOAD_CONCURRENCY = 5; - class ConversationService { private static currentConversation: AppConversation | null = null; @@ -72,52 +65,11 @@ class ConversationService { conversationId: string, files: File[], ): Promise { - const workingDir = await resolveConversationUploadWorkingDir( + return uploadFilesToConversation( conversationId, + files, this.currentConversation, ); - const workspace = new RemoteWorkspace( - getAgentServerClientOptions({ - ...this.getClientOverrides(), - 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/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 56d101fb0..0e576a65a 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -95,21 +95,32 @@ export function HomeChatLauncher() { toast.dismiss(toastId); if (hasAttachments) { - const isProvisioningTask = data.conversation_id.startsWith("task-"); - - if (isProvisioningTask) { - if (!data.task_id) { + // Cloud sandboxes provision asynchronously; uploads and the first + // message must target the runtime URL, not the bundled local server. + const shouldDeferAttachments = + !isLocal || data.conversation_id.startsWith("task-"); + + if (shouldDeferAttachments) { + const taskId = + data.task_id ?? + (data.conversation_id.startsWith("task-") + ? data.conversation_id.slice("task-".length) + : null); + + if (!taskId) { displayErrorToast(null); return; } - setPendingTaskAttachments(data.task_id, { + setPendingTaskAttachments(taskId, { content: trimmed, images: attachmentSnapshot.images, files: attachmentSnapshot.files, imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile], }); clearAllFiles(); + navigate(`/conversations/task-${taskId}`); + return; } else { try { const sent = await sendMessageWithAttachments({ diff --git a/src/hooks/mutation/use-unified-upload-files.ts b/src/hooks/mutation/use-unified-upload-files.ts index 469ea5032..6ceed4438 100644 --- a/src/hooks/mutation/use-unified-upload-files.ts +++ b/src/hooks/mutation/use-unified-upload-files.ts @@ -1,8 +1,6 @@ import { useMutation } from "@tanstack/react-query"; -import { getAgentServerWorkingDir } from "#/api/agent-server-config"; -import { resolveConversationUploadWorkingDir } from "#/api/workspace-upload-path"; +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 { @@ -11,33 +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 { conversationId, files } = variables; - - const workingDir = conversation?.workspace?.working_dir?.trim() - ? conversation.workspace.working_dir.trim() - : await resolveConversationUploadWorkingDir( - conversationId, - conversation, - ); - - return conversationUpload.mutateAsync({ - conversationUrl: conversation?.conversation_url, - sessionApiKey: conversation?.session_api_key, - workingDir: workingDir || getAgentServerWorkingDir(), - files, - }); + return uploadFilesToConversation(conversationId, files, conversation); }, meta: { disableToast: true, diff --git a/src/utils/send-message-with-attachments.ts b/src/utils/send-message-with-attachments.ts index 048fd240e..b57dd97bf 100644 --- a/src/utils/send-message-with-attachments.ts +++ b/src/utils/send-message-with-attachments.ts @@ -1,6 +1,9 @@ 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 ConversationService from "#/api/conversation-service/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"; @@ -47,9 +50,11 @@ export async function sendMessageWithAttachments(options: { imagesToEmbed.map((image) => convertImageToBase64(image)), ); + const runtime = await resolveConversationRuntime(conversationId); + const { skipped_files: skippedFiles, uploaded_files: uploadedFiles } = filesToUpload.length > 0 - ? await ConversationService.uploadFiles(conversationId, filesToUpload) + ? await uploadFilesToConversation(conversationId, filesToUpload) : { skipped_files: [], uploaded_files: [] }; skippedFiles.forEach((file) => displayErrorToast(file.reason)); @@ -75,6 +80,7 @@ export async function sendMessageWithAttachments(options: { await AgentServerConversationService.sendMessage( conversationId, messageContent, + runtime, ); return { From b362ac15600d70a78441cc5f94dc4e683c5b987b Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 21 May 2026 09:43:10 -0700 Subject: [PATCH 12/19] fix(chat): send home attachments in a single first message Skip initial_message when starting with attachments and avoid enqueueing optimistic duplicates after the attachment send already persisted the message. Co-authored-by: Cursor --- .../features/home/home-chat-launcher.test.tsx | 57 +++++++++++++++++++ .../flush-pending-task-attachments.test.ts | 5 -- .../features/home/home-chat-launcher.tsx | 19 ++----- src/utils/flush-pending-task-attachments.ts | 12 +--- 4 files changed, 63 insertions(+), 30 deletions(-) diff --git a/__tests__/components/features/home/home-chat-launcher.test.tsx b/__tests__/components/features/home/home-chat-launcher.test.tsx index bbeb51133..e1136cd2c 100644 --- a/__tests__/components/features/home/home-chat-launcher.test.tsx +++ b/__tests__/components/features/home/home-chat-launcher.test.tsx @@ -9,6 +9,25 @@ import AgentServerConversationService from "#/api/conversation-service/agent-ser const mockNavigate = vi.fn(); const mockUseActiveBackend = vi.fn(); +const sendMessageWithAttachments = vi.fn(); +const mockClearAllFiles = vi.fn(); + +let mockImages: File[] = []; +let mockFiles: File[] = []; + +vi.mock("#/utils/send-message-with-attachments", () => ({ + sendMessageWithAttachments: (...args: unknown[]) => + sendMessageWithAttachments(...args), +})); + +vi.mock("#/stores/conversation-store", () => ({ + useConversationStore: () => ({ + images: mockImages, + files: mockFiles, + imagesMarkedUploadAsFile: [], + clearAllFiles: mockClearAllFiles, + }), +})); vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }), @@ -198,7 +217,16 @@ const cloudBackend = { describe("HomeChatLauncher", () => { beforeEach(() => { vi.clearAllMocks(); + mockImages = []; + mockFiles = []; mockUseActiveBackend.mockReturnValue(localBackend); + sendMessageWithAttachments.mockResolvedValue({ + text: "hello world", + content: "hello world", + imageUrls: ["data:image/png;base64,abc"], + fileUrls: [], + timestamp: "2020-01-01T00:00:00.000Z", + }); }); afterEach(() => { @@ -293,6 +321,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 () => { const toastErrorSpy = vi.spyOn(toast, "error"); vi.spyOn(AgentServerConversationService, "createConversation").mockRejectedValue( diff --git a/__tests__/utils/flush-pending-task-attachments.test.ts b/__tests__/utils/flush-pending-task-attachments.test.ts index 9a7c960a7..26c4d6ca1 100644 --- a/__tests__/utils/flush-pending-task-attachments.test.ts +++ b/__tests__/utils/flush-pending-task-attachments.test.ts @@ -3,7 +3,6 @@ import { setPendingTaskAttachments, usePendingTaskAttachmentsStore, } from "#/stores/pending-task-attachments-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments"; const sendMessageWithAttachments = vi.fn(); @@ -22,7 +21,6 @@ vi.mock("#/i18n", () => ({ describe("flushPendingTaskAttachments", () => { beforeEach(() => { vi.clearAllMocks(); - useOptimisticUserMessageStore.getState().clearPendingMessages(); usePendingTaskAttachmentsStore.setState({ byTaskId: {} }); sendMessageWithAttachments.mockResolvedValue({ @@ -54,8 +52,5 @@ describe("flushPendingTaskAttachments", () => { content: "hello", }), ); - expect( - useOptimisticUserMessageStore.getState().pendingMessages, - ).toHaveLength(1); }); }); diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index 0e576a65a..b556ee628 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -7,7 +7,6 @@ import { useCreateConversation } from "#/hooks/mutation/use-create-conversation" import { useModelInterceptor } from "#/hooks/chat/use-model-interceptor"; import { useChatAttachmentUpload } from "#/hooks/chat/use-chat-attachment-upload"; import { useConversationStore } from "#/stores/conversation-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { setPendingTaskAttachments } from "#/stores/pending-task-attachments-store"; import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; import { useNavigation } from "#/context/navigation-context"; @@ -45,9 +44,6 @@ export function HomeChatLauncher() { const isCreating = isPending || isCreatingElsewhere; const { images, files, imagesMarkedUploadAsFile, clearAllFiles } = useConversationStore(); - const enqueuePendingMessage = useOptimisticUserMessageStore( - (state) => state.enqueuePendingMessage, - ); const { handleUpload } = useChatAttachmentUpload(); const hasSelection = isLocal @@ -67,8 +63,11 @@ export function HomeChatLauncher() { // 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 || undefined, + query: hasAttachments ? undefined : trimmed || undefined, }; if (isLocal && pendingWorkspace) { variables = { ...variables, workingDir: pendingWorkspace.path }; @@ -123,7 +122,7 @@ export function HomeChatLauncher() { return; } else { try { - const sent = await sendMessageWithAttachments({ + await sendMessageWithAttachments({ conversationId: data.conversation_id, content: trimmed, images: attachmentSnapshot.images, @@ -131,14 +130,6 @@ export function HomeChatLauncher() { imagesMarkedUploadAsFile, t, }); - enqueuePendingMessage({ - conversationId: data.conversation_id, - text: sent.text, - content: sent.content, - imageUrls: sent.imageUrls, - fileUrls: sent.fileUrls, - timestamp: sent.timestamp, - }); clearAllFiles(); } catch (error) { displayErrorToast(error instanceof Error ? error.message : null); diff --git a/src/utils/flush-pending-task-attachments.ts b/src/utils/flush-pending-task-attachments.ts index 365c09cee..f00357cd2 100644 --- a/src/utils/flush-pending-task-attachments.ts +++ b/src/utils/flush-pending-task-attachments.ts @@ -1,6 +1,5 @@ import i18n, { OPENHANDS_I18N_NAMESPACE, waitForI18n } from "#/i18n"; import { consumePendingTaskAttachments } from "#/stores/pending-task-attachments-store"; -import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; import { sendMessageWithAttachments } from "#/utils/send-message-with-attachments"; @@ -19,7 +18,7 @@ export async function flushPendingTaskAttachments( try { await waitForI18n(); - const sent = await sendMessageWithAttachments({ + await sendMessageWithAttachments({ conversationId, content: pending.content, images: pending.images, @@ -27,15 +26,6 @@ export async function flushPendingTaskAttachments( imagesMarkedUploadAsFile: pending.imagesMarkedUploadAsFile, t: i18n.getFixedT(null, OPENHANDS_I18N_NAMESPACE), }); - - useOptimisticUserMessageStore.getState().enqueuePendingMessage({ - conversationId, - text: sent.text, - content: sent.content, - imageUrls: sent.imageUrls, - fileUrls: sent.fileUrls, - timestamp: sent.timestamp, - }); } catch (error) { displayErrorToast(error instanceof Error ? error.message : null); throw error; From 14020bd2384be34c570af641e9250c613993a437 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 21 May 2026 09:49:20 -0700 Subject: [PATCH 13/19] fix(cloud): show home submit as first message during task provisioning Enqueue optimistic pending messages on cloud start-task routes so the chat shows the user's message while the sandbox provisions, hide empty-state suggestions during provisioning, and reassign pending bubbles to the real conversation id when the task is ready. Co-authored-by: Cursor --- .../components/chat/chat-interface.test.tsx | 62 ++++++++++++++ .../features/home/home-chat-launcher.test.tsx | 80 +++++++++++++++++++ .../hooks/query/use-task-polling.test.tsx | 27 +++++++ .../optimistic-user-message-store.test.ts | 20 +++++ .../features/chat/chat-interface.tsx | 14 +++- .../features/chat/pending-user-messages.tsx | 7 +- .../features/home/home-chat-launcher.tsx | 31 +++++-- src/hooks/query/use-task-polling.ts | 5 ++ src/stores/optimistic-user-message-store.ts | 17 ++++ .../enqueue-home-task-pending-message.ts | 30 +++++++ 10 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 src/utils/enqueue-home-task-pending-message.ts diff --git a/__tests__/components/chat/chat-interface.test.tsx b/__tests__/components/chat/chat-interface.test.tsx index a630ae81b..040c18445 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,32 @@ 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(); + }); }); describe("ChatInterface - Empty state", () => { diff --git a/__tests__/components/features/home/home-chat-launcher.test.tsx b/__tests__/components/features/home/home-chat-launcher.test.tsx index e1136cd2c..a9d396509 100644 --- a/__tests__/components/features/home/home-chat-launcher.test.tsx +++ b/__tests__/components/features/home/home-chat-launcher.test.tsx @@ -11,6 +11,7 @@ const mockNavigate = vi.fn(); const mockUseActiveBackend = vi.fn(); const sendMessageWithAttachments = vi.fn(); const mockClearAllFiles = vi.fn(); +const enqueueHomeTaskPendingMessage = vi.fn(); let mockImages: File[] = []; let mockFiles: File[] = []; @@ -20,6 +21,11 @@ vi.mock("#/utils/send-message-with-attachments", () => ({ sendMessageWithAttachments(...args), })); +vi.mock("#/utils/enqueue-home-task-pending-message", () => ({ + enqueueHomeTaskPendingMessage: (...args: unknown[]) => + enqueueHomeTaskPendingMessage(...args), +})); + vi.mock("#/stores/conversation-store", () => ({ useConversationStore: () => ({ images: mockImages, @@ -220,6 +226,7 @@ describe("HomeChatLauncher", () => { mockImages = []; mockFiles = []; mockUseActiveBackend.mockReturnValue(localBackend); + enqueueHomeTaskPendingMessage.mockResolvedValue(undefined); sendMessageWithAttachments.mockResolvedValue({ text: "hello world", content: "hello world", @@ -363,4 +370,77 @@ describe("HomeChatLauncher", () => { await waitFor(() => expect(toastErrorSpy).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..0eed8b25f 100644 --- a/__tests__/hooks/query/use-task-polling.test.tsx +++ b/__tests__/hooks/query/use-task-polling.test.tsx @@ -6,6 +6,7 @@ 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, @@ -63,6 +64,7 @@ describe("useTaskPolling", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + useOptimisticUserMessageStore.setState({ pendingMessages: [] }); }); afterEach(() => { @@ -95,4 +97,29 @@ describe("useTaskPolling", () => { "123", ); }); + + it("reassigns optimistic pending messages before redirecting to the real conversation", async () => { + vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue( + readyTask, + ); + useOptimisticUserMessageStore.getState().enqueuePendingMessage({ + conversationId: "task-123", + text: "hello from home", + }); + + renderHook(() => useTaskPolling(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith("/conversations/conversation-1", { + replace: true, + }); + }); + + 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__/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/src/components/features/chat/chat-interface.tsx b/src/components/features/chat/chat-interface.tsx index 28ccba014..a7c1fd3bf 100644 --- a/src/components/features/chat/chat-interface.tsx +++ b/src/components/features/chat/chat-interface.tsx @@ -198,7 +198,18 @@ export function ChatInterface() { [maybeLoadOlder], ); - const hasPendingUserMessages = pendingMessages.length > 0; + const hasPendingUserMessages = React.useMemo( + () => + conversationId + ? pendingMessages.some( + (message) => message.conversationId === conversationId, + ) + : false, + [pendingMessages, conversationId], + ); + + const isProvisioningTask = + isTask && taskStatus !== "READY" && taskStatus !== "ERROR"; // Show V1 messages immediately if events exist in store (e.g., remount), // or once loading completes. This replaces the old transition-observation @@ -416,6 +427,7 @@ export function ChatInterface() { !userEventsExist && !hasModelEntries && !isChatLoading && + !isProvisioningTask && !isArchivedConversation && ( setMessageToSend(message)} diff --git a/src/components/features/chat/pending-user-messages.tsx b/src/components/features/chat/pending-user-messages.tsx index 004f6eb4b..f53b61db0 100644 --- a/src/components/features/chat/pending-user-messages.tsx +++ b/src/components/features/chat/pending-user-messages.tsx @@ -3,6 +3,7 @@ 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 { ImageCarousel } from "#/components/features/images/image-carousel"; import { ChatMessage } from "./chat-message"; /** @@ -84,7 +85,11 @@ export function PendingUserMessages() { ? () => handleRetry(message.id) : undefined } - /> + > + {message.imageUrls.length > 0 && ( + + )} + ))} ); diff --git a/src/components/features/home/home-chat-launcher.tsx b/src/components/features/home/home-chat-launcher.tsx index b556ee628..0578c096f 100644 --- a/src/components/features/home/home-chat-launcher.tsx +++ b/src/components/features/home/home-chat-launcher.tsx @@ -8,6 +8,7 @@ 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"; @@ -92,18 +93,19 @@ export function HomeChatLauncher() { createConversation(variables, { onSuccess: async (data) => { toast.dismiss(toastId); + 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 || data.conversation_id.startsWith("task-"); + const shouldDeferAttachments = !isLocal || isTaskConversation; if (shouldDeferAttachments) { const taskId = data.task_id ?? - (data.conversation_id.startsWith("task-") - ? data.conversation_id.slice("task-".length) + (isTaskConversation + ? targetConversationId.slice("task-".length) : null); if (!taskId) { @@ -118,12 +120,18 @@ export function HomeChatLauncher() { imagesMarkedUploadAsFile: [...imagesMarkedUploadAsFile], }); clearAllFiles(); - navigate(`/conversations/task-${taskId}`); + await enqueueHomeTaskPendingMessage({ + conversationId: targetConversationId, + text: trimmed, + images: attachmentSnapshot.images, + imagesMarkedUploadAsFile, + }); + navigate(`/conversations/${targetConversationId}`); return; } else { try { await sendMessageWithAttachments({ - conversationId: data.conversation_id, + conversationId: targetConversationId, content: trimmed, images: attachmentSnapshot.images, files: attachmentSnapshot.files, @@ -138,7 +146,16 @@ export function HomeChatLauncher() { } } - navigate(`/conversations/${data.conversation_id}`); + if (isTaskConversation && trimmed) { + await enqueueHomeTaskPendingMessage({ + conversationId: targetConversationId, + text: trimmed, + images: [], + imagesMarkedUploadAsFile: [], + }); + } + + navigate(`/conversations/${targetConversationId}`); }, onError: (error) => { toast.dismiss(toastId); diff --git a/src/hooks/query/use-task-polling.ts b/src/hooks/query/use-task-polling.ts index 061705ef7..be82f6cc5 100644 --- a/src/hooks/query/use-task-polling.ts +++ b/src/hooks/query/use-task-polling.ts @@ -7,6 +7,7 @@ import { consumePendingTaskDraft, setConversationState, } from "#/utils/conversation-local-storage"; +import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store"; import { flushPendingTaskAttachments } from "#/utils/flush-pending-task-attachments"; /** @@ -76,6 +77,10 @@ export const useTaskPolling = () => { void (async () => { await flushPendingTaskAttachments(taskId, task.app_conversation_id!); + useOptimisticUserMessageStore + .getState() + .reassignPendingMessages(`task-${taskId}`, task.app_conversation_id!); + const pendingDraft = consumePendingTaskDraft(taskId); if (pendingDraft) { setConversationState(task.app_conversation_id!, { 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/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: [], + }); +} From e1feab95b3c24c2beca755a2c37466312cc969d9 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 21 May 2026 09:51:42 -0700 Subject: [PATCH 14/19] fix(chat): suppress spurious older-messages errors on new conversations Skip auto-pagination when the initial history page is complete, on cloud start-task routes, or while provisioning, and stop surfacing an error banner when older events cannot be anchored. Co-authored-by: Cursor --- .../hooks/use-load-older-events.test.tsx | 79 ++++++++++++++++--- .../features/chat/chat-interface.tsx | 18 +++-- src/hooks/use-load-older-events.ts | 63 +++++++++++++-- 3 files changed, 136 insertions(+), 24 deletions(-) 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/src/components/features/chat/chat-interface.tsx b/src/components/features/chat/chat-interface.tsx index a7c1fd3bf..56d285f40 100644 --- a/src/components/features/chat/chat-interface.tsx +++ b/src/components/features/chat/chat-interface.tsx @@ -56,6 +56,8 @@ export function ChatInterface() { const { errorMessage, removeErrorMessage, setErrorMessage } = useErrorMessageStore(); const { isTask, taskStatus, taskDetail } = useTaskPolling(); + const isProvisioningTask = + isTask && taskStatus !== "READY" && taskStatus !== "ERROR"; const conversationWebSocket = useConversationWebSocket(); const { send } = useSendMessage(); const { @@ -164,7 +166,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 = @@ -184,7 +188,14 @@ export function ChatInterface() { setErrorMessage(message); }); }, - [hasMoreOlderEvents, isLoadingOlderEvents, loadOlder, setErrorMessage, t], + [ + hasMoreOlderEvents, + isLoadingOlderEvents, + isProvisioningTask, + loadOlder, + setErrorMessage, + t, + ], ); const handleWheelForPagination = React.useCallback( @@ -208,9 +219,6 @@ export function ChatInterface() { [pendingMessages, conversationId], ); - const isProvisioningTask = - isTask && taskStatus !== "READY" && taskStatus !== "ERROR"; - // 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 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, From cd6a809ae35527a2a7f10e464ebfd59566f9aa01 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 21 May 2026 09:53:59 -0700 Subject: [PATCH 15/19] fix(chat): hide history skeleton when pending message is visible Treat optimistic home-submit bubbles as loaded content so the feed skeleton does not flash over the user's first message on new conversations. Co-authored-by: Cursor --- .../chat/message-display-continuity.test.tsx | 31 +++++++++++++++++++ .../features/chat/chat-interface.tsx | 9 ++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/__tests__/components/chat/message-display-continuity.test.tsx b/__tests__/components/chat/message-display-continuity.test.tsx index fbe747548..33d3f0b8d 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" }, }); @@ -186,6 +189,34 @@ 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(), + }); + + 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/src/components/features/chat/chat-interface.tsx b/src/components/features/chat/chat-interface.tsx index 56d285f40..c578c97fc 100644 --- a/src/components/features/chat/chat-interface.tsx +++ b/src/components/features/chat/chat-interface.tsx @@ -220,11 +220,14 @@ export function ChatInterface() { ); // 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; From 51d2c5e5d90dc92c023256dbb18eac7f6bc3b3e3 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 21 May 2026 09:57:22 -0700 Subject: [PATCH 16/19] fix(cloud): stop empty-state suggestions flashing after home submit Keep pending bubbles linked across task-to-conversation redirect, reassign them before paint on the real route, and tighten suggestion gating so the Let's Start Building overlay does not flash during provisioning. Co-authored-by: Cursor --- .../components/chat/chat-interface.test.tsx | 26 ++++++++ .../hooks/query/use-task-polling.test.tsx | 43 +++++++++++-- .../utils/pending-task-message-link.test.ts | 49 +++++++++++++++ .../features/chat/chat-interface.tsx | 14 +++-- .../features/chat/pending-user-messages.tsx | 8 ++- .../conversation-websocket-context.tsx | 3 +- src/hooks/query/use-task-polling.ts | 40 ++++++++++-- src/utils/pending-task-message-link.ts | 61 +++++++++++++++++++ 8 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 __tests__/utils/pending-task-message-link.test.ts create mode 100644 src/utils/pending-task-message-link.ts diff --git a/__tests__/components/chat/chat-interface.test.tsx b/__tests__/components/chat/chat-interface.test.tsx index 040c18445..593f15a3b 100644 --- a/__tests__/components/chat/chat-interface.test.tsx +++ b/__tests__/components/chat/chat-interface.test.tsx @@ -249,6 +249,32 @@ describe("ChatInterface - Chat Suggestions", () => { 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__/hooks/query/use-task-polling.test.tsx b/__tests__/hooks/query/use-task-polling.test.tsx index 0eed8b25f..eab273ca8 100644 --- a/__tests__/hooks/query/use-task-polling.test.tsx +++ b/__tests__/hooks/query/use-task-polling.test.tsx @@ -12,6 +12,7 @@ import { 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", @@ -64,6 +65,7 @@ describe("useTaskPolling", () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + resetPendingTaskMessageLinkState(); useOptimisticUserMessageStore.setState({ pendingMessages: [] }); }); @@ -98,7 +100,7 @@ describe("useTaskPolling", () => { ); }); - it("reassigns optimistic pending messages before redirecting to the real conversation", async () => { + it("reassigns optimistic pending messages on the real conversation route", async () => { vi.mocked(AgentServerConversationService.getStartTask).mockResolvedValue( readyTask, ); @@ -107,8 +109,31 @@ describe("useTaskPolling", () => { 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: createWrapper(), + wrapper: createWrapperForConversation("task-123"), }); await waitFor(() => { @@ -117,9 +142,15 @@ describe("useTaskPolling", () => { }); }); - const pending = useOptimisticUserMessageStore.getState().pendingMessages; - expect(pending).toHaveLength(1); - expect(pending[0].conversationId).toBe("conversation-1"); - expect(pending[0].text).toBe("hello from home"); + 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__/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/components/features/chat/chat-interface.tsx b/src/components/features/chat/chat-interface.tsx index c578c97fc..6a3303be4 100644 --- a/src/components/features/chat/chat-interface.tsx +++ b/src/components/features/chat/chat-interface.tsx @@ -33,6 +33,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"; @@ -56,8 +57,9 @@ export function ChatInterface() { const { errorMessage, removeErrorMessage, setErrorMessage } = useErrorMessageStore(); const { isTask, taskStatus, taskDetail } = useTaskPolling(); - const isProvisioningTask = - isTask && taskStatus !== "READY" && taskStatus !== "ERROR"; + // 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 { @@ -212,8 +214,11 @@ export function ChatInterface() { const hasPendingUserMessages = React.useMemo( () => conversationId - ? pendingMessages.some( - (message) => message.conversationId === conversationId, + ? pendingMessages.some((message) => + matchesPendingConversationId( + conversationId, + message.conversationId, + ), ) : false, [pendingMessages, conversationId], @@ -439,6 +444,7 @@ export function ChatInterface() { !hasModelEntries && !isChatLoading && !isProvisioningTask && + totalEvents === 0 && !isArchivedConversation && ( setMessageToSend(message)} diff --git a/src/components/features/chat/pending-user-messages.tsx b/src/components/features/chat/pending-user-messages.tsx index f53b61db0..9efeb51b5 100644 --- a/src/components/features/chat/pending-user-messages.tsx +++ b/src/components/features/chat/pending-user-messages.tsx @@ -3,6 +3,7 @@ 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"; @@ -34,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], diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx index dd2817599..3e8d511f5 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, @@ -207,7 +208,7 @@ export function ConversationWebSocketProvider({ const isLoadingHistoryMain = !!conversationId && isPreloadingHistory; - useEffect(() => { + useLayoutEffect(() => { if (!preloadedHistory || preloadedHistory.events.length === 0) { return; } diff --git a/src/hooks/query/use-task-polling.ts b/src/hooks/query/use-task-polling.ts index be82f6cc5..15c509996 100644 --- a/src/hooks/query/use-task-polling.ts +++ b/src/hooks/query/use-task-polling.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } 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"; @@ -9,6 +9,12 @@ import { } 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. @@ -60,6 +66,29 @@ export const useTaskPolling = () => { 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; @@ -77,9 +106,12 @@ export const useTaskPolling = () => { void (async () => { await flushPendingTaskAttachments(taskId, task.app_conversation_id!); - useOptimisticUserMessageStore - .getState() - .reassignPendingMessages(`task-${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) { 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; +} From 2e93dcd1e7fd8f2402220558f00594c0a96a75f8 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 21 May 2026 10:02:16 -0700 Subject: [PATCH 17/19] fix(chat): show upload-as-file toggle on all attached images Mark every attached image for the per-image upload control, not just clipboard pastes, so file picker and drag-and-drop previews match pasted screenshot behavior. Co-authored-by: Cursor --- __tests__/stores/conversation-store.test.ts | 2 +- src/components/features/chat/uploaded-files.tsx | 5 ++--- src/hooks/chat/use-chat-attachment-upload.ts | 4 ++-- src/stores/conversation-store.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/__tests__/stores/conversation-store.test.ts b/__tests__/stores/conversation-store.test.ts index dc541322f..953f839cc 100644 --- a/__tests__/stores/conversation-store.test.ts +++ b/__tests__/stores/conversation-store.test.ts @@ -87,7 +87,7 @@ describe("conversation store", () => { }); describe("pastedImageNames", () => { - it("tracks clipboard-pasted image names for the upload-as-file control", () => { + it("tracks attached image names for the upload-as-file control", () => { useConversationStore.getState().markImagesAsPasted(["shot.png"]); expect(useConversationStore.getState().pastedImageNames).toEqual([ "shot.png", diff --git a/src/components/features/chat/uploaded-files.tsx b/src/components/features/chat/uploaded-files.tsx index fad4a935f..ebfd2ff7b 100644 --- a/src/components/features/chat/uploaded-files.tsx +++ b/src/components/features/chat/uploaded-files.tsx @@ -9,7 +9,6 @@ export function UploadedFiles() { loadingFiles, loadingImages, imagesMarkedUploadAsFile, - pastedImageNames, removeFile, removeImage, toggleImageUploadAsFile, @@ -67,7 +66,7 @@ export function UploadedFiles() { image={image} onRemove={() => handleRemoveImage(index)} isLoading={loadingImages.includes(image.name)} - showUploadAsFileToggle={pastedImageNames.includes(image.name)} + showUploadAsFileToggle uploadAsFileActive={imagesMarkedUploadAsFile.includes(image.name)} onToggleUploadAsFile={() => toggleImageUploadAsFile(image.name)} /> @@ -83,7 +82,7 @@ export function UploadedFiles() { image={tempImage} onRemove={() => {}} // No remove action during loading isLoading - showUploadAsFileToggle={pastedImageNames.includes(imageName)} + showUploadAsFileToggle uploadAsFileActive={imagesMarkedUploadAsFile.includes(imageName)} onToggleUploadAsFile={() => toggleImageUploadAsFile(imageName)} /> diff --git a/src/hooks/chat/use-chat-attachment-upload.ts b/src/hooks/chat/use-chat-attachment-upload.ts index 02ce1b540..760b3a585 100644 --- a/src/hooks/chat/use-chat-attachment-upload.ts +++ b/src/hooks/chat/use-chat-attachment-upload.ts @@ -26,7 +26,7 @@ export function useChatAttachmentUpload() { } = useConversationStore(); const handleUpload = useCallback( - async (selectedFiles: File[], options?: ChatAttachmentUploadOptions) => { + async (selectedFiles: File[], _options?: ChatAttachmentUploadOptions) => { const validation = validateFiles(selectedFiles, [...images, ...files]); if (!validation.isValid) { @@ -37,7 +37,7 @@ export function useChatAttachmentUpload() { const validFiles = selectedFiles.filter((f) => !isFileImage(f)); const validImages = selectedFiles.filter((f) => isFileImage(f)); - if (options?.fromPaste && validImages.length > 0) { + if (validImages.length > 0) { markImagesAsPasted(validImages.map((image) => image.name)); } diff --git a/src/stores/conversation-store.ts b/src/stores/conversation-store.ts index ef04df830..8e3b75ee6 100644 --- a/src/stores/conversation-store.ts +++ b/src/stores/conversation-store.ts @@ -27,7 +27,7 @@ interface ConversationState { files: File[]; /** Image file names (e.g. pasted screenshots) to send via file upload instead of vision embed. */ imagesMarkedUploadAsFile: string[]; - /** Image file names attached via clipboard paste (controls per-image upload-as-file UI). */ + /** 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 From ff21cdc39858445865f58752bb9f57487954d8e4 Mon Sep 17 00:00:00 2001 From: FraterCCCLXIII Date: Thu, 21 May 2026 10:05:07 -0700 Subject: [PATCH 18/19] fix(chat): use file-plus icon for upload-as-file toggle Replace the generic upload glyph with Lucide FilePlus so the per-image control reads more clearly as "add this image as a workspace file." Co-authored-by: Cursor --- .../features/chat/pasted-image-upload-as-file-button.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 534ae714e..1ad77260d 100644 --- a/src/components/features/chat/pasted-image-upload-as-file-button.tsx +++ b/src/components/features/chat/pasted-image-upload-as-file-button.tsx @@ -1,4 +1,4 @@ -import { Check, Upload } from "lucide-react"; +import { Check, FilePlus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip"; import { I18nKey } from "#/i18n/declaration"; @@ -42,7 +42,7 @@ export function PastedImageUploadAsFileButton({ {active ? ( ) : ( - + )} From b1a37496cb7d1b89bb120f2c40d005ba39d1556a Mon Sep 17 00:00:00 2001 From: hieptl Date: Sun, 24 May 2026 16:15:53 +0700 Subject: [PATCH 19/19] refactor: remove unrelated files --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index b473ee2db..53c706ff2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3455,7 +3455,7 @@ }, "node_modules/@openhands/typescript-client": { "version": "0.1.1", - "resolved": "git+ssh://git@github.com/OpenHands/typescript-client.git#v1.23.0", + "resolved": "git+https://github.com/OpenHands/typescript-client.git#v1.23.0", "license": "MIT", "dependencies": { "@openrouter/sdk": "^0.12.35",