diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index db8f19482..256180dda 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed +- Fixed the guest rendering idle while the host agent is running (spinner vanishing mid-turn after a reconnect dropped `agent_start`): host `state` frames are now authoritative for liveness in both directions, and an idle report clears tool cards pinned by a dropped `tool_execution_end` ([#6990](https://github.com/can1357/oh-my-pi/pull/6990) by [@metaphorics](https://github.com/metaphorics)). - Fixed an issue where the agent would stop silently without a message by ensuring terminal auto-retry failures are properly surfaced as error notices. ## [17.1.0] - 2026-07-24 diff --git a/packages/collab-web/src/components/shell/Composer.tsx b/packages/collab-web/src/components/shell/Composer.tsx index 69b2ca033..01bd1a670 100644 --- a/packages/collab-web/src/components/shell/Composer.tsx +++ b/packages/collab-web/src/components/shell/Composer.tsx @@ -117,7 +117,7 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode { const readOnly = snapshot.readOnly; const uiRequest = snapshot.uiRequest; const canPrompt = live && !readOnly; - const busy = snapshot.working || (snapshot.state?.isStreaming ?? false); + const busy = snapshot.working; const queued = snapshot.state?.queuedMessageCount ?? 0; const canSend = canPrompt && text.trim().length > 0; diff --git a/packages/collab-web/src/lib/client.ts b/packages/collab-web/src/lib/client.ts index 0c6fe414b..90453f941 100644 --- a/packages/collab-web/src/lib/client.ts +++ b/packages/collab-web/src/lib/client.ts @@ -334,8 +334,15 @@ export class GuestClient { break; case "state": this.#state = frame.state; + // Host state is authoritative for liveness in both directions: the + // payload is built at fire time, so `isStreaming` is never stale. + // This covers a connected guest that misses the discrete `agent_start` + // without receiving a new `welcome` (for example, mid-stream). + this.#working = frame.state.isStreaming; if (!frame.state.isStreaming) { - this.#working = false; + // Host idle implies no tool can be running, so clear any card + // pinned by a dropped `tool_execution_end` off this signal. + this.#activeTools = new Map(); if (this.#streamDone) { this.#stream = null; this.#streamDone = false; diff --git a/packages/collab-web/test/client.test.ts b/packages/collab-web/test/client.test.ts index d979910a4..73d891540 100644 --- a/packages/collab-web/test/client.test.ts +++ b/packages/collab-web/test/client.test.ts @@ -192,6 +192,34 @@ describe("GuestClient frame apply", () => { client.applyFrameForTest({ t: "state", state: { ...STATE, isStreaming: false } }); expect(client.getSnapshot().working).toBe(false); }); + it("a state frame recovers a stuck-idle guest when agent_start was dropped", () => { + // The host begins streaming mid-turn, but the matching `agent_start` + // never arrived (e.g. dropped on a reconnect). Before the fix nothing + // set `working` true except `agent_start`, so the guest stayed idle. + const client = liveClient(); + expect(client.getSnapshot().working).toBe(false); + client.applyFrameForTest({ t: "state", state: { ...STATE, isStreaming: true } }); + expect(client.getSnapshot().working).toBe(true); + }); + + it("an idle state frame clears a pinned tool card when tool_execution_end was dropped", () => { + // Host reports idle, but the matching `tool_execution_end` was dropped, + // leaving a stuck tool card. The authoritative idle signal must clear it. + const client = liveClient(); + client.applyFrameForTest({ + t: "event", + event: { + type: "tool_execution_start", + toolCallId: "tc1", + toolName: "bash", + args: { command: "ls" }, + intent: "Listing", + }, + }); + expect(client.getSnapshot().activeTools.size).toBe(1); + client.applyFrameForTest({ t: "state", state: { ...STATE, isStreaming: false } }); + expect(client.getSnapshot().activeTools.size).toBe(0); + }); it("bus progress frames update the progress map", () => { const client = liveClient();