Skip to content
38 changes: 27 additions & 11 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/api/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>;
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/api/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
7 changes: 2 additions & 5 deletions apps/web/src/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<unknown> {
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"
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/api/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ export async function timedCall<T>(
}
}

/**
* 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<unknown> {
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. */
Expand Down
17 changes: 11 additions & 6 deletions apps/web/src/pages/profiles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,17 @@ export function Profiles({ initialField }: { initialField?: Field } = {}) {
const [viewing, setViewing] = useState<string | null>(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) => {
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/pages/project-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -243,12 +254,7 @@ export function ProjectMenu({
<ExportDialog
projectId={project.id}
onClose={() => 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)}
/>
</div>
) : null}
Expand Down
53 changes: 53 additions & 0 deletions apps/web/src/pages/providers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
60 changes: 43 additions & 17 deletions apps/web/src/pages/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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";
Expand Down Expand Up @@ -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<string> {
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. */
Expand Down
13 changes: 9 additions & 4 deletions apps/web/src/pages/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,14 @@ function Account({
onSignOut?: () => Promise<void>;
}) {
const [signingOut, setSigningOut] = useState(false);
const signOut = async (leave: () => Promise<void>) => {
setSigningOut(true);
try {
await leave();
} finally {
setSigningOut(false);
}
};
const copy = wording.settings.account;
return (
<Section title={copy.section} description={copy.lead}>
Expand All @@ -773,10 +781,7 @@ function Account({
<Button
variant="outline"
loading={signingOut}
onClick={() => {
setSigningOut(true);
void onSignOut().finally(() => setSigningOut(false));
}}
onClick={() => void signOut(onSignOut)}
>
{wording.action.signOut}
</Button>
Expand Down
16 changes: 10 additions & 6 deletions apps/web/src/pages/workspace/approaches-comparison.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -48,14 +49,17 @@ export function ApproachesComparison({
}) {
const [choosing, setChoosing] = useState<string | null>(null);
const [fault, setFault] = useState<string | null>(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 (
<section className="approaches" aria-label={text.label}>
Expand Down Expand Up @@ -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}
</Button>
Expand Down
Loading