fix(ui): retain first-chat uploads through task creation

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-19 15:06:00 -05:00
1 parent 2e5bf12411
commit 9d972c8b1e
3 files changed
+62 -10

No files matched your search

+11
View File
@@ -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
+40
View File
@@ -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(<TaskChatThread comments={[]} onAdd={async () => {}} 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(<TaskChatThread {...props} issueId="" />);
const composer = container.querySelector('[data-testid="mock-editor"]');
const input = container.querySelector<HTMLInputElement>('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(<TaskChatThread {...props} issueId="persisted-chat" initialHistoryPending />);
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(<TaskChatThread {...props} issueId="persisted-chat" initialHistoryPending={false} />);
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<HTMLButtonElement>('[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",
+11 -10
View File
@@ -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<string | null | undefined>(
() => (historyPending ? undefined : issueId),
const historyKey = conversationMode && draftKey ? draftKey : issueId;
const [revealedHistoryKey, setRevealedHistoryKey] = useState<string | null | undefined>(
() => (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);