diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 63372a47..cbda015d 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,19 @@ 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, + parkedOnApproval(waits, openApprovalIds, runId), + ) && !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..0fbaea85 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -294,3 +294,23 @@ 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 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, + heldOnApproval: boolean, +): boolean { + return ( + sentAt !== null && + lastTurnRole === "person" && + (runEndedAt === null || runEndedAt < sentAt) && + !heldOnApproval + ); +} diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 95122f98..233bf286 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 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 = parkedOnApproval( + [approval], + new Set(["appr-1"]), + "run-c", + ); + 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); + // 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); + }); + test("an inference call that finishes with tool work settles even while the message run stays open on a park", () => { const started = foldLiveRun( emptyLiveRun,