From f37102f3fbf90bd2b7f17c3110fc2aa535629c41 Mon Sep 17 00:00:00 2001
From: Sawyer Cutler
Date: Wed, 4 Nov 2026 13:39:49 -0800
Subject: [PATCH 1/4] fix(web): handle missing room data instead of inventing
it
---
apps/web/src/App.tsx | 7 +-
apps/web/src/config.ts | 3 +
apps/web/src/pages/collaborators.tsx | 11 +-
.../src/pages/workspace/agent-activity.tsx | 12 +-
.../pages/workspace/artifact-pane.test.tsx | 1 +
apps/web/src/pages/workspace/collaboration.ts | 6 +-
.../src/pages/workspace/deliverable-block.tsx | 8 +-
.../web/src/pages/workspace/dispatch-card.tsx | 16 +-
.../src/pages/workspace/document-decision.tsx | 4 +-
.../pages/workspace/empty-project.test.tsx | 1 +
.../src/pages/workspace/filed-versions.tsx | 8 +-
apps/web/src/pages/workspace/index.tsx | 236 +++++++++++-------
.../src/pages/workspace/ready-room.test.tsx | 81 ++++++
.../src/pages/workspace/work-unit-view.tsx | 8 +-
14 files changed, 271 insertions(+), 131 deletions(-)
create mode 100644 apps/web/src/pages/workspace/ready-room.test.tsx
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 4840629d..2af5b60d 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -732,10 +732,9 @@ export function App() {
if (cause instanceof ApiFailure) toast.error(cause.detail.message);
else if (cause instanceof ProductApiError)
void responseCode(cause).then((code) => {
- toast.error(
- wording.library.refused[code ?? ""] ??
- humanFault(cause, wording.library.actionFailed),
- );
+ const refused =
+ code === undefined ? undefined : wording.library.refused[code];
+ toast.error(refused ?? humanFault(cause, wording.library.actionFailed));
void refresh();
});
else void refresh();
diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts
index 6b8b2982..255134ab 100644
--- a/apps/web/src/config.ts
+++ b/apps/web/src/config.ts
@@ -423,6 +423,7 @@ export const wording = {
},
activity: {
label: "Who is working",
+ notRead: "Who is working could not be read.",
role: {
coordinator: "Coordinator",
writer: "Writer",
@@ -811,6 +812,8 @@ export const wording = {
instruct_coder: () => "Send this change to the app builder?",
} as Record string>,
document: (document: string) => `Happy with the ${document}?`,
+ /** An ask that names neither a document nor a tool. */
+ unnamed: "Happy for this to go ahead?",
/** A request_approval ask that covers several documents, by their short names. */
documents: (names: readonly string[]) =>
`Happy with the ${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}?`,
diff --git a/apps/web/src/pages/collaborators.tsx b/apps/web/src/pages/collaborators.tsx
index e0dd83ee..097499c2 100644
--- a/apps/web/src/pages/collaborators.tsx
+++ b/apps/web/src/pages/collaborators.tsx
@@ -42,11 +42,12 @@ function expiresInLabel(expiresAt: string): string {
async function refusal(cause: unknown): Promise {
if (!(cause instanceof ProductApiError))
return cause instanceof Error ? cause.message : String(cause);
- const code = (await responseCode(cause)) ?? "";
- return (
- wording.settings.collaborators.refused[code] ??
- wording.settings.collaborators.failed
- );
+ const code = await responseCode(cause);
+ const refused =
+ code === undefined
+ ? undefined
+ : wording.settings.collaborators.refused[code];
+ return refused ?? wording.settings.collaborators.failed;
}
export function Collaborators({ user }: { user: HubUser | null }) {
diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx
index f2688353..55ef36b7 100644
--- a/apps/web/src/pages/workspace/agent-activity.tsx
+++ b/apps/web/src/pages/workspace/agent-activity.tsx
@@ -174,8 +174,12 @@ export function heldOnApproval(
.join(",");
}
-/** The roles with a run mid-turn right now, from the hub's native activity read. */
-export function useRolesAtWork(projectId: string): ReadonlySet {
- const agents = useProjectActivity(projectId).data?.agents ?? [];
- return new Set(agents.map((agent) => agent.role));
+/** The roles with a run mid-turn right now, from the hub's native activity read; undefined until it is read. */
+export function useRolesAtWork(
+ projectId: string,
+): ReadonlySet | undefined {
+ const agents = useProjectActivity(projectId).data?.agents;
+ return agents === undefined
+ ? undefined
+ : new Set(agents.map((agent) => agent.role));
}
diff --git a/apps/web/src/pages/workspace/artifact-pane.test.tsx b/apps/web/src/pages/workspace/artifact-pane.test.tsx
index 1b25798a..dc4f41e3 100644
--- a/apps/web/src/pages/workspace/artifact-pane.test.tsx
+++ b/apps/web/src/pages/workspace/artifact-pane.test.tsx
@@ -34,6 +34,7 @@ const document: DeliverableState = {
content: "Previous content",
filedAt: "2026-10-30T11:00:00.000Z",
},
+ versions: [],
},
};
function render(
diff --git a/apps/web/src/pages/workspace/collaboration.ts b/apps/web/src/pages/workspace/collaboration.ts
index 178ccde3..35588672 100644
--- a/apps/web/src/pages/workspace/collaboration.ts
+++ b/apps/web/src/pages/workspace/collaboration.ts
@@ -132,14 +132,14 @@ export function shortDocumentName(kind: string, title: string): string {
*/
export function approvalQuestion(
toolName: string | undefined,
- document: string,
+ document: string | undefined,
subject?: string,
): string {
const asked =
toolName === undefined ? undefined : wording.approval.question[toolName];
if (asked !== undefined) return asked(subject);
- if (document !== "" || toolName === undefined)
- return wording.approval.document(document);
+ if (document !== undefined) return wording.approval.document(document);
+ if (toolName === undefined) return wording.approval.unnamed;
const tool = toolName
.slice(toolName.lastIndexOf(".") + 1)
.replaceAll("_", " ");
diff --git a/apps/web/src/pages/workspace/deliverable-block.tsx b/apps/web/src/pages/workspace/deliverable-block.tsx
index d23a0138..40353377 100644
--- a/apps/web/src/pages/workspace/deliverable-block.tsx
+++ b/apps/web/src/pages/workspace/deliverable-block.tsx
@@ -56,7 +56,7 @@ export type LoadedDeliverable = ReadVersion & {
sha256: string;
before: (ReadVersion & { version: number }) | null;
historyError?: string;
- versions?: readonly RetainedVersion[];
+ versions: readonly RetainedVersion[];
};
export type DeliverableState =
@@ -238,7 +238,7 @@ function ReadyDocument({
const compareWith = changesOpen ? (baseline ?? predecessor) : null;
const afterText = textFor(viewing);
const beforeText = compareWith === null ? null : textFor(compareWith);
- const viewed = document.versions?.find((item) => item.version === viewing);
+ const viewed = document.versions.find((item) => item.version === viewing);
const mediaType = viewed?.mediaType ?? document.mediaType;
const title = viewed?.title ?? document.title;
const kind = mediaKind(mediaType, title);
@@ -381,7 +381,7 @@ function ReadyDocument({
const barGroups = [
approvalGroup,
versionGroup({
- versions: document.versions ?? [],
+ versions: document.versions,
viewing,
pending: pendingVersion !== null,
canCompare:
@@ -526,7 +526,7 @@ function ReadyDocument({
item.version)}
+ packageVersions={document.versions.map((item) => item.version)}
onReadPackageVersion={fetchVersion}
shareOpen={shareOpen}
onShareOpenChange={setShareOpen}
diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx
index 942b30f1..4ea887a2 100644
--- a/apps/web/src/pages/workspace/dispatch-card.tsx
+++ b/apps/web/src/pages/workspace/dispatch-card.tsx
@@ -77,9 +77,10 @@ export function dispatchesOf(
const role = ROLE_OF[part.name];
if (role === undefined) return [];
const pending = part.status !== "output-available";
- const args = record(part.input) ?? {};
- const instruction = str(args["instruction"]);
- const kind = str(args["kind"]);
+ // A call still streaming its arguments has none to read yet.
+ const args = record(part.input);
+ const instruction = str(args?.["instruction"]);
+ const kind = str(args?.["kind"]);
const base = {
call: part.toolCallId,
tool: part.name,
@@ -97,8 +98,9 @@ export function dispatchesOf(
refused: str(result.content) ?? text.refusedUnknown,
},
];
- const concepts = Array.isArray(args["concepts"])
- ? args["concepts"].flatMap((item) => {
+ const named = args?.["concepts"];
+ const concepts = Array.isArray(named)
+ ? named.flatMap((item) => {
const label = str(record(item)?.["label"]);
return label === undefined ? [] : [label];
})
@@ -162,8 +164,8 @@ export type DispatchRoom = {
>;
/** The native approvals, pending or as their records settled them. */
readonly approvals: readonly DocumentApproval[];
- /** The runs mid-turn, with the tool calls and words their own inference events carried. */
- readonly activity: readonly AgentActivity[];
+ /** The runs mid-turn, with the tool calls and words their own inference events carried; undefined until read. */
+ readonly activity: readonly AgentActivity[] | undefined;
readonly onOpenDocument?: (kind: string) => void;
};
diff --git a/apps/web/src/pages/workspace/document-decision.tsx b/apps/web/src/pages/workspace/document-decision.tsx
index 08c5e603..377a20e8 100644
--- a/apps/web/src/pages/workspace/document-decision.tsx
+++ b/apps/web/src/pages/workspace/document-decision.tsx
@@ -62,7 +62,7 @@ function decisionTitle(approval: DocumentApproval, shown: boolean): string {
);
const question = approvalQuestion(
approval.toolName,
- shortName(approval) ?? "",
+ shortName(approval),
approval.subject,
);
return shown ? `${question} ${text.openOnRight}` : question;
@@ -141,7 +141,7 @@ function settledLine(approval: DocumentApproval): string {
? undefined
: text.settled[approval.toolName];
return text.approved(
- settled ?? approvalQuestion(approval.toolName, "", approval.subject),
+ settled ?? approvalQuestion(approval.toolName, undefined, approval.subject),
);
}
diff --git a/apps/web/src/pages/workspace/empty-project.test.tsx b/apps/web/src/pages/workspace/empty-project.test.tsx
index 4d64a05e..31a21cd7 100644
--- a/apps/web/src/pages/workspace/empty-project.test.tsx
+++ b/apps/web/src/pages/workspace/empty-project.test.tsx
@@ -26,6 +26,7 @@ function ready(title: string, content: string): DeliverableState {
version: 1,
sha256: sha,
before: null,
+ versions: [],
},
};
}
diff --git a/apps/web/src/pages/workspace/filed-versions.tsx b/apps/web/src/pages/workspace/filed-versions.tsx
index 1208a9fe..4fe141a9 100644
--- a/apps/web/src/pages/workspace/filed-versions.tsx
+++ b/apps/web/src/pages/workspace/filed-versions.tsx
@@ -13,11 +13,10 @@ export type FiledVersion = {
export function useFiledVersions(input: {
readonly projectId: string;
- /** The room has its first read; versions before it are history, not news. */
- readonly ready: boolean;
+ /** From the room's first read on; versions in that read are history, not news. */
readonly documents: readonly FiledVersion[];
}): readonly FiledVersion[] {
- const { projectId, ready, documents } = input;
+ const { projectId, documents } = input;
const seen = useRef
");
+ });
+});
diff --git a/apps/web/src/pages/workspace/work-unit-view.tsx b/apps/web/src/pages/workspace/work-unit-view.tsx
index 8d226f2c..19e92a16 100644
--- a/apps/web/src/pages/workspace/work-unit-view.tsx
+++ b/apps/web/src/pages/workspace/work-unit-view.tsx
@@ -60,9 +60,9 @@ function Elapsed({ since }: { since: string }) {
function liveLine(
dispatch: Dispatch,
subject: string,
- activity: readonly AgentActivity[],
+ activity: readonly AgentActivity[] | undefined,
): string {
- const agent = activity.find((item) =>
+ const agent = activity?.find((item) =>
dispatch.runId === undefined
? item.role === dispatch.role
: item.runId === dispatch.runId,
@@ -84,7 +84,7 @@ function WorkLine({
status: DispatchStatus;
subject: string;
steps: number;
- activity: readonly AgentActivity[];
+ activity: readonly AgentActivity[] | undefined;
}): ReactNode {
switch (status.state) {
case "waiting":
@@ -217,7 +217,7 @@ export function WorkUnitView({
status={status}
subject={subject}
steps={steps.length}
- activity={room?.activity ?? []}
+ activity={room?.activity}
/>
}
labels={wording.activity.trigger}
From 8a162dff6c84dc4f85575ccbf4545068590bdd24 Mon Sep 17 00:00:00 2001
From: Sawyer Cutler
Date: Wed, 4 Nov 2026 13:49:22 -0800
Subject: [PATCH 2/4] fix(web): start room reads early and keep unread
decisions unknown
---
apps/web/src/App.tsx | 15 +-
apps/web/src/config.ts | 3 +
.../src/pages/workspace/agent-activity.tsx | 18 +-
.../src/pages/workspace/document-decision.tsx | 7 +-
apps/web/src/pages/workspace/index.tsx | 175 +++++++++++-------
apps/web/src/pages/workspace/next-step.tsx | 11 +-
.../src/pages/workspace/ready-room.test.tsx | 19 +-
.../web/src/pages/workspace/room-skeleton.tsx | 2 +-
.../src/pages/workspace/settled-approvals.ts | 6 +-
apps/web/src/pages/workspace/typed-yes.tsx | 7 +-
10 files changed, 160 insertions(+), 103 deletions(-)
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 2af5b60d..99ec337c 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -728,15 +728,16 @@ export function App() {
? opening.text
: undefined;
+ const reportRefusal = async (cause: ProductApiError) => {
+ const code = await responseCode(cause);
+ const refused =
+ code === undefined ? undefined : wording.library.refused[code];
+ toast.error(refused ?? humanFault(cause, wording.library.actionFailed));
+ void refresh();
+ };
const reportProjectError = (cause: unknown) => {
if (cause instanceof ApiFailure) toast.error(cause.detail.message);
- else if (cause instanceof ProductApiError)
- void responseCode(cause).then((code) => {
- const refused =
- code === undefined ? undefined : wording.library.refused[code];
- toast.error(refused ?? humanFault(cause, wording.library.actionFailed));
- void refresh();
- });
+ else if (cause instanceof ProductApiError) void reportRefusal(cause);
else void refresh();
};
diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts
index 255134ab..a23a3c1f 100644
--- a/apps/web/src/config.ts
+++ b/apps/web/src/config.ts
@@ -814,6 +814,8 @@ export const wording = {
document: (document: string) => `Happy with the ${document}?`,
/** An ask that names neither a document nor a tool. */
unnamed: "Happy for this to go ahead?",
+ /** The settled line of an ask that names neither a document nor a tool. */
+ settledUnnamed: "the request",
/** A request_approval ask that covers several documents, by their short names. */
documents: (names: readonly string[]) =>
`Happy with the ${names.slice(0, -1).join(", ")} and ${names.at(-1) ?? ""}?`,
@@ -977,6 +979,7 @@ export const wording = {
coordinatorStopped: "The coordinator stopped before it replied.",
coordinatorNotStarted: "The coordinator for this project did not start.",
projectNotOpened: "The project could not be opened.",
+ projectLoading: "Loading this project…",
projectTooShort: "At least ten characters to start a project.",
stepFailed: "That step could not finish.",
},
diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx
index 55ef36b7..d3736137 100644
--- a/apps/web/src/pages/workspace/agent-activity.tsx
+++ b/apps/web/src/pages/workspace/agent-activity.tsx
@@ -4,7 +4,7 @@
* carried. A phrase is a fixed reading of the latest call, never a guess at
* progress.
*/
-import { useQuery } from "@tanstack/react-query";
+import { queryOptions, useQuery } from "@tanstack/react-query";
import type { AgentActivity } from "@corbits-builder/client";
import {
readProjectActivity,
@@ -135,8 +135,8 @@ export function activityPhrase(agent: AgentActivity): {
};
}
-export function useProjectActivity(projectId: string) {
- return useQuery({
+export function projectActivityQuery(projectId: string) {
+ return queryOptions({
queryKey: keys.projectActivity.of(projectId),
queryFn: ({ signal }) => readProjectActivity(projectId, signal),
// While anyone is mid-turn, a hand-off and each step read sooner.
@@ -146,12 +146,16 @@ export function useProjectActivity(projectId: string) {
});
}
+export function useProjectActivity(projectId: string) {
+ return useQuery(projectActivityQuery(projectId));
+}
+
/**
* Each live run's state by its latest committed event, polled while any run is live. A run that
* parks on an approval raises no room event, so this is how the page learns the person's turn came.
*/
-export function useProjectRunStates(projectId: string) {
- return useQuery({
+export function projectRunStatesQuery(projectId: string) {
+ return queryOptions({
queryKey: keys.projectRunStates.of(projectId),
queryFn: ({ signal }) => readProjectRunStates(projectId, signal),
refetchInterval: (query) =>
@@ -162,6 +166,10 @@ export function useProjectRunStates(projectId: string) {
});
}
+export function useProjectRunStates(projectId: string) {
+ return useQuery(projectRunStatesQuery(projectId));
+}
+
/** The runs held on an approval, as one string a React effect can depend on. */
export function heldOnApproval(
states: { runs: readonly { runId: string; state: string }[] } | undefined,
diff --git a/apps/web/src/pages/workspace/document-decision.tsx b/apps/web/src/pages/workspace/document-decision.tsx
index 377a20e8..b51004cb 100644
--- a/apps/web/src/pages/workspace/document-decision.tsx
+++ b/apps/web/src/pages/workspace/document-decision.tsx
@@ -136,10 +136,9 @@ function settledLine(approval: DocumentApproval): string {
.map(({ title, version }) => `${title} v${String(version)}`)
.join(", "),
);
- const settled =
- approval.toolName === undefined
- ? undefined
- : text.settled[approval.toolName];
+ if (approval.toolName === undefined)
+ return text.approved(text.settledUnnamed);
+ const settled = text.settled[approval.toolName];
return text.approved(
settled ?? approvalQuestion(approval.toolName, undefined, approval.subject),
);
diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx
index 0131888e..deaa74a3 100644
--- a/apps/web/src/pages/workspace/index.tsx
+++ b/apps/web/src/pages/workspace/index.tsx
@@ -12,7 +12,13 @@
*/
import { wording } from "../../config.ts";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ queryOptions,
+ skipToken,
+ usePrefetchQuery,
+ useQuery,
+ useQueryClient,
+} from "@tanstack/react-query";
import { Paperclip, Sparkles } from "lucide-react";
import {
Attachment,
@@ -36,6 +42,8 @@ import {
} from "@corbits/react-ui";
import {
heldOnApproval,
+ projectActivityQuery,
+ projectRunStatesQuery,
useProjectActivity,
useProjectRunStates,
useRolesAtWork,
@@ -136,7 +144,11 @@ import {
} from "./design-concepts.tsx";
import { useLiveAppPresence } from "./live-app.tsx";
import { useQuestionsLayout } from "../../questions-layout-setting.ts";
-import { NextStepLine, useProjectNextStep } from "./next-step.tsx";
+import {
+ NextStepLine,
+ projectNextStepQuery,
+ useProjectNextStep,
+} from "./next-step.tsx";
import { composerPlaceholder } from "./composer-placeholder.ts";
import { Dictated } from "../../dictation.jsx";
import { useShellReply } from "../../flourish.tsx";
@@ -148,7 +160,26 @@ import { useModelCatalog } from "../providers.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 acceptedMaterialsQuery(projectId: string) {
+ return queryOptions({
+ queryKey: keys.acceptedMaterials.of(projectId),
+ retry: false,
+ refetchOnWindowFocus: false,
+ queryFn: () => acceptedMaterials(projectId),
+ });
+}
+
+// Only an active project has a coordinator whose decisions can be open.
+function openDecisionsQuery(projectId: string, active: boolean) {
+ return queryOptions({
+ queryKey: keys.openDecisions.of(projectId),
+ retry: false,
+ staleTime: 0,
+ refetchOnWindowFocus: false,
+ queryFn: active ? () => openDecisions(projectId) : skipToken,
+ });
+}
function documentApprovalsFrom(
waits: readonly Wait[],
@@ -357,6 +388,14 @@ export function StageWorkspace(props: StageWorkspaceProps) {
useEffect(() => {
if (providerConnected && providerMissing) void startCoordinator();
}, [providerConnected]);
+ // The room's other reads start with it, so the ready room mounts with them in hand.
+ usePrefetchQuery(projectActivityQuery(projectId));
+ usePrefetchQuery(projectRunStatesQuery(projectId));
+ usePrefetchQuery(projectNextStepQuery(projectId));
+ usePrefetchQuery(acceptedMaterialsQuery(projectId));
+ // A prefetch cannot wait on the project's status, so this read observes the decisions instead.
+ useQuery(openDecisionsQuery(projectId, status === "active"));
+ useLiveAppPresence(projectId);
if (status === undefined) {
if (projectRead.isError)
@@ -375,7 +414,12 @@ export function StageWorkspace(props: StageWorkspaceProps) {
// Each project is its own tenant beneath the workspace, and its runs live there.
runTenantId={project?.tenantId}
snapshot={snapshot}
- start={{ fault: startFault, modelNeeded, retry: startCoordinator }}
+ start={{
+ fault: startFault,
+ modelNeeded,
+ providerMissing,
+ retry: startCoordinator,
+ }}
refetchProject={projectRead.refetch}
/>
);
@@ -401,6 +445,7 @@ function ReadyRoom({
start: {
fault: string | null;
modelNeeded: ModelNeeded | null;
+ providerMissing: boolean;
retry: () => Promise;
};
refetchProject: () => Promise;
@@ -410,7 +455,7 @@ function ReadyRoom({
const startFault = start.fault;
const modelNeeded = start.modelNeeded;
const startCoordinator = start.retry;
- const providerMissing = modelNeeded?.code === "model_provider_missing";
+ const providerMissing = start.providerMissing;
const events = snapshot.conversation.events;
const mirror = snapshot.conversation.turns;
const runId = archived ? null : snapshot.conversation.runId;
@@ -459,12 +504,7 @@ function ReadyRoom({
// When this session last sent the coordinator a message, until a run ends after it or it fails.
const [awaitingSince, setAwaitingSince] = useState(null);
const [enablingAi, setEnablingAi] = useState(false);
- const accepted = useQuery({
- queryKey: keys.acceptedMaterials.of(projectId),
- retry: false,
- refetchOnWindowFocus: false,
- queryFn: () => acceptedMaterials(projectId),
- });
+ const accepted = useQuery(acceptedMaterialsQuery(projectId));
const limits = accepted.data;
const [files, setFiles] = useState([]);
const [attachFault, setAttachFault] = useState(null);
@@ -485,31 +525,28 @@ function ReadyRoom({
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),
- });
+ const decisions = useQuery(
+ openDecisionsQuery(projectId, status === "active"),
+ );
+ // Once per outage: each failed poll is a new error, and a dismissed toast stays dismissed.
useEffect(() => {
- if (decisions.error === null) return;
+ if (!decisions.isError) return;
toast.error(
humanFault(decisions.error, "Open decisions could not be read."),
{
id: "waits-read",
},
);
- }, [decisions.error]);
- const waits = decisions.data ?? NO_WAITS;
+ }, [decisions.isError]);
+ // Undefined until read: an unread list is not one with nothing open.
+ const waits = decisions.data;
const activity = useProjectActivity(projectId);
useEffect(() => {
- if (activity.error === null) return;
+ if (!activity.isError) return;
toast.error(humanFault(activity.error, wording.activity.notRead), {
id: "activity-read",
});
- }, [activity.error]);
+ }, [activity.isError]);
const reloadRef = useRef<() => void>(() => {});
useEffect(() => {
@@ -652,7 +689,8 @@ function ReadyRoom({
);
const latestAssistant =
[...mirror].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
- const documentApprovals = documentApprovalsFrom(waits, artifacts);
+ const documentApprovals =
+ waits === undefined ? undefined : documentApprovalsFrom(waits, artifacts);
const { settled: settledApprovals, settle } = useSettledApprovals(
projectId,
documentApprovals,
@@ -666,16 +704,19 @@ function ReadyRoom({
(item) => item.approvalId === approval.approvalId,
),
);
- const pendingApprovals = pendingFrom(waits);
+ const pendingApprovals = waits === undefined ? undefined : pendingFrom(waits);
+ const shownApprovals =
+ pendingApprovals === undefined
+ ? settledApprovals
+ : [...settledApprovals, ...pendingApprovals];
const openApprovalIds = new Set(
- pendingApprovals.map((approval) => approval.approvalId),
+ pendingApprovals?.map((approval) => approval.approvalId),
);
+ // Until the open decisions are read, no approval is known to hold the coordinator.
+ const parked =
+ waits !== undefined && parkedOnApproval(waits, openApprovalIds, runId);
const liveTurn = pendingLiveTurn(liveRun, mirror);
- const streaming = liveTurnStreaming(
- liveRun,
- mirror,
- parkedOnApproval(waits, openApprovalIds, runId),
- );
+ const streaming = liveTurnStreaming(liveRun, mirror, parked);
const liveText = liveTurn?.text ?? "";
const shownTurns =
liveTurn === null
@@ -866,14 +907,15 @@ function ReadyRoom({
// 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;
+ const readPendingApprovals = async (): Promise<
+ readonly OpenApproval[] | undefined
+ > => {
+ if (pendingApprovals !== undefined && pendingApprovals.length > 0)
+ return pendingApprovals;
try {
- const fresh = await queryClient.fetchQuery({
- queryKey: keys.openDecisions.of(projectId),
- staleTime: 0,
- queryFn: () => openDecisions(projectId),
- });
+ const fresh = await queryClient.fetchQuery(
+ openDecisionsQuery(projectId, true),
+ );
return pendingFrom(fresh);
} catch (cause) {
// The cached list is still the best answer; the send that follows reports a hub that is down.
@@ -918,13 +960,12 @@ function ReadyRoom({
choice: "yes" | "always" | "no";
}): Promise => {
const notSaved = "That decision was not saved.";
- const cachedWaits =
- queryClient.getQueryData(
- keys.openDecisions.of(projectId),
- ) ?? NO_WAITS;
+ const cachedWaits = queryClient.getQueryData(
+ keys.openDecisions.of(projectId),
+ );
const wait =
- waits.find((item) => item.approvalId === decision.approvalId) ??
- cachedWaits.find((item) => item.approvalId === decision.approvalId);
+ waits?.find((item) => item.approvalId === decision.approvalId) ??
+ cachedWaits?.find((item) => item.approvalId === decision.approvalId);
const approvalId = wait?.approvalId;
const tenantId = wait?.tenantId;
if (
@@ -937,10 +978,12 @@ function ReadyRoom({
setApprovalBusy(true);
setApprovalError(null);
const decided =
- documentApprovals.find((item) => item.approvalId === approvalId) ??
- documentApprovalsFrom(cachedWaits, artifacts).find(
- (item) => item.approvalId === approvalId,
- );
+ documentApprovals?.find((item) => item.approvalId === approvalId) ??
+ (cachedWaits === undefined
+ ? undefined
+ : documentApprovalsFrom(cachedWaits, artifacts).find(
+ (item) => item.approvalId === approvalId,
+ ));
try {
await decideApproval({
tenantId,
@@ -976,7 +1019,7 @@ function ReadyRoom({
setApprovalBusy(false);
}
};
- const openApproval = pendingApprovals.find(
+ 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.
@@ -985,14 +1028,14 @@ function ReadyRoom({
version: number,
): Promise => {
const currentApprovals = await readPendingApprovals();
- const currentOpen = currentApprovals.find(
+ const currentOpen = currentApprovals?.find(
(approval) => approval.packages === undefined,
);
const kind = selected?.kind;
const asked =
kind === undefined
? undefined
- : currentApprovals.find(
+ : currentApprovals?.find(
(approval) =>
approval.packages === undefined &&
asksAbout(approval, kind, version),
@@ -1008,7 +1051,7 @@ function ReadyRoom({
const deliver = async (body: string, clearComposer = false) => {
if (body.trim().length === 0 || sending) return false;
const currentApprovals = await readPendingApprovals();
- const currentOpen = currentApprovals.find(
+ const currentOpen = currentApprovals?.find(
(approval) => approval.packages === undefined,
);
return deliverWithOpenApproval({
@@ -1020,7 +1063,7 @@ function ReadyRoom({
const sendFiles = async () => {
if (sending) return false;
const currentApprovals = await readPendingApprovals();
- const currentOpen = currentApprovals.find(
+ const currentOpen = currentApprovals?.find(
(approval) => approval.packages === undefined,
);
return deliverWithOpenApproval({
@@ -1039,7 +1082,7 @@ function ReadyRoom({
if (!(await deliver(message))) restoreToComposer(message);
};
const typedYes = useTypedYes({
- pending: pendingApprovals.map((approval) => approval.approvalId),
+ pending: pendingApprovals?.map((approval) => approval.approvalId),
onApprove: (approvalId) =>
void onDecideApproval({ approvalId, choice: "yes" }),
onUndo: (message) => void undoTypedYes(message),
@@ -1082,14 +1125,11 @@ function ReadyRoom({
setRestoring(false);
}
};
- const held = waitingOnPerson(waits, openApprovalIds, runId, liveRun.running);
+ const held =
+ waits !== undefined &&
+ waitingOnPerson(waits, openApprovalIds, runId, liveRun.running);
const awaiting =
- awaitingReply(
- awaitingSince,
- turns.at(-1)?.role,
- liveRun.endedAt,
- parkedOnApproval(waits, openApprovalIds, runId),
- ) &&
+ awaitingReply(awaitingSince, turns.at(-1)?.role, liveRun.endedAt, parked) &&
!archived &&
startFault === null &&
modelNeeded === null;
@@ -1115,7 +1155,7 @@ function ReadyRoom({
inFlight,
failed: snapshot.failed,
seen: dispatchSeen.current,
- approvals: [...settledApprovals, ...pendingApprovals],
+ approvals: shownApprovals,
activity: agents,
onOpenDocument: (kind) =>
openDocument(
@@ -1184,9 +1224,7 @@ function ReadyRoom({
: designConcepts(
options,
inFlight,
- [...settledApprovals, ...pendingApprovals].flatMap(
- (approval) => approval.concepts ?? [],
- ),
+ shownApprovals.flatMap((approval) => approval.concepts ?? []),
(option) => {
const state = documentState(option);
return state?.status === "ready"
@@ -1230,7 +1268,6 @@ function ReadyRoom({
) ||
options.length > 0 ||
inFlight.some((run) => run.concept !== undefined);
- useLiveAppPresence(projectId);
const appFiled = snapshot.artifacts.some((item) => item.kind === "app");
const firstBuilder = appFiled
? undefined
@@ -1246,7 +1283,7 @@ function ReadyRoom({
);
// While an approval is open the pane follows the document it approves.
const approvalDeliverableId =
- pendingApprovals.find((approval) => approval.document !== undefined)
+ pendingApprovals?.find((approval) => approval.document !== undefined)
?.document?.kind ?? null;
const filed = useFiledVersions({
projectId,
@@ -1602,7 +1639,7 @@ function ReadyRoom({
}))}
onOpenDocument={openDocument}
shownDocument={chatFirst ? null : (selected?.kind ?? null)}
- approvals={[...settledApprovals, ...pendingApprovals]}
+ approvals={shownApprovals}
results={liveRun.results}
runs={unseenRuns}
{...(shownChoice === undefined || archived
diff --git a/apps/web/src/pages/workspace/next-step.tsx b/apps/web/src/pages/workspace/next-step.tsx
index d8fcc6e0..7a2d93e9 100644
--- a/apps/web/src/pages/workspace/next-step.tsx
+++ b/apps/web/src/pages/workspace/next-step.tsx
@@ -2,7 +2,7 @@
* Where the project stands and the person's one next step, as the hub derives them from native
* state. The line is pinned above the conversation when the person's preference turns it on.
*/
-import { useQuery } from "@tanstack/react-query";
+import { queryOptions, useQuery } from "@tanstack/react-query";
import { Button, StatusDot } from "@corbits/react-ui";
import type {
ProjectNextAction,
@@ -19,16 +19,19 @@ const POLL_MS = 5_000;
const NEXT_STEP_LINE = "next_step_line";
const text = wording.nextStep;
-export function useProjectNextStep(projectId: string, enabled = true) {
- return useQuery({
+export function projectNextStepQuery(projectId: string) {
+ return queryOptions({
queryKey: keys.projectNextStep.of(projectId),
- enabled,
queryFn: ({ signal }) => readProjectNextStep(projectId, signal),
refetchInterval: POLL_MS,
refetchOnWindowFocus: false,
});
}
+export function useProjectNextStep(projectId: string, enabled = true) {
+ return useQuery({ ...projectNextStepQuery(projectId), enabled });
+}
+
function useNextStepLineOn() {
const preferences = useQuery({
queryKey: keys.preferences,
diff --git a/apps/web/src/pages/workspace/ready-room.test.tsx b/apps/web/src/pages/workspace/ready-room.test.tsx
index 9d8ed6b2..ede9172d 100644
--- a/apps/web/src/pages/workspace/ready-room.test.tsx
+++ b/apps/web/src/pages/workspace/ready-room.test.tsx
@@ -36,15 +36,21 @@ const emptyRoom: RoomSnapshot = {
};
describe("project room", () => {
- test("an unread room shows its loading state, not an empty project", () => {
- const unread = room(client());
- expect(unread).toContain("Loading this project…");
- expect(unread).not.toContain(wording.archivedProject.conversationTitle);
+ test("an unread room is loading, with the room's other reads already under way", () => {
+ const queryClient = client();
+ expect(room(queryClient)).toContain(wording.message.projectLoading);
+ for (const key of [
+ keys.projectActivity.of(projectId),
+ keys.projectRunStates.of(projectId),
+ keys.projectNextStep.of(projectId),
+ keys.acceptedMaterials.of(projectId),
+ ])
+ expect(queryClient.getQueryState(key)?.fetchStatus).toBe("fetching");
const loaded = client();
loaded.setQueryData(keys.projectRoom.of(projectId), emptyRoom);
const read = room(loaded);
- expect(read).not.toContain("Loading this project…");
+ expect(read).not.toContain(wording.message.projectLoading);
expect(read).toContain(wording.archivedProject.conversationTitle);
});
@@ -69,9 +75,6 @@ describe("project room", () => {
queryKey: keys.projectActivity.of(projectId),
queryFn: () => Promise.reject(new Error("The hub is down.")),
});
- expect(
- failed.getQueryState(keys.projectActivity.of(projectId))?.status,
- ).toBe("error");
expect(render(failed)).toContain("unread");
const idle = client();
diff --git a/apps/web/src/pages/workspace/room-skeleton.tsx b/apps/web/src/pages/workspace/room-skeleton.tsx
index d5f58823..998ff836 100644
--- a/apps/web/src/pages/workspace/room-skeleton.tsx
+++ b/apps/web/src/pages/workspace/room-skeleton.tsx
@@ -12,7 +12,7 @@ export function RoomSkeleton() {
return (
- Loading this project…
+ {wording.message.projectLoading}
diff --git a/apps/web/src/pages/workspace/settled-approvals.ts b/apps/web/src/pages/workspace/settled-approvals.ts
index 7d6a8420..0ff1db56 100644
--- a/apps/web/src/pages/workspace/settled-approvals.ts
+++ b/apps/web/src/pages/workspace/settled-approvals.ts
@@ -49,7 +49,8 @@ function settledCard(
export function useSettledApprovals(
projectId: string,
- open: readonly OpenApproval[],
+ /** Undefined until the open decisions are read. */
+ open: readonly OpenApproval[] | undefined,
artifacts: readonly ProjectArtifact[],
): {
settled: readonly DocumentApproval[];
@@ -70,8 +71,9 @@ export function useSettledApprovals(
}, [projectId]);
// An ask that leaves the pending list was decided, here or elsewhere.
- const openKey = open.map((approval) => approval.approvalId).join("\n");
+ const openKey = open?.map((approval) => approval.approvalId).join("\n");
useEffect(() => {
+ if (openKey === undefined) return;
void queryClient.invalidateQueries({ queryKey });
}, [projectId, openKey]);
diff --git a/apps/web/src/pages/workspace/typed-yes.tsx b/apps/web/src/pages/workspace/typed-yes.tsx
index 3ed7054a..38d215da 100644
--- a/apps/web/src/pages/workspace/typed-yes.tsx
+++ b/apps/web/src/pages/workspace/typed-yes.tsx
@@ -34,8 +34,8 @@ export function useTypedYes({
onUndo,
onDropped,
}: {
- /** The open approval cards, by id. */
- pending: readonly string[];
+ /** The open approval cards, by id; undefined until the open decisions are read. */
+ pending: readonly string[] | undefined;
onApprove: (approvalId: string) => void;
onUndo: (message: string) => void;
/** The card was answered some other way during the countdown. */
@@ -70,7 +70,7 @@ export function useTypedYes({
};
}, [held]);
- const open = held !== null && pending.includes(held.approvalId);
+ const open = held !== null && pending?.includes(held.approvalId) === true;
useEffect(() => {
if (held === null || open) return;
setHeld(null);
@@ -79,6 +79,7 @@ export function useTypedYes({
/** Holds the message when it answers the one open card; false means send it as usual. */
const hold = (message: string): boolean => {
+ if (pending === undefined) return false;
const [only] = pending;
if (
held !== null ||
From 689dce6096490b46956fbca6b84b91326b553797 Mon Sep 17 00:00:00 2001
From: Sawyer Cutler
Date: Wed, 4 Nov 2026 13:50:39 -0800
Subject: [PATCH 3/4] fix(web): name an approval without concepts instead of
defaulting
---
apps/web/src/pages/workspace/index.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx
index deaa74a3..3dd009c0 100644
--- a/apps/web/src/pages/workspace/index.tsx
+++ b/apps/web/src/pages/workspace/index.tsx
@@ -1224,7 +1224,10 @@ function ReadyRoom({
: designConcepts(
options,
inFlight,
- shownApprovals.flatMap((approval) => approval.concepts ?? []),
+ // An ask that names no concepts adds none.
+ shownApprovals.flatMap((approval) =>
+ approval.concepts === undefined ? [] : approval.concepts,
+ ),
(option) => {
const state = documentState(option);
return state?.status === "ready"
From 7782559382f8e60f0d4e6ae833a1647da710e42c Mon Sep 17 00:00:00 2001
From: Sawyer Cutler
Date: Wed, 4 Nov 2026 14:09:37 -0800
Subject: [PATCH 4/4] fix(web): ask about a blank-named document by its tool
---
.../src/pages/workspace/document-decision.tsx | 7 ++++---
.../web/src/pages/workspace/transcript.test.tsx | 17 +++++++++++++++++
2 files changed, 21 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/pages/workspace/document-decision.tsx b/apps/web/src/pages/workspace/document-decision.tsx
index b51004cb..8c3925e5 100644
--- a/apps/web/src/pages/workspace/document-decision.tsx
+++ b/apps/web/src/pages/workspace/document-decision.tsx
@@ -42,9 +42,10 @@ const FOLDED_INSTRUCTION_ASKS = new Set(["design_app"]);
function shortName(approval: DocumentApproval): string | undefined {
const { document } = approval;
- return document === undefined
- ? undefined
- : shortDocumentName(document.kind, document.title);
+ if (document === undefined) return undefined;
+ const name = shortDocumentName(document.kind, document.title);
+ // A blank title has no name to ask about, so the card falls back to the tool's wording.
+ return name === "" ? undefined : name;
}
// The documents a request_approval ask approves together, when it covers more than one.
diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx
index 233bf286..48550793 100644
--- a/apps/web/src/pages/workspace/transcript.test.tsx
+++ b/apps/web/src/pages/workspace/transcript.test.tsx
@@ -393,6 +393,23 @@ describe("conversation transcript", () => {
}
});
+ test("a document with a blank name is asked about by its tool", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onAlways={() => {}}
+ onNo={() => {}}
+ />,
+ );
+ expect(html).toContain("browser navigate");
+ expect(html).not.toContain(wording.approval.document(""));
+ });
+
test("a design approval lists each look and starts one by name", () => {
const html = renderToStaticMarkup(