From 8415870520cdeedf62ae12aaa259df56b1bd7045 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 26 Apr 2026 21:06:19 -0700 Subject: [PATCH 1/4] fix(chat): pin historical user-message timestamps to activity created_at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User flagged that all historical user bubbles render with the same "now" clock after a chat reload — both messages in the screenshot showed 9:01:58 PM despite being sent hours apart. ChatTab.tsx:142 minted user messages with createMessage(...) which calls new Date().toISOString() — fine for a freshly-typed message, wrong for hydrated history. Every reload re-stamped all user bubbles to the render moment, collapsing the visible chronology. The agent path on line 157 already overrides with a.created_at; mirror that. One-line fix (spread + override timestamp) plus a comment explaining why the override is load-bearing so the next refactor doesn't drop it. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ChatTab.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 68734f11..c4b326e7 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -136,10 +136,14 @@ async function loadMessagesFromDB(workspaceId: string): Promise<{ messages: Chat const messages: ChatMessage[] = []; // Activities are newest-first, reverse for chronological order for (const a of [...activities].reverse()) { - // Extract user message from request_body + // Extract user message from request_body. Pin the timestamp to + // the activity row's created_at — without this override every + // historical user bubble re-stamps to "now" on each chat reload, + // and ALL user messages collapse to the same render-time clock + // (same as the agent path on line 157). const userText = extractRequestText(a.request_body); if (userText && !isInternalSelfMessage(userText)) { - messages.push(createMessage("user", userText)); + messages.push({ ...createMessage("user", userText), timestamp: a.created_at }); } // Extract agent response — text AND any file attachments so a From fe204f04da7c96b5f11a41720a2b574041983ec1 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 26 Apr 2026 21:18:22 -0700 Subject: [PATCH 2/4] test(chat): extract historyHydration helper + 12 unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User pushed back: the timestamp bug should have been caught by E2E. Right — my earlier coverage tested the server contract (notify endpoint, WS broadcast filter) but never the chat-history HYDRATION path. Without a unit test that froze the wall clock and asserted timestamps came from created_at, a future refactor could re-introduce the same bug. This commit: 1. Extracts the per-row → ChatMessage[] mapping out of the closure inside loadMessagesFromDB into chat/historyHydration.ts. Pure function, no React dependency, easy to test. 2. Adds 12 vitest cases in __tests__/historyHydration.test.ts covering: - Timestamp regression (3 tests, with system time frozen to 2030 so a regression starts producing "2030-…" timestamps and the assertion fails unmistakably). The third test mirrors the user's screenshot: two rows with distinct created_at must produce distinct timestamps. - User-message extraction (text, internal-self filter, null body) - Agent-message extraction (text, error→system role, file attachments, null body, body with neither text nor files) - End-to-end: a single row with both request and response emits two messages with the same timestamp (the canonical canvas-source row pattern) 3. The new file-attachment test caught a SECOND latent bug — the helper was passing `response_body.result ?? response_body` to extractFiles FromTask, which passes the STRING "" for the notify-with- attachments shape `{result: "", parts: [...]}` and silently returns []. So a chat reload after an agent attached a file would lose the chips. Fixed by only unwrapping `result` when it's an object (the task-shape) and falling through to response_body otherwise (the notify shape). ChatTab now imports the helper and the loop body becomes one line: `messages.push(...activityRowToMessages(a, isInternalSelfMessage))`. Verification: - 12/12 historyHydration tests pass - 1072/1072 full canvas vitest pass (was 1060 before, +12) - tsc --noEmit clean Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ChatTab.tsx | 44 +--- .../chat/__tests__/historyHydration.test.ts | 192 ++++++++++++++++++ .../components/tabs/chat/historyHydration.ts | 65 ++++++ 3 files changed, 267 insertions(+), 34 deletions(-) create mode 100644 canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts create mode 100644 canvas/src/components/tabs/chat/historyHydration.ts diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index c4b326e7..bc551bef 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -10,9 +10,10 @@ import { closeWebSocketGracefully } from "@/lib/ws-close"; import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types"; import { uploadChatFiles, downloadChatFile } from "./chat/uploads"; import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews"; -import { extractResponseText, extractRequestText, extractFilesFromTask } from "./chat/message-parser"; +import { extractResponseText, extractFilesFromTask } from "./chat/message-parser"; import { AgentCommsPanel } from "./chat/AgentCommsPanel"; import { appendActivityLine } from "./chat/activityLog"; +import { activityRowToMessages, type ActivityRowForHydration } from "./chat/historyHydration"; import { runtimeDisplayName } from "@/lib/runtime-names"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -125,42 +126,17 @@ function extractReplyText(resp: A2AResponse): string { */ async function loadMessagesFromDB(workspaceId: string): Promise<{ messages: ChatMessage[]; error: string | null }> { try { - const activities = await api.get | null; - response_body: Record | null; - }>>(`/workspaces/${workspaceId}/activity?type=a2a_receive&source=canvas&limit=50`); + const activities = await api.get( + `/workspaces/${workspaceId}/activity?type=a2a_receive&source=canvas&limit=50`, + ); const messages: ChatMessage[] = []; - // Activities are newest-first, reverse for chronological order + // Activities are newest-first, reverse for chronological order. + // Per-row mapping lives in chat/historyHydration.ts so it can be + // unit-tested without spinning up the full ChatTab component + // (regression cover for the timestamp-collapse bug). for (const a of [...activities].reverse()) { - // Extract user message from request_body. Pin the timestamp to - // the activity row's created_at — without this override every - // historical user bubble re-stamps to "now" on each chat reload, - // and ALL user messages collapse to the same render-time clock - // (same as the agent path on line 157). - const userText = extractRequestText(a.request_body); - if (userText && !isInternalSelfMessage(userText)) { - messages.push({ ...createMessage("user", userText), timestamp: a.created_at }); - } - - // Extract agent response — text AND any file attachments so a - // chat reload surfaces historical download chips, not just plain - // text. `result` is nested on successful A2A responses; some - // older rows stored the raw `result` payload at the top level, - // so fall back to the body itself when `.result` is absent. - if (a.response_body) { - const text = extractResponseText(a.response_body); - const attachments = extractFilesFromTask( - (a.response_body.result ?? a.response_body) as Record, - ); - if (text || attachments.length > 0) { - const role = a.status === "error" || text.toLowerCase().startsWith("agent error") ? "system" : "agent"; - messages.push({ ...createMessage(role, text, attachments), timestamp: a.created_at }); - } - } + messages.push(...activityRowToMessages(a, isInternalSelfMessage)); } return { messages, error: null }; } catch (err) { diff --git a/canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts b/canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts new file mode 100644 index 00000000..fa164621 --- /dev/null +++ b/canvas/src/components/tabs/chat/__tests__/historyHydration.test.ts @@ -0,0 +1,192 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { activityRowToMessages, type ActivityRowForHydration } from "../historyHydration"; + +const NEVER_INTERNAL = (_text: string) => false; + +function makeRow(overrides: Partial = {}): ActivityRowForHydration { + return { + activity_type: "a2a_receive", + status: "ok", + created_at: "2026-04-25T18:00:00.000Z", + request_body: null, + response_body: null, + ...overrides, + }; +} + +describe("activityRowToMessages", () => { + // The bug that prompted extracting this helper: every call to + // createMessage() inside the loader stamped `timestamp: new Date()`, + // so every reload re-stamped every historical user bubble to the + // render moment. Two messages sent hours apart both rendered with + // the same "now" clock. These tests pin the override so a future + // refactor that drops the spread-and-override fails loudly. + + describe("timestamp preservation (regression cover)", () => { + beforeEach(() => { + // Freeze the wall clock to a value that's CLEARLY different from + // any row.created_at the tests use, so any test that doesn't + // override the timestamp will mint "2030-…" and the assertion + // against "2026-…" will fail unmistakably. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2030-01-01T00:00:00.000Z")); + }); + afterEach(() => vi.useRealTimers()); + + it("user message timestamp pins to row.created_at, NOT new Date()", () => { + const row = makeRow({ + created_at: "2026-04-25T18:00:00.000Z", + request_body: { params: { message: { parts: [{ kind: "text", text: "hello from earlier today" }] } } }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + const user = msgs.find((m) => m.role === "user")!; + expect(user.timestamp).toBe("2026-04-25T18:00:00.000Z"); + // Negative assertion: the wall clock is set to 2030. If the + // override regresses, timestamp will start with "2030-". + expect(user.timestamp.startsWith("2030")).toBe(false); + }); + + it("agent message timestamp pins to row.created_at, NOT new Date()", () => { + const row = makeRow({ + created_at: "2026-04-25T18:05:00.000Z", + response_body: { result: "agent reply" }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + const agent = msgs.find((m) => m.role === "agent")!; + expect(agent.timestamp).toBe("2026-04-25T18:05:00.000Z"); + expect(agent.timestamp.startsWith("2030")).toBe(false); + }); + + it("two rows with distinct created_at produce two distinct timestamps (the user-visible bug)", () => { + // The actual screenshot symptom: two user messages sent hours + // apart both rendered with the same render-moment timestamp. + // If activityRowToMessages defers to new Date(), both messages + // here will share the frozen 2030 wall clock instead of their + // distinct created_at values. + const a = activityRowToMessages( + makeRow({ + created_at: "2026-04-25T14:00:00.000Z", + request_body: { params: { message: { parts: [{ kind: "text", text: "first" }] } } }, + }), + NEVER_INTERNAL, + ); + const b = activityRowToMessages( + makeRow({ + created_at: "2026-04-25T21:01:58.000Z", + request_body: { params: { message: { parts: [{ kind: "text", text: "second" }] } } }, + }), + NEVER_INTERNAL, + ); + expect(a[0].timestamp).toBe("2026-04-25T14:00:00.000Z"); + expect(b[0].timestamp).toBe("2026-04-25T21:01:58.000Z"); + expect(a[0].timestamp).not.toBe(b[0].timestamp); + }); + }); + + describe("user-message extraction", () => { + it("emits a user message when request_body has text", () => { + const row = makeRow({ + request_body: { params: { message: { parts: [{ kind: "text", text: "hi agent" }] } } }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs).toHaveLength(1); + expect(msgs[0].role).toBe("user"); + expect(msgs[0].content).toBe("hi agent"); + }); + + it("drops user messages flagged as internal self-messages", () => { + // The heartbeat self-trigger ("Delegation results are ready...") + // gets recorded as a canvas-source row but isn't a real user + // message — the predicate filters it out so it doesn't render + // as the user talking to themselves. + const row = makeRow({ + request_body: { params: { message: { parts: [{ kind: "text", text: "Delegation results are ready..." }] } } }, + }); + const msgs = activityRowToMessages(row, (t) => t.startsWith("Delegation results are ready")); + expect(msgs.find((m) => m.role === "user")).toBeUndefined(); + }); + + it("emits no user message when request_body is null", () => { + const row = makeRow({ request_body: null }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs.find((m) => m.role === "user")).toBeUndefined(); + }); + }); + + describe("agent-message extraction", () => { + it("emits an agent message from response_body.result string", () => { + const row = makeRow({ response_body: { result: "agent says hi" } }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs).toHaveLength(1); + expect(msgs[0].role).toBe("agent"); + expect(msgs[0].content).toBe("agent says hi"); + }); + + it("emits role=system when status=error", () => { + // System role gets distinct rendering (red bubble) for failed + // calls; if this regresses errors will look like normal agent + // replies and the user won't realise something went wrong. + const row = makeRow({ + status: "error", + response_body: { result: "delegation failed" }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs[0].role).toBe("system"); + }); + + it("attaches file parts hydrated from response_body (parts at root)", () => { + // The notify-with-attachments shape: response_body = + // {result: "", parts: [{kind:"file", ...}]}. If + // extractFilesFromTask doesn't see the attachments here, a chat + // reload after an agent attached a file would lose the chips. + const row = makeRow({ + response_body: { + result: "Done — see attached.", + parts: [ + { kind: "file", file: { name: "build.zip", uri: "workspace:/tmp/build.zip", size: 12345 } }, + ], + }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + const agent = msgs.find((m) => m.role === "agent")!; + expect(agent.attachments).toEqual([ + { name: "build.zip", uri: "workspace:/tmp/build.zip", size: 12345 }, + ]); + }); + + it("emits no agent message when response_body is null", () => { + const row = makeRow({ response_body: null }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs.find((m) => m.role === "agent" || m.role === "system")).toBeUndefined(); + }); + + it("emits no agent message when response_body has neither text nor files", () => { + const row = makeRow({ response_body: { unrelated: "metadata" } }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs.find((m) => m.role === "agent")).toBeUndefined(); + }); + }); + + describe("end-to-end shape", () => { + it("a single row with both user request and agent reply emits two messages with the same timestamp", () => { + // Mirrors the canonical canvas-source row: user types something, + // agent replies, both stored on the same activity_logs row. UI + // renders them as the user bubble immediately followed by the + // agent bubble — keep that pairing intact. + const row = makeRow({ + created_at: "2026-04-25T18:00:00.000Z", + request_body: { params: { message: { parts: [{ kind: "text", text: "what's 2+2?" }] } } }, + response_body: { result: "4" }, + }); + const msgs = activityRowToMessages(row, NEVER_INTERNAL); + expect(msgs).toHaveLength(2); + expect(msgs[0].role).toBe("user"); + expect(msgs[0].content).toBe("what's 2+2?"); + expect(msgs[0].timestamp).toBe("2026-04-25T18:00:00.000Z"); + expect(msgs[1].role).toBe("agent"); + expect(msgs[1].content).toBe("4"); + expect(msgs[1].timestamp).toBe("2026-04-25T18:00:00.000Z"); + }); + }); +}); diff --git a/canvas/src/components/tabs/chat/historyHydration.ts b/canvas/src/components/tabs/chat/historyHydration.ts new file mode 100644 index 00000000..b6feab5b --- /dev/null +++ b/canvas/src/components/tabs/chat/historyHydration.ts @@ -0,0 +1,65 @@ +import { type ChatMessage, createMessage } from "./types"; +import { extractResponseText, extractRequestText, extractFilesFromTask } from "./message-parser"; + +/** Activity row shape the chat history loader consumes. Only the fields + * it actually reads are listed — the platform sends more (id, target_id, + * method, summary, etc.) but the hydration is defined by these four. */ +export interface ActivityRowForHydration { + activity_type: string; + status: string; + created_at: string; + request_body: Record | null; + response_body: Record | null; +} + +/** Map a single activity_logs row to the chat messages it represents. + * + * An a2a_receive row can produce up to two messages: + * 1. A user-side bubble derived from request_body (the message the + * user sent), unless the request was an internal self-message. + * 2. An agent-side bubble derived from response_body (text + + * file attachments), with role=system when status=error. + * + * CRITICAL: both messages MUST adopt `row.created_at` as their + * timestamp. createMessage() defaults to new Date() — appropriate for + * freshly-typed messages, wrong for hydrated history because every + * reload would re-stamp every bubble to the render moment. The + * regression that prompted extracting this helper showed up as every + * user message in the chat collapsing to the same "now" clock after + * reload (see test_user_messages_pin_timestamps_to_created_at). + */ +export function activityRowToMessages( + row: ActivityRowForHydration, + isInternalSelfMessage: (text: string) => boolean, +): ChatMessage[] { + const out: ChatMessage[] = []; + + const userText = extractRequestText(row.request_body); + if (userText && !isInternalSelfMessage(userText)) { + out.push({ ...createMessage("user", userText), timestamp: row.created_at }); + } + + if (row.response_body) { + const text = extractResponseText(row.response_body); + // Pick the right object to feed extractFilesFromTask: + // - Task-shape: {result: {parts: [...]}} → unwrap result + // - Notify-shape: {result: "", parts: [...]} → use the body + // Naively doing `result ?? body` would pass the string "" to + // the file extractor for the notify case, returning [] and dropping + // the file chips on reload. Only unwrap when result is an object. + const filesSource: Record = + row.response_body.result && typeof row.response_body.result === "object" + ? (row.response_body.result as Record) + : row.response_body; + const attachments = extractFilesFromTask(filesSource); + if (text || attachments.length > 0) { + const role: ChatMessage["role"] = + row.status === "error" || text.toLowerCase().startsWith("agent error") + ? "system" + : "agent"; + out.push({ ...createMessage(role, text, attachments), timestamp: row.created_at }); + } + } + + return out; +} From 6430b3b699b3673604bf43f8ecc89ca87b2eb874 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 26 Apr 2026 21:41:28 -0700 Subject: [PATCH 3/4] 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) { From 0032f9c906265c4dcb7ebe9f73913334649cbd3e Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Sun, 26 Apr 2026 22:52:53 -0700 Subject: [PATCH 4/4] fix(chat): drop unused extractResponseText import after helper extraction Reviewer bot flagged: ChatTab.tsx imported extractResponseText but no longer used it after the loop body moved to historyHydration.ts (the helper imports it directly). Drop from the named import to unblock merge. extractFilesFromTask remains used at line 515 for the WS A2A_RESPONSE handler's reply-files extraction. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/tabs/ChatTab.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index bc551bef..adcd18fc 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -10,7 +10,7 @@ import { closeWebSocketGracefully } from "@/lib/ws-close"; import { type ChatMessage, type ChatAttachment, createMessage, appendMessageDeduped } from "./chat/types"; import { uploadChatFiles, downloadChatFile } from "./chat/uploads"; import { AttachmentChip, PendingAttachmentPill } from "./chat/AttachmentViews"; -import { extractResponseText, extractFilesFromTask } from "./chat/message-parser"; +import { extractFilesFromTask } from "./chat/message-parser"; import { AgentCommsPanel } from "./chat/AgentCommsPanel"; import { appendActivityLine } from "./chat/activityLog"; import { activityRowToMessages, type ActivityRowForHydration } from "./chat/historyHydration";