From 37a48c1d235f9de1cf6d4337b289faa756670efe Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 4 Nov 2026 01:13:50 -0800 Subject: [PATCH 1/2] fix(web): stop thinking while the coordinator waits on an approval --- apps/web/src/pages/workspace/index.tsx | 12 ++++-------- apps/web/src/pages/workspace/live-run.ts | 19 +++++++++++++++++++ .../src/pages/workspace/transcript.test.tsx | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 63372a47..c14a8503 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -89,6 +89,7 @@ import { } from "./tab-follow.ts"; import { awaitingCommit, + awaitingReply, committedSteps, emptyLiveRun, foldLiveRun, @@ -998,19 +999,14 @@ export function StageWorkspace({ } }; const held = waitingOnPerson(waits, openApprovalIds, runId, liveRun.running); - // A message sent here waits on the coordinator from the moment it leaves, before the stream - // reports its run: until a reply follows it or a run ends after it. - const awaitingReply = - awaitingSince !== null && - turns.at(-1)?.role === "person" && - (liveRun.endedAt === null || liveRun.endedAt < awaitingSince) && + const awaiting = + awaitingReply(awaitingSince, turns.at(-1)?.role, liveRun.endedAt, held) && !archived && startFault === null && modelNeeded === null; // The mark stays for the whole turn: between model calls and during long tool work the live // reply shows nothing moving. - const thinking = - !sendFailed && (sending || awaitingReply || (working && !held)); + const thinking = !sendFailed && (sending || awaiting || (working && !held)); // Each concept's option is its own tab beside the others, never an ordinary document. const options = artifacts.filter((item) => item.kind === DESIGN_OPTION); const paneDeliverables = documentsForPane( diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index c24ef242..84e3cd36 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -294,3 +294,22 @@ export function waitingOnPerson( ) ); } + +/** + * A message sent here waits on the coordinator from the moment it leaves, before the stream + * reports its run: until a reply follows it, a run ends after it, or the coordinator is held on + * the person. An approval card alone brings no reply text while its run stays open. + */ +export function awaitingReply( + sentAt: number | null, + lastTurnRole: ConversationTurn["role"] | undefined, + runEndedAt: number | null, + held: boolean, +): boolean { + return ( + sentAt !== null && + lastTurnRole === "person" && + (runEndedAt === null || runEndedAt < sentAt) && + !held + ); +} diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 95122f98..79e9614c 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -9,6 +9,7 @@ import { wording } from "../../config.ts"; import { DocumentDecision } from "./document-decision.tsx"; import { awaitingCommit, + awaitingReply, emptyLiveRun, foldLiveRun, liveTurnStreaming, @@ -171,6 +172,23 @@ describe("conversation transcript", () => { ); }); + test("a sent message waits on the coordinator until it is held on the person, and again once answered", () => { + const approval = { runId: "run-c", approvalId: "appr-1" }; + const question = { runId: "run-c", park: { seq: 4 } }; + const heldOnCard = waitingOnPerson( + [approval], + new Set(["appr-1"]), + "run-c", + true, + ); + expect(awaitingReply(10, "person", null, false)).toBe(true); + expect(awaitingReply(10, "person", null, heldOnCard)).toBe(false); + expect(awaitingReply(10, "assistant", null, false)).toBe(false); + expect(awaitingReply(10, "person", 12, false)).toBe(false); + const answered = waitingOnPerson([question], new Set(), "run-c", true); + expect(awaitingReply(20, "person", 12, answered)).toBe(true); + }); + test("an inference call that finishes with tool work settles even while the message run stays open on a park", () => { const started = foldLiveRun( emptyLiveRun, From 6522085d3cd08d343caa392da2162da952006472 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 4 Nov 2026 01:22:43 -0800 Subject: [PATCH 2/2] fix(web): keep thinking after the person answers a question --- apps/web/src/pages/workspace/index.tsx | 7 ++++++- apps/web/src/pages/workspace/live-run.ts | 9 +++++---- apps/web/src/pages/workspace/transcript.test.tsx | 8 ++++---- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index c14a8503..cbda015d 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -1000,7 +1000,12 @@ export function StageWorkspace({ }; const held = waitingOnPerson(waits, openApprovalIds, runId, liveRun.running); const awaiting = - awaitingReply(awaitingSince, turns.at(-1)?.role, liveRun.endedAt, held) && + awaitingReply( + awaitingSince, + turns.at(-1)?.role, + liveRun.endedAt, + parkedOnApproval(waits, openApprovalIds, runId), + ) && !archived && startFault === null && modelNeeded === null; diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index 84e3cd36..0fbaea85 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -297,19 +297,20 @@ export function waitingOnPerson( /** * A message sent here waits on the coordinator from the moment it leaves, before the stream - * reports its run: until a reply follows it, a run ends after it, or the coordinator is held on - * the person. An approval card alone brings no reply text while its run stays open. + * reports its run: until a reply follows it, a run ends after it, or its run is held on an open + * approval. An approval card alone brings no reply text while its run stays open. A standing + * question does not end the wait: an answer to it is sent before the stream reports the new run. */ export function awaitingReply( sentAt: number | null, lastTurnRole: ConversationTurn["role"] | undefined, runEndedAt: number | null, - held: boolean, + heldOnApproval: boolean, ): boolean { return ( sentAt !== null && lastTurnRole === "person" && (runEndedAt === null || runEndedAt < sentAt) && - !held + !heldOnApproval ); } diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 79e9614c..233bf286 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -172,20 +172,20 @@ describe("conversation transcript", () => { ); }); - test("a sent message waits on the coordinator until it is held on the person, and again once answered", () => { + 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 } }; - const heldOnCard = waitingOnPerson( + const heldOnCard = parkedOnApproval( [approval], new Set(["appr-1"]), "run-c", - true, ); expect(awaitingReply(10, "person", null, false)).toBe(true); expect(awaitingReply(10, "person", null, heldOnCard)).toBe(false); expect(awaitingReply(10, "assistant", null, false)).toBe(false); expect(awaitingReply(10, "person", 12, false)).toBe(false); - const answered = waitingOnPerson([question], new Set(), "run-c", true); + // The answer is sent while the question still stands and before the stream reports the run. + const answered = parkedOnApproval([question], new Set(), "run-c"); expect(awaitingReply(20, "person", 12, answered)).toBe(true); });