From 9d972c8b1e3e4514d2dc3ddba0d70afe5ad32049 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 19 Oct 2026 15:06:00 -0500 Subject: [PATCH] fix(ui): retain first-chat uploads through task creation Co-Authored-By: Paperclip --- maps/feature-map/agent-chat.md | 11 +++++++ ui/src/components/TaskChatThread.test.tsx | 40 +++++++++++++++++++++++ ui/src/components/TaskChatThread.tsx | 21 ++++++------ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/maps/feature-map/agent-chat.md b/maps/feature-map/agent-chat.md index a5fc7048e1d..c8cb94c5fe3 100644 --- a/maps/feature-map/agent-chat.md +++ b/maps/feature-map/agent-chat.md @@ -39,6 +39,17 @@ Preconditions: follow the [baseline](./README.md#before-driving-a-journey). Enab Automated: [chat landing](../../ui/src/pages/AgentChats.test.tsx) checks recent-chat routing; [conversation service](../../server/src/__tests__/agent-conversations.test.ts) covers persisted conversation behavior. +Automated: [thread and composer](../../ui/src/components/TaskChatThread.test.tsx) +keeps an in-flight attachment through first-chat creation on desktop and mobile, +while initial history for an existing conversation stays deferred. The +[first-upload browser journey](../../tests/e2e/agent-chat-projects.spec.ts) +checks the visible file chip, server attachment, no premature agent run, and +saved reference after send and reload. That journey uses a simulated agent. + +Manual: In a conversation with no messages, attach a file before the first +send. Confirm the same file stays in the composer after the chat is created. +Send it once, reload, and confirm the file and message remain. + Manual: Start a conversation, send a recognizable request, and inspect its reply. Navigate away and reopen the same history. Use `/new` for a fresh session within the same standing conversation. Test an unavailable historical agent and an diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index 12f9dc11194..90ab8e828b7 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -343,6 +343,46 @@ it.each([true, false])("defers hidden content until history is ready and keeps i expect(container.querySelector('.task-chat-history-pending')).toBeNull(); }); +it("defers initial history for a saved conversation with its own draft key", () => { + render( {}} issueId="saved-chat" + conversationMode draftKey="agent-chat:company:saved-agent" initialHistoryPending />); + expect(container.querySelector('[data-testid="mock-editor"]')).toBeNull(); + expect(container.querySelector('[data-testid="task-chat-composer-dock"]')?.hasAttribute("inert")).toBe(true); +}); + +it.each([true, false])("keeps an upload attached when a conversation draft becomes a saved task (mobile=%s)", async (mobile) => { + sidebarState.isMobile = mobile; + const onAdd = vi.fn().mockResolvedValue(undefined); + let resolveUpload!: (receipt: { id: string; contentPath: string; originalFilename: string }) => void; + const onAttachImage = vi.fn().mockReturnValue(new Promise(resolve => { resolveUpload = resolve; })); + const props = { comments: [], onAdd, onAttachImage, conversationMode: true, draftKey: "agent-chat:company:agent" }; + render(); + const composer = container.querySelector('[data-testid="mock-editor"]'); + const input = container.querySelector('input[type="file"]')!; + const file = new File(["notes"], "chat-notes.txt", { type: "text/plain" }); + Object.defineProperty(input, "files", { configurable: true, value: [file] }); + await act(async () => { input.dispatchEvent(new Event("change", { bubbles: true })); }); + expect(onAttachImage).toHaveBeenCalledExactlyOnceWith(file); + expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')?.textContent).toContain("chat-notes.txt"); + + render(); + expect(container.querySelector('[data-testid="mock-editor"]')).toBe(composer); + expect(container.querySelector('[data-testid="task-chat-composer-attachments"]')?.textContent).toContain("chat-notes.txt"); + expect(onAdd).not.toHaveBeenCalled(); + const id = "9af8228f-0be7-45ae-a104-6fbe0af6f1d3"; + await act(async () => { resolveUpload({ id, contentPath: `/api/attachments/${id}/content`, originalFilename: file.name }); }); + render(); + await act(async () => { await new Promise(resolve => requestAnimationFrame(resolve)); }); + const chip = container.querySelector('[data-testid="task-chat-composer-attachments"] [data-slot="attachment"]'); + expect(chip?.getAttribute("data-state")).toBe("done"); + const send = container.querySelector('[data-testid="task-chat-composer-send"]')!; + expect(send.disabled).toBe(false); + await act(async () => { send.click(); }); + expect(onAdd).toHaveBeenCalledTimes(1); + expect(onAdd.mock.calls[0]?.[0]).toBe(`[chat-notes.txt](/api/attachments/${id}/content)`); + expect(onAdd.mock.calls[0]?.[3]).toEqual([id]); +}); + it("prepares comment-link history and its composer before reveal", async () => { const props = { issueId: "linked-issue", diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index f0468ecd8bd..3c95db55326 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2924,12 +2924,13 @@ export function TaskChatThread(props: TaskChatThreadProps) { (logTranscriptByRun.get(run.id)?.length ?? 0) === 0 : Boolean(logErrorsByRun?.has(run.id)), ); - const [revealedIssue, setRevealedIssue] = useState( - () => (historyPending ? undefined : issueId), + const historyKey = conversationMode && draftKey ? draftKey : issueId; + const [revealedHistoryKey, setRevealedHistoryKey] = useState( + () => (historyPending ? undefined : historyKey), ); - const historyRevealed = revealedIssue === issueId; - const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ issueId: typeof issueId } | null>(null); - const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.issueId === issueId; + const historyRevealed = revealedHistoryKey === historyKey; + const [expiredHistoryWait, setExpiredHistoryWait] = useState<{ historyKey: typeof historyKey } | null>(null); + const historyWaitExpired = expiredHistoryWait !== null && expiredHistoryWait.historyKey === historyKey; const prepareInitialContent = Boolean(scrollNavigation.hash) || !historyPending || historyRevealed || historyWaitExpired; // Supporting requests can stall without rejecting. Bound the first reveal // independently of their pending states so saved conversation and the @@ -2937,19 +2938,19 @@ export function TaskChatThread(props: TaskChatThreadProps) { useEffect(() => { if (historyRevealed) return; const timer = window.setTimeout(() => { - setExpiredHistoryWait({ issueId }); - setRevealedIssue(issueId); + setExpiredHistoryWait({ historyKey }); + setRevealedHistoryKey(historyKey); }, INITIAL_HISTORY_REVEAL_TIMEOUT_MS); return () => window.clearTimeout(timer); - }, [historyRevealed, issueId]); + }, [historyRevealed, historyKey]); // Mount and measure the real thread while concealed, then reveal in one // commit. A frame also lets ancestor navigation scroll restoration finish. // Readiness is latched per issue: refetches never hide existing conversation. useEffect(() => { if (historyRevealed || historyPending) return; - const frame = requestAnimationFrame(() => setRevealedIssue(issueId)); + const frame = requestAnimationFrame(() => setRevealedHistoryKey(historyKey)); return () => cancelAnimationFrame(frame); - }, [historyPending, historyRevealed, issueId]); + }, [historyPending, historyRevealed, historyKey]); useEffect(() => { if (!historyRevealed || !issueId) return; scheduleIssueDetailPaintMeasure(ISSUE_DETAIL_CONTENT_PAINT_MARK, ISSUE_DETAIL_CONTENT_MEASURE);