From af6fdb671bb022eaf7e846903b4a674ad79d4211 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 7 Nov 2026 20:55:45 -0800 Subject: [PATCH 1/5] fix(web): take the coordinator's working state from the hub alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript's stream flag stayed set when the run ended while the tab was hidden, so "Thinking…" stuck under the reply until a reload. The hub's activity read already answers the same question and is re-read every poll; the flag is no longer consulted. --- apps/web/src/pages/workspace/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 87f9c593..97543e16 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -742,16 +742,16 @@ function ReadyRoom({ lastShown?.role === "assistant" && !streaming && !sending ? suggestedReplies(lastShown.text) : []; - // Mid-turn by native state, whoever sent the message: this stream saw the message run start and - // not end, or the hub's activity read saw one start after the last end this stream saw. + // Mid-turn by the hub's activity read, whoever sent the message: a run started after the last + // end this stream saw. The stream's own flag is not consulted; a frame it misses while the tab + // is hidden would leave it set for good. const agents = activity.data?.agents; const coordinatorTurnSince = agents?.find( (agent) => agent.role === "coordinator", )?.since; const working = - liveRun.running || - (coordinatorTurnSince !== undefined && - Date.parse(coordinatorTurnSince) > (liveRun.endedAt ?? 0)); + coordinatorTurnSince !== undefined && + Date.parse(coordinatorTurnSince) > (liveRun.endedAt ?? 0); // The engine treats the first id as history and every later change as a // send: it pins that turn and reserves space under it. A turn loaded from // the mirror is not a send. Passing it pins the old message and the reserve From 5b72c7b63a117238280fc31d2f098feedc2d89d8 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 7 Nov 2026 21:03:25 -0800 Subject: [PATCH 2/5] fix(web): say outright that no run end has been seen The invented zero stood in for a stream that had seen no end yet. --- apps/web/src/pages/workspace/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 97543e16..d4c12a69 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -751,7 +751,8 @@ function ReadyRoom({ )?.since; const working = coordinatorTurnSince !== undefined && - Date.parse(coordinatorTurnSince) > (liveRun.endedAt ?? 0); + (liveRun.endedAt === null || + Date.parse(coordinatorTurnSince) > liveRun.endedAt); // The engine treats the first id as history and every later change as a // send: it pins that turn and reserves space under it. A turn loaded from // the mirror is not a send. Passing it pins the old message and the reserve From a5dab8767e00983a06d148d65866235f90539f36 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 7 Nov 2026 21:05:44 -0800 Subject: [PATCH 3/5] refactor(web): name the coordinator's working rule and test it coordinatorWorking in live-run.ts beside the other run helpers; the room finds the coordinator's activity once. --- apps/web/src/pages/workspace/index.tsx | 20 +++++-------------- apps/web/src/pages/workspace/live-run.ts | 13 ++++++++++++ .../src/pages/workspace/transcript.test.tsx | 13 ++++++++++++ 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index d4c12a69..7bf50ec3 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -102,6 +102,7 @@ import { awaitingCommit, awaitingReply, committedSteps, + coordinatorWorking, emptyLiveRun, foldLiveRun, latestAssistantAt, @@ -742,17 +743,9 @@ function ReadyRoom({ lastShown?.role === "assistant" && !streaming && !sending ? suggestedReplies(lastShown.text) : []; - // Mid-turn by the hub's activity read, whoever sent the message: a run started after the last - // end this stream saw. The stream's own flag is not consulted; a frame it misses while the tab - // is hidden would leave it set for good. const agents = activity.data?.agents; - const coordinatorTurnSince = agents?.find( - (agent) => agent.role === "coordinator", - )?.since; - const working = - coordinatorTurnSince !== undefined && - (liveRun.endedAt === null || - Date.parse(coordinatorTurnSince) > liveRun.endedAt); + const coordinator = agents?.find((agent) => agent.role === "coordinator"); + const working = coordinatorWorking(liveRun, coordinator?.since); // The engine treats the first id as history and every later change as a // send: it pins that turn and reserves space under it. A turn loaded from // the mirror is not a send. Passing it pins the old message and the reserve @@ -1245,11 +1238,8 @@ function ReadyRoom({ for (const dispatch of dispatches) noteDispatch(dispatchRoom, dispatch); // The document a writer or reviewer run works on, from the coordinator's own hand-off call as // the hub's activity read saw it start: the latest such call made before the run started. - const coordinatorTools = agents?.find( - (agent) => agent.role === "coordinator", - )?.tools; - if (coordinatorTools !== undefined) - for (const tool of coordinatorTools) { + if (coordinator !== undefined) + for (const tool of coordinator.tools) { const role = HANDOFF_ROLE[tool.name]; if (role !== undefined && tool.subject !== undefined) handoffCalls.current.set(`${tool.at}:${tool.name}`, { diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index 0fbaea85..0e9e3e67 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -159,6 +159,19 @@ export function foldLiveRun( return { run: { ...run, current }, reload: reload || folded.refresh }; } +/** + * The coordinator is mid-turn by the hub's activity read: its run started after + * the last end this stream saw. The stream's own running flag is not consulted; + * a frame it misses while the tab is hidden would leave it set for good. + */ +export function coordinatorWorking( + run: LiveRun, + coordinatorSince: string | undefined, +): boolean { + if (coordinatorSince === undefined) return false; + return run.endedAt === null || Date.parse(coordinatorSince) > run.endedAt; +} + function sameText(committed: string, live: string): boolean { const text = committed.trim(); return text === live.trim() || text === clippedTurnText(live).trim(); diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 0102490a..309c45d6 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -11,6 +11,7 @@ import { DocumentDecision } from "./document-decision.tsx"; import { awaitingCommit, awaitingReply, + coordinatorWorking, emptyLiveRun, foldLiveRun, liveTurnStreaming, @@ -210,6 +211,18 @@ describe("conversation transcript", () => { ); }); + test("the coordinator works by the hub's read, not by a run this stream never saw end", () => { + const ended = { ...emptyLiveRun, running: true, endedAt: 2_000 }; + expect(coordinatorWorking(ended, undefined)).toBe(false); + expect(coordinatorWorking(ended, new Date(1_000).toISOString())).toBe( + false, + ); + expect(coordinatorWorking(ended, new Date(3_000).toISOString())).toBe(true); + expect( + coordinatorWorking(emptyLiveRun, new Date(1_000).toISOString()), + ).toBe(true); + }); + test("a sent message waits on the coordinator until its approval card opens, and through an answered question", () => { const approval = { runId: "run-c", approvalId: "appr-1" }; const question = { runId: "run-c", park: { seq: 4 } }; From 04cb3a218d333ea3f9ebb28d2a515d503ad5d731 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 7 Nov 2026 21:06:53 -0800 Subject: [PATCH 4/5] Revert "refactor(web): name the coordinator's working rule and test it" This reverts commit a5dab8767e00983a06d148d65866235f90539f36. --- apps/web/src/pages/workspace/index.tsx | 20 ++++++++++++++----- apps/web/src/pages/workspace/live-run.ts | 13 ------------ .../src/pages/workspace/transcript.test.tsx | 13 ------------ 3 files changed, 15 insertions(+), 31 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 7bf50ec3..d4c12a69 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -102,7 +102,6 @@ import { awaitingCommit, awaitingReply, committedSteps, - coordinatorWorking, emptyLiveRun, foldLiveRun, latestAssistantAt, @@ -743,9 +742,17 @@ function ReadyRoom({ lastShown?.role === "assistant" && !streaming && !sending ? suggestedReplies(lastShown.text) : []; + // Mid-turn by the hub's activity read, whoever sent the message: a run started after the last + // end this stream saw. The stream's own flag is not consulted; a frame it misses while the tab + // is hidden would leave it set for good. const agents = activity.data?.agents; - const coordinator = agents?.find((agent) => agent.role === "coordinator"); - const working = coordinatorWorking(liveRun, coordinator?.since); + const coordinatorTurnSince = agents?.find( + (agent) => agent.role === "coordinator", + )?.since; + const working = + coordinatorTurnSince !== undefined && + (liveRun.endedAt === null || + Date.parse(coordinatorTurnSince) > liveRun.endedAt); // The engine treats the first id as history and every later change as a // send: it pins that turn and reserves space under it. A turn loaded from // the mirror is not a send. Passing it pins the old message and the reserve @@ -1238,8 +1245,11 @@ function ReadyRoom({ for (const dispatch of dispatches) noteDispatch(dispatchRoom, dispatch); // The document a writer or reviewer run works on, from the coordinator's own hand-off call as // the hub's activity read saw it start: the latest such call made before the run started. - if (coordinator !== undefined) - for (const tool of coordinator.tools) { + const coordinatorTools = agents?.find( + (agent) => agent.role === "coordinator", + )?.tools; + if (coordinatorTools !== undefined) + for (const tool of coordinatorTools) { const role = HANDOFF_ROLE[tool.name]; if (role !== undefined && tool.subject !== undefined) handoffCalls.current.set(`${tool.at}:${tool.name}`, { diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index 0e9e3e67..0fbaea85 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -159,19 +159,6 @@ export function foldLiveRun( return { run: { ...run, current }, reload: reload || folded.refresh }; } -/** - * The coordinator is mid-turn by the hub's activity read: its run started after - * the last end this stream saw. The stream's own running flag is not consulted; - * a frame it misses while the tab is hidden would leave it set for good. - */ -export function coordinatorWorking( - run: LiveRun, - coordinatorSince: string | undefined, -): boolean { - if (coordinatorSince === undefined) return false; - return run.endedAt === null || Date.parse(coordinatorSince) > run.endedAt; -} - function sameText(committed: string, live: string): boolean { const text = committed.trim(); return text === live.trim() || text === clippedTurnText(live).trim(); diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 309c45d6..0102490a 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -11,7 +11,6 @@ import { DocumentDecision } from "./document-decision.tsx"; import { awaitingCommit, awaitingReply, - coordinatorWorking, emptyLiveRun, foldLiveRun, liveTurnStreaming, @@ -211,18 +210,6 @@ describe("conversation transcript", () => { ); }); - test("the coordinator works by the hub's read, not by a run this stream never saw end", () => { - const ended = { ...emptyLiveRun, running: true, endedAt: 2_000 }; - expect(coordinatorWorking(ended, undefined)).toBe(false); - expect(coordinatorWorking(ended, new Date(1_000).toISOString())).toBe( - false, - ); - expect(coordinatorWorking(ended, new Date(3_000).toISOString())).toBe(true); - expect( - coordinatorWorking(emptyLiveRun, new Date(1_000).toISOString()), - ).toBe(true); - }); - test("a sent message waits on the coordinator until its approval card opens, and through an answered question", () => { const approval = { runId: "run-c", approvalId: "appr-1" }; const question = { runId: "run-c", park: { seq: 4 } }; From 35c750c55edce0471aeec6b5639aafb1c1dfb99e Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 7 Nov 2026 21:07:49 -0800 Subject: [PATCH 5/5] refactor(web): find the coordinator's activity once --- apps/web/src/pages/workspace/index.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index d4c12a69..be363108 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -742,17 +742,14 @@ function ReadyRoom({ lastShown?.role === "assistant" && !streaming && !sending ? suggestedReplies(lastShown.text) : []; - // Mid-turn by the hub's activity read, whoever sent the message: a run started after the last - // end this stream saw. The stream's own flag is not consulted; a frame it misses while the tab - // is hidden would leave it set for good. const agents = activity.data?.agents; - const coordinatorTurnSince = agents?.find( - (agent) => agent.role === "coordinator", - )?.since; + const coordinator = agents?.find((agent) => agent.role === "coordinator"); + // Mid-turn by the hub's read: the coordinator's run began after the last end this stream saw. + // The stream's own flag is not used; a frame missed while the tab is hidden leaves it set. const working = - coordinatorTurnSince !== undefined && + coordinator !== undefined && (liveRun.endedAt === null || - Date.parse(coordinatorTurnSince) > liveRun.endedAt); + Date.parse(coordinator.since) > liveRun.endedAt); // The engine treats the first id as history and every later change as a // send: it pins that turn and reserves space under it. A turn loaded from // the mirror is not a send. Passing it pins the old message and the reserve @@ -1245,11 +1242,8 @@ function ReadyRoom({ for (const dispatch of dispatches) noteDispatch(dispatchRoom, dispatch); // The document a writer or reviewer run works on, from the coordinator's own hand-off call as // the hub's activity read saw it start: the latest such call made before the run started. - const coordinatorTools = agents?.find( - (agent) => agent.role === "coordinator", - )?.tools; - if (coordinatorTools !== undefined) - for (const tool of coordinatorTools) { + if (coordinator !== undefined) + for (const tool of coordinator.tools) { const role = HANDOFF_ROLE[tool.name]; if (role !== undefined && tool.subject !== undefined) handoffCalls.current.set(`${tool.at}:${tool.name}`, {