diff --git a/apps/web/package.json b/apps/web/package.json
index bb6894ed..6f545fe0 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -14,6 +14,7 @@
"@corbits-builder/client": "workspace:*",
"@corbits-builder/schemas": "workspace:*",
"@corbits/react-ui": "0.5.1",
+ "@corbits/slidedeck": "workspace:*",
"@radix-ui/react-dialog": "1.3.1",
"@radix-ui/react-dropdown-menu": "2.2.1",
"@radix-ui/react-slot": "1.5.0",
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index d012c0b9..8939b459 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -187,16 +187,18 @@ function ProjectLoadFailure({
onBackToProjects: () => void;
}) {
const [copied, setCopied] = useState(false);
- const copyDetail = () => {
- void navigator.clipboard
- .writeText(detail)
- .then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- })
- .catch(() => {
- // Clipboard access can be denied; the text is still selectable in the disclosure.
- });
+ const copyDetail = async () => {
+ try {
+ await navigator.clipboard.writeText(detail);
+ } catch (cause) {
+ // Clipboard access can be denied, or missing outside a secure origin;
+ // the text stays selectable in the disclosure.
+ console.warn("Copying the failure detail failed.", cause);
+ toast.error(wording.message.copyFailed);
+ return;
+ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
};
return (
@@ -216,7 +218,7 @@ function ProjectLoadFailure({
{wording.projectLoadFailure.details}
{detail}
-
+ void copyDetail()}>
{copied
? wording.projectLoadFailure.copied
: wording.projectLoadFailure.copy}
@@ -600,15 +602,18 @@ export function App() {
return;
}
let cancelled = false;
- void getHubSession()
- .then((session) => {
+ const readUser = async () => {
+ try {
+ const session = await getHubSession();
if (!cancelled) setUser(session?.user ?? null);
- })
- .catch((cause: unknown) => {
- // A dropped connection is the loading screen's to show, never a toast.
- if (!cancelled && cause instanceof ApiFailure)
- toast.error(humanFault(cause, wording.auth.accountUnreadable));
- });
+ } catch (cause) {
+ if (cancelled) return;
+ // A hub that is down or unreachable is the loading screen's to show, never a toast.
+ if (cause instanceof ApiFailure && cause.detail.retryable) return;
+ toast.error(humanFault(cause, wording.auth.accountUnreadable));
+ }
+ };
+ void readUser();
return () => {
cancelled = true;
};
@@ -616,8 +621,9 @@ export function App() {
const signOut = async () => {
try {
await signOutHub();
- } catch {
- // The session is ended locally either way.
+ } catch (cause) {
+ // The session is ended locally either way; the person hears the hub kept it.
+ toast.error(humanFault(cause, wording.auth.signOutFailed));
}
setUser(null);
setAuth("signed-out");
@@ -645,20 +651,31 @@ export function App() {
};
useEffect(() => {
- void getHubSession()
- .then((session) => setAuth(session ? "signed-in" : "signed-out"))
- .catch(() => setSessionReadFailed(true));
+ const readSession = async () => {
+ try {
+ const session = await getHubSession();
+ setAuth(session ? "signed-in" : "signed-out");
+ } catch (cause) {
+ if (!(cause instanceof ApiFailure)) throw cause;
+ setSessionReadFailed(true);
+ }
+ };
+ void readSession();
}, []);
// The workspace tenant is created the first time this session signs in; the
// fold reads below all scope themselves with it, so they wait for it.
useEffect(() => {
if (auth !== "signed-in") return;
- void ensureWorkspace()
- .then(() => refresh())
- .catch((cause: unknown) =>
- setError(humanFault(cause, wording.workspace.setupFailed)),
- );
+ const setUpWorkspace = async () => {
+ try {
+ await ensureWorkspace();
+ await refresh();
+ } catch (cause) {
+ setError(humanFault(cause, wording.workspace.setupFailed));
+ }
+ };
+ void setUpWorkspace();
}, [auth, refresh]);
// The summary of the open project, from the list read; null when none or
@@ -682,9 +699,14 @@ export function App() {
if (recheckedListFor.current === selected) return;
recheckedListFor.current = selected;
const id = selected;
- void projectsRead.refetch().finally(() => {
- setSettledMissId(id);
- });
+ const recheck = async () => {
+ try {
+ await projectsRead.refetch();
+ } finally {
+ setSettledMissId(id);
+ }
+ };
+ void recheck();
}, [
view,
selected,
@@ -721,16 +743,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/api/artifacts.ts b/apps/web/src/api/artifacts.ts
index a84858d9..8e1fcb85 100644
--- a/apps/web/src/api/artifacts.ts
+++ b/apps/web/src/api/artifacts.ts
@@ -12,7 +12,7 @@ import type {
StartApprovalRound,
TriageDecisionInput,
} from "@corbits-builder/schemas/approval-round";
-import { productClient } from "./transport.ts";
+import { productClient, refusalBody } from "./transport.ts";
import { workspaceTenantId } from "./auth.ts";
import { ApiFailure } from "./types.ts";
import { wording } from "../config.ts";
@@ -76,10 +76,7 @@ export async function keepLiveApp(projectId: string, signal?: AbortSignal) {
/** A hub refusal's status, code and body, whatever the body holds; undefined for other failures. */
async function refusal(cause: unknown) {
if (!(cause instanceof ProductApiError)) return undefined;
- const body: unknown = await cause.response
- .clone()
- .json()
- .catch(() => undefined);
+ const body = await refusalBody(cause);
const record = (
body !== null && typeof body === "object" ? body : {}
) as Record;
diff --git a/apps/web/src/api/ask.test.ts b/apps/web/src/api/ask.test.ts
new file mode 100644
index 00000000..96bf7734
--- /dev/null
+++ b/apps/web/src/api/ask.test.ts
@@ -0,0 +1,12 @@
+import { expect, test } from "bun:test";
+import { askOf } from "./ask.ts";
+
+test("design concepts outside the schema's limits are not offered", () => {
+ const concept = { label: "Calm", direction: "Soft greys and wide margins." };
+ expect(askOf("ask", { concepts: [concept] }).concepts).toEqual([concept]);
+ expect(
+ askOf("ask", { concepts: [{ ...concept, label: "x".repeat(81) }] })
+ .concepts,
+ ).toBeUndefined();
+ expect(askOf("ask", { concepts: [null] }).concepts).toBeUndefined();
+});
diff --git a/apps/web/src/api/ask.ts b/apps/web/src/api/ask.ts
index 5dad3568..79f4574d 100644
--- a/apps/web/src/api/ask.ts
+++ b/apps/web/src/api/ask.ts
@@ -1,5 +1,9 @@
+import { type } from "arktype";
+import { DesignConcept } from "@corbits-builder/schemas/artifact";
import type { Wait } from "./types.ts";
+const Names = type("string[]");
+
/** What an ask asks, from its native tool name and arguments, as the decision card reads it. */
export function askOf(
toolName: string | undefined,
@@ -9,33 +13,20 @@ export function askOf(
"toolName" | "packages" | "concepts" | "instruction" | "subject" | "kinds"
> {
const packages = args["packages"];
- const kinds = args["kinds"];
+ const listed = Names(args["kinds"]);
const instruction = args["instruction"];
const subject = Object.values(args).find(
(value) => typeof value === "string",
);
- const concepts = args["concepts"];
- const designConcepts =
- Array.isArray(concepts) &&
- concepts.every(
- (item: { label?: unknown; direction?: unknown } | null) =>
- typeof item?.label === "string" && typeof item.direction === "string",
- )
- ? (concepts as { label: string; direction: string }[])
- : undefined;
- const installs =
- Array.isArray(packages) &&
- packages.every((item) => typeof item === "string")
- ? (packages as string[])
- : undefined;
+ const concepts = DesignConcept.array()(args["concepts"]);
+ const designConcepts = concepts instanceof type.errors ? undefined : concepts;
+ const installs = Names(packages);
return {
...(toolName === undefined ? {} : { toolName }),
- ...(installs === undefined ? {} : { packages: installs }),
+ ...(installs instanceof type.errors ? {} : { packages: installs }),
...(designConcepts === undefined ? {} : { concepts: designConcepts }),
...(typeof instruction === "string" ? { instruction } : {}),
...(typeof subject === "string" ? { subject } : {}),
- ...(Array.isArray(kinds) && kinds.every((item) => typeof item === "string")
- ? { kinds: kinds as string[] }
- : {}),
+ ...(listed instanceof type.errors ? {} : { kinds: listed }),
};
}
diff --git a/apps/web/src/api/auth.test.ts b/apps/web/src/api/auth.test.ts
new file mode 100644
index 00000000..e98a5d7a
--- /dev/null
+++ b/apps/web/src/api/auth.test.ts
@@ -0,0 +1,23 @@
+import { afterEach, expect, test } from "bun:test";
+import { wording } from "../config.ts";
+import { getHubSession } from "./auth.ts";
+
+const realFetch = globalThis.fetch;
+afterEach(() => {
+ globalThis.fetch = realFetch;
+});
+
+function answer(body: string) {
+ globalThis.fetch = Object.assign(async () => new Response(body), {
+ preconnect: realFetch.preconnect,
+ });
+}
+
+test("only better-auth's null means signed out; an unreadable session is an error", async () => {
+ answer("null");
+ expect(await getHubSession()).toBeNull();
+ answer("proxy");
+ await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable);
+ answer(JSON.stringify({ user: { id: "u1" } }));
+ await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable);
+});
diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts
index 83746342..dda02da2 100644
--- a/apps/web/src/api/auth.ts
+++ b/apps/web/src/api/auth.ts
@@ -4,9 +4,11 @@
* `sign-up/email`, `sign-out`), all with the session cookie included.
*/
import type { Workspace } from "@corbits-builder/client";
+import { type } from "arktype";
import { productName, wording } from "../config.ts";
import { hubCredentials, hubOrigin } from "./origin.ts";
import { productClient, timedCall } from "./transport.ts";
+import { ApiFailure } from "./types.ts";
import {
readStoredPreference,
writeStoredPreference,
@@ -20,55 +22,63 @@ export type HubUser = {
export type HubSession = { user: HubUser } | null;
-type AuthJson = {
- user?: { id?: unknown; name?: unknown; email?: unknown } | null;
- message?: unknown;
- error?: { code?: unknown; message?: unknown };
-};
+// better-auth answers with `message` or `error.message` on a refusal, and `user` on a session.
+const AuthMessage = type({ message: "string > 0" });
+const AuthErrorMessage = type({ error: { message: "string > 0" } });
+const AuthSession = type({
+ user: { id: "string", email: "string", "name?": "unknown" },
+});
async function hubAuth(
path: string,
init: RequestInit,
): Promise<{ response: Response; parsed: unknown }> {
let response: Response;
+ let text: string;
try {
response = await fetch(`${hubOrigin()}/api/auth${path}`, {
...init,
credentials: hubCredentials(),
});
- } catch {
- throw new Error(wording.auth.hubUnreachable);
+ text = await response.text();
+ } catch (cause) {
+ throw new ApiFailure(
+ {
+ code: "host_unreachable",
+ message: wording.auth.hubUnreachable,
+ correlationId: "-",
+ retryable: true,
+ },
+ undefined,
+ { cause },
+ );
}
- const text = await response.text();
let parsed: unknown;
try {
parsed = text.length === 0 ? undefined : JSON.parse(text);
- } catch {
+ } catch (cause) {
+ // A proxy in front of the hub can answer with a page instead of JSON.
+ if (!(cause instanceof SyntaxError)) throw cause;
parsed = undefined;
}
return { response, parsed };
}
function messageOf(parsed: unknown, fallback: string): string {
- const body = parsed as AuthJson | undefined;
- if (typeof body?.message === "string" && body.message.length > 0)
- return body.message;
- if (typeof body?.error?.message === "string" && body.error.message.length > 0)
- return body.error.message;
- return fallback;
+ const body = AuthMessage(parsed);
+ if (!(body instanceof type.errors)) return body.message;
+ const nested = AuthErrorMessage(parsed);
+ return nested instanceof type.errors ? fallback : nested.error.message;
}
function userOf(parsed: unknown): HubUser | null {
- const user = (parsed as AuthJson | undefined)?.user;
- if (!user || typeof user.id !== "string" || typeof user.email !== "string")
- return null;
+ const session = AuthSession(parsed);
+ if (session instanceof type.errors) return null;
+ const { id, email, name } = session.user;
return {
- id: user.id,
- name:
- typeof user.name === "string" && user.name.trim().length > 0
- ? user.name
- : user.email,
- email: user.email,
+ id,
+ name: typeof name === "string" && name.trim().length > 0 ? name : email,
+ email,
};
}
@@ -76,10 +86,30 @@ export async function getHubSession(): Promise {
const { response, parsed } = await hubAuth("/get-session", { method: "GET" });
if (response.status === 401) return null;
if (!response.ok) {
- throw new Error(messageOf(parsed, `HTTP ${String(response.status)}`));
+ throw new ApiFailure(
+ {
+ code: "session_unreadable",
+ message: messageOf(parsed, `HTTP ${String(response.status)}`),
+ correlationId: "-",
+ retryable: response.status >= 500,
+ },
+ response.status,
+ );
}
+ // better-auth answers a request with no session with a JSON null.
+ if (parsed === null) return null;
const user = userOf(parsed);
- return user ? { user } : null;
+ if (user === null)
+ throw new ApiFailure(
+ {
+ code: "session_unreadable",
+ message: wording.auth.accountUnreadable,
+ correlationId: "-",
+ retryable: false,
+ },
+ response.status,
+ );
+ return { user };
}
async function emailAuth(
diff --git a/apps/web/src/api/credentials.ts b/apps/web/src/api/credentials.ts
index 2beb5056..1f50dfd1 100644
--- a/apps/web/src/api/credentials.ts
+++ b/apps/web/src/api/credentials.ts
@@ -48,14 +48,12 @@ function providerOf(row: ModelProviderView): Provider {
}
/** Stores one API key behind the onboarding "connect inference" step. */
-export function setCredential(input: {
+export async function setCredential(input: {
plugin: string;
secret: string;
baseURL?: string;
label?: string;
}) {
- const client = productClient();
- return workspaceTenantId().then((tenantId) =>
- client.addModelCredential(tenantId, input),
- );
+ const tenantId = await workspaceTenantId();
+ return productClient().addModelCredential(tenantId, input);
}
diff --git a/apps/web/src/api/projects.test.ts b/apps/web/src/api/projects.test.ts
new file mode 100644
index 00000000..a0658baa
--- /dev/null
+++ b/apps/web/src/api/projects.test.ts
@@ -0,0 +1,19 @@
+import { expect, test } from "bun:test";
+import JSZip from "jszip";
+import { wording } from "../config.ts";
+import { readImportFile } from "./projects.ts";
+
+test("a zip whose project.json is damaged is refused as an invalid import", async () => {
+ const zip = new JSZip();
+ zip.file("project.json", JSON.stringify({ padding: "x".repeat(5000) }), {
+ compression: "DEFLATE",
+ });
+ const archive = await zip.generateAsync({ type: "arraybuffer" });
+ const bytes = new Uint8Array(archive);
+ // Inside the deflated entry, past its header: the archive still opens.
+ for (let at = 45; at < 60; at++) bytes[at] = (bytes[at] ?? 0) ^ 0xff;
+
+ await expect(readImportFile(new Blob([archive]))).rejects.toThrow(
+ wording.importProject.invalid,
+ );
+});
diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts
index b2033900..3ab7e226 100644
--- a/apps/web/src/api/projects.ts
+++ b/apps/web/src/api/projects.ts
@@ -3,7 +3,7 @@
* typed client. Fields the hub does not record carry their empty value (see
* `types.ts`).
*/
-import { productClient } from "./transport.ts";
+import { productClient, refusalBody } from "./transport.ts";
import { ApiFailure, type ProjectSummary } from "./types.ts";
import { workspaceTenantId } from "./auth.ts";
import {
@@ -91,10 +91,7 @@ export async function createProject(payload: {
/** The hub's refusal to create a project, with its code and message when it gave them. */
async function creationFailure(cause: unknown): Promise {
if (!(cause instanceof ProductApiError)) return cause;
- const body: unknown = await cause.response
- .clone()
- .json()
- .catch(() => undefined);
+ const body = await refusalBody(cause);
const record = body !== null && typeof body === "object" ? body : {};
const code =
"code" in record && typeof record.code === "string"
@@ -255,34 +252,45 @@ export async function exportSections(
}
}
-const invalidImport = () =>
- new ApiFailure({
- code: "invalid_project_import",
- message: wording.importProject.invalid,
- correlationId: "-",
- retryable: false,
- });
+const invalidImport = (cause?: unknown) =>
+ new ApiFailure(
+ {
+ code: "invalid_project_import",
+ message: wording.importProject.invalid,
+ correlationId: "-",
+ retryable: false,
+ },
+ undefined,
+ { cause },
+ );
const startsWithZipSignature = (bytes: Uint8Array) =>
bytes[0] === 0x50 && bytes[1] === 0x4b;
async function projectJsonOf(archive: Uint8Array): Promise {
- const entry = (await JSZip.loadAsync(archive)).file("project.json");
- if (entry === null) throw invalidImport();
- return entry.async("string");
+ try {
+ const entry = (await JSZip.loadAsync(archive)).file("project.json");
+ if (entry === null) throw invalidImport();
+ // JSZip inflates lazily, so a damaged entry only fails here.
+ return await entry.async("string");
+ } catch (cause) {
+ // JSZip rejects only an archive it cannot read.
+ if (cause instanceof ApiFailure) throw cause;
+ throw invalidImport(cause);
+ }
}
/** The project.json of an export's zip, or the file itself when it is that JSON. */
async function importJson(file: Blob): Promise {
const bytes = new Uint8Array(await file.arrayBuffer());
+ const text = startsWithZipSignature(bytes)
+ ? await projectJsonOf(bytes)
+ : new TextDecoder().decode(bytes);
try {
- return JSON.parse(
- startsWithZipSignature(bytes)
- ? await projectJsonOf(bytes)
- : new TextDecoder().decode(bytes),
- );
- } catch {
- throw invalidImport();
+ return JSON.parse(text);
+ } catch (cause) {
+ if (!(cause instanceof SyntaxError)) throw cause;
+ throw invalidImport(cause);
}
}
diff --git a/apps/web/src/api/run-stream.test.ts b/apps/web/src/api/run-stream.test.ts
index b6ccc816..f1a34a0b 100644
--- a/apps/web/src/api/run-stream.test.ts
+++ b/apps/web/src/api/run-stream.test.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "bun:test";
+import { expect, spyOn, test } from "bun:test";
import { subscribeWorkflowRun, type RunStreamSource } from "./run-stream.ts";
test("a dropped stream says so, retries, and a reopen reports the gap", () => {
@@ -48,3 +48,29 @@ test("a dropped stream says so, retries, and a reopen reports the gap", () => {
});
expect(seen).toEqual(["inference.start", "change", "error", "reconnect"]);
});
+
+test("a run frame that is not a run event never reaches the handler", () => {
+ const listeners = new Map void>();
+ const seen: string[] = [];
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
+ subscribeWorkflowRun(
+ "tenant",
+ "run_1",
+ { onEvent: (event) => seen.push(event.type) },
+ {
+ createEventSource: (): RunStreamSource => ({
+ addEventListener: (type, listener) => listeners.set(type, listener),
+ close() {},
+ }),
+ setTimeout: () => 0,
+ clearTimeout: () => {},
+ },
+ );
+ const run = listeners.get("run");
+ run?.({ data: "not json" });
+ run?.({ data: "null" });
+ run?.({ data: JSON.stringify({ type: 7 }) });
+ run?.({ data: JSON.stringify({ type: "inference.done" }) });
+ expect(seen).toEqual(["inference.done"]);
+ warn.mockRestore();
+});
diff --git a/apps/web/src/api/run-stream.ts b/apps/web/src/api/run-stream.ts
index a7e5390d..c2ea329f 100644
--- a/apps/web/src/api/run-stream.ts
+++ b/apps/web/src/api/run-stream.ts
@@ -8,27 +8,38 @@
* `status` frames say the room's durable state moved (mail delivered for the
* run, or its sidecar route lost) without carrying that state.
*/
+import { type } from "arktype";
import { hubEventSourceCredentials, hubOrigin } from "./origin.ts";
import { openSharedEventSource } from "../shared-event-source.ts";
+import { readJson } from "../json.ts";
-export type RunStreamEvent = {
- type: string;
- seq?: number;
- data?: {
- partial?: {
- text?: string;
- thinking?: string;
- toolCalls?: readonly {
- id?: unknown;
- name?: unknown;
- partialArguments?: unknown;
- }[];
- };
- callId?: unknown;
- name?: unknown;
- arguments?: unknown;
- };
-};
+// The fields this interface reads from Interchange's `InferenceEvent` variants; a frame carries
+// more. A tool frame's `partial` is its output text, so it may be a string.
+const RunStreamEvent = type({
+ type: "string",
+ "seq?": "number",
+ "data?": {
+ "partial?": type({
+ "text?": "string",
+ "thinking?": "string",
+ "toolCalls?": type({
+ "id?": "unknown",
+ "name?": "unknown",
+ "partialArguments?": "unknown",
+ }).array(),
+ }).or("string"),
+ "callId?": "unknown",
+ "name?": "unknown",
+ "arguments?": "unknown",
+ },
+});
+export type RunStreamEvent = typeof RunStreamEvent.infer;
+
+/** An inference frame's partial message; a tool frame's text output is not one. */
+export function partialOf(event: RunStreamEvent) {
+ const partial = event.data?.partial;
+ return typeof partial === "object" ? partial : undefined;
+}
const MAX_BACKOFF_MS = 30_000;
const BASE_BACKOFF_MS = 1_000;
@@ -44,8 +55,11 @@ export function applyRunStreamEvent(
): { live: string; refresh: boolean } {
if (event.type === "inference.start") return { live: "", refresh: false };
if (event.type === "inference.text.delta") {
- const text = event.data?.partial?.text ?? "";
- return { live: text.length > 0 ? text : live, refresh: false };
+ const text = partialOf(event)?.text;
+ return {
+ live: text !== undefined && text.length > 0 ? text : live,
+ refresh: false,
+ };
}
if (event.type === "inference.error") return { live: "", refresh: true };
if (event.type === "inference.done") return { live, refresh: true };
@@ -69,8 +83,8 @@ export type SubscribeRunStreamDeps = {
const defaultDeps: SubscribeRunStreamDeps = {
createEventSource: (url, withCredentials) =>
openSharedEventSource(url, withCredentials),
- setTimeout: (callback, ms) => setTimeout(callback, ms) as unknown as number,
- clearTimeout: (handle) => clearTimeout(handle),
+ setTimeout: (callback, ms) => window.setTimeout(callback, ms),
+ clearTimeout: (handle) => window.clearTimeout(handle),
};
export type RunStreamHandlers = {
@@ -108,11 +122,9 @@ export function subscribeWorkflowRun(
});
es.addEventListener("run", (message) => {
if (closed) return;
- let event: RunStreamEvent;
- try {
- event = JSON.parse(message.data) as RunStreamEvent;
- } catch {
- // A frame that is not JSON is not a run event.
+ const event = readJson(message.data, RunStreamEvent);
+ if (event === null) {
+ console.warn("A run frame was not a run event.");
return;
}
handlers.onEvent(event);
diff --git a/apps/web/src/api/transport.ts b/apps/web/src/api/transport.ts
index deb4efa0..bd5bc58a 100644
--- a/apps/web/src/api/transport.ts
+++ b/apps/web/src/api/transport.ts
@@ -63,6 +63,23 @@ export async function timedCall(
}
}
+/**
+ * A refusal's body as JSON; undefined when it is not JSON, such as a proxy's
+ * error page, or when the connection dropped while it was read, so the caller
+ * still says the refusal in its own words.
+ */
+export async function refusalBody(cause: ProductApiError): Promise {
+ try {
+ return await cause.response.clone().json();
+ } catch (failure) {
+ if (failure instanceof SyntaxError) return undefined;
+ if (!(failure instanceof TypeError || failure instanceof DOMException))
+ throw failure;
+ console.warn("A refusal's body could not be read.", failure);
+ return undefined;
+ }
+}
+
let client: ProductApiClient | undefined;
/** The typed client against the hub origin, cookies included. */
diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts
index 8d2da6dc..9a818c2a 100644
--- a/apps/web/src/api/types.ts
+++ b/apps/web/src/api/types.ts
@@ -15,8 +15,12 @@ export type ApiErrorDetail = {
export class ApiFailure extends Error {
readonly detail: ApiErrorDetail;
readonly httpStatus: number | undefined;
- constructor(detail: ApiErrorDetail, httpStatus?: number) {
- super(detail.message);
+ constructor(
+ detail: ApiErrorDetail,
+ httpStatus?: number,
+ options?: ErrorOptions,
+ ) {
+ super(detail.message, options);
this.name = "ApiFailure";
this.detail = detail;
this.httpStatus = httpStatus;
diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts
index d805b74f..91d6aa47 100644
--- a/apps/web/src/config.ts
+++ b/apps/web/src/config.ts
@@ -109,6 +109,7 @@ export const wording = {
user_manual: "User Manual",
} as Record,
documentUnread: "new version",
+ documentRetryFailed: "The project could not be read. Try again.",
tips: {
label: "Tips",
setting: "Show tips",
@@ -422,6 +423,7 @@ export const wording = {
},
activity: {
label: "Who is working",
+ notRead: "Who is working could not be read.",
role: {
coordinator: "Coordinator",
writer: "Writer",
@@ -633,12 +635,15 @@ export const wording = {
`Slide ${String(index)} of ${String(total)}`,
pageCount: (index: number, total: number) =>
`Page ${String(index)} of ${String(total)}`,
- noSlides: "This approval package contains no slides.",
+ loadingSlides: "Loading slides…",
+ slidesUnavailable:
+ "The slides could not load. Reload the page to try again.",
speakerNotes: "Speaker notes",
downloadPdf: "Download PDF",
openPdf: "Open PDF",
downloadPptx: "Download PPTX",
pdfTitle: "Approval package PDF preview",
+ pdfUnreadable: "The PDF in this package could not be read.",
manifest: {
audience: "Audience",
decisionNeeded: "Decision needed",
@@ -808,6 +813,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) ?? ""}?`,
@@ -971,6 +980,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.",
},
@@ -1028,6 +1038,8 @@ export const wording = {
addLater: "You can add one later in Settings → Providers.",
login: (label: string) => `Login with ${label} (Subscription)`,
loginConnection: (label: string) => `${productName} ${label} login`,
+ loginUnsupported: (label: string) =>
+ `${productName} cannot sign in to ${label} yet.`,
signIn: "Log in",
signInAgain: "Log in again",
waiting: "Waiting for sign-in…",
@@ -1048,6 +1060,8 @@ export const wording = {
keylessHint: `For servers that need no API key; ${productName} sends a placeholder.`,
custom: {
tile: "Custom endpoint",
+ modelsUnreadable:
+ "This provider's models could not be read, so nothing was saved.",
tileHint: "Your own server, on the API it speaks.",
addTitle: "Add a custom endpoint",
addLead: "Connect your own server. It is saved once the test passes.",
diff --git a/apps/web/src/dictation.test.ts b/apps/web/src/dictation.test.ts
new file mode 100644
index 00000000..1b2c1a57
--- /dev/null
+++ b/apps/web/src/dictation.test.ts
@@ -0,0 +1,38 @@
+import { afterEach, expect, spyOn, test } from "bun:test";
+import { openMicrophone } from "./dictation.tsx";
+
+const realNavigator = globalThis.navigator;
+const realAudioContext = globalThis.AudioContext;
+
+afterEach(() => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: realNavigator,
+ configurable: true,
+ });
+ Object.defineProperty(globalThis, "AudioContext", {
+ value: realAudioContext,
+ configurable: true,
+ });
+});
+
+test("a level meter that fails after the microphone opens stops the microphone", async () => {
+ let stopped = 0;
+ const stream = { getTracks: () => [{ stop: () => (stopped += 1) }] };
+ Object.defineProperty(globalThis, "navigator", {
+ value: { mediaDevices: { getUserMedia: async () => stream } },
+ configurable: true,
+ });
+ Object.defineProperty(globalThis, "AudioContext", {
+ value: class {
+ constructor() {
+ throw new DOMException("no audio output", "NotSupportedError");
+ }
+ },
+ configurable: true,
+ });
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
+
+ expect(await openMicrophone()).toBeNull();
+ expect(stopped).toBe(1);
+ warn.mockRestore();
+});
diff --git a/apps/web/src/dictation.tsx b/apps/web/src/dictation.tsx
index 53363d34..f0b8bfbc 100644
--- a/apps/web/src/dictation.tsx
+++ b/apps/web/src/dictation.tsx
@@ -29,6 +29,32 @@ const WAVE_BARS = 24;
const SILENCE_AFTER_SPEECH_MS = 1500;
const SOUND_FLOOR = 0.015;
+/**
+ * The microphone and an analyser reading its level, or null when either is
+ * refused or missing. A failure after the microphone opens closes it again.
+ */
+export async function openMicrophone(): Promise<{
+ stream: MediaStream;
+ context: AudioContext;
+ analyser: AnalyserNode;
+} | null> {
+ let stream: MediaStream | undefined;
+ let context: AudioContext | undefined;
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ context = new AudioContext();
+ const analyser = context.createAnalyser();
+ analyser.fftSize = 512;
+ context.createMediaStreamSource(stream).connect(analyser);
+ return { stream, context, analyser };
+ } catch (cause) {
+ stream?.getTracks().forEach((track) => track.stop());
+ void context?.close();
+ console.warn("The microphone level is unavailable.", cause);
+ return null;
+ }
+}
+
/** Why it stopped, in words a person can act on. Anything else is shown as is. */
const REFUSALS: Record = {
"not-allowed":
@@ -152,14 +178,9 @@ function useDictation(value: string, onValueChange: (value: string) => void) {
void meter.context.close();
meter = null;
};
- try {
- const stream = await navigator.mediaDevices.getUserMedia({
- audio: true,
- });
- const context = new AudioContext();
- const analyser = context.createAnalyser();
- analyser.fftSize = 512;
- context.createMediaStreamSource(stream).connect(analyser);
+ const microphone = await openMicrophone();
+ if (microphone !== null) {
+ const { stream, context, analyser } = microphone;
const samples = new Float32Array(analyser.fftSize);
const timer = window.setInterval(() => {
analyser.getFloatTimeDomainData(samples);
@@ -177,8 +198,6 @@ function useDictation(value: string, onValueChange: (value: string) => void) {
}
}, 50);
meter = { stream, context, timer };
- } catch {
- meter = null;
}
recognition.onresult = (event) => {
diff --git a/apps/web/src/json.test.ts b/apps/web/src/json.test.ts
new file mode 100644
index 00000000..f4d76199
--- /dev/null
+++ b/apps/web/src/json.test.ts
@@ -0,0 +1,12 @@
+import { expect, test } from "bun:test";
+import { type } from "arktype";
+import { readJson } from "./json.ts";
+
+const Shape = type({ count: "number" });
+
+test("JSON of the wrong shape, JSON null and text that is not JSON all read as null", () => {
+ expect(readJson('{"count":2}', Shape)).toEqual({ count: 2 });
+ expect(readJson('{"count":"2"}', Shape)).toBeNull();
+ expect(readJson("null", Shape)).toBeNull();
+ expect(readJson("{not json", Shape)).toBeNull();
+});
diff --git a/apps/web/src/json.ts b/apps/web/src/json.ts
new file mode 100644
index 00000000..35675d41
--- /dev/null
+++ b/apps/web/src/json.ts
@@ -0,0 +1,21 @@
+/**
+ * JSON this interface did not write: hub content, mail, stream frames and
+ * browser storage. Text that is not JSON, or JSON of the wrong shape, reads as
+ * null so the caller shows its own unreadable state.
+ */
+import { type, type Type } from "arktype";
+
+export function readJson(
+ text: string,
+ schema: S,
+): S["infer"] | null {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(text);
+ } catch (cause) {
+ if (!(cause instanceof SyntaxError)) throw cause;
+ return null;
+ }
+ const read = schema(parsed);
+ return read instanceof type.errors ? null : read;
+}
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/custom-providers.tsx b/apps/web/src/pages/custom-providers.tsx
index 78b7d224..575097b3 100644
--- a/apps/web/src/pages/custom-providers.tsx
+++ b/apps/web/src/pages/custom-providers.tsx
@@ -31,12 +31,12 @@ import { useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Ellipsis, X } from "lucide-react";
import { useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { toast } from "sonner";
-import type {
+import {
CustomApi,
- CustomModel,
- CustomProviderCreate,
- ModelCatalog,
- ModelProvider,
+ type CustomModel,
+ type CustomProviderCreate,
+ type ModelCatalog,
+ type ModelProvider,
} from "@corbits-builder/schemas/models";
import { Button, QuietState } from "../components.jsx";
import { keys } from "../queries/keys.ts";
@@ -318,10 +318,16 @@ function keylessOf(provider: ModelProvider): boolean {
return provider.connections.some((connection) => connection.keyless);
}
-function draftOf(provider: ModelProvider): CustomDraft {
+/** A custom provider whose plugin is an API this form can edit. */
+type EditableProvider = ModelProvider & { plugin: CustomApi };
+
+const editable = (provider: ModelProvider): provider is EditableProvider =>
+ CustomApi.allows(provider.plugin);
+
+function draftOf(provider: EditableProvider): CustomDraft {
return {
name: provider.label,
- api: provider.plugin as CustomApi,
+ api: provider.plugin,
baseURL: provider.baseURL,
secret: "",
keyless: keylessOf(provider),
@@ -403,7 +409,7 @@ async function testConnection(draft: CustomDraft): Promise {
* One custom provider's connection form: its rows, whether it can be submitted, whether
* submitting tests a new connection first, and `save`, which throws a failed test's reason.
*/
-export function useCustomProviderForm(apis: Apis, original?: ModelProvider) {
+export function useCustomProviderForm(apis: Apis, original?: EditableProvider) {
const [draft, setDraft] = useState(() =>
original ? draftOf(original) : BLANK_DRAFT,
);
@@ -437,6 +443,10 @@ export function useCustomProviderForm(apis: Apis, original?: ModelProvider) {
>
),
save: async (): Promise => {
+ const models = parsedModels(
+ original ? original.models.map(modelDraftOf) : [draft.model],
+ );
+ if (models === null) throw new Error(copy.custom.modelsUnreadable);
if (tests) await testConnection(draft);
const tenantId = await workspaceTenantId();
const client = productClient();
@@ -444,14 +454,14 @@ export function useCustomProviderForm(apis: Apis, original?: ModelProvider) {
if (!original) {
await client.createCustomProvider(tenantId, {
name,
- models: parsedModels([draft.model])!,
+ models,
connection: endpointOf(draft),
});
return;
}
await client.updateCustomProvider(tenantId, original.id, {
name,
- models: parsedModels(original.models.map(modelDraftOf))!,
+ models,
...(tests ? { connection: endpointOf(draft) } : {}),
});
},
@@ -848,6 +858,7 @@ function CustomProviderActions({
}) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
+ const editableProvider = editable(provider) ? provider : null;
const [removing, setRemoving] = useState(false);
// Removing takes two clicks, both in the menu, as deleting a project does.
const [confirming, setConfirming] = useState(false);
@@ -886,7 +897,10 @@ function CustomProviderActions({
- setEditing(true)}>
+ setEditing(true)}
+ >
{copy.custom.edit}
@@ -912,9 +926,9 @@ function CustomProviderActions({
)}
- {editing ? (
+ {editing && editableProvider !== null ? (
setEditing(false)}
onSaved={onChanged}
@@ -930,7 +944,7 @@ function EditCustomProviderDialog({
onClose,
onSaved,
}: {
- provider: ModelProvider;
+ provider: EditableProvider;
apis: Apis;
onClose: () => void;
onSaved: () => void;
diff --git a/apps/web/src/pages/invite-accept.tsx b/apps/web/src/pages/invite-accept.tsx
index c13117ae..a63ab06f 100644
--- a/apps/web/src/pages/invite-accept.tsx
+++ b/apps/web/src/pages/invite-accept.tsx
@@ -10,6 +10,7 @@
import { wording } from "../config.ts";
import { useEffect, useState } from "react";
import { toast } from "sonner";
+import { ProductApiError } from "@corbits-builder/client";
import { Banner, Button, Mark, StateLabel } from "../components.jsx";
import { productClient } from "../api/transport.ts";
import { getHubSession, type HubUser } from "../api/auth.ts";
@@ -26,7 +27,7 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) {
useEffect(() => {
let cancelled = false;
- void (async () => {
+ async function load() {
try {
const client = productClient();
const [record, session] = await Promise.all([
@@ -44,14 +45,24 @@ export function InvitationAccept({ invitationId }: { invitationId: string }) {
roles.data.find((role) => role.id === record.roleId)?.name ??
null,
);
- } catch {
- // The role name is a courtesy; the accept does not depend on it.
+ } catch (cause) {
+ // The role name is a courtesy the invitee may not be allowed to
+ // read; the accept does not depend on it.
+ if (
+ !(cause instanceof ProductApiError) ||
+ (cause.response.status !== 403 && cause.response.status !== 404)
+ )
+ console.warn(
+ "The invitation's role name could not be read.",
+ cause,
+ );
}
} catch (cause) {
if (!cancelled)
setFailed(cause instanceof Error ? cause.message : String(cause));
}
- })();
+ }
+ void load();
return () => {
cancelled = true;
};
diff --git a/apps/web/src/pages/model-setup.tsx b/apps/web/src/pages/model-setup.tsx
index 7252eebb..13ddfe08 100644
--- a/apps/web/src/pages/model-setup.tsx
+++ b/apps/web/src/pages/model-setup.tsx
@@ -29,7 +29,11 @@ export const MODEL_SETUP_LATER_KEY = "sb.model-setup.later";
export function modelSetupDeferred(): boolean {
try {
return sessionStorage.getItem(MODEL_SETUP_LATER_KEY) === "1";
- } catch {
+ } catch (error) {
+ console.warn(
+ "Browser storage is unavailable; model setup asks again.",
+ error,
+ );
return false;
}
}
@@ -38,8 +42,9 @@ export function modelSetupDeferred(): boolean {
export function clearModelSetupDeferral(): void {
try {
sessionStorage.removeItem(MODEL_SETUP_LATER_KEY);
- } catch {
+ } catch (error) {
// Unavailable storage never held the deferral either.
+ console.warn("Browser storage is unavailable.", error);
}
}
@@ -160,8 +165,12 @@ export function ModelSetup({
onClick={() => {
try {
sessionStorage.setItem(MODEL_SETUP_LATER_KEY, "1");
- } catch {
+ } catch (error) {
// Continuing still leaves this screen for the rest of the mount.
+ console.warn(
+ "Browser storage refused the deferral; model setup asks again next visit.",
+ error,
+ );
}
onLater();
}}
diff --git a/apps/web/src/pages/profiles.tsx b/apps/web/src/pages/profiles.tsx
index d5edfb28..4374c3f6 100644
--- a/apps/web/src/pages/profiles.tsx
+++ b/apps/web/src/pages/profiles.tsx
@@ -98,12 +98,17 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) {
const [viewing, setViewing] = useState(null);
useEffect(() => {
if (initialField) return;
- void workspaceTenantId()
- .then((tenantId) => productClient().personalSettings(tenantId))
- .then((record) =>
- setField(record.fields.models ?? { value: null, version: 0 }),
- )
- .catch((cause: unknown) => setError(message(cause)));
+ const readField = async () => {
+ try {
+ const record = await productClient().personalSettings(
+ await workspaceTenantId(),
+ );
+ setField(record.fields.models ?? { value: null, version: 0 });
+ } catch (cause) {
+ setError(message(cause));
+ }
+ };
+ void readField();
}, [initialField]);
const save = async (choice: ModelChoice) => {
diff --git a/apps/web/src/pages/project-menu.tsx b/apps/web/src/pages/project-menu.tsx
index c3d09667..a73e80e7 100644
--- a/apps/web/src/pages/project-menu.tsx
+++ b/apps/web/src/pages/project-menu.tsx
@@ -125,6 +125,17 @@ export function ProjectMenu({
}
};
+ const runExport = async (sections: ExportSection[]) => {
+ try {
+ await act(async () => {
+ const note = await exportProject(project, sections);
+ if (note !== undefined) onNotice?.(note, true);
+ });
+ } finally {
+ setExporting(false);
+ }
+ };
+
const openInfo = (withName = false) => {
setFocusName(withName);
setInfoOpen(true);
@@ -243,12 +254,7 @@ export function ProjectMenu({
setExporting(false)}
- onExport={(sections) =>
- void act(async () => {
- const note = await exportProject(project, sections);
- if (note !== undefined) onNotice?.(note, true);
- }).then(() => setExporting(false))
- }
+ onExport={(sections) => void runExport(sections)}
/>
) : null}
diff --git a/apps/web/src/pages/providers.test.ts b/apps/web/src/pages/providers.test.ts
new file mode 100644
index 00000000..d4352f6e
--- /dev/null
+++ b/apps/web/src/pages/providers.test.ts
@@ -0,0 +1,53 @@
+import { afterAll, expect, mock, test } from "bun:test";
+import { environmentManager } from "@tanstack/react-query";
+import type { ModelLoginState } from "@corbits-builder/client";
+import * as actualTransport from "../api/transport.ts";
+import { wording } from "../config.ts";
+
+const realTransport = { ...actualTransport };
+let answers: ModelLoginState[] = [];
+
+mock.module("../api/transport.ts", () => ({
+ ...realTransport,
+ productClient: () => ({
+ modelLogin: async () => answers.shift() ?? { status: "pending" },
+ }),
+}));
+// The poll is a browser's: without a window, react-query schedules no refetch timers.
+const wasServer = environmentManager.isServer();
+environmentManager.setIsServer(() => false);
+afterAll(() => {
+ environmentManager.setIsServer(() => wasServer);
+ mock.module("../api/transport.ts", () => realTransport);
+});
+
+const { loginCredential } = await import("./providers.tsx");
+
+test("a login that completes after a pending poll yields its credential", async () => {
+ answers = [
+ { status: "pending" },
+ { status: "completed", credentialId: "cred_1" },
+ ];
+
+ expect(await loginCredential("t1", "login_1", Date.now() + 60_000)).toBe(
+ "cred_1",
+ );
+});
+
+test("a login the hub reports failed rejects with the hub's message", async () => {
+ answers = [
+ { status: "failed", message: "The provider refused the sign-in." },
+ ];
+
+ await expect(
+ loginCredential("t1", "login_2", Date.now() + 60_000),
+ ).rejects.toThrow("The provider refused the sign-in.");
+});
+
+test("a login still pending at its deadline rejects as timed out", async () => {
+ answers = [];
+
+ await expect(
+ loginCredential("t1", "login_3", Date.now() + 1),
+ ).rejects.toThrow(wording.providers.timedOut);
+});
diff --git a/apps/web/src/pages/providers.tsx b/apps/web/src/pages/providers.tsx
index 2ab0aaaa..1efef7ad 100644
--- a/apps/web/src/pages/providers.tsx
+++ b/apps/web/src/pages/providers.tsx
@@ -12,15 +12,17 @@
import { wording } from "../config.ts";
import { EmptyState, Input, Section } from "@corbits/react-ui";
import { DialogFieldLabel } from "@corbits/react-ui/ui/dialog";
-import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { QueryObserver, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
-import type {
- ModelProvider,
- ProviderStatus,
+import {
+ ModelLoginPlugin,
+ type ModelProvider,
+ type ProviderStatus,
} from "@corbits-builder/schemas/models";
-import type { ModelLoginPlugin } from "@corbits-builder/client";
+import { type } from "arktype";
import { Banner, Button, QuietState } from "../components.jsx";
+import { queryClient } from "../queries/client.ts";
import { keys } from "../queries/keys.ts";
import { productClient } from "../api/transport.ts";
import { workspaceTenantId } from "../api/auth.ts";
@@ -61,26 +63,53 @@ export function useModelCatalog() {
async function logIn(provider: ModelProvider) {
const tenantId = await workspaceTenantId();
const client = productClient();
- const plugin = provider.plugin as ModelLoginPlugin;
+ const plugin = ModelLoginPlugin(provider.plugin);
+ if (plugin instanceof type.errors)
+ throw new Error(copy.loginUnsupported(provider.label));
const connection = copy.loginConnection(provider.label);
const login = await client.startModelLogin(tenantId, plugin, connection);
window.open(login.authorizeUrl, "_blank", "noopener");
- const deadline = Date.now() + LOGIN_DEADLINE_MS;
- for (;;) {
- if (Date.now() > deadline) throw new Error(copy.timedOut);
- const state = await client.modelLogin(tenantId, login.loginId);
- if (state.status === "failed") throw new Error(state.message);
- if (state.status === "completed") {
- await client.finishModelLogin(
- tenantId,
- plugin,
- state.credentialId,
- connection,
- );
- return;
- }
- await new Promise((resolve) => setTimeout(resolve, POLL_MS));
- }
+ const credentialId = await loginCredential(
+ tenantId,
+ login.loginId,
+ Date.now() + LOGIN_DEADLINE_MS,
+ );
+ await client.finishModelLogin(tenantId, plugin, credentialId, connection);
+}
+
+/**
+ * The credential a hub login ends with, polled until it ends. The person signs
+ * in on the provider's tab, so the poll runs while this one is in the
+ * background, and the deadline holds even while the browser says it is offline.
+ */
+export function loginCredential(
+ tenantId: string,
+ loginId: string,
+ deadline: number,
+): Promise {
+ const observer = new QueryObserver(queryClient, {
+ queryKey: keys.modelLogin(loginId),
+ queryFn: async ({ signal }) => {
+ if (Date.now() > deadline) throw new Error(copy.timedOut);
+ return productClient().modelLogin(tenantId, loginId, signal);
+ },
+ refetchInterval: POLL_MS,
+ refetchIntervalInBackground: true,
+ networkMode: "always",
+ retry: false,
+ gcTime: 0,
+ meta: { persist: false },
+ });
+ return new Promise((resolve, reject) => {
+ const stop = observer.subscribe((result) => {
+ const state = result.data;
+ if (result.isError) reject(result.error);
+ else if (state?.status === "failed") reject(new Error(state.message));
+ else if (state?.status === "completed") resolve(state.credentialId);
+ else return;
+ stop();
+ });
+ });
}
/** The subscription logins in the catalog, and one at a time signing in through the hub. */
diff --git a/apps/web/src/pages/settings.tsx b/apps/web/src/pages/settings.tsx
index e27bbf0f..ed093ac8 100644
--- a/apps/web/src/pages/settings.tsx
+++ b/apps/web/src/pages/settings.tsx
@@ -758,6 +758,14 @@ function Account({
onSignOut?: () => Promise;
}) {
const [signingOut, setSigningOut] = useState(false);
+ const signOut = async (leave: () => Promise) => {
+ setSigningOut(true);
+ try {
+ await leave();
+ } finally {
+ setSigningOut(false);
+ }
+ };
const copy = wording.settings.account;
return (
@@ -773,10 +781,7 @@ function Account({
{
- setSigningOut(true);
- void onSignOut().finally(() => setSigningOut(false));
- }}
+ onClick={() => void signOut(onSignOut)}
>
{wording.action.signOut}
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/approaches-comparison.tsx b/apps/web/src/pages/workspace/approaches-comparison.tsx
index 567d4f86..c946838a 100644
--- a/apps/web/src/pages/workspace/approaches-comparison.tsx
+++ b/apps/web/src/pages/workspace/approaches-comparison.tsx
@@ -18,6 +18,7 @@ import type {
} from "@corbits-builder/schemas/approaches";
import { useState } from "react";
import { wording } from "../../config.ts";
+import { humanFault } from "./human-fault.ts";
const text = wording.approaches;
@@ -48,14 +49,17 @@ export function ApproachesComparison({
}) {
const [choosing, setChoosing] = useState(null);
const [fault, setFault] = useState(null);
- const choose = (approach: Approach) => {
+ const choose = async (approach: Approach) => {
if (onChoose === undefined) return;
setChoosing(approach.id);
setFault(null);
- void onChoose(text.message(approach.name))
- .then(setFault)
- .catch(() => setFault(text.notSent))
- .finally(() => setChoosing(null));
+ try {
+ setFault(await onChoose(text.message(approach.name)));
+ } catch (cause) {
+ setFault(humanFault(cause, text.notSent));
+ } finally {
+ setChoosing(null);
+ }
};
return (
@@ -95,7 +99,7 @@ export function ApproachesComparison({
size="sm"
variant={recommended ? "primary" : "outline"}
disabled={choosing !== null}
- onClick={() => choose(approach)}
+ onClick={() => void choose(approach)}
>
{choosing === approach.id ? text.choosing : text.choose}
diff --git a/apps/web/src/pages/workspace/approval-package-preview.test.tsx b/apps/web/src/pages/workspace/approval-package-preview.test.tsx
index 946ddc50..37e64cca 100644
--- a/apps/web/src/pages/workspace/approval-package-preview.test.tsx
+++ b/apps/web/src/pages/workspace/approval-package-preview.test.tsx
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
+import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
+import { prerender } from "react-dom/static";
import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact";
+import { wording } from "../../config.ts";
import {
APPROVAL_PACKAGE_MEDIA_TYPE,
ApprovalPackagePreview,
@@ -8,6 +11,12 @@ import {
parseApprovalPackage,
} from "./approval-package-preview.tsx";
+// Waits for the lazily loaded slides, as a browser would.
+async function render(node: ReactNode) {
+ const { prelude } = await prerender(node);
+ return new Response(prelude).text();
+}
+
const sampleBundle: ApprovalPackageBundle = {
manifest: {
audience: "Executive steering committee",
@@ -91,13 +100,13 @@ describe("approval package preview", () => {
expect(parseApprovalPackage(JSON.stringify({ manifest: {} }))).toBeNull();
const pdfFile = approvalPackagePdfFile("portal-package-v1", sampleBundle);
- expect(pdfFile.name).toBe("portal-package-v1.pdf");
- expect(pdfFile.type).toBe("application/pdf");
+ expect(pdfFile?.name).toBe("portal-package-v1.pdf");
+ expect(pdfFile?.type).toBe("application/pdf");
expect(APPROVAL_PACKAGE_MEDIA_TYPE).toContain("approval-package+json");
});
- test("renders slides view with theme colors, comparison columns, and speaker notes", () => {
- const html = renderToStaticMarkup(
+ test("renders slides view with theme colors, comparison columns, and speaker notes", async () => {
+ const html = await render(
{
expect(html).toContain("#1e293b");
});
- test("renders PDF view with print-accurate page sheets and download link without blocked data: iframe", () => {
- const html = renderToStaticMarkup(
+ test("renders PDF view with print-accurate page sheets and download link without blocked data: iframe", async () => {
+ const html = await render(
{
expect(html).toContain("Customer Portal Approval Package");
});
- test("enforces high-contrast text on dark-background slide decks", () => {
+ test("a PDF whose base64 does not decode offers no download and says it could not be read", async () => {
+ const html = await render(
+ ,
+ );
+ expect(html).toContain(wording.approvalPackage.pdfUnreadable);
+ expect(html).not.toContain(wording.approvalPackage.downloadPdf);
+ expect(html).toContain("Page 1 of 2");
+ });
+
+ test("a deck that is not a valid slide deck says it could not be read in place of its slides and pages, and keeps the PDF download and sources", async () => {
+ const content = JSON.stringify({
+ ...sampleBundle,
+ deck: {
+ theme: { colors: { background: "#ffffff" } },
+ slides: [{ layout: "title", title: "Half a deck" }],
+ },
+ });
+ const views = await Promise.all(
+ (["slides", "pdf", "sources"] as const).map((tab) =>
+ render(
+ ,
+ ),
+ ),
+ );
+ const [slides, pdf, sources] = views;
+ expect(slides).toContain(wording.approvalPackage.unreadable);
+ expect(slides).not.toContain("Half a deck");
+ expect(pdf).toContain(wording.approvalPackage.unreadable);
+ expect(pdf).toContain(wording.approvalPackage.downloadPdf);
+ expect(pdf).not.toContain("Half a deck");
+ expect(sources).toContain("Customer Portal Brief");
+ });
+
+ test("draws text inside groups on free slides, and nested list items are numbered like their parent unless they say otherwise", async () => {
+ const html = await render(
+ ,
+ );
+ expect(html).toContain("Grouped headline
");
+ expect(html).toContain("PilotPick regions ");
+ expect(html).toContain("Launch");
+ });
+
+ test("enforces high-contrast text on dark-background slide decks", async () => {
const darkDeckBundle: ApprovalPackageBundle = {
...sampleBundle,
deck: {
@@ -147,6 +249,7 @@ describe("approval package preview", () => {
text: "#f8fafc",
muted: "#64748b",
},
+ fonts: { heading: "Inter", body: "Inter" },
},
slides: [
{
@@ -158,7 +261,7 @@ describe("approval package preview", () => {
],
},
};
- const html = renderToStaticMarkup(
+ const html = await render(
{text.slidesUnavailable}
;
}
-interface DeckTheme {
- colors?: DeckThemeColors | undefined;
- fonts?:
- | {
- heading?: string;
- body?: string;
- }
- | undefined;
-}
-
-interface ResolvedSlidePalette {
- isDark: boolean;
- background: string;
- text: string;
- heading: string;
- subtitle: string;
- accentBar: string;
- columnHeading: string;
- columnBg: string;
- columnBorder: string;
-}
-
-type SlideBodyBlock =
- | string
- | {
- type?: string;
- kind?: string;
- text?: unknown;
- ordered?: boolean;
- items?: unknown[];
- };
-
-interface DeckSlide {
- layout?: string;
- title?: string;
- subtitle?: string;
- body?: string | SlideBodyBlock[];
- left?: unknown;
- right?: unknown;
- elements?: unknown[];
- caption?: string;
- notes?: string;
- speakerNotes?: string;
-}
-
-function isRecord(value: unknown): value is Record {
- return typeof value === "object" && value !== null && !Array.isArray(value);
-}
-
-function normalizeHex(color: string | undefined): string | undefined {
- if (color === undefined || color.trim() === "") return undefined;
- const trimmed = color.trim();
- if (/^[0-9a-fA-F]{6}$/.test(trimmed)) return `#${trimmed}`;
- return trimmed;
-}
-
-function parseHexRgb(hex: string | undefined): [number, number, number] | null {
- const normalized = normalizeHex(hex);
- if (normalized === undefined) return null;
- const raw = normalized.startsWith("#") ? normalized.slice(1) : normalized;
- if (/^[0-9a-fA-F]{3}$/.test(raw)) {
- const r = Number.parseInt(raw.charAt(0) + raw.charAt(0), 16);
- const g = Number.parseInt(raw.charAt(1) + raw.charAt(1), 16);
- const b = Number.parseInt(raw.charAt(2) + raw.charAt(2), 16);
- return [r, g, b];
- }
- if (/^[0-9a-fA-F]{6}$/.test(raw)) {
- const r = Number.parseInt(raw.slice(0, 2), 16);
- const g = Number.parseInt(raw.slice(2, 4), 16);
- const b = Number.parseInt(raw.slice(4, 6), 16);
- return [r, g, b];
+// The slide deck schema is costly to build, so it loads only once a package's slides are shown.
+// A chunk that fails to load (a redeploy dropped its hash, the network, a blocker) would throw
+// while rendering and take the whole app down, so it shows a line in the slides' place instead.
+async function slidesModule() {
+ try {
+ return await import("./approval-package-slides.tsx");
+ } catch (cause) {
+ if (!(cause instanceof TypeError)) throw cause;
+ console.warn("The approval package slides could not load.", cause);
+ return null;
}
- return null;
}
+const DeckSlides = lazy(async () => ({
+ default: (await slidesModule())?.DeckSlides ?? SlidesUnavailable,
+}));
+const DeckPages = lazy(async () => ({
+ default: (await slidesModule())?.DeckPages ?? SlidesUnavailable,
+}));
-function channelLuminance(channel: number): number {
- const srgb = channel / 255;
- return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
-}
-
-function relativeLuminance(hex: string | undefined): number | null {
- const rgb = parseHexRgb(hex);
- if (rgb === null) return null;
- return (
- 0.2126 * channelLuminance(rgb[0]) +
- 0.7152 * channelLuminance(rgb[1]) +
- 0.0722 * channelLuminance(rgb[2])
- );
-}
-
-function pickLightOnDark(
- color: string | undefined,
- minLum: number,
- fallback: string,
-): string {
- if (color === undefined) return fallback;
- const lum = relativeLuminance(color);
- return lum !== null && lum >= minLum ? color : fallback;
-}
-
-function pickDarkOnLight(
- color: string | undefined,
- maxLum: number,
- fallback: string,
-): string {
- if (color === undefined) return fallback;
- const lum = relativeLuminance(color);
- return lum !== null && lum <= maxLum ? color : fallback;
-}
-
-// Dark-background decks can still declare dark brand tokens for primary/secondary;
-// clamping by relative luminance keeps headings and subtitles legible on the stage.
-function resolveSlidePalette(theme: DeckTheme): ResolvedSlidePalette {
- const colors = theme.colors;
- const rawBg = normalizeHex(colors?.background);
- const bgLum = relativeLuminance(rawBg);
- const isDark = bgLum !== null && bgLum < 0.25;
-
- const primary = normalizeHex(colors?.primary);
- const secondary = normalizeHex(colors?.secondary ?? colors?.muted);
- const accent = normalizeHex(colors?.accent ?? colors?.primary);
- const rawText = normalizeHex(colors?.text);
-
- if (isDark) {
- const brightPrimary = pickLightOnDark(primary, 0.35, "#38bdf8");
- return {
- isDark: true,
- background: rawBg === undefined ? "#0f172a" : rawBg,
- text: pickLightOnDark(rawText, 0.55, "#f8fafc"),
- heading: pickLightOnDark(primary, 0.55, "#f8fafc"),
- subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"),
- accentBar: pickLightOnDark(accent, 0.25, brightPrimary),
- columnHeading: pickLightOnDark(accent, 0.35, brightPrimary),
- columnBg: "rgba(255, 255, 255, 0.055)",
- columnBorder: "rgba(255, 255, 255, 0.14)",
- };
- }
-
- return {
- isDark: false,
- background: rawBg === undefined ? "#ffffff" : rawBg,
- text: pickDarkOnLight(rawText, 0.35, "#0f172a"),
- heading: pickDarkOnLight(primary, 0.45, "#0f172a"),
- subtitle: pickDarkOnLight(secondary, 0.45, "#475569"),
- accentBar: primary ?? accent ?? "#0284c7",
- columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"),
- columnBg: "rgba(15, 23, 42, 0.035)",
- columnBorder: "rgba(15, 23, 42, 0.1)",
- };
-}
+const loadingSlides = {text.loadingSlides}
;
export function parseApprovalPackage(
content: string,
@@ -189,370 +54,37 @@ export function parseApprovalPackage(
}
}
+/** The package's PDF as a file, or null when its base64 does not decode. */
export function approvalPackagePdfFile(
stem: string,
bundle: ApprovalPackageBundle,
-): File {
+): File | null {
const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(bundle.pdfBase64);
const encoded = (
dataUrlMatch === null ? bundle.pdfBase64 : (dataUrlMatch[1] as string)
).replace(/\s+/g, "");
- let bytes = new Uint8Array(0);
+ let binary: string;
try {
- const binary = atob(encoded);
- bytes = new Uint8Array(binary.length);
- for (let index = 0; index < binary.length; index += 1) {
- bytes[index] = binary.charCodeAt(index);
- }
+ binary = atob(encoded);
} catch (error) {
- if (!(error instanceof Error)) throw error;
- bytes = new Uint8Array(0);
- }
- return new File([bytes], `${stem}.pdf`, { type: "application/pdf" });
-}
-
-function readDeckTheme(deck: Record): DeckTheme {
- const rawTheme = deck["theme"];
- if (!isRecord(rawTheme)) {
- return { colors: undefined, fonts: undefined };
- }
- const colors = isRecord(rawTheme["colors"])
- ? (rawTheme["colors"] as DeckThemeColors)
- : undefined;
- const fonts = isRecord(rawTheme["fonts"])
- ? (rawTheme["fonts"] as DeckTheme["fonts"])
- : undefined;
- return { colors, fonts };
-}
-
-function readDeckSlides(deck: Record): DeckSlide[] {
- const rawSlides = deck["slides"];
- if (!Array.isArray(rawSlides)) return [];
- return rawSlides.filter(isRecord) as DeckSlide[];
-}
-
-function slideStageStyle(
- theme: DeckTheme,
- palette: ResolvedSlidePalette,
-): CSSProperties {
- const style: Record = {
- backgroundColor: palette.background,
- color: palette.text,
- "--slide-bg": palette.background,
- "--slide-fg": palette.text,
- "--slide-heading": palette.heading,
- "--slide-subtitle": palette.subtitle,
- "--slide-accent": palette.accentBar,
- "--slide-column-bg": palette.columnBg,
- "--slide-column-border": palette.columnBorder,
- };
- if (theme.fonts?.body !== undefined) {
- style["fontFamily"] = theme.fonts.body;
- }
- return style as CSSProperties;
-}
-
-function renderRichRun(run: unknown, key: number): ReactNode {
- if (typeof run === "string") return run;
- if (!isRecord(run) || typeof run["text"] !== "string") return null;
- let node: ReactNode = run["text"];
- if (run["bold"] === true || Number(run["weight"]) >= 600) {
- node = {node} ;
- }
- if (run["italic"] === true) {
- node = {node} ;
- }
- if (run["underline"] === true) {
- node = {node} ;
- }
- if (run["strike"] === true) {
- node = {node} ;
- }
- return {node} ;
-}
-
-function renderRichText(value: unknown): ReactNode {
- if (typeof value === "string") return value;
- if (Array.isArray(value)) {
- return value.map((run, index) => renderRichRun(run, index));
- }
- return null;
-}
-
-function renderNestedList(nestedItems: unknown[], ordered: boolean): ReactNode {
- if (nestedItems.length === 0) return null;
- if (ordered) {
- return {renderListItems(nestedItems)} ;
- }
- return {renderListItems(nestedItems)} ;
-}
-
-function renderListItems(items: unknown[]) {
- return items.map((item, index) => {
- if (isRecord(item) && "text" in item) {
- const nestedOrdered = item["ordered"] === true;
- return (
-
- {renderRichText(item["text"])}
- {Array.isArray(item["items"])
- ? renderNestedList(item["items"], nestedOrdered)
- : null}
-
- );
- }
- return {renderRichText(item)} ;
- });
-}
-
-function SlideBodyBlockView({ block }: { block: SlideBodyBlock }) {
- if (typeof block === "string") return {block}
;
- const blockType = block.type ?? block.kind;
- if (blockType === "paragraph") {
- const rendered = renderRichText(block.text);
- return rendered === null ? null : {rendered}
;
- }
- if (blockType === "list" && Array.isArray(block.items)) {
- if (block.ordered === true) {
- return {renderListItems(block.items)} ;
- }
- return {renderListItems(block.items)} ;
- }
- const fallback = renderRichText(block.text);
- return fallback === null ? null : {fallback}
;
-}
-
-function SlideBodyContent({ body }: { body: unknown }) {
- if (typeof body === "string") {
- return (
-
- );
- }
- if (!Array.isArray(body) || body.length === 0) return null;
- return (
-
- {body.map((block, index) => (
-
- ))}
-
- );
-}
-
-function SlideColumns({
- slide,
- headingColor,
-}: {
- slide: DeckSlide;
- headingColor: string;
-}) {
- if (slide.layout === "two-column") {
- return (
-
- );
- }
- if (slide.layout === "comparison") {
- const leftSide = isRecord(slide.left) ? slide.left : undefined;
- const rightSide = isRecord(slide.right) ? slide.right : undefined;
- const leftHeading =
- typeof leftSide?.["heading"] === "string"
- ? leftSide["heading"]
- : undefined;
- const rightHeading =
- typeof rightSide?.["heading"] === "string"
- ? rightSide["heading"]
- : undefined;
- return (
-
-
- {leftHeading === undefined ? null : (
-
- {leftHeading}
-
- )}
-
-
-
- {rightHeading === undefined ? null : (
-
- {rightHeading}
-
- )}
-
-
-
- );
- }
- return null;
-}
-
-function collectFreeParagraphs(elements: unknown[]): ReactNode[] {
- const out: ReactNode[] = [];
- for (const [elIdx, el] of elements.entries()) {
- if (!isRecord(el)) continue;
- if (el["type"] === "group" && Array.isArray(el["children"])) {
- out.push(...collectFreeParagraphs(el["children"]));
- continue;
- }
- if (el["type"] === "text" && Array.isArray(el["paragraphs"])) {
- for (const [pIdx, para] of el["paragraphs"].entries()) {
- if (!isRecord(para) || !Array.isArray(para["runs"])) continue;
- out.push(
-
- {renderRichText(para["runs"])}
-
,
- );
- }
- }
+ if (
+ !(error instanceof DOMException && error.name === "InvalidCharacterError")
+ )
+ throw error;
+ return null;
}
- return out;
-}
-
-function SlideCanvas({
- slide,
- index,
- total,
- theme,
- pageFooter,
-}: {
- slide: DeckSlide;
- index: number;
- total: number;
- theme: DeckTheme;
- pageFooter?: string;
-}) {
- const palette = resolveSlidePalette(theme);
- const layout = slide.layout ?? "content";
- const titleStyle: CSSProperties = { color: palette.heading };
- if (theme.fonts?.heading !== undefined) {
- titleStyle.fontFamily = theme.fonts.heading;
+ const bytes = new Uint8Array(binary.length);
+ for (let index = 0; index < binary.length; index += 1) {
+ bytes[index] = binary.charCodeAt(index);
}
- const freeParagraphs =
- layout === "free" && Array.isArray(slide.elements)
- ? collectFreeParagraphs(slide.elements)
- : [];
- return (
-
-
-
- {typeof slide.title === "string" ||
- typeof slide.subtitle === "string" ? (
-
- {layout === "title" ? (
-
- ) : null}
- {typeof slide.title === "string" ? (
-
- {slide.title}
-
- ) : null}
- {typeof slide.subtitle === "string" ? (
-
- {slide.subtitle}
-
- ) : null}
-
- ) : null}
-
- {freeParagraphs.length === 0 ? null : (
-
{freeParagraphs}
- )}
-
- {typeof slide.caption === "string" ? (
-
- {slide.caption}
-
- ) : null}
-
-
- {pageFooter ?? `${String(index + 1)} / ${String(total)}`}
-
-
- );
-}
-
-function SlideCard({
- slide,
- index,
- total,
- theme,
-}: {
- slide: DeckSlide;
- index: number;
- total: number;
- theme: DeckTheme;
-}) {
- return (
-
-
-
- {text.slideCount(index + 1, total)}
-
- {typeof slide.layout === "string" ? (
- {slide.layout}
- ) : null}
-
-
-
- );
+ return new File([bytes], `${stem}.pdf`, { type: "application/pdf" });
}
export function SlidesView({ deck }: { deck: Record }) {
- const theme = readDeckTheme(deck);
- const slides = readDeckSlides(deck);
- if (slides.length === 0) {
- return {text.noSlides}
;
- }
return (
-
- {slides.map((slide, index) => (
-
- ))}
-
+
+
+
);
}
@@ -566,20 +98,24 @@ function PdfView({
stem: string;
}) {
const [blobUrl, setBlobUrl] = useState(null);
+ const file = useMemo(
+ () => approvalPackagePdfFile(stem, bundle),
+ [stem, bundle],
+ );
useEffect(() => {
if (
+ file === null ||
typeof URL === "undefined" ||
typeof URL.createObjectURL !== "function"
) {
return;
}
- const file = approvalPackagePdfFile(stem, bundle);
const objectUrl = URL.createObjectURL(file);
setBlobUrl(objectUrl);
return () => {
URL.revokeObjectURL(objectUrl);
};
- }, [stem, bundle]);
+ }, [file]);
const dataUrlMatch = /^data:[^;,]*;base64,(.*)$/s.exec(bundle.pdfBase64);
const fallbackHref =
@@ -588,9 +124,6 @@ function PdfView({
: bundle.pdfBase64;
const pdfHref = blobUrl ?? fallbackHref;
- const theme = readDeckTheme(bundle.deck);
- const slides = readDeckSlides(bundle.deck);
-
return (
@@ -600,43 +133,33 @@ function PdfView({
{stem}.pdf
-
- {blobUrl === null ? null : (
+ {file === null ? (
+
{text.pdfUnreadable}
+ ) : (
+
+
+ )}
- {slides.length === 0 ? (
- {text.noSlides}
- ) : (
-
- {slides.map((slide, index) => (
-
-
-
- ))}
-
- )}
+
+
+
);
}
@@ -767,7 +290,7 @@ export function ApprovalPackagePreview({
}) {
const [activeTab, setActiveTab] =
useState(initialTab);
- const bundle = parseApprovalPackage(content);
+ const bundle = useMemo(() => parseApprovalPackage(content), [content]);
if (bundle === null) {
return {text.unreadable}
;
}
diff --git a/apps/web/src/pages/workspace/approval-package-slides.tsx b/apps/web/src/pages/workspace/approval-package-slides.tsx
new file mode 100644
index 00000000..259d2c7a
--- /dev/null
+++ b/apps/web/src/pages/workspace/approval-package-slides.tsx
@@ -0,0 +1,443 @@
+import { Badge } from "@corbits/react-ui";
+import {
+ freeElements,
+ validateDeck,
+ type Block,
+ type Deck,
+ type FreeElement,
+ type FreeRun,
+ type ListItem,
+ type RichText,
+ type Slide,
+ type Theme,
+} from "@corbits/slidedeck/deck";
+import { useMemo, type CSSProperties, type ReactNode } from "react";
+import { wording } from "../../config.ts";
+
+const text = wording.approvalPackage;
+
+interface ResolvedSlidePalette {
+ isDark: boolean;
+ background: string;
+ text: string;
+ heading: string;
+ subtitle: string;
+ accentBar: string;
+ columnHeading: string;
+ columnBg: string;
+ columnBorder: string;
+}
+
+function normalizeHex(color: string): string {
+ return color.startsWith("#") ? color : `#${color}`;
+}
+
+function channelLuminance(channel: number): number {
+ const srgb = channel / 255;
+ return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
+}
+
+function relativeLuminance(hex: string): number {
+ const raw = hex.slice(1);
+ return (
+ 0.2126 * channelLuminance(Number.parseInt(raw.slice(0, 2), 16)) +
+ 0.7152 * channelLuminance(Number.parseInt(raw.slice(2, 4), 16)) +
+ 0.0722 * channelLuminance(Number.parseInt(raw.slice(4, 6), 16))
+ );
+}
+
+function pickLightOnDark(
+ color: string,
+ minLum: number,
+ fallback: string,
+): string {
+ return relativeLuminance(color) >= minLum ? color : fallback;
+}
+
+function pickDarkOnLight(
+ color: string,
+ maxLum: number,
+ fallback: string,
+): string {
+ return relativeLuminance(color) <= maxLum ? color : fallback;
+}
+
+// Dark-background decks can still declare dark brand tokens for primary/secondary;
+// clamping by relative luminance keeps headings and subtitles legible on the stage.
+function resolveSlidePalette(theme: Theme): ResolvedSlidePalette {
+ const background = normalizeHex(theme.colors.background);
+ const primary = normalizeHex(theme.colors.primary);
+ const secondary = normalizeHex(theme.colors.secondary);
+ const accent = normalizeHex(theme.colors.accent);
+ const rawText = normalizeHex(theme.colors.text);
+
+ if (relativeLuminance(background) < 0.25) {
+ const brightPrimary = pickLightOnDark(primary, 0.35, "#38bdf8");
+ return {
+ isDark: true,
+ background,
+ text: pickLightOnDark(rawText, 0.55, "#f8fafc"),
+ heading: pickLightOnDark(primary, 0.55, "#f8fafc"),
+ subtitle: pickLightOnDark(secondary, 0.5, "#cbd5e1"),
+ accentBar: pickLightOnDark(accent, 0.25, brightPrimary),
+ columnHeading: pickLightOnDark(accent, 0.35, brightPrimary),
+ columnBg: "rgba(255, 255, 255, 0.055)",
+ columnBorder: "rgba(255, 255, 255, 0.14)",
+ };
+ }
+
+ return {
+ isDark: false,
+ background,
+ text: pickDarkOnLight(rawText, 0.35, "#0f172a"),
+ heading: pickDarkOnLight(primary, 0.45, "#0f172a"),
+ subtitle: pickDarkOnLight(secondary, 0.45, "#475569"),
+ accentBar: primary,
+ columnHeading: pickDarkOnLight(primary, 0.45, "#0284c7"),
+ columnBg: "rgba(15, 23, 42, 0.035)",
+ columnBorder: "rgba(15, 23, 42, 0.1)",
+ };
+}
+
+function readDeck(value: unknown): Deck | null {
+ const validated = validateDeck(value);
+ return validated.ok ? validated.deck : null;
+}
+
+function slideStageStyle(
+ theme: Theme,
+ palette: ResolvedSlidePalette,
+): CSSProperties {
+ const style: Record = {
+ backgroundColor: palette.background,
+ color: palette.text,
+ fontFamily: theme.fonts.body,
+ "--slide-bg": palette.background,
+ "--slide-fg": palette.text,
+ "--slide-heading": palette.heading,
+ "--slide-subtitle": palette.subtitle,
+ "--slide-accent": palette.accentBar,
+ "--slide-column-bg": palette.columnBg,
+ "--slide-column-border": palette.columnBorder,
+ };
+ return style as CSSProperties;
+}
+
+interface Emphasis {
+ bold: boolean;
+ italic: boolean;
+ underline: boolean;
+ strike: boolean;
+}
+
+function renderEmphasis(text: string, emphasis: Emphasis, key: number) {
+ let node: ReactNode = text;
+ if (emphasis.bold) node = {node} ;
+ if (emphasis.italic) node = {node} ;
+ if (emphasis.underline) node = {node} ;
+ if (emphasis.strike) node = {node} ;
+ return {node} ;
+}
+
+function renderRichText(value: RichText): ReactNode {
+ if (typeof value === "string") return value;
+ return value.map((run, index) =>
+ typeof run === "string"
+ ? run
+ : renderEmphasis(
+ run.text,
+ {
+ bold: run.bold === true,
+ italic: run.italic === true,
+ underline: run.underline === true,
+ strike: run.strike === true,
+ },
+ index,
+ ),
+ );
+}
+
+function renderFreeRuns(runs: readonly (string | FreeRun)[]): ReactNode {
+ return runs.map((run, index) =>
+ typeof run === "string"
+ ? run
+ : renderEmphasis(
+ run.text,
+ {
+ bold: run.weight !== undefined && run.weight >= 600,
+ italic: run.italic === true,
+ underline: run.underline === true,
+ strike: run.strike === true,
+ },
+ index,
+ ),
+ );
+}
+
+function renderList(items: readonly ListItem[], ordered: boolean): ReactNode {
+ const rendered = items.map((item, index) => {
+ if (typeof item === "string" || Array.isArray(item)) {
+ return {renderRichText(item)} ;
+ }
+ return (
+
+ {renderRichText(item.text)}
+ {renderList(item.items, item.ordered ?? ordered)}
+
+ );
+ });
+ return ordered ? {rendered} : ;
+}
+
+function SlideBlock({ block }: { block: Block }) {
+ switch (block.type) {
+ case "paragraph":
+ return {renderRichText(block.text)}
;
+ case "list":
+ return renderList(block.items, block.ordered);
+ default:
+ return null;
+ }
+}
+
+function SlideBlocks({ blocks }: { blocks: readonly Block[] }) {
+ if (blocks.length === 0) return null;
+ return (
+
+ {blocks.map((block, index) => (
+
+ ))}
+
+ );
+}
+
+function SlideColumn({
+ heading,
+ blocks,
+ headingColor,
+}: {
+ heading?: string;
+ blocks: readonly Block[];
+ headingColor: string;
+}) {
+ return (
+
+ {heading === undefined ? null : (
+
+ {heading}
+
+ )}
+
+
+ );
+}
+
+function FreeText({ elements }: { elements: readonly FreeElement[] }) {
+ const paragraphs: ReactNode[] = [];
+ for (const [element, path] of freeElements(elements, [])) {
+ if (element.type !== "text") continue;
+ for (const [index, paragraph] of element.paragraphs.entries()) {
+ paragraphs.push(
+
+ {renderFreeRuns(paragraph.runs)}
+
,
+ );
+ }
+ }
+ if (paragraphs.length === 0) return null;
+ return {paragraphs}
;
+}
+
+function SlideContent({
+ slide,
+ headingColor,
+}: {
+ slide: Slide;
+ headingColor: string;
+}) {
+ switch (slide.layout) {
+ case "content":
+ case "blank":
+ return ;
+ case "two-column":
+ return (
+
+
+
+
+ );
+ case "comparison":
+ return (
+
+
+
+
+ );
+ case "free":
+ return ;
+ default:
+ return null;
+ }
+}
+
+function SlideCanvas({
+ slide,
+ index,
+ total,
+ theme,
+ pageFooter,
+}: {
+ slide: Slide;
+ index: number;
+ total: number;
+ theme: Theme;
+ pageFooter?: string;
+}) {
+ const palette = resolveSlidePalette(theme);
+ const title = "title" in slide ? slide.title : undefined;
+ const subtitle = "subtitle" in slide ? slide.subtitle : undefined;
+ const caption = slide.layout === "image" ? slide.caption : undefined;
+ return (
+
+
+
+ {title === undefined && subtitle === undefined ? null : (
+
+ {slide.layout === "title" ? (
+
+ ) : null}
+ {title === undefined ? null : (
+
+ {title}
+
+ )}
+ {subtitle === undefined ? null : (
+
+ {subtitle}
+
+ )}
+
+ )}
+
+ {caption === undefined ? null : (
+
+ {caption}
+
+ )}
+
+
+ {pageFooter ?? `${String(index + 1)} / ${String(total)}`}
+
+
+ );
+}
+
+function SlideCard({
+ slide,
+ index,
+ total,
+ theme,
+}: {
+ slide: Slide;
+ index: number;
+ total: number;
+ theme: Theme;
+}) {
+ return (
+
+
+
+ {text.slideCount(index + 1, total)}
+
+ {slide.layout}
+
+
+
+ );
+}
+
+/** The package's slides as cards, or the unreadable line when the deck is not a valid slide deck. */
+export function DeckSlides({ deck }: { deck: Record }) {
+ const readable = useMemo(() => readDeck(deck), [deck]);
+ if (readable === null) {
+ return {text.unreadable}
;
+ }
+ return (
+
+ {readable.slides.map((slide, index) => (
+
+ ))}
+
+ );
+}
+
+/** The package's slides as print sheets, or the unreadable line when the deck is not a valid slide deck. */
+export function DeckPages({ deck }: { deck: Record }) {
+ const readable = useMemo(() => readDeck(deck), [deck]);
+ if (readable === null) {
+ return {text.unreadable}
;
+ }
+ return (
+
+ {readable.slides.map((slide, index) => (
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/pages/workspace/approval-rounds.test.tsx b/apps/web/src/pages/workspace/approval-rounds.test.tsx
index a1b30aff..7367506d 100644
--- a/apps/web/src/pages/workspace/approval-rounds.test.tsx
+++ b/apps/web/src/pages/workspace/approval-rounds.test.tsx
@@ -1,6 +1,7 @@
-import { describe, expect, mock, test } from "bun:test";
+import { beforeAll, describe, expect, mock, test } from "bun:test";
import React, { isValidElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
+import { prerender } from "react-dom/static";
import { ThemeProvider } from "@corbits/react-ui";
import { QueryClientProvider } from "@tanstack/react-query";
import type { ApprovalPackageBundle } from "@corbits-builder/schemas/artifact";
@@ -212,6 +213,7 @@ mock.module("../../api/artifacts.ts", () => ({
const { ApprovalRoundsPanel, SharePackageDialog } =
await import("./approval-rounds.tsx");
+const { SlidesView } = await import("./approval-package-preview.tsx");
const openRound: ApprovalRound = {
roundId: "rnd_1",
@@ -274,6 +276,17 @@ const sampleBundle: ApprovalPackageBundle = {
],
},
deck: {
+ theme: {
+ colors: {
+ primary: "#1e293b",
+ secondary: "#334155",
+ accent: "#2563eb",
+ background: "#ffffff",
+ text: "#0f172a",
+ muted: "#64748b",
+ },
+ fonts: { heading: "Inter", body: "Inter" },
+ },
slides: [
{
layout: "title",
@@ -606,6 +619,11 @@ function createStatefulHarness(renderRoot: () => ReactNode) {
}
describe("approval rounds panel", () => {
+ // Loads the lazily split slides once, as a browser would, so synchronous renders draw them.
+ beforeAll(async () => {
+ await prerender( );
+ });
+
test("renders open round with reviewer states, Add new reviewer button, per-person Download package buttons, inline feedback triage with Incorporate and Dismiss (no Reply), and disabled settle button while a reviewer is pending", () => {
const html = renderToStaticMarkup(
{
decisionNeeded: "Approve Q3 executive budget",
},
deck: {
+ ...sampleBundle.deck,
slides: [
{
layout: "title",
@@ -687,6 +706,7 @@ describe("approval rounds panel", () => {
decisionNeeded: "Approve technical architecture",
},
deck: {
+ ...sampleBundle.deck,
slides: [
{
layout: "title",
diff --git a/apps/web/src/pages/workspace/approval-rounds.tsx b/apps/web/src/pages/workspace/approval-rounds.tsx
index ee0376ae..f6e987ae 100644
--- a/apps/web/src/pages/workspace/approval-rounds.tsx
+++ b/apps/web/src/pages/workspace/approval-rounds.tsx
@@ -891,7 +891,7 @@ function PaneSlideDeckView({
{bundle === null ? (
- {wording.approvalPackage.noSlides}
+ {wording.approvalPackage.unreadable}
) : (
)}
@@ -902,7 +902,9 @@ function PaneSlideDeckView({
{bundle === null ? (
-
{wording.approvalPackage.noSlides}
+
+ {wording.approvalPackage.unreadable}
+
) : (
)}
diff --git a/apps/web/src/pages/workspace/artifact-pane.test.tsx b/apps/web/src/pages/workspace/artifact-pane.test.tsx
index 1b25798a..10c01f23 100644
--- a/apps/web/src/pages/workspace/artifact-pane.test.tsx
+++ b/apps/web/src/pages/workspace/artifact-pane.test.tsx
@@ -1,5 +1,6 @@
-import { describe, expect, test } from "bun:test";
+import { beforeAll, describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
+import { prerender } from "react-dom/static";
import type { ProjectArtifact } from "@corbits-builder/client";
import {
ArtifactPane,
@@ -8,7 +9,10 @@ import {
defaultTab,
resolveSelection,
} from "./artifact-pane.tsx";
-import { APPROVAL_PACKAGE_MEDIA_TYPE } from "./approval-package-preview.tsx";
+import {
+ APPROVAL_PACKAGE_MEDIA_TYPE,
+ SlidesView,
+} from "./approval-package-preview.tsx";
import type { DeliverableState } from "./deliverable-block.tsx";
import { writeWorkspaceApprovalsEnabled } from "../../workspace-approvals-setting.ts";
@@ -34,6 +38,7 @@ const document: DeliverableState = {
content: "Previous content",
filedAt: "2026-10-30T11:00:00.000Z",
},
+ versions: [],
},
};
function render(
@@ -55,6 +60,11 @@ function render(
}
describe("persistent artifact pane", () => {
+ // Loads the lazily split slides once, as a browser would, so synchronous renders draw them.
+ beforeAll(async () => {
+ await prerender(
);
+ });
+
test("exists before artifacts without modal or Expand controls", () => {
const html = render(null);
expect(html).toContain('aria-label="Artifacts"');
@@ -160,6 +170,17 @@ describe("persistent artifact pane", () => {
],
},
deck: {
+ theme: {
+ colors: {
+ primary: "#1e293b",
+ secondary: "#334155",
+ accent: "#2563eb",
+ background: "#ffffff",
+ text: "#0f172a",
+ muted: "#64748b",
+ },
+ fonts: { heading: "Inter", body: "Inter" },
+ },
slides: [
{
layout: "title",
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/conversation-parts.ts b/apps/web/src/pages/workspace/conversation-parts.ts
index 46b9537f..1d89e524 100644
--- a/apps/web/src/pages/workspace/conversation-parts.ts
+++ b/apps/web/src/pages/workspace/conversation-parts.ts
@@ -14,6 +14,7 @@ import type {
} from "@corbits/react-ui";
import {
applyRunStreamEvent,
+ partialOf,
type RunStreamEvent,
} from "../../api/run-stream.ts";
@@ -124,7 +125,7 @@ export function foldLiveTurn(
if (event.type === "inference.start" || event.type === "inference.error") {
return { turn: { text: folded.live, parts: [] }, refresh: folded.refresh };
}
- const partial = event.data?.partial;
+ const partial = partialOf(event);
const thinking =
typeof partial?.thinking === "string"
? partial.thinking
diff --git a/apps/web/src/pages/workspace/deliverable-block.tsx b/apps/web/src/pages/workspace/deliverable-block.tsx
index d23a0138..a0c02515 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);
@@ -262,6 +262,20 @@ function ReadyDocument({
return loaded.content;
};
+ // Reads a version the room has not loaded, then acts on it; a failed read is the block's error line.
+ const withVersion = async (version: number, use: () => void) => {
+ setPendingVersion(version);
+ setReadError(null);
+ try {
+ await fetchVersion(version);
+ use();
+ } catch (cause) {
+ setReadError(cause instanceof Error ? cause.message : String(cause));
+ } finally {
+ setPendingVersion(null);
+ }
+ };
+
const selectVersion = (version: number) => {
if (version === viewing) return;
onViewVersion?.();
@@ -270,13 +284,7 @@ function ReadyDocument({
setViewing(version);
return;
}
- setPendingVersion(version);
- void fetchVersion(version)
- .then(() => setViewing(version))
- .catch((cause: unknown) => {
- setReadError(cause instanceof Error ? cause.message : String(cause));
- })
- .finally(() => setPendingVersion(null));
+ void withVersion(version, () => setViewing(version));
};
const appliedRequestSeq = useRef
(null);
@@ -292,17 +300,10 @@ function ReadyDocument({
const comparePrevious = () => {
if (predecessor === null) return;
- setPendingVersion(predecessor);
- setReadError(null);
- void fetchVersion(predecessor)
- .then(() => {
- setBaseline(predecessor);
- if (!changesOpen) onToggleChanges();
- })
- .catch((cause: unknown) => {
- setReadError(cause instanceof Error ? cause.message : String(cause));
- })
- .finally(() => setPendingVersion(null));
+ void withVersion(predecessor, () => {
+ setBaseline(predecessor);
+ if (!changesOpen) onToggleChanges();
+ });
};
const stopComparing = () => {
@@ -381,7 +382,7 @@ function ReadyDocument({
const barGroups = [
approvalGroup,
versionGroup({
- versions: document.versions ?? [],
+ versions: document.versions,
viewing,
pending: pendingVersion !== null,
canCompare:
@@ -526,7 +527,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/design-preview.tsx b/apps/web/src/pages/workspace/design-preview.tsx
index 28c18b89..9567444e 100644
--- a/apps/web/src/pages/workspace/design-preview.tsx
+++ b/apps/web/src/pages/workspace/design-preview.tsx
@@ -16,6 +16,8 @@ import {
} from "react";
import { useQuery } from "@tanstack/react-query";
import { wording } from "../../config.ts";
+import { DesignBundle } from "@corbits-builder/schemas/artifact";
+import { readJson } from "../../json.ts";
import { Markdown } from "../../markdown.jsx";
import { keys } from "../../queries/keys.ts";
import { readProjectActivity } from "../../api/activity.ts";
@@ -87,14 +89,7 @@ const stateHash = (screen: string, state: string | null) =>
state === null ? `#/${screen}` : `#/${screen}/${state}`;
export function parseBundle(content: string): Record | null {
- try {
- const parsed = JSON.parse(content) as { files?: unknown };
- return typeof parsed.files === "object" && parsed.files !== null
- ? (parsed.files as Record)
- : null;
- } catch {
- return null;
- }
+ return readJson(content, DesignBundle)?.files ?? null;
}
const SCREEN_PREFIX = "designs/screens/";
diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx
index 09118dca..dd4b684c 100644
--- a/apps/web/src/pages/workspace/dispatch-card.tsx
+++ b/apps/web/src/pages/workspace/dispatch-card.tsx
@@ -64,7 +64,8 @@ function parsed(content: unknown): unknown {
if (typeof content !== "string") return content;
try {
return JSON.parse(content) as unknown;
- } catch {
+ } catch (cause) {
+ if (!(cause instanceof SyntaxError)) throw cause;
return content;
}
}
@@ -77,9 +78,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,
@@ -103,8 +105,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];
})
@@ -168,8 +171,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 | null>(null);
const [filed, setFiled] = useState([]);
@@ -27,7 +26,6 @@ export function useFiledVersions(input: {
}, [projectId]);
useEffect(() => {
- if (!ready) return;
const known = seen.current;
seen.current = new Map(documents.map((item) => [item.kind, item.version]));
if (known === null) return;
@@ -35,7 +33,7 @@ export function useFiledVersions(input: {
.filter((item) => item.version > (known.get(item.kind) ?? 0))
.map(({ kind, title, version }) => ({ kind, title, version }));
if (fresh.length > 0) setFiled((current) => [...current, ...fresh]);
- }, [ready, documents]);
+ }, [documents]);
return filed;
}
diff --git a/apps/web/src/pages/workspace/human-fault.test.ts b/apps/web/src/pages/workspace/human-fault.test.ts
new file mode 100644
index 00000000..a353b61c
--- /dev/null
+++ b/apps/web/src/pages/workspace/human-fault.test.ts
@@ -0,0 +1,22 @@
+import { expect, test } from "bun:test";
+import { ProductApiError } from "@corbits-builder/client";
+import { humanFault, humanFaultWithCode, responseCode } from "./human-fault.ts";
+
+test("a refusal whose body is not JSON has no code", async () => {
+ const page = new Response("Bad gateway", { status: 502 });
+
+ expect(await responseCode(new ProductApiError(page))).toBeUndefined();
+});
+
+test("a refusal whose body cannot be read still gets the caller's words", async () => {
+ const dropped = new ReadableStream({
+ start(controller) {
+ controller.error(new TypeError("connection reset"));
+ },
+ });
+ const refusal = new ProductApiError(new Response(dropped, { status: 409 }));
+
+ expect(await humanFaultWithCode(refusal, "Not sent.")).toBe(
+ humanFault(refusal, "Not sent."),
+ );
+});
diff --git a/apps/web/src/pages/workspace/human-fault.ts b/apps/web/src/pages/workspace/human-fault.ts
index 14e63df4..fca94bc1 100644
--- a/apps/web/src/pages/workspace/human-fault.ts
+++ b/apps/web/src/pages/workspace/human-fault.ts
@@ -5,6 +5,7 @@
* sentence, or the caller's own plain words when those words are already human.
*/
import { ProductApiError } from "@corbits-builder/client";
+import { refusalBody } from "../../api/transport.ts";
const STATUS_LINE =
/^(?:bad request|unauthorized|forbidden|not found|method not allowed|not acceptable|conflict|gone|unprocessable content|too many requests|internal server error|not implemented|bad gateway|service unavailable|gateway timeout)$/i;
@@ -62,10 +63,7 @@ export async function responseCode(
cause: unknown,
): Promise {
if (!(cause instanceof ProductApiError)) return undefined;
- const body: unknown = await cause.response
- .clone()
- .json()
- .catch(() => undefined);
+ const body = await refusalBody(cause);
return body !== null &&
typeof body === "object" &&
"code" in body &&
diff --git a/apps/web/src/pages/workspace/index.tsx b/apps/web/src/pages/workspace/index.tsx
index c0dcc804..05210787 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,
@@ -57,6 +65,7 @@ import type { InFlightRun } from "@corbits-builder/schemas/artifact";
import { toast } from "sonner";
import { sendMessage } from "../../api/conversation.ts";
import { subscribeWorkflowRun } from "../../api/run-stream.ts";
+import { refusalBody } from "../../api/transport.ts";
import {
acceptedMaterials,
attachMaterials,
@@ -122,6 +131,7 @@ import {
reloadProjectDocument,
roomDocumentKey,
roomSnapshotReady,
+ type RoomSnapshot,
} from "./load-room.ts";
import { RoomFault, RoomSkeleton } from "./room-skeleton.tsx";
import { designDraftFiled, useDesignDraft } from "./design-preview.tsx";
@@ -136,7 +146,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";
@@ -153,7 +167,26 @@ const BIND_POLL_MS = 1_000;
const BIND_WAIT_MS = 15_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[],
@@ -260,10 +293,7 @@ async function modelRefusal(
): Promise<{ code: string; message: string } | undefined> {
if (!(cause instanceof ProductApiError) || cause.response.status !== 409)
return undefined;
- const body: unknown = await cause.response
- .clone()
- .json()
- .catch(() => undefined);
+ const body = await refusalBody(cause);
return body !== null &&
typeof body === "object" &&
"code" in body &&
@@ -275,14 +305,9 @@ async function modelRefusal(
: undefined;
}
-export function StageWorkspace({
- projectId,
- projectTitle,
- onChanged,
- onOpenSettings,
- openingMessage,
- listedArchived,
-}: {
+type ModelNeeded = { code: string; message: string };
+
+type StageWorkspaceProps = {
/** The open project's id. */
projectId: string;
projectTitle: string;
@@ -294,11 +319,14 @@ export function StageWorkspace({
* artifact but the first conversation message, sent here once the
* coordinator binds. */
openingMessage?: string;
-}) {
- // The conversation record comes from the room query. Null events means no
- // coordinator is bound yet (the hub's 409). Empty arrays are not a stand-in
- // for a read that has not happened.
- const queryClient = useQueryClient();
+};
+
+/**
+ * Reads the project and its room, and starts its coordinator, while the room shows its loading
+ * or fault state; the ready room mounts only with a loaded snapshot.
+ */
+export function StageWorkspace(props: StageWorkspaceProps) {
+ const { projectId, listedArchived } = props;
// An archived project has no coordinator or conversation to read, so the room waits on its lifecycle.
const projectRead = useQuery({
queryKey: keys.project.of(projectId),
@@ -329,17 +357,117 @@ export function StageWorkspace({
refetchOnWindowFocus: false,
queryFn: ({ client }) => loadProjectRoom(projectId, client, !archived),
});
- const snapshot = status === undefined ? undefined : room.data;
- const events = snapshot?.conversation.events ?? null;
- const mirror = snapshot?.conversation.turns ?? [];
- const runId = archived ? null : (snapshot?.conversation.runId ?? null);
- // Each project is its own tenant beneath the workspace, and its runs live there.
- const runTenantId = project?.tenantId;
+ const [startFault, setStartFault] = useState(null);
+ // Why the coordinator has no model to run on, which only the person can settle.
+ const [modelNeeded, setModelNeeded] = useState(null);
+ // Only a missing provider is settled by connecting one; a profile choice needs Settings.
+ const providerMissing = modelNeeded?.code === "model_provider_missing";
+ const startCoordinator = async () => {
+ setStartFault(null);
+ setModelNeeded(null);
+ try {
+ await startProjectCoordinator(projectId);
+ await room.refetch();
+ } catch (cause) {
+ const needed = await modelRefusal(cause);
+ if (needed !== undefined) return setModelNeeded(needed);
+ setStartFault(
+ await humanFaultWithCode(cause, wording.message.coordinatorNotStarted),
+ );
+ }
+ };
+ // Opening an active project starts its coordinator when it is not running; a running one is
+ // left alone. A project archived while open drops the conversation it can no longer read.
+ useEffect(() => {
+ if (status === "active") void startCoordinator();
+ else if (status === "archived" && room.data !== undefined)
+ void room.refetch();
+ }, [projectId, status]);
+ const catalog = useModelCatalog();
+ const providerConnected =
+ catalog.data !== undefined && !needsModelSetup(catalog.data.providers);
+ // However the provider gets connected (Enable AI, Settings, another tab, the server), the
+ // shared catalog read sees it and the refused start is tried again. Only on that change: a hub
+ // that still refuses would otherwise retry on every refusal.
+ 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)
+ return void projectRead.refetch()} />;
+ return ;
+ }
+ const snapshot = room.data;
+ if (!roomSnapshotReady(projectId, snapshot)) {
+ if (room.isError) return void room.refetch()} />;
+ return ;
+ }
+ return (
+
+ );
+}
+
+/** The room with its first read in hand: everything here works on a loaded snapshot. */
+function ReadyRoom({
+ projectId,
+ projectTitle,
+ onChanged,
+ onOpenSettings,
+ openingMessage,
+ status,
+ runTenantId,
+ snapshot,
+ start,
+ refetchProject,
+}: StageWorkspaceProps & {
+ status: string;
+ runTenantId: string | undefined;
+ /** Null events means no coordinator is bound yet (the hub's 409). */
+ snapshot: RoomSnapshot;
+ start: {
+ fault: string | null;
+ modelNeeded: ModelNeeded | null;
+ providerMissing: boolean;
+ retry: () => Promise;
+ };
+ refetchProject: () => Promise;
+}) {
+ const queryClient = useQueryClient();
+ const archived = status === "archived";
+ const startFault = start.fault;
+ const modelNeeded = start.modelNeeded;
+ const startCoordinator = start.retry;
+ const providerMissing = start.providerMissing;
+ const events = snapshot.conversation.events;
+ const mirror = snapshot.conversation.turns;
+ const runId = archived ? null : snapshot.conversation.runId;
// The coder's filed app source is the resume point for a later coder, not a document to read.
- const artifacts = (snapshot?.artifacts ?? [])
+ const artifacts = snapshot.artifacts
.filter((item) => item.kind !== "app")
.map(paneArtifact);
- const documents = snapshot?.documents ?? {};
+ const documents = snapshot.documents;
const [liveRun, setLiveRun] = useState(emptyLiveRun);
const questionsLayout = useQuestionsLayout();
const [approvalBusy, setApprovalBusy] = useState(false);
@@ -379,21 +507,8 @@ export function StageWorkspace({
const [sendFault, setSendFault] = useState(null);
// When this session last sent the coordinator a message, until a run ends after it or it fails.
const [awaitingSince, setAwaitingSince] = useState(null);
- const [startFault, setStartFault] = useState(null);
- // Why the coordinator has no model to run on, which only the person can settle.
- const [modelNeeded, setModelNeeded] = useState<{
- code: string;
- message: string;
- } | null>(null);
- // Only a missing provider is settled by connecting one; a profile choice needs Settings.
- const providerMissing = modelNeeded?.code === "model_provider_missing";
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);
@@ -414,28 +529,31 @@ export function StageWorkspace({
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.isError) return;
+ toast.error(humanFault(activity.error, wording.activity.notRead), {
+ id: "activity-read",
+ });
+ }, [activity.isError]);
const reloadRef = useRef<() => void>(() => {});
useEffect(() => {
- if (status === undefined) return;
const reloader = createRoomReloader(() =>
loadProjectRoom(projectId, queryClient, status === "active"),
);
@@ -568,14 +686,15 @@ export function StageWorkspace({
// The steps map only grows as the live run commits, which re-renders with a new mirror.
[mirror, sent],
);
- const mailThreads = snapshot?.conversation.threads;
+ const mailThreads = snapshot.conversation.threads;
const specialistUpdates = useMemo(
- () => threadUpdates(mailThreads ?? []),
+ () => threadUpdates(mailThreads),
[mailThreads],
);
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,
@@ -589,16 +708,19 @@ export function StageWorkspace({
(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
@@ -620,8 +742,8 @@ export function StageWorkspace({
: [];
// Mid-turn by native state, whoever sent the message: this stream saw the message run start and
// not end, or the hub's activity read saw one start after the last end this stream saw.
- const agents = useProjectActivity(projectId).data?.agents ?? [];
- const coordinatorTurnSince = agents.find(
+ const agents = activity.data?.agents;
+ const coordinatorTurnSince = agents?.find(
(agent) => agent.role === "coordinator",
)?.since;
const working =
@@ -639,15 +761,17 @@ export function StageWorkspace({
return turn.role === "person" ? turnItemId(turn, index) : found;
}, undefined);
const [retryingKey, setRetryingKey] = useState(null);
- const retryDocument = (item: ProjectArtifact) => {
+ const retryDocument = async (item: ProjectArtifact) => {
const key = documentKey(projectId, item);
if (documents[key]?.status !== "error") return;
setRetryingKey(key);
- void reloadProjectDocument(projectId, key, queryClient, !archived).finally(
- () => {
- setRetryingKey((current) => (current === key ? null : current));
- },
- );
+ try {
+ await reloadProjectDocument(projectId, key, queryClient, !archived);
+ } catch (cause) {
+ toast.error(humanFault(cause, wording.documentRetryFailed));
+ } finally {
+ setRetryingKey((current) => (current === key ? null : current));
+ }
};
const documentState = (
item: ProjectArtifact,
@@ -788,17 +912,19 @@ export function StageWorkspace({
// 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: unknown) {
- void cause;
+ } catch (cause) {
+ // The cached list is still the best answer; the send that follows reports a hub that is down.
+ console.warn("Open decisions could not be re-read.", cause);
return pendingApprovals;
}
};
@@ -841,13 +967,12 @@ export function StageWorkspace({
message?: string;
}): Promise<"saved" | "settled" | { fault: string }> => {
const notSaved = { fault: "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 (
@@ -860,10 +985,12 @@ export function StageWorkspace({
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,
@@ -932,7 +1059,7 @@ export function StageWorkspace({
at: new Date().toISOString(),
};
};
- 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.
@@ -941,14 +1068,14 @@ export function StageWorkspace({
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),
@@ -966,7 +1093,7 @@ export function StageWorkspace({
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({
@@ -980,7 +1107,7 @@ export function StageWorkspace({
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({
@@ -999,7 +1126,7 @@ export function StageWorkspace({
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),
@@ -1011,7 +1138,11 @@ export function StageWorkspace({
if (files.length > 0) return void sendFiles();
if (shell.answers(value)) return setValue("");
if (typedYes.hold(value)) return setValue("");
- const role = earlierDocumentRole(value, artifacts, rolesAtWork);
+ // Without a read of who is at work there is no one to warn about.
+ const role =
+ rolesAtWork === undefined
+ ? undefined
+ : earlierDocumentRole(value, artifacts, rolesAtWork);
if (role === undefined || caution === role) return sendNow();
setCaution(role);
};
@@ -1021,7 +1152,7 @@ export function StageWorkspace({
setMobilePane("conversation");
setChangesAsked(true);
};
- const unbound = snapshot !== undefined && events === null;
+ const unbound = events === null;
const composerDisabled =
sending || approvalBusy || providerMissing || unbound;
useEffect(() => {
@@ -1034,27 +1165,6 @@ export function StageWorkspace({
// stays and reads as the coordinator at work, not as a project starting from nothing.
const between =
unbound && mirror.length > 0 && startFault === null && modelNeeded === null;
- const startCoordinator = async () => {
- setStartFault(null);
- setModelNeeded(null);
- try {
- await startProjectCoordinator(projectId);
- await room.refetch();
- } catch (cause) {
- const needed = await modelRefusal(cause);
- if (needed !== undefined) return setModelNeeded(needed);
- setStartFault(
- await humanFaultWithCode(cause, wording.message.coordinatorNotStarted),
- );
- }
- };
- // Opening an active project starts its coordinator when it is not running; a running one is
- // left alone. A project archived while open drops the conversation it can no longer read.
- useEffect(() => {
- if (status === "active") void startCoordinator();
- else if (status === "archived" && room.data !== undefined)
- void room.refetch();
- }, [projectId, status]);
const awaitingBind =
status === "active" &&
unbound &&
@@ -1069,15 +1179,6 @@ export function StageWorkspace({
clearTimeout(fallback);
};
}, [awaitingBind]);
- const catalog = useModelCatalog();
- const providerConnected =
- catalog.data !== undefined && !needsModelSetup(catalog.data.providers);
- // However the provider gets connected (Enable AI, Settings, another tab, the server), the
- // shared catalog read sees it and the refused start is tried again. Only on that change: a hub
- // that still refuses would otherwise retry on every refusal.
- useEffect(() => {
- if (providerConnected && providerMissing) void startCoordinator();
- }, [providerConnected]);
const [restoring, setRestoring] = useState(false);
const [restoreFault, setRestoreFault] = useState(null);
const restore = async () => {
@@ -1085,7 +1186,7 @@ export function StageWorkspace({
setRestoreFault(null);
try {
await restoreProject(projectId);
- await projectRead.refetch();
+ await refetchProject();
onChanged();
} catch (cause) {
setRestoreFault(
@@ -1095,14 +1196,11 @@ export function StageWorkspace({
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;
@@ -1120,16 +1218,16 @@ export function StageWorkspace({
projectId,
designDraftFiled(
artifacts.some((item) => item.kind === "design"),
- snapshot?.inFlight,
+ snapshot.inFlight,
),
);
- const inFlight = snapshot?.inFlight ?? [];
+ const inFlight = snapshot.inFlight;
const dispatchRoom: DispatchRoom = {
- artifacts: snapshot?.artifacts ?? [],
+ artifacts: snapshot.artifacts,
inFlight,
- failed: snapshot?.failed ?? [],
+ failed: snapshot.failed,
seen: dispatchSeen.current,
- approvals: [...settledApprovals, ...pendingApprovals],
+ approvals: shownApprovals,
activity: agents,
onOpenDocument: (kind) =>
openDocument(
@@ -1144,16 +1242,19 @@ export function StageWorkspace({
for (const dispatch of dispatches) noteDispatch(dispatchRoom, dispatch);
// The document a writer or reviewer run works on, from the coordinator's own hand-off call as
// the hub's activity read saw it start: the latest such call made before the run started.
- for (const tool of agents.find((agent) => agent.role === "coordinator")
- ?.tools ?? []) {
- const role = HANDOFF_ROLE[tool.name];
- if (role !== undefined && tool.subject !== undefined)
- handoffCalls.current.set(`${tool.at}:${tool.name}`, {
- role,
- kind: tool.subject,
- at: Date.parse(tool.at),
- });
- }
+ const coordinatorTools = agents?.find(
+ (agent) => agent.role === "coordinator",
+ )?.tools;
+ if (coordinatorTools !== undefined)
+ for (const tool of coordinatorTools) {
+ const role = HANDOFF_ROLE[tool.name];
+ if (role !== undefined && tool.subject !== undefined)
+ handoffCalls.current.set(`${tool.at}:${tool.name}`, {
+ role,
+ kind: tool.subject,
+ at: Date.parse(tool.at),
+ });
+ }
const handoffKind = (run: InFlightRun): string | undefined => {
const known = runKinds.current.get(run.runId);
if (known !== undefined) return known;
@@ -1195,8 +1296,9 @@ export function StageWorkspace({
: designConcepts(
options,
inFlight,
- [...settledApprovals, ...pendingApprovals].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);
@@ -1241,10 +1343,7 @@ export function StageWorkspace({
) ||
options.length > 0 ||
inFlight.some((run) => run.concept !== undefined);
- useLiveAppPresence(projectId);
- const appFiled = (snapshot?.artifacts ?? []).some(
- (item) => item.kind === "app",
- );
+ const appFiled = snapshot.artifacts.some((item) => item.kind === "app");
const firstBuilder = appFiled
? undefined
: inFlight.find((run) => run.role === "coder");
@@ -1255,15 +1354,14 @@ export function StageWorkspace({
const userManual = useUserManual(
projectId,
liveApp !== null,
- rolesAtWork.has("coder"),
+ rolesAtWork?.has("coder") === true,
);
// 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,
- ready: roomSnapshotReady(projectId, snapshot),
documents: artifacts.map((item) =>
item.kind === DESIGN_OPTION
? {
@@ -1296,13 +1394,6 @@ export function StageWorkspace({
paneDeliverables.find((item) => item.kind === shownId) ?? null;
const selectedState = selected === null ? undefined : documentState(selected);
- if (projectRead.isError && status === undefined)
- return void projectRead.refetch()} />;
- if (!roomSnapshotReady(projectId, snapshot)) {
- if (room.isError) return void room.refetch()} />;
- return ;
- }
-
// Until the first document is filed there is nothing to read beside the conversation, so it
// takes the whole width; the document pane arrives with that document.
const chatFirst =
@@ -1623,7 +1714,7 @@ export function StageWorkspace({
}))}
onOpenDocument={openDocument}
shownDocument={chatFirst ? null : (selected?.kind ?? null)}
- approvals={[...settledApprovals, ...pendingApprovals]}
+ approvals={shownApprovals}
results={liveRun.results}
runs={unseenRuns}
{...(shownChoice === undefined || archived
@@ -1691,7 +1782,7 @@ export function StageWorkspace({
selectedState.retryable
? {
onRetry: () => {
- if (selected) retryDocument(selected);
+ if (selected) void retryDocument(selected);
},
}
: {})}
diff --git a/apps/web/src/pages/workspace/load-room.ts b/apps/web/src/pages/workspace/load-room.ts
index 9dbf90cc..1f0e7fad 100644
--- a/apps/web/src/pages/workspace/load-room.ts
+++ b/apps/web/src/pages/workspace/load-room.ts
@@ -269,40 +269,37 @@ export async function loadProjectRoom(
);
for (const key of settled.keys()) {
if (generation.get(projectId) !== ticket) break;
- try {
- const owner = JSON.parse(key) as unknown;
- if (Array.isArray(owner) && owner[0] === projectId && !live.has(key))
- settled.delete(key);
- } catch {
- // A key this loader did not write. Leave it.
- }
+ // Every key is a `roomDocumentKey`, written by this loader or restored from the saved cache
+ // it persisted, so it always parses.
+ const owner: unknown = JSON.parse(key);
+ if (Array.isArray(owner) && owner[0] === projectId && !live.has(key))
+ settled.delete(key);
}
+ const settleDocument = async (artifact: ProjectArtifact, key: string) => {
+ let state: DeliverableState;
+ try {
+ state = {
+ status: "ready",
+ document: await loadDeliverable(projectId, artifact),
+ };
+ } catch (cause) {
+ const failure = documentReadFailure(cause);
+ state = {
+ status: "error",
+ message: failure.message,
+ retryable: failure.retryable,
+ };
+ }
+ if (stale()) return;
+ settled.set(key, state);
+ };
await Promise.all(
fresh.artifacts.flatMap((artifact) => {
const key = roomDocumentKey(projectId, artifact);
const have = settled.get(key);
if (have !== undefined && have.status !== "loading") return [];
- return [
- (async () => {
- let state: DeliverableState;
- try {
- state = {
- status: "ready",
- document: await loadDeliverable(projectId, artifact),
- };
- } catch (cause) {
- const failure = documentReadFailure(cause);
- state = {
- status: "error",
- message: failure.message,
- retryable: failure.retryable,
- };
- }
- if (generation.get(projectId) !== ticket) return;
- settled.set(key, state);
- })(),
- ];
+ return [settleDocument(artifact, key)];
}),
);
@@ -319,7 +316,7 @@ export async function loadProjectRoom(
}
/** Reads one document again. The last real record stays until the new read finishes. */
-export function reloadProjectDocument(
+export async function reloadProjectDocument(
projectId: string,
docKey: string,
client: QueryClient,
@@ -327,7 +324,9 @@ export function reloadProjectDocument(
): Promise {
settled.delete(docKey);
refetchKeys.add(docKey);
- return loadProjectRoom(projectId, client, withConversation).finally(() => {
+ try {
+ return await loadProjectRoom(projectId, client, withConversation);
+ } finally {
refetchKeys.delete(docKey);
- });
+ }
}
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/pane-file.tsx b/apps/web/src/pages/workspace/pane-file.tsx
index f7d0ca1a..11b4b164 100644
--- a/apps/web/src/pages/workspace/pane-file.tsx
+++ b/apps/web/src/pages/workspace/pane-file.tsx
@@ -69,11 +69,23 @@ const canShare = (type: string) =>
files: [new File([], `file.${EXTENSION[type] ?? "bin"}`, { type })],
}) === true;
-function share(file: File) {
- navigator.share({ files: [file] }).catch((cause: unknown) => {
+async function share(file: File) {
+ try {
+ await navigator.share({ files: [file] });
+ } catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError"))
failed(cause);
- });
+ }
+}
+
+// Only the build is caught: a failure in what the press then does with the file is not a failed build.
+async function builtOrReported(prepared: Prepared): Promise {
+ try {
+ return await prepared.built;
+ } catch (cause) {
+ failed(cause);
+ return null;
+ }
}
type Built = { file: File; note?: string; stale?: boolean };
@@ -118,17 +130,35 @@ export function useFileGroup(source: PaneFile | null): PaneBarGroup {
at: Date.now(),
built: file.build(),
};
- next.built.then(
- (built) => {
- next.ready = built;
- },
- () => {
- if (prepared.current === next) prepared.current = null;
- },
- );
+ void keepOrForget(next);
prepared.current = next;
return next;
};
+ // A failed build is reported by the press that waits on it; this only lets the next press build again.
+ const keepOrForget = async (held: Prepared) => {
+ const [outcome] = await Promise.allSettled([held.built]);
+ if (outcome.status === "fulfilled") held.ready = outcome.value;
+ else if (prepared.current === held) prepared.current = null;
+ };
+ const download = async (file: PaneFile) => {
+ const built = await builtOrReported(prepare(file));
+ if (built === null) return;
+ save(built.file);
+ (built.stale === true ? toast.warning : toast.success)(
+ built.note ?? wording.paneFile.downloaded(built.file.name),
+ );
+ };
+ // The build outlived the press, so sharing needs a press of its own.
+ const offerShare = async (held: Prepared) => {
+ const built = await builtOrReported(held);
+ if (built === null) return;
+ toast(wording.paneFile.shareReady(built.file.name), {
+ action: {
+ label: wording.paneFile.shareNow,
+ onClick: () => void share(built.file),
+ },
+ });
+ };
useEffect(() => {
if (source?.prepare === "shown") prepare(source);
});
@@ -138,14 +168,7 @@ export function useFileGroup(source: PaneFile | null): PaneBarGroup {
id: "download",
label: source.download,
icon: ,
- onSelect: () => {
- prepare(source).built.then(({ file, note, stale }) => {
- save(file);
- (stale === true ? toast.warning : toast.success)(
- note ?? wording.paneFile.downloaded(file.name),
- );
- }, failed);
- },
+ onSelect: () => void download(source),
},
...(canShare(source.type)
? [
@@ -157,20 +180,9 @@ export function useFileGroup(source: PaneFile | null): PaneBarGroup {
prepare(source);
},
onSelect: () => {
- const { ready, built } = prepare(source);
- if (ready !== undefined) {
- share(ready.file);
- return;
- }
- // The build outlived the press, so sharing needs a press of its own.
- built.then(({ file }) => {
- toast(wording.paneFile.shareReady(file.name), {
- action: {
- label: wording.paneFile.shareNow,
- onClick: () => share(file),
- },
- });
- }, failed);
+ const held = prepare(source);
+ if (held.ready === undefined) void offerShare(held);
+ else void share(held.ready.file);
},
},
]
diff --git a/apps/web/src/pages/workspace/ready-room.test.tsx b/apps/web/src/pages/workspace/ready-room.test.tsx
new file mode 100644
index 00000000..ede9172d
--- /dev/null
+++ b/apps/web/src/pages/workspace/ready-room.test.tsx
@@ -0,0 +1,84 @@
+import { describe, expect, test } from "bun:test";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { wording } from "../../config.ts";
+import { keys } from "../../queries/keys.ts";
+import { useRolesAtWork } from "./agent-activity.tsx";
+import { StageWorkspace } from "./index.tsx";
+import type { RoomSnapshot } from "./load-room.ts";
+
+const projectId = "project-1";
+
+function client(): QueryClient {
+ return new QueryClient({ defaultOptions: { queries: { retry: false } } });
+}
+
+function room(queryClient: QueryClient): string {
+ return renderToStaticMarkup(
+
+ {}}
+ onOpenSettings={() => {}}
+ />
+ ,
+ );
+}
+
+const emptyRoom: RoomSnapshot = {
+ conversation: { events: null, turns: [], threads: [], runId: null },
+ artifacts: [],
+ inFlight: [],
+ failed: [],
+ documents: {},
+};
+
+describe("project room", () => {
+ 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(wording.message.projectLoading);
+ expect(read).toContain(wording.archivedProject.conversationTitle);
+ });
+
+ test("a failed activity read is not nobody at work", async () => {
+ function Roles() {
+ const roles = useRolesAtWork(projectId);
+ return (
+
+ {roles === undefined ? "unread" : `at work: ${[...roles].join(",")}`}
+
+ );
+ }
+ const render = (queryClient: QueryClient) =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+ const failed = client();
+ await failed.prefetchQuery({
+ queryKey: keys.projectActivity.of(projectId),
+ queryFn: () => Promise.reject(new Error("The hub is down.")),
+ });
+ expect(render(failed)).toContain("unread");
+
+ const idle = client();
+ idle.setQueryData(keys.projectActivity.of(projectId), { agents: [] });
+ expect(render(idle)).toContain("at work: ");
+ });
+});
diff --git a/apps/web/src/pages/workspace/room-reload.ts b/apps/web/src/pages/workspace/room-reload.ts
index c210b831..86a29dff 100644
--- a/apps/web/src/pages/workspace/room-reload.ts
+++ b/apps/web/src/pages/workspace/room-reload.ts
@@ -13,8 +13,8 @@ export type RoomReloadDeps = {
const defaultDeps: RoomReloadDeps = {
now: () => Date.now(),
- setTimeout: (callback, ms) => setTimeout(callback, ms) as unknown as number,
- clearTimeout: (handle) => clearTimeout(handle),
+ setTimeout: (callback, ms) => window.setTimeout(callback, ms),
+ clearTimeout: (handle) => window.clearTimeout(handle),
};
export function createRoomReloader(
@@ -26,9 +26,18 @@ export function createRoomReloader(
let timer: number | null = null;
let disposed = false;
+ const read = async () => {
+ try {
+ await load();
+ } catch (cause) {
+ // `loadProjectRoom` keeps the last room on a failed read, and with none it fails the room
+ // read itself, which shows the fault. A background read has nothing more to show.
+ console.warn("The room could not be reloaded.", cause);
+ }
+ };
const fire = () => {
last = deps.now();
- void load().catch(() => undefined);
+ void read();
};
return {
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/seen-versions.ts b/apps/web/src/pages/workspace/seen-versions.ts
index 83b2581e..673150c1 100644
--- a/apps/web/src/pages/workspace/seen-versions.ts
+++ b/apps/web/src/pages/workspace/seen-versions.ts
@@ -1,5 +1,7 @@
/** The version of each document this viewer last opened, kept in this browser. */
import { useMemo } from "react";
+import { type } from "arktype";
+import { readJson } from "../../json.ts";
import {
useStoredPreference,
writeStoredPreference,
@@ -9,17 +11,12 @@ const SEEN_KEY = "builder-seen-document-versions";
export type SeenVersions = Readonly>;
+const Seen = type({ "[string]": "number" });
+// Nothing stored, or nothing readable, means no version has been opened yet.
+const NONE_SEEN: SeenVersions = {};
+
function parseSeen(raw: string): SeenVersions {
- try {
- const parsed: unknown = JSON.parse(raw);
- return typeof parsed === "object" &&
- parsed !== null &&
- !Array.isArray(parsed)
- ? (parsed as SeenVersions)
- : {};
- } catch {
- return {};
- }
+ return readJson(raw, Seen) ?? NONE_SEEN;
}
export function useSeenVersions(): SeenVersions {
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/specialist-updates.test.ts b/apps/web/src/pages/workspace/specialist-updates.test.ts
new file mode 100644
index 00000000..0de39d84
--- /dev/null
+++ b/apps/web/src/pages/workspace/specialist-updates.test.ts
@@ -0,0 +1,11 @@
+import { expect, test } from "bun:test";
+import { specialistUpdate } from "./specialist-updates.tsx";
+
+test("mail is read for its summary, and JSON that is not an envelope reads as text", () => {
+ const summary = JSON.stringify({ result: { summary: "Brief drafted." } });
+ expect(specialistUpdate("writer", summary, 1)?.text).toBe("Brief drafted.");
+ expect(specialistUpdate("writer", "null", 1)?.text).toBe("null");
+ expect(
+ specialistUpdate("writer", JSON.stringify({ result: { summary: 3 } }), 1),
+ ).toBeNull();
+});
diff --git a/apps/web/src/pages/workspace/specialist-updates.tsx b/apps/web/src/pages/workspace/specialist-updates.tsx
index e148a502..a7531d0b 100644
--- a/apps/web/src/pages/workspace/specialist-updates.tsx
+++ b/apps/web/src/pages/workspace/specialist-updates.tsx
@@ -18,6 +18,8 @@ import {
import { Markdown } from "../../markdown.jsx";
import type { ConversationThread } from "../../api/conversation.ts";
import { participantLabel, visibleChatText } from "./visible-chat.ts";
+import { type } from "arktype";
+import { readJson } from "../../json.ts";
export type SpecialistUpdate = {
readonly sender: string;
@@ -31,16 +33,16 @@ export type UpdateGroup = {
readonly updates: readonly SpecialistUpdate[];
};
+const MailEnvelope = type("object");
+const MailResult = type({ result: { summary: "string" } });
+
// Mail between agents is a JSON envelope; its prose field is what a person can read.
// A task envelope is the hand-off, already shown as that call in the coordinator's turn.
function mailProse(text: string): string {
- try {
- const value = JSON.parse(text) as { result?: { summary?: unknown } };
- if (typeof value.result?.summary === "string") return value.result.summary;
- } catch {
- return text;
- }
- return "";
+ const envelope = readJson(text, MailEnvelope);
+ if (envelope === null) return text;
+ const result = MailResult(envelope);
+ return result instanceof type.errors ? "" : result.result.summary;
}
/** One specialist turn as an update, or null when nothing in it is for a person. */
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(
{
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch (cause) {
+ if (!(cause instanceof DOMException)) throw cause;
+ toast.error(wording.message.copyFailed);
+ return;
+ }
+ setCopied(true);
+ setTimeout(() => setCopied(false), COPIED_MS);
+ };
return (
{
- navigator.clipboard.writeText(text).then(
- () => {
- setCopied(true);
- setTimeout(() => setCopied(false), COPIED_MS);
- },
- () => toast.error(wording.message.copyFailed),
- );
- }}
+ onClick={() => void copy()}
>
{copied ? : }
diff --git a/apps/web/src/pages/workspace/typed-yes.tsx b/apps/web/src/pages/workspace/typed-yes.tsx
index fc95220c..2d450517 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 ||
diff --git a/apps/web/src/pages/workspace/visible-chat.ts b/apps/web/src/pages/workspace/visible-chat.ts
index 8f0df425..f1980be1 100644
--- a/apps/web/src/pages/workspace/visible-chat.ts
+++ b/apps/web/src/pages/workspace/visible-chat.ts
@@ -159,8 +159,9 @@ function stripJson(text: string): string {
index = end - 1;
continue;
}
- } catch {
+ } catch (cause) {
// Not a payload. Keep the brace and keep scanning.
+ if (!(cause instanceof SyntaxError)) throw cause;
}
out += char;
}
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}
diff --git a/apps/web/src/queries/client.ts b/apps/web/src/queries/client.ts
index 818e83b1..1e34cb82 100644
--- a/apps/web/src/queries/client.ts
+++ b/apps/web/src/queries/client.ts
@@ -36,20 +36,27 @@ const persistOptions: PersistQueryClientOptions = {
storage: { getItem: get, setItem: set, removeItem: del },
}),
maxAge: CACHE_LIFETIME_MS,
- // A query that still has data is kept after a failed refetch.
+ // A query that still has data is kept after a failed refetch. One marked
+ // `meta: { persist: false }` only matters while this page waits on it.
dehydrateOptions: {
- shouldDehydrateQuery: (query) => query.state.data !== undefined,
+ shouldDehydrateQuery: (query) =>
+ query.state.data !== undefined && query.meta?.["persist"] !== false,
},
};
/**
- * Resolves after the saved cache is on the client, before the first paint. An
- * unreadable cache leaves the client empty and the page loads from the network.
+ * Puts the saved cache on the client and saves it from then on. The cache only
+ * spares the first reads, so a restore that fails for any reason leaves the
+ * client empty and the page loads from the network.
*/
-export const queryCacheReady: Promise = persistQueryClientRestore(
- persistOptions,
-)
- .catch(() => undefined)
- .then(() => {
- persistQueryClientSubscribe(persistOptions);
- });
+async function restoreQueryCache(): Promise {
+ try {
+ await persistQueryClientRestore(persistOptions);
+ } catch (cause) {
+ console.warn("The saved query cache could not be restored.", cause);
+ }
+ persistQueryClientSubscribe(persistOptions);
+}
+
+/** Resolves after the saved cache is on the client, before the first paint. */
+export const queryCacheReady: Promise = restoreQueryCache();
diff --git a/apps/web/src/queries/keys.ts b/apps/web/src/queries/keys.ts
index 9006ef26..5f961a5e 100644
--- a/apps/web/src/queries/keys.ts
+++ b/apps/web/src/queries/keys.ts
@@ -76,6 +76,8 @@ export const keys = {
of: (projectId: string) => ["project", projectId] as const,
},
modelCatalog: ["modelCatalog"] as const,
+ /** One subscription login the hub is waiting on. */
+ modelLogin: (loginId: string) => ["modelLogin", loginId] as const,
/** The person's own model choice. */
modelChoice: ["modelChoice"] as const,
/** The person's own working preferences. */
diff --git a/apps/web/src/single-flight.ts b/apps/web/src/single-flight.ts
index 46861deb..1d92c323 100644
--- a/apps/web/src/single-flight.ts
+++ b/apps/web/src/single-flight.ts
@@ -6,12 +6,15 @@
/** Wraps an async function so concurrent calls share the one in-flight promise. */
export function singleFlight(run: () => Promise): () => Promise {
let pending: Promise | null = null;
- return () => {
- if (pending) return pending;
- const call = run().finally(() => {
+ const settle = async (call: Promise) => {
+ try {
+ return await call;
+ } finally {
pending = null;
- });
- pending = call;
- return call;
+ }
+ };
+ return () => {
+ pending ??= settle(run());
+ return pending;
};
}
diff --git a/bun.lock b/bun.lock
index b49d7df0..0c3bd480 100644
--- a/bun.lock
+++ b/bun.lock
@@ -124,6 +124,7 @@
"@corbits-builder/client": "workspace:*",
"@corbits-builder/schemas": "workspace:*",
"@corbits/react-ui": "0.5.1",
+ "@corbits/slidedeck": "workspace:*",
"@radix-ui/react-dialog": "1.3.1",
"@radix-ui/react-dropdown-menu": "2.2.1",
"@radix-ui/react-slot": "1.5.0",
diff --git a/packages/client/src/client/credentials.ts b/packages/client/src/client/credentials.ts
index 57316c42..02880102 100644
--- a/packages/client/src/client/credentials.ts
+++ b/packages/client/src/client/credentials.ts
@@ -1,3 +1,4 @@
+import type { ModelLoginPlugin } from "@corbits-builder/schemas/models";
import { tenantPath, type ClientDeps, type ProductApiClient } from "./shared";
/**
@@ -62,7 +63,13 @@ export type ModelLoginState = Readonly<
| { status: "completed"; credentialId: string }
| { status: "failed"; message: string }
>;
-export type ModelLoginPlugin = "codex" | "xai";
+export type { ModelLoginPlugin };
+
+// CODEX_BASE_URL and XAI_OAUTH_PROXY_BASE_URL: the only endpoints these logins' tokens work on.
+const LOGIN_BASE_URLS: Record = {
+ codex: "https://chatgpt.com/backend-api",
+ xai: "https://cli-chat-proxy.grok.com/v1",
+};
const DEFAULT_BASE_URLS: Record = {
openai: "https://api.openai.com/v1",
@@ -70,9 +77,7 @@ const DEFAULT_BASE_URLS: Record = {
anthropic: "https://api.anthropic.com/v1",
"google-genai": "https://generativelanguage.googleapis.com/v1beta",
"openai-compatible": "",
- // CODEX_BASE_URL and XAI_OAUTH_PROXY_BASE_URL: the only endpoints these logins' tokens work on.
- codex: "https://chatgpt.com/backend-api",
- xai: "https://cli-chat-proxy.grok.com/v1",
+ ...LOGIN_BASE_URLS,
};
function uniqueSuffix(): string {
@@ -204,7 +209,7 @@ export function buildCredentialsMethods(
// tenant-owned credential. A new credential name per sign-in, because a tenant-owned login
// never replaces an existing credential.
async startModelLogin(tenantId, plugin, name, signal) {
- const baseURL = DEFAULT_BASE_URLS[plugin]!;
+ const baseURL = LOGIN_BASE_URLS[plugin];
const nativeProvider = await ensureNativeProvider(
tenantId,
plugin,
@@ -250,7 +255,7 @@ export function buildCredentialsMethods(
const created = (await request(`${root}/catalog/providers`, signal, {
name: `${name} ${uniqueSuffix()}`,
plugin,
- baseURL: DEFAULT_BASE_URLS[plugin],
+ baseURL: LOGIN_BASE_URLS[plugin],
credentialId,
})) as { id?: unknown };
if (typeof created?.id !== "string")
diff --git a/packages/schemas/src/models.ts b/packages/schemas/src/models.ts
index a69cafb4..784b6ea6 100644
--- a/packages/schemas/src/models.ts
+++ b/packages/schemas/src/models.ts
@@ -42,6 +42,10 @@ export const CustomModel = type({
);
export type CustomModel = typeof CustomModel.infer;
+// The subscriptions a person signs in to through the hub instead of pasting a key.
+export const ModelLoginPlugin = type("'codex' | 'xai'");
+export type ModelLoginPlugin = typeof ModelLoginPlugin.infer;
+
// The wire API a custom provider speaks, named by the native adapter plugin that serves it.
export const CustomApi = type(
"'openai-compatible' | 'openai-responses' | 'anthropic'",
diff --git a/packages/slidedeck/package.json b/packages/slidedeck/package.json
index 25be8b68..15a61897 100644
--- a/packages/slidedeck/package.json
+++ b/packages/slidedeck/package.json
@@ -6,7 +6,8 @@
"author": "Sawyer Cutler ",
"type": "module",
"exports": {
- ".": "./src/index.ts"
+ ".": "./src/index.ts",
+ "./deck": "./src/deck.ts"
},
"dependencies": {
"@corbits/pdf": "workspace:*",