diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 4840629d..99ec337c 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -728,16 +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) => {
- toast.error(
- wording.library.refused[code ?? ""] ??
- 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 6b8b2982..a23a3c1f 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,10 @@ 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?",
+ /** 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) ?? ""}?`,
@@ -974,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/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..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,
@@ -174,8 +182,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..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.
@@ -62,7 +63,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;
@@ -136,12 +137,11 @@ 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, "", 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/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/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(
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 ||
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}