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)}
/>