fix(mobile-chat): render tool-call chain + suppress false 'unreachable' banner while agent works #2789
@@ -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({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<ToolTraceChips trace={m.toolTrace} />
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
@@ -641,7 +650,16 @@ export function MobileChat({
|
||||
<span>{thinkingElapsed}s</span>
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
|
||||
@@ -763,3 +763,120 @@ describe("MobileChat — multi-send tap path (CR2 #2762)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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<typeof renderChat>;
|
||||
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<typeof renderChat>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */}
|
||||
<ChatErrorBanner
|
||||
message={displayError}
|
||||
// Suppress the banner while the agent is demonstrably WORKING. The
|
||||
// clear-on-thinking effect above only fires on the thinking
|
||||
// TRANSITION, so a send error that lands mid-turn (a long poll-mode
|
||||
// turn whose POST times out at the CF edge while currentTask is still
|
||||
// set) would otherwise show "unreachable" beside the live "●●● Ns"
|
||||
// timer. Gating render on !thinking is the durable fix (mirrors
|
||||
// MobileChat); a still-unresolved error resurfaces once the turn ends.
|
||||
message={thinking ? null : displayError}
|
||||
isOnline={isOnline}
|
||||
onRestart={() => setConfirmRestart(true)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user