From 2ec9fc295cdab4db0ea79b3a946207f0518cabb9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 19:44:27 -0800 Subject: [PATCH 1/3] fix(web): deny open coordinator approval when sending a chat message --- apps/web/src/pages/workspace/index.tsx | 43 +++++---- .../web/src/pages/workspace/typed-yes.test.ts | 93 ++++++++++++++++++- apps/web/src/pages/workspace/typed-yes.tsx | 53 +++++++++++ 3 files changed, 171 insertions(+), 18 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 3fd7faa5..e447127c 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -71,7 +71,11 @@ import { cardDocuments } from "./collaboration.ts"; import { useFiledVersions } from "./filed-versions.tsx"; import { threadUpdates } from "./specialist-updates.tsx"; import { useSettledApprovals, type OpenApproval } from "./settled-approvals.ts"; -import { useTypedYes } from "./typed-yes.tsx"; +import { + chooseApproachWithOpenApproval, + deliverWithOpenApproval, + useTypedYes, +} from "./typed-yes.tsx"; import { asksAbout, chosenApproach } from "./approach-choice.ts"; import { selectedDeliverable } from "./reading-selection.ts"; import { @@ -624,7 +628,7 @@ export function StageWorkspace({ return documents[key]; }; - const deliver = async ( + const deliverMail = async ( body: string, clearComposer = false, ): Promise => { @@ -849,6 +853,9 @@ export function StageWorkspace({ setApprovalBusy(false); } }; + const openApproval = pendingApprovals.find( + (approval) => approval.packages === undefined, + ); // Choosing an approach is the person's yes to the version's open ask; the message names the pick. const chooseApproach = async ( message: string, @@ -863,32 +870,34 @@ export function StageWorkspace({ approval.packages === undefined && asksAbout(approval, kind, version), ); - if (asked !== undefined) { - const fault = await onDecideApproval({ - approvalId: asked.approvalId, - choice: "yes", - }); - if (fault !== null) return fault; - } - return (await deliver(message)) ? null : wording.approaches.notSent; + return chooseApproachWithOpenApproval({ + askedApprovalId: asked?.approvalId, + openApprovalId: openApproval?.approvalId, + decide: onDecideApproval, + deliver: () => deliverMail(message), + notSent: wording.approaches.notSent, + }); }; - const openApproval = pendingApprovals.find( - (approval) => approval.packages === undefined, - ); + const deliver = (body: string, clearComposer = false) => + deliverWithOpenApproval({ + openApprovalId: openApproval?.approvalId, + decide: onDecideApproval, + deliver: () => deliverMail(body, clearComposer), + }); const sendNow = () => { setCaution(null); void deliver(value, true); }; const restoreToComposer = (message: string) => setValue((current) => (current.trim().length === 0 ? message : current)); + const undoTypedYes = async (message: string) => { + if (!(await deliver(message))) restoreToComposer(message); + }; const typedYes = useTypedYes({ pending: pendingApprovals.map((approval) => approval.approvalId), onApprove: (approvalId) => void onDecideApproval({ approvalId, choice: "yes" }), - onUndo: (message) => - void deliver(message).then((sent) => { - if (!sent) restoreToComposer(message); - }), + onUndo: (message) => void undoTypedYes(message), onDropped: restoreToComposer, }); const shell = useShellReply(documentPaneRef); diff --git a/apps/web/src/pages/workspace/typed-yes.test.ts b/apps/web/src/pages/workspace/typed-yes.test.ts index 0fc5b661..5e288226 100644 --- a/apps/web/src/pages/workspace/typed-yes.test.ts +++ b/apps/web/src/pages/workspace/typed-yes.test.ts @@ -1,5 +1,9 @@ import { expect, test } from "bun:test"; -import { isAffirmation } from "./typed-yes.tsx"; +import { + chooseApproachWithOpenApproval, + deliverWithOpenApproval, + isAffirmation, +} from "./typed-yes.tsx"; test("only a whole plain affirmation approves", () => { for (const message of [ @@ -22,3 +26,90 @@ test("only a whole plain affirmation approves", () => { ]) expect(isAffirmation(message)).toBe(false); }); + +test("sending a message while a coordinator approval is open denies it first", async () => { + const steps: string[] = []; + const sent = await deliverWithOpenApproval({ + openApprovalId: "appr-design", + decide: async (decision) => { + steps.push(`decide:${decision.approvalId}:${decision.choice}`); + return null; + }, + deliver: async () => { + steps.push("deliver"); + return true; + }, + }); + expect(sent).toBe(true); + expect(steps).toEqual(["decide:appr-design:no", "deliver"]); +}); + +test("sending a message when no coordinator approval is open delivers directly", async () => { + const steps: string[] = []; + const sent = await deliverWithOpenApproval({ + openApprovalId: undefined, + decide: async () => { + steps.push("decide"); + return null; + }, + deliver: async () => { + steps.push("deliver"); + return true; + }, + }); + expect(sent).toBe(true); + expect(steps).toEqual(["deliver"]); +}); + +test("a failed denial stops before delivering the message", async () => { + const steps: string[] = []; + const sent = await deliverWithOpenApproval({ + openApprovalId: "appr-design", + decide: async () => { + steps.push("decide"); + return "That decision was not saved."; + }, + deliver: async () => { + steps.push("deliver"); + return true; + }, + }); + expect(sent).toBe(false); + expect(steps).toEqual(["decide"]); +}); + +test("choosing an approach approves a matching ask without denying it and denies an unrelated open ask", async () => { + const matchingSteps: string[] = []; + const matchingFault = await chooseApproachWithOpenApproval({ + askedApprovalId: "appr-approaches", + openApprovalId: "appr-approaches", + decide: async (decision) => { + matchingSteps.push(`decide:${decision.approvalId}:${decision.choice}`); + return null; + }, + deliver: async () => { + matchingSteps.push("deliver"); + return true; + }, + notSent: "not sent", + }); + expect(matchingFault).toBeNull(); + expect(matchingSteps).toEqual(["decide:appr-approaches:yes", "deliver"]); + + const unrelatedSteps: string[] = []; + const unrelatedFault = await chooseApproachWithOpenApproval({ + askedApprovalId: undefined, + openApprovalId: "appr-design", + decide: async (decision) => { + unrelatedSteps.push(`decide:${decision.approvalId}:${decision.choice}`); + return null; + }, + deliver: async () => { + unrelatedSteps.push("deliver"); + return true; + }, + notSent: "not sent", + }); + expect(unrelatedFault).toBeNull(); + expect(unrelatedSteps).toEqual(["decide:appr-design:no", "deliver"]); +}); diff --git a/apps/web/src/pages/workspace/typed-yes.tsx b/apps/web/src/pages/workspace/typed-yes.tsx index a60e4dad..3ed7054a 100644 --- a/apps/web/src/pages/workspace/typed-yes.tsx +++ b/apps/web/src/pages/workspace/typed-yes.tsx @@ -98,6 +98,59 @@ export function useTypedYes({ return { held, hold, undo }; } +/** + * Sends a message while a coordinator approval card may be open: denies the open card first + * so the parked coordinator wakes to read the message. + */ +export async function deliverWithOpenApproval({ + openApprovalId, + decide, + deliver, +}: { + openApprovalId: string | undefined; + decide: (decision: { + approvalId: string; + choice: "yes" | "always" | "no"; + }) => Promise; + deliver: () => Promise; +}): Promise { + if (openApprovalId !== undefined) { + const fault = await decide({ approvalId: openApprovalId, choice: "no" }); + if (fault !== null) return false; + } + return deliver(); +} + +/** + * Picks an approach while a coordinator approval card may be open: approves when the open + * card asks about the chosen version, or denies any other open coordinator card first. + */ +export async function chooseApproachWithOpenApproval({ + askedApprovalId, + openApprovalId, + decide, + deliver, + notSent, +}: { + askedApprovalId: string | undefined; + openApprovalId: string | undefined; + decide: (decision: { + approvalId: string; + choice: "yes" | "always" | "no"; + }) => Promise; + deliver: () => Promise; + notSent: string; +}): Promise { + if (askedApprovalId !== undefined) { + const fault = await decide({ approvalId: askedApprovalId, choice: "yes" }); + if (fault !== null) return fault; + } else if (openApprovalId !== undefined) { + const fault = await decide({ approvalId: openApprovalId, choice: "no" }); + if (fault !== null) return fault; + } + return (await deliver()) ? null : notSent; +} + /** The countdown on the card it will approve, with its undo. */ export function TypedYesCountdown({ held, From c21438053ae73180166bf6f3d2968cec568ecb2d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 20:43:24 -0800 Subject: [PATCH 2/3] fix(web): deny open coordinator approval before attaching files --- apps/web/src/pages/workspace/index.tsx | 26 +++++++++++---- .../web/src/pages/workspace/typed-yes.test.ts | 32 +++++++++++++++++++ 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index e447127c..6eecae6d 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -697,8 +697,8 @@ export function StageWorkspace({ setFiles((current) => [...current, ...fitting]); }; - const sendFiles = async () => { - if (sending) return; + const deliverFiles = async (): Promise => { + if (sending) return false; const text = value.trim(); const attached = files; setSending(true); @@ -736,6 +736,7 @@ export function StageWorkspace({ setSent((previous) => previous.filter((item) => item !== pending)); reloadRef.current(); onChanged(); + return true; } catch (cause) { setAwaitingSince(null); setSent((previous) => previous.filter((item) => item !== pending)); @@ -743,6 +744,7 @@ export function StageWorkspace({ setValue((current) => (current === "" ? text : current)); setFiles(attached); setAttachFault(await attachFaultText(cause)); + return false; } finally { setSending(false); } @@ -879,11 +881,21 @@ export function StageWorkspace({ }); }; const deliver = (body: string, clearComposer = false) => - deliverWithOpenApproval({ - openApprovalId: openApproval?.approvalId, - decide: onDecideApproval, - deliver: () => deliverMail(body, clearComposer), - }); + body.trim().length === 0 || sending + ? Promise.resolve(false) + : deliverWithOpenApproval({ + openApprovalId: openApproval?.approvalId, + decide: onDecideApproval, + deliver: () => deliverMail(body, clearComposer), + }); + const sendFiles = () => + sending + ? Promise.resolve(false) + : deliverWithOpenApproval({ + openApprovalId: openApproval?.approvalId, + decide: onDecideApproval, + deliver: deliverFiles, + }); const sendNow = () => { setCaution(null); void deliver(value, true); diff --git a/apps/web/src/pages/workspace/typed-yes.test.ts b/apps/web/src/pages/workspace/typed-yes.test.ts index 5e288226..ea880b9c 100644 --- a/apps/web/src/pages/workspace/typed-yes.test.ts +++ b/apps/web/src/pages/workspace/typed-yes.test.ts @@ -113,3 +113,35 @@ test("choosing an approach approves a matching ask without denying it and denies expect(unrelatedFault).toBeNull(); expect(unrelatedSteps).toEqual(["decide:appr-design:no", "deliver"]); }); + +test("sending attached files while a coordinator approval is open denies it before attaching materials", async () => { + const steps: string[] = []; + const attached = await deliverWithOpenApproval({ + openApprovalId: "appr-brief", + decide: async (decision) => { + steps.push(`decide:${decision.approvalId}:${decision.choice}`); + return null; + }, + deliver: async () => { + steps.push("attachMaterials"); + return true; + }, + }); + expect(attached).toBe(true); + expect(steps).toEqual(["decide:appr-brief:no", "attachMaterials"]); + + const failedSteps: string[] = []; + const notAttached = await deliverWithOpenApproval({ + openApprovalId: "appr-brief", + decide: async (decision) => { + failedSteps.push(`decide:${decision.approvalId}:${decision.choice}`); + return "That decision was not saved."; + }, + deliver: async () => { + failedSteps.push("attachMaterials"); + return true; + }, + }); + expect(notAttached).toBe(false); + expect(failedSteps).toEqual(["decide:appr-brief:no"]); +}); From 78e0b2c8c4dfa29a32e1c33226056f4b0324bf6c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 1 Nov 2026 22:22:01 -0800 Subject: [PATCH 3/3] fix(web): query open decisions via TanStack Query and refresh on send --- apps/web/src/api/decisions.ts | 8 +- apps/web/src/pages/workspace/index.tsx | 198 +++++++++++------- apps/web/src/pages/workspace/live-run.ts | 12 +- .../src/pages/workspace/transcript.test.tsx | 31 ++- apps/web/src/queries/keys.ts | 4 + 5 files changed, 170 insertions(+), 83 deletions(-) diff --git a/apps/web/src/api/decisions.ts b/apps/web/src/api/decisions.ts index 884b332d..2bff0e12 100644 --- a/apps/web/src/api/decisions.ts +++ b/apps/web/src/api/decisions.ts @@ -75,13 +75,17 @@ function parkWait( }; } -export async function openDecisions(): Promise { +export async function openDecisions(onlyProjectId?: string): Promise { const client = productClient(); const tenantId = await workspaceTenantId(); const { projects } = await client.projects(tenantId, { status: "active" }); + const targetProjects = + onlyProjectId === undefined + ? projects + : projects.filter((record) => record.projectId === onlyProjectId); const waits: Wait[] = []; - for (const record of projects) { + for (const record of targetProjects) { const [conversation, { data: approvals }] = await Promise.all([ readConversationEvents(record.projectId), client.nativeApprovals(record.tenantId), diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx index 6eecae6d..731f56bd 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -94,7 +94,7 @@ import { type LiveRun, } from "./live-run.ts"; import { createRoomReloader } from "./room-reload.ts"; -import { decideApproval } from "../../api/decisions.ts"; +import { decideApproval, openDecisions } from "../../api/decisions.ts"; import type { DeliverableState } from "./deliverable-block.tsx"; import { formatPassageQuote, type PassageQuote } from "./passage-note.tsx"; import { DesignTarget } from "./mockup-tweaks.tsx"; @@ -127,6 +127,34 @@ import { useShellReply } from "../../flourish.tsx"; const FALLBACK_RELOAD_MS = 30_000; // After a run closes, the mirror's commit can trail its last frame. const SETTLE_RELOAD_MS = [1_000, 3_000, 7_000, 15_000]; +const NO_WAITS: readonly Wait[] = []; + +function documentApprovalsFrom( + waits: readonly Wait[], + artifacts: readonly ProjectArtifact[], +): OpenApproval[] { + return waits.flatMap((wait): OpenApproval[] => { + if (wait.approvalId === undefined || wait.tenantId === undefined) return []; + const at = + wait.createdAt === undefined ? Number.NaN : Date.parse(wait.createdAt); + return [ + { + approvalId: wait.approvalId, + tenantId: wait.tenantId, + ...(Number.isNaN(at) ? {} : { at }), + ...cardDocuments(wait, artifacts), + ...(wait.toolName === undefined ? {} : { toolName: wait.toolName }), + ...(wait.kinds === undefined ? {} : { kinds: wait.kinds }), + ...(wait.instruction === undefined + ? {} + : { instruction: wait.instruction }), + ...(wait.subject === undefined ? {} : { subject: wait.subject }), + ...(wait.packages === undefined ? {} : { packages: wait.packages }), + ...(wait.concepts === undefined ? {} : { concepts: wait.concepts }), + }, + ]; + }); +} export { selectedDeliverable }; @@ -375,10 +403,28 @@ export function StageWorkspace({ const committedAtRef = useRef(null); committedAtRef.current = latestAssistantAt(mirror); const reportedFailure = useRef(null); - const [waits, setWaits] = useState([]); const rolesAtWork = useRolesAtWork(projectId); const [caution, setCaution] = useState(null); const [gateDone, setGateDone] = useState(0); + // The waits that belong to this project, from the open decisions. + const decisions = useQuery({ + queryKey: keys.openDecisions.of(projectId), + enabled: status === "active", + retry: false, + staleTime: 0, + refetchOnWindowFocus: false, + queryFn: () => openDecisions(projectId), + }); + useEffect(() => { + if (decisions.error === null) return; + toast.error( + humanFault(decisions.error, "Open decisions could not be read."), + { + id: "waits-read", + }, + ); + }, [decisions.error]); + const waits = decisions.data ?? NO_WAITS; const reloadRef = useRef<() => void>(() => {}); useEffect(() => { @@ -461,12 +507,16 @@ export function StageWorkspace({ }; }, [projectId, runId]); - // A room that moved may mean a message reached the coordinator from elsewhere; read who is at work. + // A room that moved may mean a message reached the coordinator from elsewhere, or a coordinator + // or coder decision opened; read who is at work and the open decisions. useEffect(() => { void queryClient.invalidateQueries({ queryKey: keys.projectActivity.of(projectId), }); - }, [projectId, queryClient, snapshot]); + void queryClient.invalidateQueries({ + queryKey: keys.openDecisions.of(projectId), + }); + }, [projectId, queryClient, snapshot, gateDone]); const settling = awaitingCommit(liveRun, mirror); useEffect(() => { @@ -504,32 +554,6 @@ export function StageWorkspace({ toast.error(message); }, [events, mirror]); - // The waits that belong to this project, from the open decisions. - useEffect(() => { - let cancelled = false; - void import("../../api/decisions.ts").then(({ openDecisions }) => - openDecisions() - .then((all) => { - if (!cancelled) - setWaits(all.filter((wait) => wait.projectId === projectId)); - }) - .catch((cause: unknown) => { - if (!cancelled) - toast.error( - humanFault(cause, "Open decisions could not be read."), - { - id: "waits-read", - }, - ); - }), - ); - return () => { - cancelled = true; - }; - // A coordinator decision arrives with a room event; a coder's package - // request raises none, so every room reload re-reads the open decisions. - }, [projectId, gateDone, snapshot]); - for (const [at, parts] of committedSteps(liveRun, mirror)) stepsByAt.current.set(at, parts); const turns = useMemo( @@ -760,37 +784,37 @@ export function StageWorkspace({ const addToPrompt = (passage: PassageQuote) => addToComposer(formatPassageQuote(passage)); - const documentApprovals = waits.flatMap((wait): OpenApproval[] => { - if (wait.approvalId === undefined || wait.tenantId === undefined) return []; - const at = - wait.createdAt === undefined ? Number.NaN : Date.parse(wait.createdAt); - return [ - { - approvalId: wait.approvalId, - tenantId: wait.tenantId, - ...(Number.isNaN(at) ? {} : { at }), - ...cardDocuments(wait, artifacts), - ...(wait.toolName === undefined ? {} : { toolName: wait.toolName }), - ...(wait.kinds === undefined ? {} : { kinds: wait.kinds }), - ...(wait.instruction === undefined - ? {} - : { instruction: wait.instruction }), - ...(wait.subject === undefined ? {} : { subject: wait.subject }), - ...(wait.packages === undefined ? {} : { packages: wait.packages }), - ...(wait.concepts === undefined ? {} : { concepts: wait.concepts }), - }, - ]; - }); + const documentApprovals = documentApprovalsFrom(waits, artifacts); const { settled: settledApprovals, settle } = useSettledApprovals( projectId, documentApprovals, artifacts, ); // A card decided here is already its settled line; the pending read lags behind it. - const pendingApprovals = documentApprovals.filter( - (approval) => - !settledApprovals.some((item) => item.approvalId === approval.approvalId), - ); + const pendingFrom = (list: readonly Wait[]): OpenApproval[] => + documentApprovalsFrom(list, artifacts).filter( + (approval) => + !settledApprovals.some( + (item) => item.approvalId === approval.approvalId, + ), + ); + const pendingApprovals = pendingFrom(waits); + // A park created moments after `inference.done` can already stand on the hub before the next + // room reload lands; read once on demand when the cached list shows no open ask. + const readPendingApprovals = async (): Promise => { + if (pendingApprovals.length > 0) return pendingApprovals; + try { + const fresh = await queryClient.fetchQuery({ + queryKey: keys.openDecisions.of(projectId), + staleTime: 0, + queryFn: () => openDecisions(projectId), + }); + return pendingFrom(fresh); + } catch (cause: unknown) { + void cause; + return pendingApprovals; + } + }; const chooseDocument = (id: string) => setDocumentChoice({ id, filedCount: filed.length }); const openDocument = (id: string) => { @@ -805,7 +829,13 @@ export function StageWorkspace({ choice: "yes" | "always" | "no"; }): Promise => { const notSaved = "That decision was not saved."; - const wait = waits.find((item) => item.approvalId === decision.approvalId); + const cachedWaits = + queryClient.getQueryData( + keys.openDecisions.of(projectId), + ) ?? NO_WAITS; + const wait = + waits.find((item) => item.approvalId === decision.approvalId) ?? + cachedWaits.find((item) => item.approvalId === decision.approvalId); const approvalId = wait?.approvalId; const tenantId = wait?.tenantId; if ( @@ -817,9 +847,11 @@ export function StageWorkspace({ return notSaved; setApprovalBusy(true); setApprovalError(null); - const decided = documentApprovals.find( - (item) => item.approvalId === approvalId, - ); + const decided = + documentApprovals.find((item) => item.approvalId === approvalId) ?? + documentApprovalsFrom(cachedWaits, artifacts).find( + (item) => item.approvalId === approvalId, + ); try { await decideApproval({ tenantId, @@ -863,39 +895,51 @@ export function StageWorkspace({ message: string, version: number, ): Promise => { + const currentApprovals = await readPendingApprovals(); + const currentOpen = currentApprovals.find( + (approval) => approval.packages === undefined, + ); const kind = selected?.kind; const asked = kind === undefined ? undefined - : pendingApprovals.find( + : currentApprovals.find( (approval) => approval.packages === undefined && asksAbout(approval, kind, version), ); return chooseApproachWithOpenApproval({ askedApprovalId: asked?.approvalId, - openApprovalId: openApproval?.approvalId, + openApprovalId: currentOpen?.approvalId, decide: onDecideApproval, deliver: () => deliverMail(message), notSent: wording.approaches.notSent, }); }; - const deliver = (body: string, clearComposer = false) => - body.trim().length === 0 || sending - ? Promise.resolve(false) - : deliverWithOpenApproval({ - openApprovalId: openApproval?.approvalId, - decide: onDecideApproval, - deliver: () => deliverMail(body, clearComposer), - }); - const sendFiles = () => - sending - ? Promise.resolve(false) - : deliverWithOpenApproval({ - openApprovalId: openApproval?.approvalId, - decide: onDecideApproval, - deliver: deliverFiles, - }); + const deliver = async (body: string, clearComposer = false) => { + if (body.trim().length === 0 || sending) return false; + const currentApprovals = await readPendingApprovals(); + const currentOpen = currentApprovals.find( + (approval) => approval.packages === undefined, + ); + return deliverWithOpenApproval({ + openApprovalId: currentOpen?.approvalId, + decide: onDecideApproval, + deliver: () => deliverMail(body, clearComposer), + }); + }; + const sendFiles = async () => { + if (sending) return false; + const currentApprovals = await readPendingApprovals(); + const currentOpen = currentApprovals.find( + (approval) => approval.packages === undefined, + ); + return deliverWithOpenApproval({ + openApprovalId: currentOpen?.approvalId, + decide: onDecideApproval, + deliver: deliverFiles, + }); + }; const sendNow = () => { setCaution(null); void deliver(value, true); diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index fde867fc..ae0cc6b3 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -237,13 +237,19 @@ export function pendingLiveTurn( }; } -/** The run closed with text the mirror has not committed yet. */ +/** + * The run closed with text the mirror has not committed yet, or an inference + * call finished with tool work whose sidecar park can land just after + * `inference.done`. + */ export function awaitingCommit( run: LiveRun, mirror: readonly ConversationTurn[], ): boolean { - if (!run.ended) return false; - return (pendingLiveTurn(run, mirror)?.text.length ?? 0) > 0; + const pending = pendingLiveTurn(run, mirror); + if (pending === null) return false; + if (run.ended) return pending.text.length > 0; + return !hasContent(run.current) && pending.parts.length > 0; } /** diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index b9b36309..ab3610f2 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -3,7 +3,12 @@ import { isValidElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { wording } from "../../config.ts"; import { DocumentDecision } from "./document-decision.tsx"; -import { waitingOnPerson } from "./live-run.ts"; +import { + awaitingCommit, + emptyLiveRun, + foldLiveRun, + waitingOnPerson, +} from "./live-run.ts"; import { RoomThread } from "./transcript.tsx"; function findElement( @@ -142,6 +147,30 @@ describe("conversation transcript", () => { ); }); + test("an inference call that finishes with tool work settles even while the message run stays open on a park", () => { + const started = foldLiveRun( + emptyLiveRun, + { type: "inference.start" }, + null, + ).run; + const withTool = foldLiveRun( + started, + { + type: "inference.tool_call.end", + data: { + callId: "call-1", + name: "request_approval", + arguments: { kinds: ["approaches"] }, + }, + }, + null, + ).run; + expect(awaitingCommit(withTool, [])).toBe(false); + const done = foldLiveRun(withTool, { type: "inference.done" }, null).run; + expect(done.ended).toBe(false); + expect(awaitingCommit(done, [])).toBe(true); + }); + test("a specialist's report joins its hand-off as one message and a long run of lone ones folds", () => { const summary = "Wrote the limits for your weather app, covering offline use and units."; diff --git a/apps/web/src/queries/keys.ts b/apps/web/src/queries/keys.ts index d1f61c16..564b9c6b 100644 --- a/apps/web/src/queries/keys.ts +++ b/apps/web/src/queries/keys.ts @@ -45,6 +45,10 @@ export const keys = { all: ["decidedApprovals"] as const, of: (projectId: string) => ["decidedApprovals", projectId] as const, }, + openDecisions: { + all: ["openDecisions"] as const, + of: (projectId: string) => ["openDecisions", projectId] as const, + }, projectNextStep: { all: ["projectNextStep"] as const, of: (projectId: string) => ["projectNextStep", projectId] as const,