Skip to content
Merged
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@corbits-builder/client": "workspace:*",
"@corbits-builder/schemas": "workspace:*",
"@corbits/react-ui": "0.5.1",
"@corbits/slidedeck": "workspace:*",
"@radix-ui/react-dialog": "1.3.1",
"@radix-ui/react-dropdown-menu": "2.2.1",
"@radix-ui/react-slot": "1.5.0",
Expand Down
102 changes: 62 additions & 40 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,16 +187,18 @@ function ProjectLoadFailure({
onBackToProjects: () => void;
}) {
const [copied, setCopied] = useState(false);
const copyDetail = () => {
void navigator.clipboard
.writeText(detail)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {
// Clipboard access can be denied; the text is still selectable in the disclosure.
});
const copyDetail = async () => {
try {
await navigator.clipboard.writeText(detail);
} catch (cause) {
// Clipboard access can be denied, or missing outside a secure origin;
// the text stays selectable in the disclosure.
console.warn("Copying the failure detail failed.", cause);
toast.error(wording.message.copyFailed);
return;
}
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};

return (
Expand All @@ -216,7 +218,7 @@ function ProjectLoadFailure({
<details className="project-load-failure-details">
<summary>{wording.projectLoadFailure.details}</summary>
<pre>{detail}</pre>
<Button variant="outline" onClick={copyDetail}>
<Button variant="outline" onClick={() => void copyDetail()}>
{copied
? wording.projectLoadFailure.copied
: wording.projectLoadFailure.copy}
Expand Down Expand Up @@ -600,24 +602,28 @@ export function App() {
return;
}
let cancelled = false;
void getHubSession()
.then((session) => {
const readUser = async () => {
try {
const session = await getHubSession();
if (!cancelled) setUser(session?.user ?? null);
})
.catch((cause: unknown) => {
// A dropped connection is the loading screen's to show, never a toast.
if (!cancelled && cause instanceof ApiFailure)
toast.error(humanFault(cause, wording.auth.accountUnreadable));
});
} catch (cause) {
if (cancelled) return;
// A hub that is down or unreachable is the loading screen's to show, never a toast.
if (cause instanceof ApiFailure && cause.detail.retryable) return;
toast.error(humanFault(cause, wording.auth.accountUnreadable));
}
};
void readUser();
return () => {
cancelled = true;
};
}, [auth]);
const signOut = async () => {
try {
await signOutHub();
} catch {
// The session is ended locally either way.
} catch (cause) {
// The session is ended locally either way; the person hears the hub kept it.
toast.error(humanFault(cause, wording.auth.signOutFailed));
}
setUser(null);
setAuth("signed-out");
Expand Down Expand Up @@ -645,20 +651,31 @@ export function App() {
};

useEffect(() => {
void getHubSession()
.then((session) => setAuth(session ? "signed-in" : "signed-out"))
.catch(() => setSessionReadFailed(true));
const readSession = async () => {
try {
const session = await getHubSession();
setAuth(session ? "signed-in" : "signed-out");
} catch (cause) {
if (!(cause instanceof ApiFailure)) throw cause;
setSessionReadFailed(true);
}
};
void readSession();
}, []);

// The workspace tenant is created the first time this session signs in; the
// fold reads below all scope themselves with it, so they wait for it.
useEffect(() => {
if (auth !== "signed-in") return;
void ensureWorkspace()
.then(() => refresh())
.catch((cause: unknown) =>
setError(humanFault(cause, wording.workspace.setupFailed)),
);
const setUpWorkspace = async () => {
try {
await ensureWorkspace();
await refresh();
} catch (cause) {
setError(humanFault(cause, wording.workspace.setupFailed));
}
};
void setUpWorkspace();
}, [auth, refresh]);

// The summary of the open project, from the list read; null when none or
Expand All @@ -682,9 +699,14 @@ export function App() {
if (recheckedListFor.current === selected) return;
recheckedListFor.current = selected;
const id = selected;
void projectsRead.refetch().finally(() => {
setSettledMissId(id);
});
const recheck = async () => {
try {
await projectsRead.refetch();
} finally {
setSettledMissId(id);
}
};
void recheck();
}, [
view,
selected,
Expand Down Expand Up @@ -721,16 +743,16 @@ export function App() {
? opening.text
: undefined;

const reportRefusal = async (cause: ProductApiError) => {
const code = await responseCode(cause);
const refused =
code === undefined ? undefined : wording.library.refused[code];
toast.error(refused ?? humanFault(cause, wording.library.actionFailed));
void refresh();
};
const reportProjectError = (cause: unknown) => {
if (cause instanceof ApiFailure) toast.error(cause.detail.message);
else if (cause instanceof ProductApiError)
void responseCode(cause).then((code) => {
toast.error(
wording.library.refused[code ?? ""] ??
humanFault(cause, wording.library.actionFailed),
);
void refresh();
});
else if (cause instanceof ProductApiError) void reportRefusal(cause);
else void refresh();
};

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
12 changes: 12 additions & 0 deletions apps/web/src/api/ask.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { expect, test } from "bun:test";
import { askOf } from "./ask.ts";

test("design concepts outside the schema's limits are not offered", () => {
const concept = { label: "Calm", direction: "Soft greys and wide margins." };
expect(askOf("ask", { concepts: [concept] }).concepts).toEqual([concept]);
expect(
askOf("ask", { concepts: [{ ...concept, label: "x".repeat(81) }] })
.concepts,
).toBeUndefined();
expect(askOf("ask", { concepts: [null] }).concepts).toBeUndefined();
});
29 changes: 10 additions & 19 deletions apps/web/src/api/ask.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type } from "arktype";
import { DesignConcept } from "@corbits-builder/schemas/artifact";
import type { Wait } from "./types.ts";

const Names = type("string[]");

/** What an ask asks, from its native tool name and arguments, as the decision card reads it. */
export function askOf(
toolName: string | undefined,
Expand All @@ -9,33 +13,20 @@ export function askOf(
"toolName" | "packages" | "concepts" | "instruction" | "subject" | "kinds"
> {
const packages = args["packages"];
const kinds = args["kinds"];
const listed = Names(args["kinds"]);
const instruction = args["instruction"];
const subject = Object.values(args).find(
(value) => typeof value === "string",
);
const concepts = args["concepts"];
const designConcepts =
Array.isArray(concepts) &&
concepts.every(
(item: { label?: unknown; direction?: unknown } | null) =>
typeof item?.label === "string" && typeof item.direction === "string",
)
? (concepts as { label: string; direction: string }[])
: undefined;
const installs =
Array.isArray(packages) &&
packages.every((item) => typeof item === "string")
? (packages as string[])
: undefined;
const concepts = DesignConcept.array()(args["concepts"]);
const designConcepts = concepts instanceof type.errors ? undefined : concepts;
const installs = Names(packages);
return {
...(toolName === undefined ? {} : { toolName }),
...(installs === undefined ? {} : { packages: installs }),
...(installs instanceof type.errors ? {} : { packages: installs }),
...(designConcepts === undefined ? {} : { concepts: designConcepts }),
...(typeof instruction === "string" ? { instruction } : {}),
...(typeof subject === "string" ? { subject } : {}),
...(Array.isArray(kinds) && kinds.every((item) => typeof item === "string")
? { kinds: kinds as string[] }
: {}),
...(listed instanceof type.errors ? {} : { kinds: listed }),
};
}
23 changes: 23 additions & 0 deletions apps/web/src/api/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { afterEach, expect, test } from "bun:test";
import { wording } from "../config.ts";
import { getHubSession } from "./auth.ts";

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

function answer(body: string) {
globalThis.fetch = Object.assign(async () => new Response(body), {
preconnect: realFetch.preconnect,
});
}

test("only better-auth's null means signed out; an unreadable session is an error", async () => {
answer("null");
expect(await getHubSession()).toBeNull();
answer("<html>proxy</html>");
await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable);
answer(JSON.stringify({ user: { id: "u1" } }));
await expect(getHubSession()).rejects.toThrow(wording.auth.accountUnreadable);
});
Loading