Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@intx/types": "workspace:*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@tanstack/react-query": "catalog:",
"arktype": "catalog:",
"lucide-react": "^1.27.0",
"react": "^19.2.0",
Expand Down
60 changes: 26 additions & 34 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ import {
} from "@intx/types";
import { type } from "arktype";
import type { ArkErrors } from "arktype";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";

import type { APIQuery } from "./api";
import { toAPIQuery } from "./api";
import { UnauthenticatedError, tenantKeys } from "./query-client";

export type AgentDefinition = typeof WorkflowDefinitionResponse.infer;
export type AgentInstance = typeof WorkflowRunResponse.infer;
Expand Down Expand Up @@ -205,43 +207,33 @@ export async function loadAgentDirectory(
}

/**
* Loads a bench's full agent directory, re-fetching whenever `tenantId`
* changes or `reloadKey` is bumped — the same "no push, refetch on demand"
* convention `useAPIQuery` uses, so a freshly created definition shows up
* the moment the create dialog closes.
* Loads a bench's full agent directory. One query owns definitions +
* instances + models (models are best-effort inside `loadAgentDirectory`) so
* the page keeps a single loading/error envelope. Pass no reloadKey —
* invalidate `tenantKeys.agentDirectory(tenantId)` after create.
*/
export function useAgentDirectory(
tenantId: string | undefined,
reloadKey: number,
): APIQuery<AgentDirectoryData> {
const [state, setState] = useState<APIQuery<AgentDirectoryData>>({
kind: "loading",
});

useEffect(() => {
if (tenantId === undefined) return;
let cancelled = false;
setState({ kind: "loading" });
loadAgentDirectory(tenantId)
.then((data) => {
if (cancelled) return;
setState({ kind: "ready", data });
})
.catch((cause: unknown) => {
if (cancelled) return;
const result = useQuery({
queryKey:
tenantId === undefined
? (["tenant", "none", "agents", "directory"] as const)
: tenantKeys.agentDirectory(tenantId),
enabled: tenantId !== undefined,
queryFn: async () => {
if (tenantId === undefined) {
throw new Error("tenantId required when agent directory is enabled");
}
try {
return await loadAgentDirectory(tenantId);
} catch (cause) {
if (cause instanceof AgentDirectoryError && cause.status === 401) {
setState({ kind: "unauthenticated" });
return;
throw new UnauthenticatedError();
}
setState({
kind: "error",
message: cause instanceof Error ? cause.message : String(cause),
});
});
return () => {
cancelled = true;
};
}, [tenantId, reloadKey]);

return state;
throw cause;
}
},
});
return toAPIQuery(result);
}
112 changes: 63 additions & 49 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
WorkflowRunSummary,
paginatedSchema,
} from "@intx/types";
import { useQuery } from "@tanstack/react-query";
import { type } from "arktype";
import type { ArkErrors } from "arktype";
import { useEffect, useState } from "react";

import { UnauthenticatedError, pathToQueryKey } from "./query-client";

export const ProfileSchema = UserProfile;
export const PrincipalsSchema = paginatedSchema(PrincipalSummary);
Expand Down Expand Up @@ -73,63 +75,75 @@ export type APIQuery<T> =
/** An arktype schema, seen as the validating call every `Type` provides. */
type Validator<T> = (data: unknown) => T | ArkErrors;

/**
* Map a TanStack Query result onto the APIQuery discriminant pages already
* render through QueryView. `isLoading` (pending + fetching) is the loading
* state — bare `isPending` would flash skeletons when cached data exists.
*/
export function toAPIQuery<T>(result: {
readonly isLoading: boolean;
readonly isError: boolean;
readonly error: unknown;
readonly data: T | undefined;
readonly isPending: boolean;
readonly fetchStatus: "fetching" | "paused" | "idle";
}): APIQuery<T> {
if (result.isLoading) return { kind: "loading" };
if (result.isError) {
if (result.error instanceof UnauthenticatedError) {
return { kind: "unauthenticated" };
}
return {
kind: "error",
message:
result.error instanceof Error
? result.error.message
: String(result.error),
};
}
if (result.data !== undefined) return { kind: "ready", data: result.data };
// Disabled queries (empty path, unresolved tenant) have no data and are not
// fetching — still report loading so callers that gate on "ready" stay quiet.
return { kind: "loading" };
}

/**
* Fetches one hub endpoint and reports exactly what happened: loading, no
* session (401), a failure, or validated data. Pass a module-level schema so
* the effect does not re-run on every render.
* identity stays stable; the schema never enters the query key.
*
* Empty paths are disabled and never fetch — the boundary owns the gate so
* call sites that still pass `""` when a tenant is unresolved cannot hit
* the network with a broken URL.
*/
export function useAPIQuery<T>(
path: string,
schema: Validator<T>,
/** Bump this to force a re-fetch of an otherwise-unchanged path, e.g.
* after a mutation the hub doesn't push updates for. */
reloadKey: number = 0,
): APIQuery<T> {
const [state, setState] = useState<APIQuery<T>>({ kind: "loading" });

useEffect(() => {
let cancelled = false;
const settle = (next: APIQuery<T>) => {
if (!cancelled) setState(next);
};
void (async () => {
try {
const response = await fetch(path, {
headers: { accept: "application/json" },
});
if (response.status === 401) {
settle({ kind: "unauthenticated" });
return;
}
if (!response.ok) {
settle({
kind: "error",
message: `The hub answered ${response.status} for ${path}.`,
});
return;
}
const parsed = schema(await response.json());
if (parsed instanceof type.errors) {
settle({
kind: "error",
message: `Unexpected response shape from ${path}: ${parsed.summary}`,
});
return;
}
settle({ kind: "ready", data: parsed });
} catch (cause) {
settle({
kind: "error",
message: cause instanceof Error ? cause.message : String(cause),
});
const enabled = path !== "";
const result = useQuery({
queryKey: pathToQueryKey(path),
enabled,
queryFn: async () => {
const response = await fetch(path, {
headers: { accept: "application/json" },
});
if (response.status === 401) {
throw new UnauthenticatedError();
}
})();
return () => {
cancelled = true;
};
}, [path, schema, reloadKey]);

return state;
if (!response.ok) {
throw new Error(`The hub answered ${response.status} for ${path}.`);
}
const parsed = schema(await response.json());
if (parsed instanceof type.errors) {
throw new Error(
`Unexpected response shape from ${path}: ${parsed.summary}`,
);
}
return parsed;
},
});
return toAPIQuery(result);
}

export class APIMutationError extends Error {
Expand Down
41 changes: 27 additions & 14 deletions apps/web/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// The whole interface as a pure function of the current path and session
// state. The entry point owns the browser history and the one session probe;
// screens that talk to the hub only mount once the session is confirmed, so
// a signed-out browser fires no authenticated request anywhere.

import { BootScreen, Button, CorbitsMark, EmptyState } from "@corbits/react-ui";
import { QueryClientProvider } from "@tanstack/react-query";
import { CircleAlert } from "lucide-react";
import { useMemo } from "react";

import { AuthScreen } from "./auth-screen";
import { BenchProvider } from "./bench-context";
Expand All @@ -8,6 +15,7 @@ import { NavigationProvider, type Navigate } from "./navigation";
import { NotFoundPage } from "./pages/not-found-page";
import { OnboardingPage } from "./pages/onboarding-page";
import { ProvisioningErrorPage } from "./pages/provisioning-error-page";
import { createAppQueryClient } from "./query-client";
import { APP_ROUTES, matchesRoute, ONBOARDING_PATH } from "./routes";
import type { SessionState, SessionUser } from "./session";
import { AppShell } from "./shell/app-shell";
Expand All @@ -32,24 +40,29 @@ function Shell({
readonly user: SessionUser;
readonly onSignOut: () => void;
}) {
// One client per signed-in shell mount — above BenchProvider so principals
// and every tenant-scoped page share the same cache.
const queryClient = useMemo(() => createAppQueryClient(), []);
const route = APP_ROUTES.find((candidate) =>
matchesRoute(candidate.path, path),
);
return (
<NavigationProvider navigate={navigate}>
<BenchProvider>
<CommandPaletteProvider navigate={navigate} />
<AppShell path={path} user={user} onSignOut={onSignOut}>
{path === ONBOARDING_PATH ? (
<OnboardingPage />
) : route === undefined ? (
<NotFoundPage path={path} />
) : (
route.render(path, navigate)
)}
</AppShell>
</BenchProvider>
</NavigationProvider>
<QueryClientProvider client={queryClient}>
<NavigationProvider navigate={navigate}>
<BenchProvider>
<CommandPaletteProvider navigate={navigate} />
<AppShell path={path} user={user} onSignOut={onSignOut}>
{path === ONBOARDING_PATH ? (
<OnboardingPage />
) : route === undefined ? (
<NotFoundPage path={path} />
) : (
route.render(path, navigate)
)}
</AppShell>
</BenchProvider>
</NavigationProvider>
</QueryClientProvider>
);
}

Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/bench-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
// bench (the chat page, the benches page, the header switcher) reads this
// context instead of re-deriving "membership[0]" on its own.

import { useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";

import { PrincipalsSchema, useAPIQuery } from "./api";
import type { APIQuery, Principal, PrincipalsPage } from "./api";
import { meKeys, tenantKeys } from "./query-client";

const STORAGE_KEY = "workbench.selectedTenantId";

Expand Down Expand Up @@ -55,12 +57,8 @@ function resolveSelection(
}

export function BenchProvider({ children }: { readonly children: ReactNode }) {
const [reloadKey, setReloadKey] = useState(0);
const memberships = useAPIQuery(
"/api/me/principals",
PrincipalsSchema,
reloadKey,
);
const queryClient = useQueryClient();
const memberships = useAPIQuery("/api/me/principals", PrincipalsSchema);
const [stored, setStored] = useState<string | null>(() =>
readStoredTenantId(),
);
Expand All @@ -83,16 +81,22 @@ export function BenchProvider({ children }: { readonly children: ReactNode }) {
selectedTenantId: resolved?.tenantId ?? null,
selectedPrincipalId: resolved?.principalId ?? null,
selectTenant: (tenantId: string) => {
const previous = stored;
if (previous !== null && previous !== tenantId) {
// Drop the left-behind bench's cache entirely — do not invalidate
// (which would refetch for a bench the user is no longer on).
queryClient.removeQueries({ queryKey: tenantKeys.all(previous) });
}
writeStoredTenantId(tenantId);
setStored(tenantId);
},
onBenchCreated: (tenantId: string) => {
writeStoredTenantId(tenantId);
setStored(tenantId);
setReloadKey((value) => value + 1);
void queryClient.invalidateQueries({ queryKey: meKeys.principals });
},
}),
[memberships, resolved],
[memberships, resolved, stored, queryClient],
);

return (
Expand Down
13 changes: 10 additions & 3 deletions apps/web/src/pages/agents-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,15 @@ import type { BadgeTone, ViewMode } from "@corbits/react-ui";
import { Bot, Copy, Plus, Workflow } from "lucide-react";
import { useState } from "react";
import type { ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";

import type { AgentDefinition, AgentInstance } from "../agents-api";
import type { AgentDirectoryData } from "../agents-api";
import type { APIQuery } from "../api";
import { useAgentDirectory } from "../agents-api";
import { useBench } from "../bench-context";
import { countProp } from "../optional-props";
import { tenantKeys } from "../query-client";
import { QueryView } from "../query-view";
import { CreateAgentDialog } from "./create-agent-dialog";
import {
Expand Down Expand Up @@ -467,8 +469,8 @@ export function AgentsRoute() {
// BenchProvider is the only source of the active tenant — never re-fetch
// /api/me/principals and take memberships[0], which ignores the switcher.
const { memberships, selectedTenantId } = useBench();
const [reloadKey, setReloadKey] = useState(0);
const directory = useAgentDirectory(selectedTenantId ?? undefined, reloadKey);
const queryClient = useQueryClient();
const directory = useAgentDirectory(selectedTenantId ?? undefined);

const resolvedDirectory: APIQuery<AgentDirectoryData> =
memberships.kind !== "ready"
Expand All @@ -488,7 +490,12 @@ export function AgentsRoute() {
return (
<AgentsPage
directory={resolvedDirectory}
onAgentCreated={() => setReloadKey((key) => key + 1)}
onAgentCreated={() => {
if (selectedTenantId === null) return;
void queryClient.invalidateQueries({
queryKey: tenantKeys.agentDirectory(selectedTenantId),
});
}}
/>
);
}
Loading
Loading