From 9357abb279dccaf5066ec77eade654ba73a57553 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 4 Nov 2026 13:16:39 -0800 Subject: [PATCH 1/8] fix(web): surface swallowed errors and close the microphone on failure --- apps/web/src/App.tsx | 47 ++++++------ apps/web/src/api/auth.ts | 28 ++++++- apps/web/src/api/projects.ts | 41 ++++++---- apps/web/src/api/types.ts | 8 +- apps/web/src/config.ts | 2 + apps/web/src/dictation.test.ts | 38 ++++++++++ apps/web/src/dictation.tsx | 39 +++++++--- apps/web/src/pages/invite-accept.tsx | 11 ++- apps/web/src/pages/model-setup.tsx | 15 +++- .../approval-package-preview.test.tsx | 18 ++++- .../workspace/approval-package-preview.tsx | 74 ++++++++++++------- .../web/src/pages/workspace/dispatch-card.tsx | 3 +- apps/web/src/pages/workspace/index.tsx | 21 +++--- apps/web/src/pages/workspace/room-reload.ts | 11 ++- apps/web/src/pages/workspace/visible-chat.ts | 3 +- 15 files changed, 260 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/dictation.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d012c0b9..e620fbd4 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -187,16 +187,17 @@ 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; the text is still selectable in the disclosure. + if (!(cause instanceof DOMException)) throw cause; + toast.error(wording.message.copyFailed); + return; + } + setCopied(true); + setTimeout(() => setCopied(false), 1500); }; return ( @@ -216,7 +217,7 @@ function ProjectLoadFailure({
{wording.projectLoadFailure.details}
{detail}
- {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..7bec9c06 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"; @@ -55,6 +59,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 +169,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/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/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:*", From 5d3fa9edf9f862ec1d1b1e2000181c93c5264a89 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 4 Nov 2026 14:28:17 -0800 Subject: [PATCH 6/8] fix(web): handle missing room data instead of inventing it (#171) * fix(web): handle missing room data instead of inventing it * fix(web): start room reads early and keep unread decisions unknown * fix(web): name an approval without concepts instead of defaulting * fix(web): ask about a blank-named document by its tool --- apps/web/src/App.tsx | 16 +- apps/web/src/config.ts | 6 + apps/web/src/pages/collaborators.tsx | 11 +- .../src/pages/workspace/agent-activity.tsx | 30 +- .../pages/workspace/artifact-pane.test.tsx | 1 + apps/web/src/pages/workspace/collaboration.ts | 6 +- .../src/pages/workspace/deliverable-block.tsx | 8 +- .../web/src/pages/workspace/dispatch-card.tsx | 16 +- .../src/pages/workspace/document-decision.tsx | 18 +- .../pages/workspace/empty-project.test.tsx | 1 + .../src/pages/workspace/filed-versions.tsx | 8 +- apps/web/src/pages/workspace/index.tsx | 404 +++++++++++------- apps/web/src/pages/workspace/next-step.tsx | 11 +- .../src/pages/workspace/ready-room.test.tsx | 84 ++++ .../web/src/pages/workspace/room-skeleton.tsx | 2 +- .../src/pages/workspace/settled-approvals.ts | 6 +- .../src/pages/workspace/transcript.test.tsx | 17 + apps/web/src/pages/workspace/typed-yes.tsx | 7 +- .../src/pages/workspace/work-unit-view.tsx | 8 +- 19 files changed, 439 insertions(+), 221 deletions(-) create mode 100644 apps/web/src/pages/workspace/ready-room.test.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c7488baa..66615a02 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -727,16 +727,16 @@ export function App() { ? opening.text : undefined; + const reportRefusal = async (cause: ProductApiError) => { + const code = await responseCode(cause); + const refused = + code === undefined ? undefined : wording.library.refused[code]; + toast.error(refused ?? humanFault(cause, wording.library.actionFailed)); + void refresh(); + }; const reportProjectError = (cause: unknown) => { if (cause instanceof ApiFailure) toast.error(cause.detail.message); - else if (cause instanceof ProductApiError) - void responseCode(cause).then((code) => { - toast.error( - wording.library.refused[code ?? ""] ?? - humanFault(cause, wording.library.actionFailed), - ); - void refresh(); - }); + else if (cause instanceof ProductApiError) void reportRefusal(cause); else void refresh(); }; diff --git a/apps/web/src/config.ts b/apps/web/src/config.ts index 790d3cc0..f55bcc1b 100644 --- a/apps/web/src/config.ts +++ b/apps/web/src/config.ts @@ -423,6 +423,7 @@ export const wording = { }, activity: { label: "Who is working", + notRead: "Who is working could not be read.", role: { coordinator: "Coordinator", writer: "Writer", @@ -812,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) ?? ""}?`, @@ -975,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.", }, diff --git a/apps/web/src/pages/collaborators.tsx b/apps/web/src/pages/collaborators.tsx index e0dd83ee..097499c2 100644 --- a/apps/web/src/pages/collaborators.tsx +++ b/apps/web/src/pages/collaborators.tsx @@ -42,11 +42,12 @@ function expiresInLabel(expiresAt: string): string { async function refusal(cause: unknown): Promise { if (!(cause instanceof ProductApiError)) return cause instanceof Error ? cause.message : String(cause); - const code = (await responseCode(cause)) ?? ""; - return ( - wording.settings.collaborators.refused[code] ?? - wording.settings.collaborators.failed - ); + const code = await responseCode(cause); + const refused = + code === undefined + ? undefined + : wording.settings.collaborators.refused[code]; + return refused ?? wording.settings.collaborators.failed; } export function Collaborators({ user }: { user: HubUser | null }) { diff --git a/apps/web/src/pages/workspace/agent-activity.tsx b/apps/web/src/pages/workspace/agent-activity.tsx index f2688353..d3736137 100644 --- a/apps/web/src/pages/workspace/agent-activity.tsx +++ b/apps/web/src/pages/workspace/agent-activity.tsx @@ -4,7 +4,7 @@ * carried. A phrase is a fixed reading of the latest call, never a guess at * progress. */ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import type { AgentActivity } from "@corbits-builder/client"; import { readProjectActivity, @@ -135,8 +135,8 @@ export function activityPhrase(agent: AgentActivity): { }; } -export function useProjectActivity(projectId: string) { - return useQuery({ +export function projectActivityQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectActivity.of(projectId), queryFn: ({ signal }) => readProjectActivity(projectId, signal), // While anyone is mid-turn, a hand-off and each step read sooner. @@ -146,12 +146,16 @@ export function useProjectActivity(projectId: string) { }); } +export function useProjectActivity(projectId: string) { + return useQuery(projectActivityQuery(projectId)); +} + /** * Each live run's state by its latest committed event, polled while any run is live. A run that * parks on an approval raises no room event, so this is how the page learns the person's turn came. */ -export function useProjectRunStates(projectId: string) { - return useQuery({ +export function projectRunStatesQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectRunStates.of(projectId), queryFn: ({ signal }) => readProjectRunStates(projectId, signal), refetchInterval: (query) => @@ -162,6 +166,10 @@ export function useProjectRunStates(projectId: string) { }); } +export function useProjectRunStates(projectId: string) { + return useQuery(projectRunStatesQuery(projectId)); +} + /** The runs held on an approval, as one string a React effect can depend on. */ export function heldOnApproval( states: { runs: readonly { runId: string; state: string }[] } | undefined, @@ -174,8 +182,12 @@ export function heldOnApproval( .join(","); } -/** The roles with a run mid-turn right now, from the hub's native activity read. */ -export function useRolesAtWork(projectId: string): ReadonlySet { - const agents = useProjectActivity(projectId).data?.agents ?? []; - return new Set(agents.map((agent) => agent.role)); +/** The roles with a run mid-turn right now, from the hub's native activity read; undefined until it is read. */ +export function useRolesAtWork( + projectId: string, +): ReadonlySet | undefined { + const agents = useProjectActivity(projectId).data?.agents; + return agents === undefined + ? undefined + : new Set(agents.map((agent) => agent.role)); } diff --git a/apps/web/src/pages/workspace/artifact-pane.test.tsx b/apps/web/src/pages/workspace/artifact-pane.test.tsx index 7bec9c06..10c01f23 100644 --- a/apps/web/src/pages/workspace/artifact-pane.test.tsx +++ b/apps/web/src/pages/workspace/artifact-pane.test.tsx @@ -38,6 +38,7 @@ const document: DeliverableState = { content: "Previous content", filedAt: "2026-10-30T11:00:00.000Z", }, + versions: [], }, }; function render( diff --git a/apps/web/src/pages/workspace/collaboration.ts b/apps/web/src/pages/workspace/collaboration.ts index 178ccde3..35588672 100644 --- a/apps/web/src/pages/workspace/collaboration.ts +++ b/apps/web/src/pages/workspace/collaboration.ts @@ -132,14 +132,14 @@ export function shortDocumentName(kind: string, title: string): string { */ export function approvalQuestion( toolName: string | undefined, - document: string, + document: string | undefined, subject?: string, ): string { const asked = toolName === undefined ? undefined : wording.approval.question[toolName]; if (asked !== undefined) return asked(subject); - if (document !== "" || toolName === undefined) - return wording.approval.document(document); + if (document !== undefined) return wording.approval.document(document); + if (toolName === undefined) return wording.approval.unnamed; const tool = toolName .slice(toolName.lastIndexOf(".") + 1) .replaceAll("_", " "); diff --git a/apps/web/src/pages/workspace/deliverable-block.tsx b/apps/web/src/pages/workspace/deliverable-block.tsx index d23a0138..40353377 100644 --- a/apps/web/src/pages/workspace/deliverable-block.tsx +++ b/apps/web/src/pages/workspace/deliverable-block.tsx @@ -56,7 +56,7 @@ export type LoadedDeliverable = ReadVersion & { sha256: string; before: (ReadVersion & { version: number }) | null; historyError?: string; - versions?: readonly RetainedVersion[]; + versions: readonly RetainedVersion[]; }; export type DeliverableState = @@ -238,7 +238,7 @@ function ReadyDocument({ const compareWith = changesOpen ? (baseline ?? predecessor) : null; const afterText = textFor(viewing); const beforeText = compareWith === null ? null : textFor(compareWith); - const viewed = document.versions?.find((item) => item.version === viewing); + const viewed = document.versions.find((item) => item.version === viewing); const mediaType = viewed?.mediaType ?? document.mediaType; const title = viewed?.title ?? document.title; const kind = mediaKind(mediaType, title); @@ -381,7 +381,7 @@ function ReadyDocument({ const barGroups = [ approvalGroup, versionGroup({ - versions: document.versions ?? [], + versions: document.versions, viewing, pending: pendingVersion !== null, canCompare: @@ -526,7 +526,7 @@ function ReadyDocument({ item.version)} + packageVersions={document.versions.map((item) => item.version)} onReadPackageVersion={fetchVersion} shareOpen={shareOpen} onShareOpenChange={setShareOpen} diff --git a/apps/web/src/pages/workspace/dispatch-card.tsx b/apps/web/src/pages/workspace/dispatch-card.tsx index cd59e892..dd4b684c 100644 --- a/apps/web/src/pages/workspace/dispatch-card.tsx +++ b/apps/web/src/pages/workspace/dispatch-card.tsx @@ -78,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, @@ -104,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]; }) @@ -169,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/index.tsx b/apps/web/src/pages/workspace/index.tsx index 1539517f..2840d52d 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, @@ -122,6 +130,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 +145,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 +166,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[], @@ -275,14 +307,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 +321,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 +359,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 +509,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 +531,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 +688,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 +710,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 +744,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 = @@ -790,14 +914,15 @@ 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) { // The cached list is still the best answer; the send that follows reports a hub that is down. @@ -844,13 +969,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 ( @@ -863,10 +987,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, @@ -935,7 +1061,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. @@ -944,14 +1070,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), @@ -969,7 +1095,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({ @@ -983,7 +1109,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({ @@ -1002,7 +1128,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), @@ -1014,7 +1140,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); }; @@ -1024,7 +1154,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(() => { @@ -1037,27 +1167,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 && @@ -1072,15 +1181,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 () => { @@ -1088,7 +1188,7 @@ export function StageWorkspace({ setRestoreFault(null); try { await restoreProject(projectId); - await projectRead.refetch(); + await refetchProject(); onChanged(); } catch (cause) { setRestoreFault( @@ -1098,14 +1198,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; @@ -1123,16 +1220,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( @@ -1147,16 +1244,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; @@ -1198,8 +1298,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); @@ -1244,10 +1345,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"); @@ -1258,15 +1356,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 ? { @@ -1299,13 +1396,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 = @@ -1626,7 +1716,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 diff --git a/apps/web/src/pages/workspace/next-step.tsx b/apps/web/src/pages/workspace/next-step.tsx index d8fcc6e0..7a2d93e9 100644 --- a/apps/web/src/pages/workspace/next-step.tsx +++ b/apps/web/src/pages/workspace/next-step.tsx @@ -2,7 +2,7 @@ * Where the project stands and the person's one next step, as the hub derives them from native * state. The line is pinned above the conversation when the person's preference turns it on. */ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { Button, StatusDot } from "@corbits/react-ui"; import type { ProjectNextAction, @@ -19,16 +19,19 @@ const POLL_MS = 5_000; const NEXT_STEP_LINE = "next_step_line"; const text = wording.nextStep; -export function useProjectNextStep(projectId: string, enabled = true) { - return useQuery({ +export function projectNextStepQuery(projectId: string) { + return queryOptions({ queryKey: keys.projectNextStep.of(projectId), - enabled, queryFn: ({ signal }) => readProjectNextStep(projectId, signal), refetchInterval: POLL_MS, refetchOnWindowFocus: false, }); } +export function useProjectNextStep(projectId: string, enabled = true) { + return useQuery({ ...projectNextStepQuery(projectId), enabled }); +} + function useNextStepLineOn() { const preferences = useQuery({ queryKey: keys.preferences, diff --git a/apps/web/src/pages/workspace/ready-room.test.tsx b/apps/web/src/pages/workspace/ready-room.test.tsx 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-skeleton.tsx b/apps/web/src/pages/workspace/room-skeleton.tsx index d5f58823..998ff836 100644 --- a/apps/web/src/pages/workspace/room-skeleton.tsx +++ b/apps/web/src/pages/workspace/room-skeleton.tsx @@ -12,7 +12,7 @@ export function RoomSkeleton() { return (

- Loading this project… + {wording.message.projectLoading}

diff --git a/apps/web/src/pages/workspace/settled-approvals.ts b/apps/web/src/pages/workspace/settled-approvals.ts index 7d6a8420..0ff1db56 100644 --- a/apps/web/src/pages/workspace/settled-approvals.ts +++ b/apps/web/src/pages/workspace/settled-approvals.ts @@ -49,7 +49,8 @@ function settledCard( export function useSettledApprovals( projectId: string, - open: readonly OpenApproval[], + /** Undefined until the open decisions are read. */ + open: readonly OpenApproval[] | undefined, artifacts: readonly ProjectArtifact[], ): { settled: readonly DocumentApproval[]; @@ -70,8 +71,9 @@ export function useSettledApprovals( }, [projectId]); // An ask that leaves the pending list was decided, here or elsewhere. - const openKey = open.map((approval) => approval.approvalId).join("\n"); + const openKey = open?.map((approval) => approval.approvalId).join("\n"); useEffect(() => { + if (openKey === undefined) return; void queryClient.invalidateQueries({ queryKey }); }, [projectId, openKey]); diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index 233bf286..48550793 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -393,6 +393,23 @@ describe("conversation transcript", () => { } }); + test("a document with a blank name is asked about by its tool", () => { + const html = renderToStaticMarkup( + {}} + onAlways={() => {}} + onNo={() => {}} + />, + ); + expect(html).toContain("browser navigate"); + expect(html).not.toContain(wording.approval.document("")); + }); + test("a design approval lists each look and starts one by name", () => { const html = renderToStaticMarkup( void; onUndo: (message: string) => void; /** The card was answered some other way during the countdown. */ @@ -70,7 +70,7 @@ export function useTypedYes({ }; }, [held]); - const open = held !== null && pending.includes(held.approvalId); + const open = held !== null && pending?.includes(held.approvalId) === true; useEffect(() => { if (held === null || open) return; setHeld(null); @@ -79,6 +79,7 @@ export function useTypedYes({ /** Holds the message when it answers the one open card; false means send it as usual. */ const hold = (message: string): boolean => { + if (pending === undefined) return false; const [only] = pending; if ( held !== null || diff --git a/apps/web/src/pages/workspace/work-unit-view.tsx b/apps/web/src/pages/workspace/work-unit-view.tsx index 8d226f2c..19e92a16 100644 --- a/apps/web/src/pages/workspace/work-unit-view.tsx +++ b/apps/web/src/pages/workspace/work-unit-view.tsx @@ -60,9 +60,9 @@ function Elapsed({ since }: { since: string }) { function liveLine( dispatch: Dispatch, subject: string, - activity: readonly AgentActivity[], + activity: readonly AgentActivity[] | undefined, ): string { - const agent = activity.find((item) => + const agent = activity?.find((item) => dispatch.runId === undefined ? item.role === dispatch.role : item.runId === dispatch.runId, @@ -84,7 +84,7 @@ function WorkLine({ status: DispatchStatus; subject: string; steps: number; - activity: readonly AgentActivity[]; + activity: readonly AgentActivity[] | undefined; }): ReactNode { switch (status.state) { case "waiting": @@ -217,7 +217,7 @@ export function WorkUnitView({ status={status} subject={subject} steps={steps.length} - activity={room?.activity ?? []} + activity={room?.activity} /> } labels={wording.activity.trigger} From 576bde49cb36d812d8d0abfd9726475a63a535de Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Wed, 4 Nov 2026 14:28:21 -0800 Subject: [PATCH 7/8] refactor(web): replace promise chains and IIFEs with named functions * fix(web): rethrow failures other than unreadable JSON or cache * refactor(web): replace promise chains and an IIFE with named functions * refactor(web): poll a subscription login with a query observer * fix(web): treat an unreadable refusal body as one without a code * fix(web): never hold the first paint on a failed cache restore * fix(web): keep a login poll running offline and out of the cache * refactor(web): share one version read between view and compare --- apps/web/src/App.tsx | 38 ++++++--- apps/web/src/api/artifacts.ts | 7 +- apps/web/src/api/credentials.ts | 8 +- apps/web/src/api/projects.ts | 7 +- apps/web/src/api/transport.ts | 17 ++++ apps/web/src/pages/profiles.tsx | 17 ++-- apps/web/src/pages/project-menu.tsx | 18 +++-- apps/web/src/pages/providers.test.ts | 53 +++++++++++++ apps/web/src/pages/providers.tsx | 60 ++++++++++---- apps/web/src/pages/settings.tsx | 13 +++- .../pages/workspace/approaches-comparison.tsx | 16 ++-- .../src/pages/workspace/deliverable-block.tsx | 37 ++++----- .../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/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/queries/client.ts | 29 ++++--- apps/web/src/queries/keys.ts | 2 + apps/web/src/single-flight.ts | 15 ++-- 21 files changed, 344 insertions(+), 173 deletions(-) create mode 100644 apps/web/src/pages/providers.test.ts create mode 100644 apps/web/src/pages/workspace/human-fault.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 66615a02..8939b459 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/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/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.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..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/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..bc4ff525 100644 --- a/apps/web/src/pages/providers.tsx +++ b/apps/web/src/pages/providers.tsx @@ -12,7 +12,7 @@ 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 { @@ -21,6 +21,7 @@ import type { } from "@corbits-builder/schemas/models"; import type { ModelLoginPlugin } from "@corbits-builder/client"; 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"; @@ -65,22 +66,47 @@ async function logIn(provider: ModelProvider) { 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({ 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 40353377..a0c02515 100644 --- a/apps/web/src/pages/workspace/deliverable-block.tsx +++ b/apps/web/src/pages/workspace/deliverable-block.tsx @@ -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 = () => { 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 2840d52d..05210787 100644 --- a/apps/web/src/pages/workspace/index.tsx +++ b/apps/web/src/pages/workspace/index.tsx @@ -65,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, @@ -292,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 && diff --git a/apps/web/src/pages/workspace/load-room.ts b/apps/web/src/pages/workspace/load-room.ts index 9dbf90cc..628e2ef9 100644 --- a/apps/web/src/pages/workspace/load-room.ts +++ b/apps/web/src/pages/workspace/load-room.ts @@ -278,31 +278,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)]; }), ); @@ -319,7 +318,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 +326,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: