From 5ec09235b51b91b8a98f96b0c4857401874961c0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 4 Nov 2026 13:59:00 -0800 Subject: [PATCH 1/7] fix(web): rethrow failures other than unreadable JSON or cache --- apps/web/src/api/artifacts.ts | 7 ++--- apps/web/src/api/projects.ts | 7 ++--- apps/web/src/api/transport.ts | 10 +++++++ .../src/pages/workspace/human-fault.test.ts | 22 ++++++++++++++++ apps/web/src/pages/workspace/human-fault.ts | 6 ++--- apps/web/src/pages/workspace/index.tsx | 6 ++--- apps/web/src/queries/client.ts | 26 ++++++++++++------- 7 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/pages/workspace/human-fault.test.ts 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/projects.ts b/apps/web/src/api/projects.ts index b94e639b..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" diff --git a/apps/web/src/api/transport.ts b/apps/web/src/api/transport.ts index deb4efa0..f15d79b2 100644 --- a/apps/web/src/api/transport.ts +++ b/apps/web/src/api/transport.ts @@ -63,6 +63,16 @@ export async function timedCall( } } +/** A refusal's body as JSON; undefined when it is not JSON, such as a proxy's error page. */ +export async function refusalBody(cause: ProductApiError): Promise { + try { + return await cause.response.clone().json(); + } catch (failure) { + if (!(failure instanceof SyntaxError)) throw failure; + return undefined; + } +} + let client: ProductApiClient | undefined; /** The typed client against the hub origin, cookies included. */ 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..d1f07e36 --- /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 { 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 fails instead of passing for one without a code", async () => { + const dropped = new ReadableStream({ + start(controller) { + controller.error(new TypeError("connection reset")); + }, + }); + const refusal = new Response(dropped, { status: 409 }); + + await expect(responseCode(new ProductApiError(refusal))).rejects.toThrow( + "connection reset", + ); +}); 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 7e320634..e73b0b2b 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -57,6 +57,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, @@ -254,10 +255,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 && diff --git a/apps/web/src/queries/client.ts b/apps/web/src/queries/client.ts index 818e83b1..88beff58 100644 --- a/apps/web/src/queries/client.ts +++ b/apps/web/src/queries/client.ts @@ -43,13 +43,21 @@ const persistOptions: PersistQueryClientOptions = { }; /** - * 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. A cache that + * is damaged or cannot be stored leaves the client empty, and the page loads + * from the network; the persister has already discarded it. */ -export const queryCacheReady: Promise = persistQueryClientRestore( - persistOptions, -) - .catch(() => undefined) - .then(() => { - persistQueryClientSubscribe(persistOptions); - }); +async function restoreQueryCache(): Promise { + // Without IndexedDB, as in a browser with site storage off, there is no cache to keep. + if (typeof indexedDB === "undefined") return; + try { + await persistQueryClientRestore(persistOptions); + } catch (cause) { + if (!(cause instanceof SyntaxError || cause instanceof DOMException)) + throw cause; + } + persistQueryClientSubscribe(persistOptions); +} + +/** Resolves after the saved cache is on the client, before the first paint. */ +export const queryCacheReady: Promise = restoreQueryCache(); From 90ffd0b30cddeb1005e05574fb3b8f55e7a733c0 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 4 Nov 2026 14:00:31 -0800 Subject: [PATCH 2/7] refactor(web): replace promise chains and an IIFE with named functions --- apps/web/src/App.tsx | 38 ++++++--- apps/web/src/api/credentials.ts | 8 +- apps/web/src/pages/profiles.tsx | 17 ++-- apps/web/src/pages/project-menu.tsx | 18 +++-- apps/web/src/pages/settings.tsx | 13 +++- .../pages/workspace/approaches-comparison.tsx | 16 ++-- .../src/pages/workspace/deliverable-block.tsx | 38 +++++---- apps/web/src/pages/workspace/load-room.ts | 47 +++++------ apps/web/src/pages/workspace/pane-file.tsx | 78 +++++++++++-------- apps/web/src/pages/workspace/transcript.tsx | 21 ++--- apps/web/src/single-flight.ts | 15 ++-- 11 files changed, 184 insertions(+), 125 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c7488baa..4fd62919 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -651,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 @@ -688,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, 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/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/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({ 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/deliverable-block.tsx b/apps/web/src/pages/workspace/deliverable-block.tsx index d23a0138..74f90f7a 100644 --- a/apps/web/src/pages/workspace/deliverable-block.tsx +++ b/apps/web/src/pages/workspace/deliverable-block.tsx @@ -262,6 +262,17 @@ function ReadyDocument({ return loaded.content; }; + const showVersion = async (version: number) => { + try { + await fetchVersion(version); + setViewing(version); + } catch (cause) { + setReadError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setPendingVersion(null); + } + }; + const selectVersion = (version: number) => { if (version === viewing) return; onViewVersion?.(); @@ -271,12 +282,7 @@ function ReadyDocument({ return; } setPendingVersion(version); - void fetchVersion(version) - .then(() => setViewing(version)) - .catch((cause: unknown) => { - setReadError(cause instanceof Error ? cause.message : String(cause)); - }) - .finally(() => setPendingVersion(null)); + void showVersion(version); }; const appliedRequestSeq = useRef(null); @@ -290,19 +296,19 @@ function ReadyDocument({ } }, [requestedVersion]); - const comparePrevious = () => { + const comparePrevious = async () => { 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)); + try { + await fetchVersion(predecessor); + setBaseline(predecessor); + if (!changesOpen) onToggleChanges(); + } catch (cause) { + setReadError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setPendingVersion(null); + } }; const stopComparing = () => { diff --git a/apps/web/src/pages/workspace/load-room.ts b/apps/web/src/pages/workspace/load-room.ts index 3ee950c2..6bafb3de 100644 --- a/apps/web/src/pages/workspace/load-room.ts +++ b/apps/web/src/pages/workspace/load-room.ts @@ -275,31 +275,30 @@ export async function loadProjectRoom( } } + 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)]; }), ); @@ -316,7 +315,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, @@ -324,7 +323,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/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: