From 6430b3b699b3673604bf43f8ecc89ca87b2eb874 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 26 Apr 2026 21:41:28 -0700 Subject: [PATCH] fix(chat): hydrate user-side file attachments on chat reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer follow-up to PR #2134 (Optional finding). The history loader walked text on the user branch but never extracted file parts — so a chat reload after a session where the user dragged in a file rendered the text bubble but lost the download chip. Symmetric to the agent branch which already handles this via extractFilesFromTask. Wire shape from ChatTab's outbound POST: request_body = {params: {message: {parts: [ {kind: "text", text: "..."}, {kind: "file", file: {uri, name, mimeType?, size?}} ]}}} extractFilesFromTask walks `task.parts`, so we feed it `params.message` (the inner object that has the parts array). Three new tests: - hydrates file attachments from request_body - emits an attachments-only bubble when text is empty (drag-drop without caption — pre-fix the empty userText short-circuited and the row was dropped entirely) - internal-self predicate suppresses the row even with attachments (defence-in-depth for future internal triggers) Stacked on #2134; this branch's parent commit is its tip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../chat/__tests__/historyHydration.test.ts | 78 +++++++++++++++++++ .../components/tabs/chat/historyHydration.ts | 22 +++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts b/canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts index fa164621..b9584f38 100644 --- a/canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts +++ b/canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts @@ -112,6 +112,84 @@ describe("activityRowToMessages", () => { const msgs = activityRowToMessages(row, NEVER_INTERNAL); expect(msgs.find((m) => m.role === "user")).toBeUndefined(); }); + + // Reviewer follow-up: the pre-fix loader didn't extract user-side + // file parts, so a chat reload after a session where the user + // dragged in a file showed the text bubble but lost the chip. + // Symmetric to the agent attachment hydration below. + + it("hydrates user-side file attachments from request_body.params.message.parts", () => { + const row = makeRow({ + request_body: { + params: { + message: { + parts: [ + { kind: "text", text: "here's the screenshot" }, + { + kind: "file", + file: { + name: "shot.png", + mimeType: "image/png", + uri: "workspace:/uploads/shot.png", + size: 4096, + }, + }, + ], + }, + }, + }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + const user = msgs.find((m) => m.role === "user")!; + expect(user.content).toBe("here's the screenshot"); + expect(user.attachments).toEqual([ + { name: "shot.png", mimeType: "image/png", uri: "workspace:/uploads/shot.png", size: 4096 }, + ]); + }); + + it("emits an attachments-only user bubble when text is empty (drag-drop without caption)", () => { + // Some users drop a file with no message — the bubble should + // still render so the file appears in history. Pre-fix the + // empty userText short-circuited and the row was dropped. + const row = makeRow({ + request_body: { + params: { + message: { + parts: [ + { kind: "file", file: { name: "report.pdf", uri: "workspace:/uploads/report.pdf" } }, + ], + }, + }, + }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs).toHaveLength(1); + expect(msgs[0].role).toBe("user"); + expect(msgs[0].content).toBe(""); + expect(msgs[0].attachments).toHaveLength(1); + expect(msgs[0].attachments![0].name).toBe("report.pdf"); + }); + + it("internal-self predicate suppresses the row even if it carries attachments", () => { + // Defence-in-depth: heartbeat self-trigger never produces + // attachments, but if a future internal trigger DID, we still + // want to suppress (otherwise it'd render as the user attaching + // something they never touched). + const row = makeRow({ + request_body: { + params: { + message: { + parts: [ + { kind: "text", text: "Delegation results are ready..." }, + { kind: "file", file: { name: "x.zip", uri: "workspace:/x.zip" } }, + ], + }, + }, + }, + }); + const msgs = activityRowToMessages(row, (t) => t.startsWith("Delegation results are ready")); + expect(msgs.find((m) => m.role === "user")).toBeUndefined(); + }); }); describe("agent-message extraction", () => { diff --git a/canvas/src/components/tabs/chat/historyHydration.ts b/canvas/src/components/tabs/chat/historyHydration.ts index b6feab5b..09bcea35 100644 --- a/canvas/src/components/tabs/chat/historyHydration.ts +++ b/canvas/src/components/tabs/chat/historyHydration.ts @@ -35,8 +35,26 @@ export function activityRowToMessages( const out: ChatMessage[] = []; const userText = extractRequestText(row.request_body); - if (userText && !isInternalSelfMessage(userText)) { - out.push({ ...createMessage("user", userText), timestamp: row.created_at }); + // Hydrate user-side file attachments out of the same A2A envelope. + // Without this, a chat reload after a session where the user dragged + // in a file shows the text bubble but loses the download chip — the + // pre-fix loader only walked text via extractRequestText. Mirrors + // the agent branch below. Wire shape from ChatTab's outbound POST: + // request_body = {params: {message: {parts: [{kind:"text"}, {kind:"file", file:{...}}]}}} + // extractFilesFromTask walks `task.parts`, so we feed it `params.message`. + const userMsg = (row.request_body?.params as Record | undefined) + ?.message as Record | undefined; + const userAttachments = userMsg ? extractFilesFromTask(userMsg) : []; + // Internal-self messages (e.g. heartbeat self-trigger) take precedence + // — drop the row even if it carries attachments, since the heartbeat + // path doesn't produce attachments anyway and keeping the bubble would + // misattribute it to the user. + const isInternal = !!userText && isInternalSelfMessage(userText); + if (!isInternal && (userText || userAttachments.length > 0)) { + out.push({ + ...createMessage("user", userText, userAttachments), + timestamp: row.created_at, + }); } if (row.response_body) {