From dd026d3f4d81a477c1f1c4499ee0d1df9a1e1303 Mon Sep 17 00:00:00 2001 From: "Brian J. Fox" Date: Mon, 2 Nov 2026 02:07:45 +0000 Subject: [PATCH] fix(web): show a run held on an approval as paused, not at work A coder waiting on a package-install approval is still mid-turn, so the hub reported it as working and the page kept its last activity line, "Running a quick check", with the clock counting, for as long as the person left the approval unanswered. The hub now reports an in-flight run with a pending approval in a third state, approval, beside working and waiting. The hand-off's line reads "Paused for your approval" with no shimmer and no clock, its step shows "Waiting for you", and its work still counts as current so a document it builds from is protected from edits. Closes #63 --- apps/hub/src/artifacts/service.ts | 21 +++++- apps/web/src/config.ts | 2 + .../web/src/pages/workspace/dispatch-card.tsx | 7 +- .../pages/workspace/dispatch-status.test.ts | 64 +++++++++++++++++++ .../src/pages/workspace/work-unit-view.tsx | 3 + apps/web/src/pages/workspace/work-unit.ts | 1 + packages/schemas/src/artifact.ts | 5 +- 7 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/pages/workspace/dispatch-status.test.ts diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index 517c44d7..5f07e2ae 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -681,6 +681,21 @@ export function createArtifactService( ), ) .orderBy(desc(workflowRun.endedAt)); + // A run held on an approval is still mid-turn, so its activity reads as work; the pending + // record is what says it waits on the person. + const heldOnApproval = new Set( + ( + await rawDB + .select({ runId: approval.anchorRunId }) + .from(approval) + .where( + and( + eq(approval.tenantId, actor.tenantId), + eq(approval.status, "pending"), + ), + ) + ).map((row) => row.runId), + ); const inFlight: InFlightRun[] = []; const failed: FailedRun[] = []; for (const run of specialists) { @@ -694,7 +709,11 @@ export function createArtifactService( role, startedAt: run.startedAt.toISOString(), status: run.status, - state: seen?.working ? "working" : "waiting", + state: heldOnApproval.has(run.runId) + ? "approval" + : seen?.working + ? "working" + : "waiting", ...(seen?.since ? { since: seen.since } : {}), ...(run.concept === null ? {} : { concept: run.concept }), ...(progress ? { progress } : {}), diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 181f313a..66cbd3a1 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -558,6 +558,8 @@ export const wording = { /** What a hand-off works on when nothing names it yet. */ unnamed: "the task", waiting: "Waiting for your go-ahead", + /** The run is held on a request only the person can approve, such as a package install. */ + approval: "Paused for your approval", /** The hand-off's approval ended without a yes, so nothing started. */ declined: { rejected: "Not started — you said not yet", diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx index d6d08a30..bb112b4f 100644 --- a/apps/web/src/pages/workspace/dispatch-card.tsx +++ b/apps/web/src/pages/workspace/dispatch-card.tsx @@ -196,6 +196,8 @@ export type DispatchStatus = } | { readonly state: "starting" } | { readonly state: "working"; readonly since: string } + /** Mid-turn but held on an approval only the person can give. */ + | { readonly state: "approval" } | { readonly state: "ready"; readonly title: string; @@ -331,6 +333,7 @@ export function dispatchStatus( return dispatch.runId === undefined ? { state: "starting" } : { state: "finished" }; + if (run.state === "approval") return { state: "approval" }; if (run.state === "working") return { state: "working", since: run.since ?? run.startedAt }; // A parked run that filed its document is done with it, whenever this session first saw it. @@ -340,11 +343,11 @@ export function dispatchStatus( : { state: "finished" }; } -/** The hand-off's run is still starting or at work, so its work is current. */ +/** The hand-off's run is still starting, at work or held on an approval, so its work is current. */ export function dispatchLive(dispatch: Dispatch, room: DispatchRoom): boolean { noteDispatch(room, dispatch); const { state } = dispatchStatus(dispatch, room); - return state === "starting" || state === "working"; + return state === "starting" || state === "working" || state === "approval"; } /** What a hand-off works on: "Constraints", "Quiet concept", "the app". */ diff --git a/apps/web/src/pages/workspace/dispatch-status.test.ts b/apps/web/src/pages/workspace/dispatch-status.test.ts new file mode 100644 index 00000000..2273b048 --- /dev/null +++ b/apps/web/src/pages/workspace/dispatch-status.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test"; +import { + dispatchLive, + dispatchStatus, + dispatchesOf, + type DispatchRoom, +} from "./dispatch-card.tsx"; +import { workUnitMessage, workUnits } from "./work-unit.ts"; + +const room = (state: "working" | "waiting" | "approval"): DispatchRoom => ({ + artifacts: [], + inFlight: [ + { + runId: "run-coder", + role: "coder", + startedAt: "2026-11-02T01:41:25.000Z", + status: "running", + state, + since: "2026-11-02T01:41:30.000Z", + }, + ], + failed: [], + seen: new Map(), + approvals: [], + activity: [], +}); + +const [coder] = dispatchesOf( + { + kind: "tool-trace", + toolCallId: "call-coder", + name: "deploy_coder", + status: "output-available", + input: { instruction: "Build it." }, + }, + { content: JSON.stringify({ runId: "run-coder" }), isError: false }, +); +if (coder === undefined) throw new Error("no dispatch"); + +test("a run held on an approval reads as paused for the person, not as at work", () => { + expect(dispatchStatus(coder, room("working"))).toEqual({ + state: "working", + since: "2026-11-02T01:41:30.000Z", + }); + expect(dispatchStatus(coder, room("approval"))).toEqual({ + state: "approval", + }); + // Its work is still current: a document it builds from should not change under it. + expect(dispatchLive(coder, room("approval"))).toBe(true); +}); + +test("the hand-off's own step shows the approval wait instead of running", () => { + const [unit] = workUnits([{ at: 100, dispatch: coder }], [], []).units; + if (unit === undefined) throw new Error("no unit"); + const message = workUnitMessage(unit, { state: "approval" }); + const [step] = message.parts; + expect(step).toMatchObject({ + kind: "tool-trace", + toolCallId: "call-coder", + status: "approval-requested", + }); + // Nothing streams while the person is the one being waited on. + expect(message.status).toBe("sent"); +}); diff --git a/apps/web/src/pages/workspace/work-unit-view.tsx b/apps/web/src/pages/workspace/work-unit-view.tsx index 011c00de..8d226f2c 100644 --- a/apps/web/src/pages/workspace/work-unit-view.tsx +++ b/apps/web/src/pages/workspace/work-unit-view.tsx @@ -102,6 +102,9 @@ function WorkLine({ ); } + // Nothing moves until the person answers, so no shimmer and no clock running on it. + case "approval": + return text.approval; case "ready": return text.steps(text.done[dispatch.role](subject), steps); case "finished": diff --git a/apps/web/src/pages/workspace/work-unit.ts b/apps/web/src/pages/workspace/work-unit.ts index d0eeecc7..e163d6e3 100644 --- a/apps/web/src/pages/workspace/work-unit.ts +++ b/apps/web/src/pages/workspace/work-unit.ts @@ -166,6 +166,7 @@ const CALL_STATUS: Record = { declined: "output-denied", starting: "pending", working: "running", + approval: "approval-requested", ready: "output-available", finished: "output-available", failed: "error", diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index 337bda9d..e12d874d 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -285,8 +285,9 @@ export const InFlightRun = type({ startedAt: "string > 0", // Interchange's run status: deployed until its first assignment arrives, then running. status: "'deployed' | 'running'", - // Mid-turn, or parked between turns waiting for mail; a parked run is idle, not at work. - state: "'working' | 'waiting'", + // Mid-turn; parked between turns waiting for mail, which is idle, not at work; or mid-turn but + // held on an approval only the person can give, so nothing moves until they answer. + state: "'working' | 'waiting' | 'approval'", // When the current state began, once the hub has seen the run start or end a turn. "since?": "string > 0", // The design concept a designer run works on, filed as that concept's design_option.