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/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 d23a0138..5d66ea55 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 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/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: