From c4a5e757b96e62729abea3fbf35034ad2f9c040e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 13 Jun 2026 16:41:27 -0700 Subject: [PATCH] fix(mobile-chat): render tool-call chain + suppress 'unreachable' banner while agent is working MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two mobile chat gaps reported from a live SEO-agent screenshot (long 1100-file download turn): 1. MISSING TOOL-CALL CHAIN — MobileChat dropped agent messages' toolTrace entirely (desktop ChatTab renders it via ToolTraceChips). Mobile now reuses the same ToolTraceChips renderer under the agent bubble (#231 parity), collapsed-by-default. The data already rides on the shared useChatHistory/useChatSocket messages; mobile just never rendered it. 2. FALSE 'Failed to send — agent may be unreachable' BANNER under the live '●●● 148s' timer. Root cause: the clear-on-thinking effect only fires on the thinking TRANSITION, so a non-524 send error that lands MID-turn (a long poll-mode turn whose POST times out at the CF edge while workspace currentTask is still set) re-shows the banner without re-triggering the effect. Fix: gate the banner at render-time on !thinking — never claim 'unreachable' while the agent is provably working (sending in flight OR currentTask set). Applied to BOTH MobileChat and desktop ChatTab (identical latent bug, keeps parity); a still-unresolved error resurfaces once the turn ends. Tests: +4 MobileChat (tool-chain render + expand; no-affordance when absent; banner suppressed while currentTask set; banner shown when not working). All 38 MobileChat + 22 ChatTab + 301 chat-dir tests pass; lint clean. Co-Authored-By: Claude Fable 5 --- canvas/src/components/mobile/MobileChat.tsx | 20 ++- .../mobile/__tests__/MobileChat.test.tsx | 117 ++++++++++++++++++ canvas/src/components/tabs/ChatTab.tsx | 9 +- 3 files changed, 144 insertions(+), 2 deletions(-) diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index 55b6d3ab9..f4170b867 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -21,6 +21,7 @@ import { } from "@/components/tabs/chat/hooks"; import { AgentCommsPanel } from "@/components/tabs/chat/AgentCommsPanel"; import { AttachmentPreview } from "@/components/tabs/chat/AttachmentPreview"; +import { ToolTraceChips } from "@/components/tabs/chat/ToolTraceChips"; import { downloadChatFile } from "@/components/tabs/chat/uploads"; import { toMobileAgent } from "./components"; @@ -606,6 +607,14 @@ export function MobileChat({ ))} )} + {/* Tool-call chain — reuse the desktop ChatTab renderer + verbatim (#231 parity). Collapsed-by-default chip list + under the agent bubble; previously mobile dropped the + whole chain so a long tool-using turn rendered as a bare + reply with no visible work. */} + {!mine && m.toolTrace && m.toolTrace.length > 0 && ( + + )}
{thinkingElapsed}s
)} - {sendError && ( + {/* Suppress the "Failed to send — agent may be unreachable" banner + while the agent is demonstrably WORKING (sending in flight OR the + workspace reports an in-flight task). The effect above only clears + on the thinking TRANSITION, so a non-524 send error that lands + mid-turn (e.g. a long poll-mode turn whose POST times out at the CF + edge while currentTask is still set) would otherwise show the + banner UNDER the live "●●● 148s" timer — the exact contradiction in + the report. Gating render on !thinking closes that for good; once + the turn ends, a still-unresolved error surfaces normally. */} + {sendError && !thinking && (
{ }); }); }); + +describe("MobileChat — tool-call chain (#231 desktop parity)", () => { + beforeEach(() => { + mockStoreState.nodes = [onlineNode]; + }); + + it("renders the tool-call chain from an agent message's tool_trace", async () => { + // useChatHistory maps the API's snake_case tool_trace → toolTrace. + vi.spyOn(api, "get").mockResolvedValueOnce({ + messages: [ + { + id: "m-trace", + role: "agent", + content: "done", + timestamp: "2026-04-25T10:00:01Z", + tool_trace: [ + { tool: "Bash", input: "ls -la" }, + { tool: "Read", input: "/tmp/x" }, + ], + }, + ], + reached_end: true, + }); + let r: ReturnType; + await act(async () => { + r = renderChat(mockAgentId); + }); + const { container } = r!; + // Collapsed-by-default: the count header is shown (previously mobile + // dropped the whole chain — the reported "missing tool call chain"). + expect(container.textContent ?? "").toContain("2 tools used"); + // Expand → the individual tool entries appear. + const toggle = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("tools used"), + ) as HTMLButtonElement; + expect(toggle).toBeTruthy(); + await act(async () => { + toggle.click(); + }); + expect(container.textContent ?? "").toContain("Bash"); + expect(container.textContent ?? "").toContain("Read"); + }); + + it("shows no tool-chain affordance for an agent message without tool_trace", async () => { + vi.spyOn(api, "get").mockResolvedValueOnce({ + messages: [ + { id: "m-plain", role: "agent", content: "hi", timestamp: "2026-04-25T10:00:01Z" }, + ], + reached_end: true, + }); + let r: ReturnType; + await act(async () => { + r = renderChat(mockAgentId); + }); + expect(r!.container.textContent ?? "").not.toContain("tools used"); + }); +}); + +describe("MobileChat — send-error banner suppressed while working (report: banner under ●●● 148s)", () => { + const busyNode = { + ...onlineNode, + id: "ws-busy", + data: { ...onlineNode.data, currentTask: "downloading 1100 files" }, + }; + + it("does NOT show the 'unreachable' banner while the agent is working (currentTask set)", async () => { + mockStoreState.nodes = [busyNode]; + // The send POST fails with a 504 (non-524) → useChatSend sets the + // "agent may be unreachable" error. But the workspace reports an in-flight + // task, so thinking=true and the banner must stay HIDDEN (the agent is + // provably reachable — the exact contradiction in the screenshot). + vi.spyOn(api, "post").mockRejectedValue( + Object.assign(new Error("API POST /workspaces/ws-busy/a2a: 504 "), { status: 504 }), + ); + const { container } = renderChat("ws-busy"); + const ta = container.querySelector("textarea") as HTMLTextAreaElement; + const sendBtn = () => + container.querySelector('[aria-label="Send"]') as HTMLButtonElement; + await act(async () => { + fireEvent.change(ta, { target: { value: "hi" } }); + }); + await act(async () => { + sendBtn().click(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + // No "unreachable" banner while currentTask drives the thinking indicator. + expect(container.textContent ?? "").not.toMatch(/unreachable/i); + // …and the thinking indicator IS present (proving thinking=true, the state + // under which the old code wrongly showed the banner). + expect( + container.querySelector('[data-testid="mobile-thinking-indicator"]'), + ).not.toBeNull(); + }); + + it("DOES show the banner when a send fails and the agent is NOT working", async () => { + mockStoreState.nodes = [onlineNode]; // currentTask: "" → not thinking once send settles + vi.spyOn(api, "post").mockRejectedValue( + Object.assign(new Error("API POST /workspaces/ws-chat-test/a2a: 522 "), { status: 522 }), + ); + const { container } = renderChat(mockAgentId); + const ta = container.querySelector("textarea") as HTMLTextAreaElement; + const sendBtn = () => + container.querySelector('[aria-label="Send"]') as HTMLButtonElement; + await act(async () => { + fireEvent.change(ta, { target: { value: "hi" } }); + }); + await act(async () => { + sendBtn().click(); + }); + await waitFor(() => { + expect(container.textContent ?? "").toMatch(/unreachable/i); + }); + }); +}); diff --git a/canvas/src/components/tabs/ChatTab.tsx b/canvas/src/components/tabs/ChatTab.tsx index 0b1425f8b..4299a33b4 100644 --- a/canvas/src/components/tabs/ChatTab.tsx +++ b/canvas/src/components/tabs/ChatTab.tsx @@ -806,7 +806,14 @@ function MyChatPanel({ workspaceId, data }: Props) { inline JSX hardcoded "see workspace logs for details" with no link — there is no separate Logs tab. */} setConfirmRestart(true)} /> -- 2.52.0