diff --git a/.claude/skills/openbot-data-access/SKILL.md b/.claude/skills/openbot-data-access/SKILL.md new file mode 100644 index 0000000..75ca44b --- /dev/null +++ b/.claude/skills/openbot-data-access/SKILL.md @@ -0,0 +1,292 @@ +--- +name: openbot-data-access +description: Governs how the OpenBot browser app reads and writes server data — every request goes through `client` in app/src/lib/client.ts, every read is a queryOptions factory in app/src/lib//queries.ts, every write is a mutationOptions factory in app/src/lib//mutations.ts, and components consume them through useQuery/useMutation. Use when adding or changing a screen that loads server data, calling a /api/... endpoint from the browser, adding a query key, writing a create/update/delete flow, deciding where a fetch belongs, or reviewing a diff that contains the word fetch under app/src. Don't use for server-side route handlers under server/ (that is not browser code), for form validation schemas (those live in lib//form.ts), for page layout and Item rows, or or for the AG-UI stream itself, which the runtime carries rather than the client. +--- + +# OpenBot Data Access + +## When To Use + +This skill applies to any change under `app/src` that moves data between the browser and the API +server. It fires on new screens, new endpoints, new query keys, and on any diff that introduces +`fetch` anywhere but `app/src/lib/client.ts`. + +It does not cover server handlers under `server/`, zod form schemas (`lib//form.ts`), or page +layout. It does cover `lib/copilot/`: the conversation itself streams over AG-UI, but the tool calls a +Bot makes during a turn are ordinary authenticated requests and go through the client like everything +else. + +## The Shape + +Every entity the browser knows about owns a directory under `app/src/lib/`: + +``` +app/src/lib/ + client.ts # the only fetch in the app + / + queries.ts # read types, key factory, queryOptions factories + mutations.ts # input type, mutationOptions factories + form.ts # zod schema (a different skill's territory) +``` + +`client.ts` owns the transport: credentials, the JSON content type, body serialisation, and turning a +failed status into an `Error` carrying the server's own message. It owns nothing about meaning — the +envelope key and the sentence a person reads stay at the call site, because those are facts about one +endpoint rather than about requests in general. + +```ts +client(path, key, options?): Promise // parsed, and `key` unwrapped +client(path, options?): Promise // for a caller that only needed it to work +tryClient(path, options?): Promise // never throws; the status is the answer +``` + +`options` is `{ method?, body?, fallback?, signal? }`. `body` is serialised by the client, which is +also what sets the content type — so a caller passes an object, never a string. Passing +`JSON.stringify(x)` sends a JSON string of a JSON string, which no endpoint accepts. + +### Three kinds of request + +Not everything crossing the wire is cached state, and the shape follows from which kind it is. + +1. **A cached read** is a `queryOptions` factory in `queries.ts`. It has a key, and something can + invalidate it. +2. **A write somebody asked for** is a `mutationOptions` factory in `mutations.ts`. It invalidates on + success. +3. **Everything else is a plain exported function**, living beside the factories for its entity. + A verdict about this moment (`decideComponent`, `testAgentConnection`), a tool call during a + Bot's turn (`callPluginTool`, the computer control surface), a frame of a screen, a step inside + another write (`storeMcpToken`). These fail closed and return a value rather than throwing, + because a refusal is usually the answer. Giving one a cache key would create a key nothing reads + and an invalidation nothing triggers. + +The third kind still lives under `lib/`. It is not licence to call the server from a component. + +There are thirteen of these today — `agents`, `audit`, `auth`, `channels`, `components`, `computers`, +`connectors`, `credentials`, `package`, `plugins`, `sandboxed`, `skills`, `copilot`. They all look the +same on purpose. `lib/agents/queries.ts` and `lib/agents/mutations.ts` are the reference pair; read them +before writing a new one. + +**The one rule that matters:** a React component never calls `fetch`. If a component file contains +`fetch`, the change is wrong regardless of whether it works. + +## Procedures + +### Procedure 1: Add a read + +1. Create or open `app/src/lib//queries.ts`. +2. Declare the browser-shaped type for the payload — `Profile`, `Status`, + `Summary`, or `Record`, matching whichever sibling name fits. This type describes + what the browser receives, not what the database stores. +3. Include the server's authorization verdicts as fields on that type (`canManage`, `systemOwned`, + `mine`, `hasAuth`) and document them. The browser renders these flags; it never recomputes + ownership or permission rules from other fields. +4. Never put a secret's value in a read type. A credential is `hasAuth: boolean` or a + `revokedAt` timestamp. Secrets are write-only in this codebase. +5. Add or extend the key factory, named `Keys`: + + ```ts + export const agentKeys = { + all: ["agents"] as const, + list: (hidden = false) => ["agents", "list", { hidden }] as const, + detail: (agentId: string) => ["agents", "detail", agentId] as const, + }; + ``` + + `all` is always the bare entity name and is the invalidation root. List keys carry their + parameters as a trailing object so two filters are two cache entries. Every array is `as const`. + A single-key entity still gets a factory: `export const packageKeys = { active: ["tenant-package", "active"] as const };`. + +6. Export a factory function returning `queryOptions({ queryKey, queryFn })`, named + `QueryOptions`. Existing spellings: `agentListQueryOptions`, `agentQueryOptions`, + `agentComponentsQueryOptions`, `activePackageQueryOptions`. +7. Inside `queryFn`, call `client` with the path, the envelope key, and a `fallback` sentence. It + sends the credentials, checks the status, raises the server's message when there is one, and + unwraps the key so the caller receives the payload rather than the wrapper: + + ```ts + queryFn: (): Promise => + client("/api/agents", "agents", { fallback: "Could not load coworkers" }), + ``` + + Where the whole body is the payload, omit the key and read it: `(await client(path, { fallback + })).json()`. Where a failed status is an *answer* rather than an error — a refused component call, + a 401 that means "not signed in" — use `tryClient` and read the status. + +8. Annotate the `queryFn` return type explicitly (`(): Promise`). `client` is generic + in its payload, so the annotation is what fixes what the key unwraps to. + +### Procedure 2: Add a write + +1. Create or open `app/src/lib//mutations.ts`. +2. Declare the input type as `Input` — the shape the API accepts, which is not the form's + shape. Mapping between them is `form.ts`'s job (`agentInputFrom`). +3. Call `client`. Do **not** write a per-entity request helper — `agentRequest`, + `componentRequest` and two others each owned a private copy of the same credentials, headers and + error extraction, and one of the four had quietly dropped the extraction. `client` owns it now: + + ```ts + mutationFn: (input: AgentInput): Promise => + client("/api/agents", "agent", { method: "POST", body: input, fallback: FALLBACK }), + ``` + + Where the write returns nothing a caller needs, omit the key and `await client(path, { ... })`. + Keep one `const FALLBACK` per file rather than repeating the sentence: the reader of the failure + cares which entity failed, and within one file that never changes. + +4. Export one factory per write, named `MutationOptions(queryClient)`, returning + `mutationOptions({ mutationFn, onSuccess })`. Existing spellings: `createAgentMutationOptions`, + `updateAgentMutationOptions`, `duplicateAgentMutationOptions`, `setAgentHiddenMutationOptions`, + `deleteAgentMutationOptions`. +5. Give `mutationFn` exactly one parameter. For a single value that is the value + (`agentId: string`); for more than one it is a named `variables` object + (`{ agentId: string; input: AgentInput }`). +6. Invalidate on success — never patch the cache by hand: + + ```ts + onSuccess: () => queryClient.invalidateQueries({ queryKey: agentKeys.all }) + ``` + + With several writes in one file, wrap that in a private `invalidate(queryClient)` helper. + Server-derived fields are the reason: a hand-patched cache entry is a guess at what the server + decided, and it is wrong the first time the server adds a rule. +7. `queryClient.removeQueries` instead of `invalidateQueries` when the data should stop existing + rather than be refetched. Sign-out is the only current case + (`lib/auth/mutations.ts`). +8. A fire-and-forget write takes no `queryClient` and has no `onSuccess` + (`recordChannelActivityMutationOptions`). Deliberate, and it carries a comment saying why the + failure is acceptable. Do not reach for this to avoid writing error handling. + +### Procedure 3: Consume from a component + +1. Import the factories, never the endpoint — and import the `queryClient`, never call + `useQueryClient()`: + + ```ts + import { queryClient } from "@/query-client"; + + const credentials = useQuery(credentialListQueryOptions()); + const createCredential = useMutation(createCredentialMutationOptions(queryClient)); + ``` + +2. **`useQueryClient()` is not the convention here.** There is exactly one client: constructed at + module scope in `app/src/query-client.ts` and handed to both `QueryClientProvider` and the router + context in `main.tsx`. The hook therefore resolves to the same object the import already holds, + at the cost of a hook call and a `const` line. The import also works where a hook cannot — a + plain event handler, a module with no component around it, a test — which is why mutation + factories take a `QueryClient` parameter rather than reaching for the hook themselves. +3. Import with the `@/` alias — `@/lib/credentials/queries`, not `../../../lib/credentials/queries`. + Both spellings exist in the tree today; the alias is the correct one. +4. Branch on all four states in order, every time — pending, error, empty, rows — and never + dereference `data` without having handled the first three: + + ```tsx + {credentials.isPending ? null : credentials.error ? ( +

Could not load credentials.

+ ) : credentials.data?.length === 0 ? ( + No credentials are configured. + ) : ( + {/* rows */} + )} + ``` + +5. **The pending branch renders nothing.** OpenBot has no loading placeholder — no "Loading…" + text, no spinner, no skeleton, no shimmer. The section's heading is already on screen; what + arrives underneath it is the answer, and a placeholder that appears and vanishes inside a + local round-trip is a flicker rather than information. A few screens still carry + `Loading …` and one carries a `Skeleton` block, both from before this + decision. They are not the pattern to copy. +6. `isPending` is still branched on, and branched on **first**. Deleting the branch instead of + returning `null` from it would show the empty-state sentence — "No credentials are + configured." — for the whole duration of the fetch, which states something false. +7. This says nothing about mutations. A button the person just pressed still says `"Saving…"` or + `"Deleting…"`, because that is feedback for an action they took rather than a placeholder for + data they are waiting on. Derive it from the mutation, not from `useState`: + `disabled={... || createCredential.isPending}`. +8. Read `credentials.tsx` in `app/src/routes/_authed/admin/` for the whole pattern end to end — + with the caveat that its pending branch still renders text. + +### Procedure 4: Preload in a route + +1. When data gates navigation, load it in `beforeLoad` with `ensureQueryData` and the same options + factory the component uses: + + ```ts + const user = await context.queryClient.ensureQueryData(currentUserQueryOptions()); + if (!user) throw redirect({ to: "/sign" }); + ``` + +2. The factory is shared between the guard and the component on purpose — one key, one fetch, and + the component's `useQuery` is already warm. +3. Authorization decided here, not inside a component (`routes/_authed.tsx`, + `routes/_authed/admin/route.tsx`). +4. Inside `beforeLoad` and `loader`, use `context.queryClient` — the router's typed handle, and the + same singleton `main.tsx` put there. No import needed at those two call sites; everywhere else, + import it. + +## Decision Tree + +- Adding a screen that displays server data → Procedure 1, then Procedure 3. +- Adding a create, update, delete, or toggle → Procedure 2, then Procedure 3. +- The data decides whether the person is allowed on the page at all → Procedure 4. +- Changing an existing payload's shape → Procedure 1, step 2, and check every consumer of the type. +- Filtering or paginating an existing list → Procedure 1, step 5: a new parameter goes in the + trailing object of the list key, not into a second key factory. +- The data is form input rather than server state → not this skill; `lib//form.ts`. +- The data arrives over the CopilotKit runtime → not this skill; `lib/copilot/`. + +## Red Flags + +| Signal | What it means | Do instead | +|--------|---------------|------------| +| `fetch(` anywhere but `lib/client.ts` | Either the read has no key and nothing can invalidate it, or the transport has been rewritten by hand | Move it into `lib//` behind a factory, and call `client` | +| A module-private `Request` helper | Superseded. Four of these existed and one had lost its `body.error` extraction | Call `client` | +| `client` on an endpoint whose refusal is an answer | Turns the boundary working into an exception the caller has to catch | `tryClient`, and read the status | +| `body: JSON.stringify(x)` at a call site | Double-encoded; the client serialises | Pass the object | +| A one-shot tool call written as a `mutationOptions` factory | Gets a `queryClient` and an invalidation it has no use for | A plain function beside the factories | +| A `fallback` sentence repeated on every write in a file | The reader cares which entity failed, and that does not change within a file | One `const FALLBACK` per file | +| `Loading …`, a spinner, or a `Skeleton` while a query is pending | OpenBot uses no loading placeholder; the flicker costs more than the reassurance buys | Return `null` from the pending branch | +| The pending branch deleted rather than returning `null` | The empty-state sentence shows for the length of the fetch, asserting something false | Keep the branch, first in the chain, returning `null` | +| `const queryClient = useQueryClient()` | A hook call and a local binding for an object that is one import away, and unavailable outside a component | `import { queryClient } from "@/query-client"` | +| `new QueryClient()` anywhere outside `app/src/query-client.ts` | A second cache; queries written by one client are invisible to the other | Import the singleton. A test needing isolation constructs its own and passes it explicitly | +| `useQuery({ queryKey: ["agents"], ... })` at a call site | An inline key drifts from the factory and silently stops matching invalidations | Call the factory: `useQuery(agentListQueryOptions())` | +| `queryClient.setQueryData(...)` after a mutation | Guesses at server-derived fields; wrong the moment the server adds a rule | `invalidateQueries({ queryKey: Keys.all })` | +| A component computing `user.role === "admin" && thing.ownerId === user.id` | Duplicates an authorization rule that the server already decided | Render the server's flag (`canManage`, `mine`) | +| A read type carrying a token, key, or `plaintext` | Secrets are write-only in OpenBot | Expose `hasAuth: boolean` or a `revokedAt` timestamp | +| A hand-written `body.error` extraction | `client` already does it, and did it more consistently than the four copies did | Pass `fallback` and let it raise | +| `queryFn` returning `{ agents: [...] }` | Leaks the transport envelope into every component | Unwrap in the `queryFn`; components see the array | +| A second `Keys` object, or keys defined in `mutations.ts` | Two sources of truth for one cache namespace | One factory per entity, in `queries.ts`; `mutations.ts` imports it | + +## Error Handling + +- **A 401 in a `queryFn`**: `client` sends the credentials, so a 401 means the session expired — the + `_authed` guard handles the redirect on the next navigation. Do not add per-query redirect logic. + The one place a 401 is expected is `currentUserQueryOptions`, which uses `tryClient` because not + being signed in is an answer there rather than a failure. +- **The server returns no `error` field**: `client` falls back to the `fallback` option. Name the + entity in it ("Could not load coworkers"). Do not print a status code to a person. +- **A refusal arrives as a thrown `Error` instead of a value**: the call site used `client` where it + needed `tryClient`. The gateway declining is the product working, not a fault. +- **The server rejects a body it should accept, or reads it as a string**: something stringified + before handing it over. `client` serialises; a caller passes the object. +- **An invalidation does not refresh the list**: the key at the call site does not match the key the + mutation invalidated. Both must come from the same `Keys` factory. Check for an inline key + array before anything else. +- **Two filters of the same list overwrite each other's cache**: the parameter is missing from the + list key. Add it to the trailing object (`list: (hidden = false) => [..., { hidden }]`). +- **A mutation succeeds but the screen shows stale server-derived fields**: something patched the + cache instead of invalidating it. Remove the patch. +- **TypeScript cannot infer the `queryFn` return**: the explicit `Promise` annotation is missing + from the `queryFn` signature. Add it rather than casting at the call site. +- **"No X are configured." flashes before the list appears**: the pending branch is missing, or it + sits after the empty check. It goes first and returns `null`. +- **A query is slow enough that the blank feels broken**: the answer is not a placeholder, it is a + slow endpoint. Fix the endpoint, or preload the data in the route (Procedure 4) so the screen is + not entered until it is there. +- **A mutation factory is needed where no hook can run** — a bare event handler, a module with no + component around it, a `bun test` file: import `queryClient` from `app/src/query-client.ts` and + pass it in. This is the reason the singleton is the convention rather than the hook. +- **An invalidation appears to do nothing and the keys do match**: two clients exist. Search for + `new QueryClient(` outside `app/src/query-client.ts`. +- **Unsure which entity directory a new endpoint belongs to**: name it after the noun the URL is + about (`/api/agents/:id/plugins` is `plugins`, keyed by agent). If no existing directory fits, + create one with the same three-file shape rather than adding the read to a neighbour. diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md new file mode 100644 index 0000000..a8e5f96 --- /dev/null +++ b/.claude/skills/openbot-screen-layout/SKILL.md @@ -0,0 +1,211 @@ +--- +name: openbot-screen-layout +description: The default layout for every OpenBot configuration screen — PageShell and its prose/wide widths, PageSection and PageRows, Item row composition, the settings-row pattern where a summary and a chevron open a dialog, and the size and variant vocabulary. This is what a new screen looks like unless an instruction says otherwise. Use when adding or changing a screen under app/src/routes, adding a row to an admin or settings page, choosing a Button or Item size, picking between a bordered and a filled row, laying out a dialog, or reviewing a diff that adds max-w-*, a hand-drawn card, or a new spacing scale under app/src. Don't use for where the data comes from (that is openbot-data-access), for the gallery components under components/gallery that a Bot draws, for the chat and channel surfaces, or for editing the primitives under components/ui themselves. +--- + +# OpenBot Screen Layout + +## When To Use + +This skill applies to any change under `app/src/routes` that puts a configuration screen on the +screen — a new admin page, a new settings section, a new row on an existing page, a detail page +behind a list. Twelve screens render through `PageShell` today and seven build their rows out of +`Item`; all of them look the same on purpose. + +It does not cover where the data comes from — that is `openbot-data-access`, which owns queries, +mutations, and the pending/error/empty/rows branching. It does not cover the gallery components +under `components/gallery`, which a Bot draws inside a conversation rather than a person navigating +to. It does not cover the primitives under `components/ui`, which are shadcn files with their own +upstream. + +## The Default + +**A new screen uses this layout. Deviating from it needs a reason given in the request.** + +This is the point of the skill. The decisions here — the width, the spacing, the heading sizes, the +row anatomy, which control means what — are already made, and they are already made the same way on +every other screen. Somebody who does not work in the frontend should be able to add a page that +looks like it belongs without making a single visual decision, by reaching for `PageShell`, +`PageSection`, `PageRows` and `Item` and filling them in. + +The failure this prevents is the one the `PageShell` doc comment describes: Admin was once nine pages +that shared no layout — four container widths, four heading sizes, four padding schemes, three of +them drawing their own buttons and inputs. It did not read as a different screen, it read as a +different application, at exactly the moment an administrator was deciding whether to trust it with +credentials. + +So: no hand-drawn containers, no new widths, no new spacing scale, no second way to draw a row. If +the screen genuinely does not fit — a table, a canvas, an editor beside a live preview — that is a +deviation worth stating out loud and worth a comment saying why. + +## The Shape + +Four components make the frame. They compose in this order and nest no other way. + +``` +PageShell the page: header (title, description, action, backButton) then children + PageSection a titled group of unrelated decisions, with a deliberately large gap above it + PageRows the grouped card: rounded-lg border bg-card + Item one row, size="sm", divided from its neighbours by + PageEmpty what a section says when it has nothing to list +``` + +`PageShell` takes a `width`: `prose` (`max-w-2xl`) is the default because configuration is mostly +reading, and a row of label-and-control has no business being wider than the sentence explaining it. +`wide` (`max-w-5xl`) exists for one screen — `admin/audit.tsx`, because an audit log is a table to be +scanned and prose width would wrap every row. It is not a licence for anything else to be wide. + +`PageEmpty` is a sentence, not an illustration with a heading. On a configuration screen "nothing +here yet" is a fact, and the section heading already said what the section is for. + +## Procedures + +### Procedure 1: Lay out a screen + +1. Open with `PageShell`, giving it `title` and `description`. Leave `width` alone. +2. On a detail screen reached from a list, pass `backButton={{ label, linkProps }}` — it draws a + chevron-left bar above the header. Do not put a Back button in `action`; `action` is for the + page's one primary verb, on the title's baseline. +3. Group the page's decisions into `PageSection`s, each with a `title` and, where the grouping is not + self-evident, a `description`. +4. Give each section one `PageRows` card. Rows go inside it as `Item size="sm"`, with `` + between them and none after the last. `PageRows` is a card with dividers, not a stack of cards — + gaps between rows are the wrong shape. +5. Read `admin/connectors.tsx` for the whole pattern end to end, and + `admin/components/$name.tsx` for a screen with two sections and mixed row kinds. + +### Procedure 2: Compose a row + +1. `ItemMedia variant="icon"` holds a leading icon from `@tabler/icons-react`. It is what makes a + card of rows scannable; a row without one reads as a paragraph. +2. `ItemContent` holds `ItemTitle` and, where the row needs a second line, `ItemDescription`. +3. `ItemActions` holds the control, or the value when the row is read-only. +4. `ItemHeader` and `ItemFooter` are `basis-full`, so they wrap onto a line of their own inside the + row. That is where a **set** goes — chips, toggles, anything that would otherwise fight the label + for horizontal space. A set does not belong in `ItemActions`. +5. `ItemDescription` is `line-clamp-2`. Where the text is the point rather than a hint, pass + `className="line-clamp-none"`. +6. To make the whole row navigate or open something, pass `render`: + `render={ - {description ? ( -

- {description} -

- ) : null} - - {children} - + )} +
+
+
+

{title}

+ {action} +
+ {description ? ( +

+ {description} +

+ ) : null} +
+ {children} +
+ ); } @@ -126,8 +148,17 @@ export function PageRows({ className?: string; }) { return ( + /* + * The rows are squared off and the card clips them. `Item` carries `rounded-lg` of its own, which + * inside a card of divided rows painted a hover as a floating pill: a middle row has no corners, + * and the first and last cannot be concentric with the card while sitting a border inside it. + */
{children}
diff --git a/app/src/components/settings/background.tsx b/app/src/components/settings/background.tsx new file mode 100644 index 0000000..a7c3cf0 --- /dev/null +++ b/app/src/components/settings/background.tsx @@ -0,0 +1,162 @@ +import type { SVGProps } from "react"; + +/** + * Decorative waiting artwork for the fixed-size computer frame. + */ +export function SettingsItemBackground(props: SVGProps) { + return ( + + ); +} diff --git a/app/src/components/settings/settings-sidebar.tsx b/app/src/components/settings/settings-sidebar.tsx index 471e58f..5a99b39 100644 --- a/app/src/components/settings/settings-sidebar.tsx +++ b/app/src/components/settings/settings-sidebar.tsx @@ -1,4 +1,8 @@ -import { IconArrowLeft } from "@tabler/icons-react"; +import { + IconArrowLeft, + IconLayoutGrid, + IconSettings, +} from "@tabler/icons-react"; import { Link, type LinkOptions } from "@tanstack/react-router"; import type * as React from "react"; import { @@ -14,11 +18,31 @@ import { const appLinkOptions = { to: "/" } satisfies LinkOptions; -const ITEMS = [ +const ITEMS: { + /** + * Whether this entry lights only on its own route. + * + * On for an entry whose path is a prefix of another's, which is the only reason to want it. Off + * everywhere else, so an entry stays lit on the pages beneath it. + */ + exact?: boolean; + icon: React.ComponentType<{ className?: string }>; + linkOptions: LinkOptions; + title: string; +}[] = [ { title: "General", + icon: IconSettings, + /* `/settings` prefixes every other route here, and would otherwise light up on all of them. */ + exact: true, linkOptions: { to: "/settings" }, }, + { + /* The same mark Admin gives UI Components. It is the same subject seen from the other side. */ + title: "Components gallery", + icon: IconLayoutGrid, + linkOptions: { to: "/settings/components-gallery" }, + }, ]; export function SettingsSidebar({ @@ -26,11 +50,11 @@ export function SettingsSidebar({ }: React.ComponentProps) { return ( - + {/* Matched to the app sidebar's header, as Admin's is. See admin-sidebar.tsx. */} + ( @@ -42,29 +66,31 @@ export function SettingsSidebar({ - - - {ITEMS.map((option) => { - return ( - - ( - - {option.title} - - )} - /> - - ); - })} - - + {/* + * Group outside menu, as Admin has it. The other way round nests a list item inside a div + * inside the `ul`, which is not markup a list is allowed to be made of. + */} + + + {ITEMS.map((option) => ( + + ( + + + {option.title} + + )} + /> + + ))} + + diff --git a/app/src/components/skills/edit-skill.tsx b/app/src/components/skills/edit-skill.tsx index fa26cad..f4b4cd0 100644 --- a/app/src/components/skills/edit-skill.tsx +++ b/app/src/components/skills/edit-skill.tsx @@ -2,8 +2,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { SkillAgents } from "@/components/skills/skill-agents"; import { SkillFields } from "@/components/skills/skill-fields"; -import { pluginKeys, pluginsPageQueryOptions } from "@/lib/plugins/queries"; -import type { SkillFormValues } from "@/lib/skills/form"; +import { saveSkillMutationOptions } from "@/lib/plugins/mutations"; +import { pluginsPageQueryOptions } from "@/lib/plugins/queries"; /** * Editing a skill, in the same panel that writes one. @@ -25,39 +25,22 @@ export function EditSkill({ slug }: { slug: string }) { */ const skill = data?.skills.find((candidate) => candidate.slug === slug); - const saveSkill = useMutation({ - mutationFn: async (values: SkillFormValues) => { - const response = await fetch("/api/plugins/skills", { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify(values), - }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as { - error?: string; - } | null; - throw new Error(body?.error ?? "The skill could not be saved."); - } - return response.json(); - }, - onSuccess: () => - queryClient.invalidateQueries({ queryKey: pluginKeys.all }), - }); + const saveSkill = useMutation(saveSkillMutationOptions(queryClient)); + + /* Nothing while it loads. "Missing" and "not yet arrived" must not read the same. */ + if (isPending) return null; if (!skill) { return (
+ {/* + * Said plainly rather than shown as an empty form. A skill can be missing because it was + * deleted in another tab, or because the link names one that is somebody else's — and an + * empty form here would invite them to write it back into existence under a slug they may + * not own. + */}

- {isPending - ? "Loading…" - : /* - * Said plainly rather than shown as an empty form. A skill can be missing because it - * was deleted in another tab, or because the link names one that is somebody else's — - * and an empty form here would invite them to write it back into existence under a - * slug they may not own. - */ - "That skill no longer exists, or it is not yours to edit."} + That skill no longer exists, or it is not yours to edit.

); diff --git a/app/src/components/skills/new-skill.tsx b/app/src/components/skills/new-skill.tsx index a81c9cf..8e7fc42 100644 --- a/app/src/components/skills/new-skill.tsx +++ b/app/src/components/skills/new-skill.tsx @@ -1,8 +1,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { SkillFields } from "@/components/skills/skill-fields"; -import { pluginKeys } from "@/lib/plugins/queries"; -import { emptySkillForm, type SkillFormValues } from "@/lib/skills/form"; +import { saveSkillMutationOptions } from "@/lib/plugins/mutations"; +import { emptySkillForm } from "@/lib/skills/form"; /** * Writing a skill, in the detail panel beside the list. @@ -14,30 +14,7 @@ export function NewSkill() { const queryClient = useQueryClient(); const navigate = useNavigate(); - const createSkill = useMutation({ - mutationFn: async (values: SkillFormValues) => { - const response = await fetch("/api/plugins/skills", { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify(values), - }); - if (!response.ok) { - /* - * The server's sentence, not one invented here. It refuses for reasons this form cannot - * check — a slug somebody else already owns is the common one — and paraphrasing that into - * "That did not work" would throw away the only part worth reading. - */ - const body = (await response.json().catch(() => null)) as { - error?: string; - } | null; - throw new Error(body?.error ?? "The skill could not be saved."); - } - return response.json(); - }, - onSuccess: () => - queryClient.invalidateQueries({ queryKey: pluginKeys.all }), - }); + const createSkill = useMutation(saveSkillMutationOptions(queryClient)); return (
diff --git a/app/src/components/skills/skill-agents.tsx b/app/src/components/skills/skill-agents.tsx index 5c1eb2c..1603f5d 100644 --- a/app/src/components/skills/skill-agents.tsx +++ b/app/src/components/skills/skill-agents.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { agentListQueryOptions } from "@/lib/agents/queries"; -import { pluginKeys } from "@/lib/plugins/queries"; +import { setPluginGrantMutationOptions } from "@/lib/plugins/mutations"; /** * Which of your Agents carry this skill. @@ -28,30 +28,11 @@ export function SkillAgents({ const mine = (agents ?? []).filter((agent) => agent.mine); const held = new Set(grantedTo); - const toggle = useMutation({ - mutationFn: async ({ agentId, on }: { agentId: string; on: boolean }) => { - const response = on - ? await fetch( - `/api/plugins/grants?kind=skill&ref=${encodeURIComponent(slug)}&agentId=${encodeURIComponent(agentId)}`, - { method: "DELETE", credentials: "include" }, - ) - : await fetch("/api/plugins/grants", { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ kind: "skill", ref: slug, agentId }), - }); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as { - error?: string; - } | null; - // The server's sentence: it knows why it refused and this component does not. - throw new Error(body?.error ?? "That Agent could not be changed."); - } - }, - onSuccess: () => - queryClient.invalidateQueries({ queryKey: pluginKeys.all }), - }); + const grant = useMutation(setPluginGrantMutationOptions(queryClient)); + + /* `on` is the current state, so a click asks for its opposite. */ + const toggle = (agentId: string, on: boolean) => + grant.mutate({ agentId, granted: !on, kind: "skill", ref: slug }); return (
@@ -67,9 +48,9 @@ export function SkillAgents({ const on = held.has(agent.id); return (
diff --git a/app/src/components/ui/dialog.tsx b/app/src/components/ui/dialog.tsx index e985879..08880c9 100644 --- a/app/src/components/ui/dialog.tsx +++ b/app/src/components/ui/dialog.tsx @@ -98,12 +98,21 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { ); } -/** Body scrolls, so the header and footer stay put on a short viewport. */ +/** + * Body scrolls, so the header and footer stay put on a short viewport. + * + * `overflow-y-auto` is the half that was missing. `flex-1 min-h-0` lets this shrink, but without an + * overflow it shrinks and then paints its content over the footer — which every dialog here was short + * enough never to show. + */ function DialogBody({ className, ...props }: React.ComponentProps<"div">) { return (
); diff --git a/app/src/lib/agents/mutations.ts b/app/src/lib/agents/mutations.ts index fc73e8a..b0b0043 100644 --- a/app/src/lib/agents/mutations.ts +++ b/app/src/lib/agents/mutations.ts @@ -1,4 +1,5 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; import { type AgentProfile, type AgentVisibility, agentKeys } from "./queries"; export type AgentInput = { @@ -12,30 +13,8 @@ export type AgentInput = { auth?: { header: string; value: string }; }; -async function agentRequest( - path: string, - init: { method: string; body?: AgentInput }, -): Promise { - const response = await fetch(path, { - method: init.method, - credentials: "include", - headers: init.body ? { "content-type": "application/json" } : undefined, - body: init.body ? JSON.stringify(init.body) : undefined, - }); - if (!response.ok) { - // The server's message is the useful one: it names the field or the permission that failed. - const message = await response - .json() - .then((body: { error?: string }) => body.error) - .catch(() => undefined); - throw new Error(message ?? "Coworker operation failed"); - } - return response; -} - -async function agentFrom(response: Response): Promise { - return ((await response.json()) as { agent: AgentProfile }).agent; -} +/** The sentence for every write here, since they all fail the same way to a reader. */ +const FALLBACK = "Coworker operation failed"; /** Server-derived fields are invalidated instead of patched by hand. */ function invalidateAgents(queryClient: QueryClient) { @@ -44,35 +23,38 @@ function invalidateAgents(queryClient: QueryClient) { export function createAgentMutationOptions(queryClient: QueryClient) { return mutationOptions({ - mutationFn: async (input: AgentInput) => - agentFrom( - await agentRequest("/api/agents", { method: "POST", body: input }), - ), + mutationFn: (input: AgentInput): Promise => + client("/api/agents", "agent", { + method: "POST", + body: input, + fallback: FALLBACK, + }), onSuccess: () => invalidateAgents(queryClient), }); } export function updateAgentMutationOptions(queryClient: QueryClient) { return mutationOptions({ - mutationFn: async (variables: { agentId: string; input: AgentInput }) => - agentFrom( - await agentRequest(`/api/agents/${variables.agentId}`, { - method: "PATCH", - body: variables.input, - }), - ), + mutationFn: (variables: { + agentId: string; + input: AgentInput; + }): Promise => + client(`/api/agents/${variables.agentId}`, "agent", { + method: "PATCH", + body: variables.input, + fallback: FALLBACK, + }), onSuccess: () => invalidateAgents(queryClient), }); } export function duplicateAgentMutationOptions(queryClient: QueryClient) { return mutationOptions({ - mutationFn: async (agentId: string) => - agentFrom( - await agentRequest(`/api/agents/${agentId}/duplicate`, { - method: "POST", - }), - ), + mutationFn: (agentId: string): Promise => + client(`/api/agents/${agentId}/duplicate`, "agent", { + method: "POST", + fallback: FALLBACK, + }), onSuccess: () => invalidateAgents(queryClient), }); } @@ -80,9 +62,9 @@ export function duplicateAgentMutationOptions(queryClient: QueryClient) { export function setAgentHiddenMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (variables: { agentId: string; hidden: boolean }) => { - await agentRequest( + await client( `/api/agents/${variables.agentId}/${variables.hidden ? "hide" : "unhide"}`, - { method: "POST" }, + { method: "POST", fallback: FALLBACK }, ); }, onSuccess: () => invalidateAgents(queryClient), @@ -92,7 +74,10 @@ export function setAgentHiddenMutationOptions(queryClient: QueryClient) { export function deleteAgentMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (agentId: string) => { - await agentRequest(`/api/agents/${agentId}`, { method: "DELETE" }); + await client(`/api/agents/${agentId}`, { + method: "DELETE", + fallback: FALLBACK, + }); }, onSuccess: () => invalidateAgents(queryClient), }); @@ -107,13 +92,11 @@ export function deleteAgentMutationOptions(queryClient: QueryClient) { */ export function issueCallbackTokenMutationOptions(queryClient: QueryClient) { return mutationOptions({ - mutationFn: async (agentId: string): Promise => { - const response = await agentRequest( - `/api/agents/${agentId}/callback-token`, - { method: "POST" }, - ); - return ((await response.json()) as { token: string }).token; - }, + mutationFn: (agentId: string): Promise => + client(`/api/agents/${agentId}/callback-token`, "token", { + method: "POST", + fallback: FALLBACK, + }), onSuccess: () => invalidateAgents(queryClient), }); } @@ -122,8 +105,9 @@ export function issueCallbackTokenMutationOptions(queryClient: QueryClient) { export function revokeCallbackTokenMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (agentId: string) => { - await agentRequest(`/api/agents/${agentId}/callback-token`, { + await client(`/api/agents/${agentId}/callback-token`, { method: "DELETE", + fallback: FALLBACK, }); }, onSuccess: () => invalidateAgents(queryClient), diff --git a/app/src/lib/agents/queries.ts b/app/src/lib/agents/queries.ts index bf027b9..ce276d6 100644 --- a/app/src/lib/agents/queries.ts +++ b/app/src/lib/agents/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client, tryClient } from "@/lib/client"; export type AgentVisibility = "public" | "private"; @@ -47,28 +48,61 @@ export const agentKeys = { export function agentListQueryOptions(hidden = false) { return queryOptions({ queryKey: agentKeys.list(hidden), - queryFn: async (): Promise => { - const response = await fetch( - `/api/agents${hidden ? "?hidden=true" : ""}`, - { - credentials: "include", - }, - ); - if (!response.ok) throw new Error("Could not load coworkers"); - return ((await response.json()) as { agents: AgentProfile[] }).agents; - }, + queryFn: (): Promise => + client(`/api/agents${hidden ? "?hidden=true" : ""}`, "agents", { + fallback: "Could not load coworkers", + }), }); } export function agentQueryOptions(agentId: string) { return queryOptions({ queryKey: agentKeys.detail(agentId), - queryFn: async (): Promise => { - const response = await fetch(`/api/agents/${agentId}`, { - credentials: "include", - }); - if (!response.ok) throw new Error("Could not load this coworker"); - return ((await response.json()) as { agent: AgentProfile }).agent; - }, + queryFn: (): Promise => + client(`/api/agents/${agentId}`, "agent", { + fallback: "Could not load this coworker", + }), }); } + +/** What the server said when it tried the endpoint. */ +export type ConnectionVerdict = + | { ok: true; events: string[] } + | { ok: false; reason: string }; + +/** + * Ask the server to reach a coworker's endpoint, from where a run will reach it. + * + * A plain function rather than a factory: the answer is about this moment, nothing caches it, and + * there is no key for anything to invalidate. Fails closed, like the other verdicts here — an + * endpoint that cannot be tested is reported as unreachable rather than thrown at the form. + * + * The unsaved key is sent so the test matches the form as it stands, not as it was last saved. + */ +export async function testAgentConnection( + endpoint: string, + key: string, +): Promise { + try { + const response = await tryClient("/api/agents/test-connection", { + method: "POST", + body: { + endpoint, + ...(key.trim() ? { headers: { Authorization: key.trim() } } : {}), + }, + }); + const body = (await response.json().catch(() => null)) as + | ConnectionVerdict + | { error?: string } + | null; + if (body && "ok" in body) return body; + return { + ok: false, + reason: + (body as { error?: string } | null)?.error ?? + "The connection could not be tested.", + }; + } catch { + return { ok: false, reason: "The connection could not be tested." }; + } +} diff --git a/app/src/lib/audit/queries.ts b/app/src/lib/audit/queries.ts index 9f0d062..fe15f9c 100644 --- a/app/src/lib/audit/queries.ts +++ b/app/src/lib/audit/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; export const auditKeys = { all: ["audit-events"] as const }; @@ -6,10 +7,9 @@ export function auditEventsQueryOptions(search = "") { return queryOptions({ queryKey: [...auditKeys.all, search] as const, queryFn: async () => { - const response = await fetch(`/api/admin/audit-events${search}`, { - credentials: "include", + const response = await client(`/api/admin/audit-events${search}`, { + fallback: "Could not load audit events", }); - if (!response.ok) throw new Error("Could not load audit events"); return response.json(); }, }); diff --git a/app/src/lib/auth/mutations.ts b/app/src/lib/auth/mutations.ts index 164a4e7..771ee0d 100644 --- a/app/src/lib/auth/mutations.ts +++ b/app/src/lib/auth/mutations.ts @@ -1,14 +1,12 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; import { authKeys } from "./queries"; async function signOut() { - const response = await fetch("/api/auth/sign-out", { + await client("/api/auth/sign-out", { method: "POST", - credentials: "include", + fallback: "Could not sign out", }); - if (!response.ok) { - throw new Error(`Could not sign out (${response.status})`); - } } export function signOutMutationOptions(queryClient: QueryClient) { diff --git a/app/src/lib/auth/queries.ts b/app/src/lib/auth/queries.ts index 2b1f2a8..aee0db5 100644 --- a/app/src/lib/auth/queries.ts +++ b/app/src/lib/auth/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { tryClient } from "@/lib/client"; export type AuthenticatedUser = { id: string; @@ -14,7 +15,11 @@ export const authKeys = { }; async function currentUser(): Promise { - const response = await fetch("/api/me", { credentials: "include" }); + /* + * `tryClient` rather than `client`: not being signed in is an answer here, not a failure, and it + * arrives as a 401 that has to be read before anything decides the request went wrong. + */ + const response = await tryClient("/api/me"); if (response.status === 401) { return null; } diff --git a/app/src/lib/channels/mutations.ts b/app/src/lib/channels/mutations.ts index 776756c..95f7185 100644 --- a/app/src/lib/channels/mutations.ts +++ b/app/src/lib/channels/mutations.ts @@ -1,4 +1,5 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client, tryClient } from "@/lib/client"; import { type AgentChannel, channelKeys } from "./queries"; /** @@ -9,19 +10,11 @@ import { type AgentChannel, channelKeys } from "./queries"; export function createChannelMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: async (agentIds: string[]): Promise => { - const response = await fetch("/api/channels", { + const response = await client("/api/channels", { method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ agentIds }), + body: { agentIds }, + fallback: "Could not start a channel", }); - if (!response.ok) { - const message = await response - .json() - .then((body: { error?: string }) => body.error) - .catch(() => undefined); - throw new Error(message ?? "Could not start a channel"); - } return ((await response.json()) as { channel: AgentChannel }).channel; }, onSuccess: () => @@ -45,15 +38,14 @@ export function recordChannelActivityMutationOptions() { agentId: string | null; at: string; }) => { - await fetch(`/api/channels/${variables.channelId}/activity`, { + /* Still fire-and-forget: `tryClient` does not throw, and the result is not read. */ + await tryClient(`/api/channels/${variables.channelId}/activity`, { method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ + body: { agentId: variables.agentId, at: variables.at, text: variables.text, - }), + }, }); }, }); diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts index 7a58550..21124da 100644 --- a/app/src/lib/channels/queries.ts +++ b/app/src/lib/channels/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; /** * A channel as the browser sees it. @@ -35,12 +36,9 @@ export function channelListQueryOptions() { return queryOptions({ queryKey: channelKeys.list(), queryFn: async (): Promise => { - const response = await fetch("/api/channels", { - credentials: "include", + return client("/api/channels", "channels", { + fallback: "Could not load channels", }); - if (!response.ok) throw new Error("Could not load channels"); - return ((await response.json()) as { channels: ChannelSummary[] }) - .channels; }, }); } @@ -49,11 +47,9 @@ export function channelQueryOptions(channelId: string) { return queryOptions({ queryKey: channelKeys.detail(channelId), queryFn: async (): Promise => { - const response = await fetch(`/api/channels/${channelId}`, { - credentials: "include", + return client(`/api/channels/${channelId}`, "channel", { + fallback: "Could not load this channel", }); - if (!response.ok) throw new Error("Could not load this channel"); - return ((await response.json()) as { channel: AgentChannel }).channel; }, }); } diff --git a/app/src/lib/client.ts b/app/src/lib/client.ts new file mode 100644 index 0000000..2abae4c --- /dev/null +++ b/app/src/lib/client.ts @@ -0,0 +1,102 @@ +/** + * The one place the browser talks to the API server. + * + * WHAT THIS REPLACES. Every read opened with the same four lines — fetch with `credentials`, check + * `ok`, throw a sentence, unwrap the envelope — and every entity that wrote more than once grew its + * own private copy of the same request helper: `agentRequest`, `componentRequest`, and two others, + * identical apart from the fallback sentence. Four copies of one function is four places for the + * `body.error` extraction to be forgotten, and it had been, in more than one of them. + * + * WHAT IT DOES NOT DO. It owns the transport and nothing about meaning. The envelope key and the + * sentence a person reads are per-endpoint facts, so they stay at the call site — a client that + * guessed the envelope would be a client that had to be argued with. + */ + +export type ClientOptions = { + /** Absent means GET. */ + method?: string; + /** Serialised as JSON, which is also what sets the content type. */ + body?: unknown; + /** + * What a person reads when the server sent no message of its own. + * + * Name the entity in it — "Could not load coworkers" rather than "Request failed" — because this + * is the sentence that reaches the screen when the server is the one that broke. + */ + fallback?: string; + /** For the calls a Bot makes on a person's behalf, which are abandoned when the turn is. */ + signal?: AbortSignal; +}; + +/** Every request in this app is authenticated, and every one of them is JSON or nothing. */ +async function send(path: string, options: ClientOptions): Promise { + return fetch(path, { + method: options.method, + credentials: "include", + headers: + options.body === undefined + ? undefined + : { "content-type": "application/json" }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + ...(options.signal ? { signal: options.signal } : {}), + }); +} + +/** + * A request whose failure is a value rather than a throw. + * + * For the endpoints where a refusal is the answer: the gateway declining a component call, or the + * catalogue announcement that is allowed to come back empty. Those callers read the status + * themselves, and turning a refusal into an exception would make the boundary working look like the + * boundary breaking. + */ +export function tryClient( + path: string, + options: ClientOptions = {}, +): Promise { + return send(path, options); +} + +/** + * A request that throws when the server says no, carrying the server's own message. + * + * With a `key`, the JSON body is parsed and that key unwrapped, so a caller receives the payload + * rather than the envelope it arrived in. Without one, the `Response` is returned for a caller that + * only needed to know it worked. + */ +export async function client( + path: string, + key: string, + options?: ClientOptions, +): Promise; +export async function client( + path: string, + options?: ClientOptions, +): Promise; +export async function client( + path: string, + keyOrOptions?: string | ClientOptions, + maybeOptions?: ClientOptions, +): Promise { + const key = typeof keyOrOptions === "string" ? keyOrOptions : undefined; + const options = + (typeof keyOrOptions === "string" ? maybeOptions : keyOrOptions) ?? {}; + + const response = await send(path, options); + + if (!response.ok) { + /* + * The server's message is the useful one: it names the field or the permission that failed. The + * fallback is only for the cases where it sent none, or sent something that is not JSON. + */ + const message = await response + .json() + .then((body: { error?: string }) => body.error) + .catch(() => undefined); + throw new Error(message ?? options.fallback ?? "That request failed."); + } + + if (key === undefined) return response; + + return ((await response.json()) as Record)[key]; +} diff --git a/app/src/lib/components/mutations.ts b/app/src/lib/components/mutations.ts new file mode 100644 index 0000000..9de0711 --- /dev/null +++ b/app/src/lib/components/mutations.ts @@ -0,0 +1,106 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { componentKeys } from "./queries"; + +/** + * Writes against a component's governance: publication, per-Bot grants, per-function grants, and the + * draft description. + * + * Every one of these is a decision the server records and may refuse, so none of them patch the + * cache — the list is invalidated and the screen re-reads whatever was actually decided. + */ + +/** The sentence for every write here, for the rare case the server sends none of its own. */ +const FALLBACK = "Component operation failed"; + +/** Server-derived fields are invalidated instead of patched by hand. */ +function invalidateComponents(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: componentKeys.all }); +} + +/** The path segment for one component, which is a tool name rather than an opaque id. */ +function componentPath(name: string): string { + return `/api/components/${encodeURIComponent(name)}`; +} + +/** + * Whether one Bot may answer with a component. + * + * Granting posts to the collection; withholding deletes from it. The absence of a grant is what + * withholds it, so there is no third state to send. + */ +export function setComponentGrantMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + name: string; + agentId: string; + granted: boolean; + }) => { + await (variables.granted + ? client(`${componentPath(variables.name)}/grants`, { + method: "POST", + body: { agentId: variables.agentId }, + fallback: FALLBACK, + }) + : client( + `${componentPath(variables.name)}/grants/${encodeURIComponent(variables.agentId)}`, + { method: "DELETE", fallback: FALLBACK }, + )); + }, + onSuccess: () => invalidateComponents(queryClient), + }); +} + +/** Whether a component may read one deployment data function. */ +export function setComponentFunctionMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + name: string; + functionName: string; + granted: boolean; + }) => { + await (variables.granted + ? client(`${componentPath(variables.name)}/functions`, { + method: "POST", + body: { function: variables.functionName }, + fallback: FALLBACK, + }) + : client( + `${componentPath(variables.name)}/functions/${encodeURIComponent(variables.functionName)}`, + { method: "DELETE", fallback: FALLBACK }, + )); + }, + onSuccess: () => invalidateComponents(queryClient), + }); +} + +/** Whether any Bot is told the component exists. */ +export function setComponentPublishedMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { name: string; published: boolean }) => { + await client(`${componentPath(variables.name)}/publication`, { + method: "POST", + body: { published: variables.published }, + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidateComponents(queryClient), + }); +} + +/** + * The description the model will read once it is published. Saving it changes nothing a Bot can see + * until publication, which is why it is a separate write. + */ +export function saveComponentDraftMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { name: string; description: string }) => { + await client(`${componentPath(variables.name)}/draft`, { + method: "PUT", + body: { description: variables.description }, + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidateComponents(queryClient), + }); +} diff --git a/app/src/lib/components/queries.ts b/app/src/lib/components/queries.ts index 2170dd3..5a0b75e 100644 --- a/app/src/lib/components/queries.ts +++ b/app/src/lib/components/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client, tryClient } from "@/lib/client"; /** A component as the Admin surface sees it: its state, its versions and who is held back from it. */ export type ComponentRecord = { @@ -34,11 +35,11 @@ export function componentListQueryOptions() { return queryOptions({ queryKey: componentKeys.list(), queryFn: async (): Promise => { - const response = await fetch("/api/components", { - credentials: "include", - }); - if (!response.ok) throw new Error("The components could not be loaded."); - return (await response.json()).components ?? []; + return ( + (await client("/api/components", "components", { + fallback: "The components could not be loaded.", + })) ?? [] + ); }, }); } @@ -58,14 +59,13 @@ export function agentComponentsQueryOptions(agentId: string | undefined) { // out an interval before it shows. refetchOnWindowFocus: true, queryFn: async (): Promise => { - const response = await fetch( - `/api/components/for-agent/${encodeURIComponent(agentId ?? "")}`, - { credentials: "include" }, + return ( + (await client( + `/api/components/for-agent/${encodeURIComponent(agentId ?? "")}`, + "components", + { fallback: "This Bot's components could not be loaded." }, + )) ?? [] ); - if (!response.ok) { - throw new Error("This Bot's components could not be loaded."); - } - return (await response.json()).components ?? []; }, }); } @@ -80,11 +80,9 @@ export async function announceGallery( }[], ): Promise { try { - const response = await fetch("/api/components/catalogue", { + const response = await tryClient("/api/components/catalogue", { method: "PUT", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ components }), + body: { components }, }); if (!response.ok) return []; return (await response.json()).added ?? []; @@ -104,12 +102,11 @@ export function dataFunctionsQueryOptions() { return queryOptions({ queryKey: ["components", "functions"] as const, queryFn: async (): Promise => { - const response = await fetch("/api/components/functions", { - credentials: "include", - }); - if (!response.ok) - throw new Error("The data functions could not be loaded."); - return (await response.json()).functions ?? []; + return ( + (await client("/api/components/functions", "functions", { + fallback: "The data functions could not be loaded.", + })) ?? [] + ); }, }); } @@ -132,14 +129,9 @@ export async function callComponentFunction( error?: string; }> { try { - const response = await fetch( + const response = await tryClient( `/api/components/${encodeURIComponent(component)}/call`, - { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ agentId, args, function: functionName }), - }, + { method: "POST", body: { agentId, args, function: functionName } }, ); const payload = await response.json().catch(() => null); if (payload && typeof payload === "object") { @@ -169,14 +161,9 @@ export async function decideComponent( functions: readonly string[] = [], ): Promise<{ allowed: boolean; reason?: string }> { try { - const response = await fetch( + const response = await tryClient( `/api/components/${encodeURIComponent(name)}/decision`, - { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ agentId, functions }), - }, + { method: "POST", body: { agentId, functions } }, ); if (!response.ok) { return { diff --git a/app/src/lib/computers/control.ts b/app/src/lib/computers/control.ts new file mode 100644 index 0000000..eddca09 --- /dev/null +++ b/app/src/lib/computers/control.ts @@ -0,0 +1,98 @@ +import { tryClient } from "@/lib/client"; + +/** + * Handing control of a Bot's computer to a person, and back. + * + * Plain functions rather than factories, and every one of them fails closed. Nothing here is cached: + * who holds the wheel is a fact about this second, and a stale copy of it would be worse than no + * copy — it would show somebody a screen they cannot drive, or let them think they can. + * + * The reads answer `null` on failure rather than throwing. A panel that cannot say who is driving + * should say nothing, not tear down the screen the person is looking at. + */ + +export type ControlState = { + holder: "bot" | "human"; + since: string; + reason?: string; + requested: boolean; + /** What the Bot is waiting for, by name only. Present means show the masked prompt. */ + secretWanted?: string; +}; + +async function callControl( + computerId: string, + path: string, + method?: string, +): Promise { + const response = await tryClient( + `/api/computers/${computerId}${path}`, + method ? { method } : {}, + ); + if (!response.ok) return null; + return (await response.json()) as ControlState; +} + +export function readControl(computerId: string) { + return callControl(computerId, "/control"); +} + +export function takeControl(computerId: string) { + return callControl(computerId, "/control/take", "POST"); +} + +export function releaseControl(computerId: string) { + return callControl(computerId, "/control/release", "POST"); +} + +/** + * Supply a secret synchronously and never echo the value back to the UI. + * + * The one call here that reports why it failed, because a person is waiting on the answer and a + * silent failure would leave them typing into something that is not listening. + */ +export async function supplySecret( + computerId: string, + text: string, +): Promise<{ ok: boolean; error?: string }> { + try { + const response = await tryClient( + `/api/computers/${computerId}/human/secret`, + { method: "POST", body: { text } }, + ); + if (response.ok) return { ok: true }; + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + return { ok: false, error: body?.error ?? "That could not be entered." }; + } catch { + return { + ok: false, + error: "The assistant's computer could not be reached.", + }; + } +} + +/** + * Serializes human input requests without blocking the caller; ordering matters for typed secrets. + */ +let inputQueue: Promise = Promise.resolve(); + +/** + * Send one human input event. Returns immediately; delivery is ordered. + */ +export function sendHumanInput( + computerId: string, + kind: "click" | "type" | "key" | "scroll", + body: Record, +): void { + inputQueue = inputQueue + .then(() => + tryClient(`/api/computers/${computerId}/human/${kind}`, { + method: "POST", + body, + }), + ) + // Fire-and-forget: the user can see/retry input failures, while the input queue must keep moving. + .catch(() => undefined); +} diff --git a/app/src/lib/computers/mutations.ts b/app/src/lib/computers/mutations.ts new file mode 100644 index 0000000..8fa014b --- /dev/null +++ b/app/src/lib/computers/mutations.ts @@ -0,0 +1,47 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { type ActionPolicy, computerKeys } from "./queries"; + +/** Stopping frees the container; resetting also deletes the browser profile. */ +export type ComputerAction = "stop" | "reset"; + +function invalidateComputers(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: computerKeys.all }); +} + +export function setComputerStateMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + botId: string; + action: ComputerAction; + }) => { + await client( + `/api/computers/${encodeURIComponent(variables.botId)}/computers/${variables.action}`, + { + method: "POST", + fallback: `The computer could not be ${variables.action}.`, + }, + ); + }, + onSuccess: () => invalidateComputers(queryClient), + }); +} + +/** + * Replace the whole policy. + * + * A PUT rather than a patch because the rules are ordered and evaluated as a set: sending a + * difference would leave the server deciding where a new rule belongs, and where a deny sits + * relative to an allow is most of what a policy means. + */ +export function saveActionPolicyMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (next: ActionPolicy): Promise => + client("/api/computers/policy", "policy", { + method: "PUT", + body: next, + fallback: "The boundary could not be saved.", + }), + onSuccess: () => invalidateComputers(queryClient), + }); +} diff --git a/app/src/lib/computers/queries.ts b/app/src/lib/computers/queries.ts new file mode 100644 index 0000000..23873ae --- /dev/null +++ b/app/src/lib/computers/queries.ts @@ -0,0 +1,69 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** One Bot's computer, as Admin sees it. */ +export type ComputerProfile = { + botId: string; + running: boolean; + startedAt: string | null; + egress: string | null; +}; + +/** Whether each Bot has a browser profile of its own, or they share one. */ +export type ComputerIsolation = "per-bot" | "shared"; + +/** What the list endpoint answers: the computers, and how they are separated. */ +export type ComputerFleet = { + computers: ComputerProfile[]; + isolation?: ComputerIsolation; +}; + +/** + * Whether the boundary acts on its verdict. + * + * `dry-run` records what it would have refused without refusing it, which is how a policy is tried + * out before it stops a Bot mid-task. + */ +export type PolicyMode = "dry-run" | "enforce"; + +/** The rules a Bot's actions are judged against. */ +export type ActionPolicy = { + mode: PolicyMode; + deny: string[]; + allow: string[]; +}; + +export const computerKeys = { + all: ["computers"] as const, + fleet: () => ["computers", "fleet"] as const, + policy: () => ["computers", "policy"] as const, +}; + +/** + * A placeholder id in the path. The endpoint answers with every computer regardless, so this is + * addressing a collection through a member's route rather than naming one. + */ +const FLEET_ID = "openbot-computer"; + +/** No envelope key: the body carries both the list and the isolation mode. */ +export function computerFleetQueryOptions() { + return queryOptions({ + queryKey: computerKeys.fleet(), + queryFn: async (): Promise => { + const response = await client(`/api/computers/${FLEET_ID}/computers`, { + fallback: "The computers could not be listed.", + }); + return response.json(); + }, + }); +} + +export function actionPolicyQueryOptions() { + return queryOptions({ + queryKey: computerKeys.policy(), + queryFn: (): Promise => + client("/api/computers/policy", "policy", { + fallback: "The boundary could not be read.", + }), + }); +} diff --git a/app/src/lib/computers/screen.ts b/app/src/lib/computers/screen.ts new file mode 100644 index 0000000..874a543 --- /dev/null +++ b/app/src/lib/computers/screen.ts @@ -0,0 +1,42 @@ +import { tryClient } from "@/lib/client"; + +/** + * One frame of a Bot's screen. + * + * Not a cached read. Frames are polled while somebody is watching and are stale the moment after + * they arrive, so holding one in a query cache would mean serving a picture of a screen that has + * since moved. + */ +export type Screenshot = { + base64: string; + width: number; + height: number; + capturedAt: string; + /** `about:blank` when the browser has not been sent anywhere yet. Absent on older computers. */ + url?: string; +}; + +/** + * Read the current frame. + * + * Fails closed, and says why: the screen going unavailable is something the person watching needs + * told, and it is not a reason to tear down the panel they are watching it in. The caller decides + * whether to keep polling. + */ +export async function readScreenshot( + computerId: string, +): Promise<{ frame?: Screenshot; error?: string }> { + const unavailable = "The screen is not available right now."; + try { + const response = await tryClient(`/api/computers/${computerId}/screenshot`); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { + error?: string; + } | null; + return { error: body?.error ?? unavailable }; + } + return { frame: (await response.json()) as Screenshot }; + } catch { + return { error: unavailable }; + } +} diff --git a/app/src/lib/connectors/mutations.ts b/app/src/lib/connectors/mutations.ts new file mode 100644 index 0000000..0c627f8 --- /dev/null +++ b/app/src/lib/connectors/mutations.ts @@ -0,0 +1,30 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { connectorKeys } from "./queries"; + +/** What Google Drive needs before it can read anything on a deployment's behalf. */ +export type GoogleDriveSetupInput = { + serviceAccountJson: string; + impersonationSubject: string; +}; + +/** + * Configure the Google Drive connector. + * + * The service account JSON is a credential, so it goes one way only: it is sent here and never read + * back. What a later read returns is whether the connector is configured, not what it was configured + * with. + */ +export function setUpGoogleDriveMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: GoogleDriveSetupInput) => { + await client("/api/admin/connectors/google-drive/setup", { + method: "POST", + body: input, + fallback: "Could not set up Google Drive", + }); + }, + onSuccess: () => + queryClient.invalidateQueries({ queryKey: connectorKeys.all }), + }); +} diff --git a/app/src/lib/connectors/queries.ts b/app/src/lib/connectors/queries.ts index ac0e08f..6f3d984 100644 --- a/app/src/lib/connectors/queries.ts +++ b/app/src/lib/connectors/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; export type ConnectorStatus = { id: string; @@ -17,12 +18,9 @@ export function connectorListQueryOptions() { return queryOptions({ queryKey: connectorKeys.list(), queryFn: async (): Promise => { - const response = await fetch("/api/admin/connectors", { - credentials: "include", + return client("/api/admin/connectors", "connectors", { + fallback: "Could not load connectors", }); - if (!response.ok) throw new Error("Could not load connectors"); - return ((await response.json()) as { connectors: ConnectorStatus[] }) - .connectors; }, }); } diff --git a/app/src/lib/copilot/bot-thread.ts b/app/src/lib/copilot/bot-thread.ts index 087b852..8248055 100644 --- a/app/src/lib/copilot/bot-thread.ts +++ b/app/src/lib/copilot/bot-thread.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { tryClient } from "@/lib/client"; /** * The thread the direct Bot chat talks in. @@ -32,10 +33,7 @@ function remember(agentId: string, threadId: string): void { async function mint(): Promise { try { - const response = await fetch("/api/threads/mint", { - method: "POST", - credentials: "include", - }); + const response = await tryClient("/api/threads/mint", { method: "POST" }); if (!response.ok) return null; const body = (await response.json()) as { threadId?: unknown }; return typeof body.threadId === "string" ? body.threadId : null; diff --git a/app/src/lib/copilot/computer-tools.tsx b/app/src/lib/copilot/computer-tools.tsx index ce480b1..a7be0e1 100644 --- a/app/src/lib/copilot/computer-tools.tsx +++ b/app/src/lib/copilot/computer-tools.tsx @@ -1,11 +1,9 @@ import { useFrontendTool } from "@copilotkit/react-core/v2"; import { z } from "zod"; +import { tryClient } from "@/lib/client"; import { ToolLine } from "@/components/channels/tool-line"; import { ComputerView } from "@/components/computer/computer-view"; -import { - type ControlState, - readControl, -} from "@/components/computer/take-the-wheel"; +import { readControl, type ControlState } from "@/lib/computers/control"; import { useActiveBotHolder } from "./active-bot"; import { reportComputerActivity } from "./computer-activity"; @@ -45,18 +43,22 @@ async function waitForPerson( async function callComputer( botId: string, path: string, - init?: RequestInit, + /* + * A body, not a `RequestInit`. The client serialises it, so a caller that stringified first would + * send a JSON string of a JSON string — which is what happened, briefly, when this moved over. + */ + init?: { method?: string; body?: unknown }, signal?: AbortSignal, ): Promise { // Announce before the call so the screen can open while the action is running. reportComputerActivity(botId); let response: Response; try { - response = await fetch(`/api/computers/${botId}${path}`, { - credentials: "include", + response = await tryClient(`/api/computers/${botId}${path}`, { + method: init?.method, + body: init?.body, // Abort cancels the request and prevents later actions, but cannot undo browser work already executing. - ...(signal ? { signal } : {}), - ...init, + signal, }); } catch (error) { // An abort is a stopped run, not a computer failure. @@ -194,8 +196,7 @@ export function ComputerTools() { "/navigate", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ url }), + body: { url }, }, signal, ); @@ -285,8 +286,7 @@ export function ComputerTools() { "/type", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }, signal, ), @@ -327,8 +327,7 @@ export function ComputerTools() { "/click", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }, signal, ), @@ -378,8 +377,7 @@ export function ComputerTools() { "/key", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }, signal, ), @@ -426,8 +424,7 @@ export function ComputerTools() { "/control/secret", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }, signal, ); @@ -476,15 +473,9 @@ export function ComputerTools() { { signal }: { signal?: AbortSignal } = {}, ) => { try { - const response = await fetch( + const response = await tryClient( `/api/agents/${encodeURIComponent(bot.current)}/declined`, - { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), - ...(signal ? { signal } : {}), - }, + { method: "POST", body: input, signal }, ); return response.ok ? "Recorded. Now tell the person what you decided and why." @@ -521,8 +512,7 @@ export function ComputerTools() { "/control/request", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }, signal, ); @@ -563,8 +553,7 @@ export function ComputerTools() { handler: async (input: { path?: string }) => callComputer(bot.current, "/files/list", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input ?? {}), + body: input ?? {}, }), render: ({ result, status }) => { const outcome = outcomeOf(result); @@ -601,8 +590,7 @@ export function ComputerTools() { handler: async (input: { path: string }) => callComputer(bot.current, "/files/read", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }), render: ({ args, result, status }) => { const outcome = outcomeOf(result); @@ -649,8 +637,7 @@ export function ComputerTools() { }) => callComputer(bot.current, "/files/write", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }), render: ({ args, result, status }) => { const outcome = outcomeOf(result); @@ -692,8 +679,7 @@ export function ComputerTools() { "/scroll", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(input), + body: input, }, signal, ), diff --git a/app/src/lib/copilot/gallery-registry.ts b/app/src/lib/copilot/gallery-registry.ts index 152f774..be999ea 100644 --- a/app/src/lib/copilot/gallery-registry.ts +++ b/app/src/lib/copilot/gallery-registry.ts @@ -38,6 +38,9 @@ export type GalleryComponent = { * Props rather than tool arguments, because they are not always the same thing: a component that * suspends the run is handed the whole interaction, `{ status, args, respond }`, and would crash * on arguments alone. + * + * Omitted by a component that cannot be drawn away from a conversation, which is then shown as an + * unpreviewable tile rather than as a component that failed. */ preview?: Record; Component: (props: Record) => ReactElement | null; @@ -103,3 +106,17 @@ export function galleryManifest(): GalleryManifestEntry[] { export const RENDERABLE_NAMES: ReadonlySet = new Set( GALLERY_COMPONENTS.map((component) => component.name), ); + +const BY_NAME: ReadonlyMap = new Map( + GALLERY_COMPONENTS.map((component) => [component.name, component]), +); + +/** + * The component behind a catalogue name, or `undefined` where this build has no renderer for it. + * + * A deployment's component rows are governance state and outlive the build that drew them, so a + * name arriving from the server is not a promise that anything here can draw it. + */ +export function galleryComponent(name: string): GalleryComponent | undefined { + return BY_NAME.get(name); +} diff --git a/app/src/lib/copilot/thread-messages.ts b/app/src/lib/copilot/thread-messages.ts new file mode 100644 index 0000000..fcc2b8a --- /dev/null +++ b/app/src/lib/copilot/thread-messages.ts @@ -0,0 +1,26 @@ +import type { Message } from "@ag-ui/core"; +import { tryClient } from "@/lib/client"; + +/** + * The messages a thread already holds, for restoring a conversation somebody comes back to. + * + * A plain fail-closed function rather than a query. Nothing caches it — the transcript this seeds is + * then owned by the running agent, so a cached copy would be a second version of the same + * conversation — and an unreadable history is not a reason to keep somebody from typing. Every + * failure returns nothing and lets the composer open. + */ +export async function readThreadMessages( + threadId: string, + agentId: string, +): Promise { + try { + const response = await tryClient( + `/api/copilotkit/threads/${encodeURIComponent(threadId)}/messages?agentId=${encodeURIComponent(agentId)}`, + ); + if (!response.ok) return []; + const stored = (await response.json())?.messages; + return Array.isArray(stored) ? (stored as Message[]) : []; + } catch { + return []; + } +} diff --git a/app/src/lib/credentials/mutations.ts b/app/src/lib/credentials/mutations.ts index 950651a..5b15e13 100644 --- a/app/src/lib/credentials/mutations.ts +++ b/app/src/lib/credentials/mutations.ts @@ -1,4 +1,5 @@ import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; import { credentialKeys } from "./queries"; export type CredentialInput = { @@ -9,20 +10,14 @@ export type CredentialInput = { plaintext: string; }; -async function credentialRequest(path: string, body?: CredentialInput) { - const response = await fetch(path, { - method: "POST", - credentials: "include", - headers: body ? { "content-type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, - }); - if (!response.ok) throw new Error("Credential operation failed"); -} - export function createCredentialMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: (input: CredentialInput) => - credentialRequest("/api/admin/credentials", input), + client("/api/admin/credentials", { + method: "POST", + body: input, + fallback: "Credential operation failed", + }), onSuccess: () => queryClient.invalidateQueries({ queryKey: credentialKeys.all }), }); @@ -31,8 +26,44 @@ export function createCredentialMutationOptions(queryClient: QueryClient) { export function revokeCredentialMutationOptions(queryClient: QueryClient) { return mutationOptions({ mutationFn: (credentialId: string) => - credentialRequest(`/api/admin/credentials/${credentialId}/revoke`), + client(`/api/admin/credentials/${credentialId}/revoke`, { + method: "POST", + fallback: "Credential operation failed", + }), onSuccess: () => queryClient.invalidateQueries({ queryKey: credentialKeys.all }), }); } + +/** + * Store an MCP server's token and hand back the credential id. + * + * A plain function rather than a factory, because it is a step inside another write rather than a + * write somebody asked for: a plugin server record keeps only the credential id, so the token has to + * become a credential before the server can be created. Returns `undefined` for an empty token, + * which is how a server with no auth is added. + * + * The token goes one way. What a later read returns is that a credential exists, never its value. + */ +export async function storeMcpToken( + serverId: string, + token?: string, +): Promise { + if (!token?.trim()) return undefined; + const credential = await client<{ id: string }>( + "/api/admin/credentials", + "credential", + { + method: "POST", + body: { + kind: "mcp", + provider: serverId, + keyId: `mcp-${serverId}`, + plaintext: token.trim(), + metadata: { server: serverId }, + }, + fallback: "The token could not be stored.", + }, + ); + return credential?.id; +} diff --git a/app/src/lib/credentials/queries.ts b/app/src/lib/credentials/queries.ts index 4cc2c7e..fe19bb4 100644 --- a/app/src/lib/credentials/queries.ts +++ b/app/src/lib/credentials/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; export type CredentialStatus = { id: string; @@ -18,12 +19,9 @@ export function credentialListQueryOptions() { return queryOptions({ queryKey: credentialKeys.list(), queryFn: async (): Promise => { - const response = await fetch("/api/admin/credentials", { - credentials: "include", + return client("/api/admin/credentials", "credentials", { + fallback: "Could not load credentials", }); - if (!response.ok) throw new Error("Could not load credentials"); - return ((await response.json()) as { credentials: CredentialStatus[] }) - .credentials; }, }); } diff --git a/app/src/lib/package/queries.ts b/app/src/lib/package/queries.ts index 630df72..24505cb 100644 --- a/app/src/lib/package/queries.ts +++ b/app/src/lib/package/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; export const packageKeys = { active: ["tenant-package", "active"] as const }; @@ -6,10 +7,9 @@ export function activePackageQueryOptions() { return queryOptions({ queryKey: packageKeys.active, queryFn: async () => { - const response = await fetch("/api/admin/package", { - credentials: "include", + const response = await client("/api/admin/package", { + fallback: "Could not load the active package", }); - if (!response.ok) throw new Error("Could not load the active package"); return response.json(); }, }); diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts new file mode 100644 index 0000000..126fb30 --- /dev/null +++ b/app/src/lib/plugins/mutations.ts @@ -0,0 +1,171 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { pluginKeys } from "./queries"; + +/** + * Writes against what a deployment has installed: MCP servers, skills, and which Bots carry them. + * + * Servers and skills are two kinds of the same thing here — a plugin the deployment holds and grants + * — which is why one grant endpoint serves both and takes the kind as an argument rather than having + * two of everything. + */ + +/** A skill as the server accepts it. `global` is an administrator writing for everybody. */ +export type SkillInput = { + slug: string; + title: string; + summary?: string; + instructions: string; + global?: boolean; +}; + +/** A curated server from the catalogue, which supplies the URL. */ +export type CuratedServerInput = { + key: string; + instanceHost?: string; + credentialId?: string; +}; + +/** + * A server somebody typed the URL of, which therefore has to pass the URL checks. + * + * `token` is carried through as the previous version did. It is already a credential by the time + * this is sent — the id beside it is what the record keeps — so the server has no use for it. + */ +export type CustomServerInput = { + id: string; + title: string; + url: string; + token?: string; + credentialId?: string; +}; + +/** Which kinds of plugin a grant can be about. */ +export type PluginKind = "mcp" | "skill"; + +const FALLBACK = "That did not work."; + +function invalidatePlugins(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: pluginKeys.all }); +} + +/** + * Whether one Bot carries one plugin. + * + * Granting posts to the collection; withholding deletes from it, and the delete identifies the row + * by query string because a grant has no id of its own — it is the three things it joins. + */ +export function setPluginGrantMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (variables: { + kind: PluginKind; + ref: string; + agentId: string; + granted: boolean; + }) => { + if (variables.granted) { + await client("/api/plugins/grants", { + method: "POST", + body: { + kind: variables.kind, + ref: variables.ref, + agentId: variables.agentId, + }, + fallback: "That Agent could not be changed.", + }); + return; + } + await client( + `/api/plugins/grants?kind=${variables.kind}&ref=${encodeURIComponent(variables.ref)}&agentId=${encodeURIComponent(variables.agentId)}`, + { method: "DELETE", fallback: "That Agent could not be changed." }, + ); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +export function addCuratedServerMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: CuratedServerInput) => { + await client("/api/plugins/servers", { + method: "POST", + body: input, + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +export function addCustomServerMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: CustomServerInput) => { + await client("/api/plugins/servers/custom", { + method: "POST", + body: input, + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +/** Re-read a server's tool list, which is what makes a newly-added tool appear. */ +export function refreshPluginServerMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (serverId: string) => { + await client(`/api/plugins/servers/${serverId}/refresh`, { + method: "POST", + body: {}, + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +export function removePluginServerMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (serverId: string) => { + await client(`/api/plugins/servers/${encodeURIComponent(serverId)}`, { + method: "DELETE", + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +/** + * Write a skill, or rewrite one. + * + * One endpoint for both: the slug is the identity, so posting an existing one replaces it. The + * fallback names saving rather than creating for that reason. + */ +export function saveSkillMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (input: SkillInput): Promise => + client("/api/plugins/skills", { + method: "POST", + body: input, + /* + * The server refuses for reasons a form cannot check — a slug somebody else already owns is + * the common one — and paraphrasing that would throw away the only part worth reading. + */ + fallback: "The skill could not be saved.", + }), + onSuccess: () => invalidatePlugins(queryClient), + }); +} + +export function removeSkillMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (slug: string) => { + await client(`/api/plugins/skills/${encodeURIComponent(slug)}`, { + method: "DELETE", + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidatePlugins(queryClient), + }); +} diff --git a/app/src/lib/plugins/queries.ts b/app/src/lib/plugins/queries.ts index aad4c73..14d3a36 100644 --- a/app/src/lib/plugins/queries.ts +++ b/app/src/lib/plugins/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client, tryClient } from "@/lib/client"; /** A tool one server offers, as the Plugins page sees it. */ export type PluginTool = { @@ -85,8 +86,9 @@ export function pluginsPageQueryOptions() { return queryOptions({ queryKey: pluginKeys.page(), queryFn: async (): Promise => { - const response = await fetch("/api/plugins", { credentials: "include" }); - if (!response.ok) throw new Error("Plugins could not be loaded."); + const response = await client("/api/plugins", { + fallback: "Plugins could not be loaded.", + }); return response.json(); }, }); @@ -101,12 +103,10 @@ export function agentPluginsQueryOptions(agentId: string) { enabled: agentId.length > 0, refetchInterval: 15_000, queryFn: async (): Promise => { - const response = await fetch( + const response = await client( `/api/plugins/for/${encodeURIComponent(agentId)}`, - { credentials: "include" }, + { fallback: "This Bot's plugins could not be read." }, ); - if (!response.ok) - throw new Error("This Bot's plugins could not be read."); return response.json(); }, }); @@ -128,12 +128,11 @@ export async function callPluginTool( agentId: string, signal?: AbortSignal, ): Promise { - const response = await fetch("/api/plugins/call", { + /* A refused tool is an outcome this returns, not an error it throws. */ + const response = await tryClient("/api/plugins/call", { method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ref, args, agentId }), - ...(signal ? { signal } : {}), + body: { ref, args, agentId }, + signal, }); const body = (await response.json().catch(() => null)) as { diff --git a/app/src/lib/sandboxed/mutations.ts b/app/src/lib/sandboxed/mutations.ts new file mode 100644 index 0000000..d56e7ab --- /dev/null +++ b/app/src/lib/sandboxed/mutations.ts @@ -0,0 +1,82 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { sandboxedKeys } from "./queries"; + +/** + * A browser-authored component as the server accepts it. + * + * `argumentSchema` and `sampleArguments` arrive parsed. The playground holds them as text while + * somebody is typing, and text that does not parse is not a draft the server should be asked to + * store — so parsing is the editor's job and this is what survives it. + */ +export type SandboxedDraftInput = { + slug: string; + title: string; + description: string; + html: string; + css: string; + jsFunctions: string; + argumentSchema: Record; + sampleArguments: Record; +}; + +const FALLBACK = "That did not work."; + +function invalidateSandboxed(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: sandboxedKeys.all }); +} + +/** The name the server knows a browser-authored component by. */ +function sandboxedName(slug: string): string { + return `custom_${slug}`; +} + +export function saveSandboxedDraftMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: SandboxedDraftInput) => { + await client("/api/sandboxed", { + method: "POST", + body: input, + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidateSandboxed(queryClient), + }); +} + +/** + * Publish what is on screen. + * + * Saves first, in the same mutation, because publishing acts on the stored draft rather than on the + * editors. Two calls rather than one endpoint, so a save that fails stops the publish — which is the + * behaviour worth keeping: publishing a draft the server never received would put something on + * screen that nobody wrote. + */ +export function publishSandboxedMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (input: SandboxedDraftInput) => { + await client("/api/sandboxed", { + method: "POST", + body: input, + fallback: FALLBACK, + }); + await client( + `/api/sandboxed/${encodeURIComponent(sandboxedName(input.slug))}/publish`, + { method: "POST", fallback: FALLBACK }, + ); + }, + onSuccess: () => invalidateSandboxed(queryClient), + }); +} + +export function deleteSandboxedMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: async (name: string) => { + await client(`/api/sandboxed/${encodeURIComponent(name)}`, { + method: "DELETE", + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidateSandboxed(queryClient), + }); +} diff --git a/app/src/lib/sandboxed/queries.ts b/app/src/lib/sandboxed/queries.ts index 73a8cc8..97cc071 100644 --- a/app/src/lib/sandboxed/queries.ts +++ b/app/src/lib/sandboxed/queries.ts @@ -1,4 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; /** A component authored in the browser, as the playground edits it. */ export type SandboxedRecord = { @@ -41,13 +42,11 @@ export function sandboxedListQueryOptions() { return queryOptions({ queryKey: sandboxedKeys.list(), queryFn: async (): Promise => { - const response = await fetch("/api/sandboxed", { - credentials: "include", - }); - if (!response.ok) { - throw new Error("The playground's components could not be loaded."); - } - return (await response.json()).components ?? []; + return ( + (await client("/api/sandboxed", "components", { + fallback: "The playground's components could not be loaded.", + })) ?? [] + ); }, }); } @@ -64,13 +63,11 @@ export function publishedSandboxedQueryOptions() { queryKey: sandboxedKeys.published(), refetchInterval: 30_000, queryFn: async (): Promise => { - const response = await fetch("/api/sandboxed/published", { - credentials: "include", - }); - if (!response.ok) { - throw new Error("The published components could not be loaded."); - } - return (await response.json()).components ?? []; + return ( + (await client("/api/sandboxed/published", "components", { + fallback: "The published components could not be loaded.", + })) ?? [] + ); }, }); } diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 3feb12c..48a0bf9 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -20,7 +20,6 @@ import { Route as AuthedAppSkillsRouteImport } from './routes/_authed/_app/skill import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminAuditRouteImport } from './routes/_authed/admin/audit' import { Route as AuthedAdminBoundariesRouteImport } from './routes/_authed/admin/boundaries' -import { Route as AuthedAdminComponentsRouteImport } from './routes/_authed/admin/components' import { Route as AuthedAdminComputersRouteImport } from './routes/_authed/admin/computers' import { Route as AuthedAdminConnectorsRouteImport } from './routes/_authed/admin/connectors' import { Route as AuthedAdminCredentialsRouteImport } from './routes/_authed/admin/credentials' @@ -30,7 +29,11 @@ import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settin import { Route as AuthedAppAgentsIndexRouteImport } from './routes/_authed/_app/agents/index' import { Route as AuthedAppChannelChannelIdRouteImport } from './routes/_authed/_app/channel/$channelId' import { Route as AuthedAppChannelNewRouteImport } from './routes/_authed/_app/channel/new' +import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed/admin/components/index' +import { Route as AuthedAdminComponentsNameRouteImport } from './routes/_authed/admin/components/$name' import { Route as AuthedAdminConnectorsGoogleDriveRouteImport } from './routes/_authed/admin/connectors/google-drive' +import { Route as AuthedSettingsComponentsGalleryIndexRouteImport } from './routes/_authed/settings/components-gallery/index' +import { Route as AuthedSettingsComponentsGalleryNameRouteImport } from './routes/_authed/settings/components-gallery/$name' const AuthedRoute = AuthedRouteImport.update({ id: '/_authed', @@ -85,11 +88,6 @@ const AuthedAdminBoundariesRoute = AuthedAdminBoundariesRouteImport.update({ path: '/boundaries', getParentRoute: () => AuthedAdminRouteRoute, } as any) -const AuthedAdminComponentsRoute = AuthedAdminComponentsRouteImport.update({ - id: '/components', - path: '/components', - getParentRoute: () => AuthedAdminRouteRoute, -} as any) const AuthedAdminComputersRoute = AuthedAdminComputersRouteImport.update({ id: '/computers', path: '/computers', @@ -136,12 +134,36 @@ const AuthedAppChannelNewRoute = AuthedAppChannelNewRouteImport.update({ path: '/channel/new', getParentRoute: () => AuthedAppRoute, } as any) +const AuthedAdminComponentsIndexRoute = + AuthedAdminComponentsIndexRouteImport.update({ + id: '/components/', + path: '/components/', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) +const AuthedAdminComponentsNameRoute = + AuthedAdminComponentsNameRouteImport.update({ + id: '/components/$name', + path: '/components/$name', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedAdminConnectorsGoogleDriveRoute = AuthedAdminConnectorsGoogleDriveRouteImport.update({ id: '/google-drive', path: '/google-drive', getParentRoute: () => AuthedAdminConnectorsRoute, } as any) +const AuthedSettingsComponentsGalleryIndexRoute = + AuthedSettingsComponentsGalleryIndexRouteImport.update({ + id: '/components-gallery/', + path: '/components-gallery/', + getParentRoute: () => AuthedSettingsRouteRoute, + } as any) +const AuthedSettingsComponentsGalleryNameRoute = + AuthedSettingsComponentsGalleryNameRouteImport.update({ + id: '/components-gallery/$name', + path: '/components-gallery/$name', + getParentRoute: () => AuthedSettingsRouteRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof AuthedAppIndexRoute @@ -152,7 +174,6 @@ export interface FileRoutesByFullPath { '/skills': typeof AuthedAppSkillsRoute '/admin/audit': typeof AuthedAdminAuditRoute '/admin/boundaries': typeof AuthedAdminBoundariesRoute - '/admin/components': typeof AuthedAdminComponentsRoute '/admin/computers': typeof AuthedAdminComputersRoute '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/admin/credentials': typeof AuthedAdminCredentialsRoute @@ -162,8 +183,12 @@ export interface FileRoutesByFullPath { '/settings/': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute '/channel/new': typeof AuthedAppChannelNewRoute + '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute + '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/agents/': typeof AuthedAppAgentsIndexRoute + '/admin/components/': typeof AuthedAdminComponentsIndexRoute + '/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute } export interface FileRoutesByTo { '/': typeof AuthedAppIndexRoute @@ -172,7 +197,6 @@ export interface FileRoutesByTo { '/skills': typeof AuthedAppSkillsRoute '/admin/audit': typeof AuthedAdminAuditRoute '/admin/boundaries': typeof AuthedAdminBoundariesRoute - '/admin/components': typeof AuthedAdminComponentsRoute '/admin/computers': typeof AuthedAdminComputersRoute '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/admin/credentials': typeof AuthedAdminCredentialsRoute @@ -182,8 +206,12 @@ export interface FileRoutesByTo { '/settings': typeof AuthedSettingsIndexRoute '/channel/$channelId': typeof AuthedAppChannelChannelIdRoute '/channel/new': typeof AuthedAppChannelNewRoute + '/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute + '/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/agents': typeof AuthedAppAgentsIndexRoute + '/admin/components': typeof AuthedAdminComponentsIndexRoute + '/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -196,7 +224,6 @@ export interface FileRoutesById { '/_authed/_app/skills': typeof AuthedAppSkillsRoute '/_authed/admin/audit': typeof AuthedAdminAuditRoute '/_authed/admin/boundaries': typeof AuthedAdminBoundariesRoute - '/_authed/admin/components': typeof AuthedAdminComponentsRoute '/_authed/admin/computers': typeof AuthedAdminComputersRoute '/_authed/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/_authed/admin/credentials': typeof AuthedAdminCredentialsRoute @@ -207,8 +234,12 @@ export interface FileRoutesById { '/_authed/settings/': typeof AuthedSettingsIndexRoute '/_authed/_app/channel/$channelId': typeof AuthedAppChannelChannelIdRoute '/_authed/_app/channel/new': typeof AuthedAppChannelNewRoute + '/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute '/_authed/admin/connectors/google-drive': typeof AuthedAdminConnectorsGoogleDriveRoute + '/_authed/settings/components-gallery/$name': typeof AuthedSettingsComponentsGalleryNameRoute '/_authed/_app/agents/': typeof AuthedAppAgentsIndexRoute + '/_authed/admin/components/': typeof AuthedAdminComponentsIndexRoute + '/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -221,7 +252,6 @@ export interface FileRouteTypes { | '/skills' | '/admin/audit' | '/admin/boundaries' - | '/admin/components' | '/admin/computers' | '/admin/connectors' | '/admin/credentials' @@ -231,8 +261,12 @@ export interface FileRouteTypes { | '/settings/' | '/channel/$channelId' | '/channel/new' + | '/admin/components/$name' | '/admin/connectors/google-drive' + | '/settings/components-gallery/$name' | '/agents/' + | '/admin/components/' + | '/settings/components-gallery/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -241,7 +275,6 @@ export interface FileRouteTypes { | '/skills' | '/admin/audit' | '/admin/boundaries' - | '/admin/components' | '/admin/computers' | '/admin/connectors' | '/admin/credentials' @@ -251,8 +284,12 @@ export interface FileRouteTypes { | '/settings' | '/channel/$channelId' | '/channel/new' + | '/admin/components/$name' | '/admin/connectors/google-drive' + | '/settings/components-gallery/$name' | '/agents' + | '/admin/components' + | '/settings/components-gallery' id: | '__root__' | '/_authed' @@ -264,7 +301,6 @@ export interface FileRouteTypes { | '/_authed/_app/skills' | '/_authed/admin/audit' | '/_authed/admin/boundaries' - | '/_authed/admin/components' | '/_authed/admin/computers' | '/_authed/admin/connectors' | '/_authed/admin/credentials' @@ -275,8 +311,12 @@ export interface FileRouteTypes { | '/_authed/settings/' | '/_authed/_app/channel/$channelId' | '/_authed/_app/channel/new' + | '/_authed/admin/components/$name' | '/_authed/admin/connectors/google-drive' + | '/_authed/settings/components-gallery/$name' | '/_authed/_app/agents/' + | '/_authed/admin/components/' + | '/_authed/settings/components-gallery/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -363,13 +403,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminBoundariesRouteImport parentRoute: typeof AuthedAdminRouteRoute } - '/_authed/admin/components': { - id: '/_authed/admin/components' - path: '/components' - fullPath: '/admin/components' - preLoaderRoute: typeof AuthedAdminComponentsRouteImport - parentRoute: typeof AuthedAdminRouteRoute - } '/_authed/admin/computers': { id: '/_authed/admin/computers' path: '/computers' @@ -433,6 +466,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAppChannelNewRouteImport parentRoute: typeof AuthedAppRoute } + '/_authed/admin/components/': { + id: '/_authed/admin/components/' + path: '/components' + fullPath: '/admin/components/' + preLoaderRoute: typeof AuthedAdminComponentsIndexRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } + '/_authed/admin/components/$name': { + id: '/_authed/admin/components/$name' + path: '/components/$name' + fullPath: '/admin/components/$name' + preLoaderRoute: typeof AuthedAdminComponentsNameRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/admin/connectors/google-drive': { id: '/_authed/admin/connectors/google-drive' path: '/google-drive' @@ -440,6 +487,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminConnectorsGoogleDriveRouteImport parentRoute: typeof AuthedAdminConnectorsRoute } + '/_authed/settings/components-gallery/': { + id: '/_authed/settings/components-gallery/' + path: '/components-gallery' + fullPath: '/settings/components-gallery/' + preLoaderRoute: typeof AuthedSettingsComponentsGalleryIndexRouteImport + parentRoute: typeof AuthedSettingsRouteRoute + } + '/_authed/settings/components-gallery/$name': { + id: '/_authed/settings/components-gallery/$name' + path: '/components-gallery/$name' + fullPath: '/settings/components-gallery/$name' + preLoaderRoute: typeof AuthedSettingsComponentsGalleryNameRouteImport + parentRoute: typeof AuthedSettingsRouteRoute + } } } @@ -459,25 +520,27 @@ const AuthedAdminConnectorsRouteWithChildren = interface AuthedAdminRouteRouteChildren { AuthedAdminAuditRoute: typeof AuthedAdminAuditRoute AuthedAdminBoundariesRoute: typeof AuthedAdminBoundariesRoute - AuthedAdminComponentsRoute: typeof AuthedAdminComponentsRoute AuthedAdminComputersRoute: typeof AuthedAdminComputersRoute AuthedAdminConnectorsRoute: typeof AuthedAdminConnectorsRouteWithChildren AuthedAdminCredentialsRoute: typeof AuthedAdminCredentialsRoute AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute AuthedAdminPluginsRoute: typeof AuthedAdminPluginsRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute + AuthedAdminComponentsNameRoute: typeof AuthedAdminComponentsNameRoute + AuthedAdminComponentsIndexRoute: typeof AuthedAdminComponentsIndexRoute } const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminAuditRoute: AuthedAdminAuditRoute, AuthedAdminBoundariesRoute: AuthedAdminBoundariesRoute, - AuthedAdminComponentsRoute: AuthedAdminComponentsRoute, AuthedAdminComputersRoute: AuthedAdminComputersRoute, AuthedAdminConnectorsRoute: AuthedAdminConnectorsRouteWithChildren, AuthedAdminCredentialsRoute: AuthedAdminCredentialsRoute, AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute, AuthedAdminPluginsRoute: AuthedAdminPluginsRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, + AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute, + AuthedAdminComponentsIndexRoute: AuthedAdminComponentsIndexRoute, } const AuthedAdminRouteRouteWithChildren = @@ -485,10 +548,16 @@ const AuthedAdminRouteRouteWithChildren = interface AuthedSettingsRouteRouteChildren { AuthedSettingsIndexRoute: typeof AuthedSettingsIndexRoute + AuthedSettingsComponentsGalleryNameRoute: typeof AuthedSettingsComponentsGalleryNameRoute + AuthedSettingsComponentsGalleryIndexRoute: typeof AuthedSettingsComponentsGalleryIndexRoute } const AuthedSettingsRouteRouteChildren: AuthedSettingsRouteRouteChildren = { AuthedSettingsIndexRoute: AuthedSettingsIndexRoute, + AuthedSettingsComponentsGalleryNameRoute: + AuthedSettingsComponentsGalleryNameRoute, + AuthedSettingsComponentsGalleryIndexRoute: + AuthedSettingsComponentsGalleryIndexRoute, } const AuthedSettingsRouteRouteWithChildren = diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 5af2dfa..06541d0 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -213,11 +213,8 @@ function ChannelBody({ isPending: boolean; hasError: boolean; }) { - if (isPending) { - return ( -

Loading channel…

- ); - } + // Nothing while the channel loads: a placeholder inside a local round-trip is a flicker. + if (isPending) return null; if (hasError || !channel) { return (

diff --git a/app/src/routes/_authed/_app/skills.tsx b/app/src/routes/_authed/_app/skills.tsx index f53a18c..410d39a 100644 --- a/app/src/routes/_authed/_app/skills.tsx +++ b/app/src/routes/_authed/_app/skills.tsx @@ -13,8 +13,10 @@ import { StaggerItem } from "@/components/layout/stagger"; import { EditSkill } from "@/components/skills/edit-skill"; import { NewSkill } from "@/components/skills/new-skill"; import { Button } from "@/components/ui/button"; +import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; import { currentUserQueryOptions } from "@/lib/auth/queries"; -import { pluginKeys, pluginsPageQueryOptions } from "@/lib/plugins/queries"; +import { removeSkillMutationOptions } from "@/lib/plugins/mutations"; +import { pluginsPageQueryOptions } from "@/lib/plugins/queries"; import { Item, ItemActions, @@ -61,25 +63,23 @@ function SkillsPage() { // roster uses when `new` and `agent` arrive together. const showCreate = isCreating === true; const showEdit = !showCreate && editingSlug !== undefined; - const { data } = useQuery(pluginsPageQueryOptions()); - const { data: me } = useQuery(currentUserQueryOptions()); + const { data, isPending: skillsPending } = useQuery( + pluginsPageQueryOptions(), + ); + const { data: me, isPending: mePending } = useQuery( + currentUserQueryOptions(), + ); + /* + * Both, because `mine` is the intersection of the two: until the person is known, nothing matches + * them and the list is empty for a reason that is not "you have no skills". + */ + const loading = skillsPending || mePending; const [error, setError] = useState(null); - const mutate = useMutation({ - mutationFn: async (run: () => Promise) => { - const response = await run(); - if (!response.ok) { - const body = (await response.json().catch(() => null)) as { - error?: string; - } | null; - throw new Error(body?.error ?? "That did not work."); - } - }, - onError: (caught: Error) => setError(caught.message), - onSuccess: () => { - setError(null); - void queryClient.invalidateQueries({ queryKey: pluginKeys.all }); - }, + const removeSkill = useMutation({ + ...removeSkillMutationOptions(queryClient), + onError: (thrown: Error) => setError(thrown.message), + onSuccess: () => setError(null), }); /* @@ -137,7 +137,20 @@ function SkillsPage() { } title="Your skills" > - {!!mine?.length && ( + {/* + * Nothing while the two queries are still in flight. The alternative is the empty state + * standing there saying this person has written no skills, which is a claim the page has + * not yet earned. + */} + {loading ? null : mine.length === 0 ? ( + + + + You don't have any skills yet. + + + + ) : ( {mine.map((skill, index) => ( @@ -184,17 +197,10 @@ function SkillsPage() { * opened over the wrong row is the ordinary way this goes wrong. */} - mutate.mutate(() => - fetch( - `/api/plugins/skills/${encodeURIComponent(skill.slug)}`, - { - method: "DELETE", - credentials: "include", - }, - ), - ) - } + onClick={() => { + setError(null); + removeSkill.mutate(skill.slug); + }} variant="destructive" > Delete /{skill.slug} diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx index 2a6319b..6a1fffe 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -87,9 +87,7 @@ function AuditPage() { ))}

- {events.isPending ? ( - Loading the trail… - ) : events.isError ? ( + {events.isPending ? null : events.isError ? (

The audit trail could not be loaded.

diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx index 518ea30..2f74ac1 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -1,6 +1,14 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import { PageSection, PageShell } from "@/components/layout/page-shell"; +import { saveActionPolicyMutationOptions } from "@/lib/computers/mutations"; +import { + type ActionPolicy, + actionPolicyQueryOptions, + type PolicyMode, +} from "@/lib/computers/queries"; +import { queryClient } from "@/query-client"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,14 +17,6 @@ import { Input } from "@/components/ui/input"; * actions are recorded in Audit with the matching rule. */ -type PolicyMode = "dry-run" | "enforce"; - -type ActionPolicy = { - mode: PolicyMode; - deny: string[]; - allow: string[]; -}; - /** * Presets are concrete CEL rules, not a separate policy language. */ @@ -44,61 +44,28 @@ export const Route = createFileRoute("/_authed/admin/boundaries")({ }); function BoundariesPage() { - const [policy, setPolicy] = useState(null); const [problem, setProblem] = useState(null); - const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const [draft, setDraft] = useState(""); - const load = useCallback(async () => { - try { - const response = await fetch("/api/computers/policy", { - credentials: "include", - }); - if (!response.ok) { - setProblem("The boundary could not be read."); - return; - } - const body = (await response.json()) as { policy: ActionPolicy }; - setPolicy(body.policy); - setProblem(null); - } catch { - setProblem("The boundary could not be reached."); - } - }, []); + const stored = useQuery(actionPolicyQueryOptions()); + const savePolicy = useMutation(saveActionPolicyMutationOptions(queryClient)); - useEffect(() => { - void load(); - }, [load]); + /* + * The saved policy wins while a save is in flight and after it lands: the server normalises what + * it stores, so what came back is the policy, not what was sent. + */ + const policy = savePolicy.data ?? stored.data ?? null; + const saving = savePolicy.isPending; - const save = useCallback(async (next: ActionPolicy) => { - setSaving(true); + const save = (next: ActionPolicy) => { setSaved(false); - try { - const response = await fetch("/api/computers/policy", { - method: "PUT", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify(next), - }); - const body = (await response.json().catch(() => null)) as { - policy?: ActionPolicy; - error?: string; - } | null; - if (!response.ok) { - setProblem(body?.error ?? "The boundary could not be saved."); - return; - } - // Display the persisted policy in case the server normalized it. - if (body?.policy) setPolicy(body.policy); - setProblem(null); - setSaved(true); - } catch { - setProblem("The boundary could not be reached."); - } finally { - setSaving(false); - } - }, []); + setProblem(null); + savePolicy.mutate(next, { + onError: (thrown: Error) => setProblem(thrown.message), + onSuccess: () => setSaved(true), + }); + }; if (problem && !policy) { return ( @@ -110,14 +77,9 @@ function BoundariesPage() { ); } + /* Nothing until the policy is known: a rule list that guesses is worse than a blank. */ if (!policy) { - return ( - -

- Loading the boundary… -

-
- ); + return {null}; } const addRule = (rule: string) => { diff --git a/app/src/routes/_authed/admin/components.tsx b/app/src/routes/_authed/admin/components.tsx deleted file mode 100644 index 2b0c10e..0000000 --- a/app/src/routes/_authed/admin/components.tsx +++ /dev/null @@ -1,429 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { createFileRoute } from "@tanstack/react-router"; -import { useState } from "react"; -import { - PageEmpty, - PageSection, - PageShell, -} from "@/components/layout/page-shell"; -import { PreviewOf } from "@/components/gallery/preview"; -import { StaggerItem } from "@/components/layout/stagger"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Field, FieldLabel } from "@/components/ui/field"; -import { Textarea } from "@/components/ui/textarea"; -import { agentListQueryOptions } from "@/lib/agents/queries"; -import { - type ComponentRecord, - componentKeys, - componentListQueryOptions, - type DataFunctionSummary, - dataFunctionsQueryOptions, -} from "@/lib/components/queries"; -import { RENDERABLE_NAMES } from "@/lib/copilot/gallery-registry"; - -/** - * Runtime governance for compiled gallery components: publication, per-Bot grants, model-facing - * descriptions, and component data-function access. - */ -export const Route = createFileRoute("/_authed/admin/components")({ - component: RouteComponent, -}); - -function RouteComponent() { - const queryClient = useQueryClient(); - const { data: components, isLoading } = useQuery(componentListQueryOptions()); - const { data: agents } = useQuery(agentListQueryOptions()); - const { data: dataFunctions } = useQuery(dataFunctionsQueryOptions()); - - const invalidate = () => { - void queryClient.invalidateQueries({ queryKey: componentKeys.all }); - }; - - const setGrant = useMutation({ - mutationFn: async ({ - name, - agentId, - granted, - }: { - name: string; - agentId: string; - granted: boolean; - }) => { - const response = granted - ? await fetch(`/api/components/${encodeURIComponent(name)}/grants`, { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ agentId }), - }) - : await fetch( - `/api/components/${encodeURIComponent(name)}/grants/${encodeURIComponent(agentId)}`, - { method: "DELETE", credentials: "include" }, - ); - if (!response.ok) throw new Error("That change could not be saved."); - }, - onSuccess: invalidate, - }); - - const setFunction = useMutation({ - mutationFn: async ({ - name, - functionName, - granted, - }: { - name: string; - functionName: string; - granted: boolean; - }) => { - const response = granted - ? await fetch(`/api/components/${encodeURIComponent(name)}/functions`, { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ function: functionName }), - }) - : await fetch( - `/api/components/${encodeURIComponent(name)}/functions/${encodeURIComponent(functionName)}`, - { method: "DELETE", credentials: "include" }, - ); - if (!response.ok) throw new Error("That change could not be saved."); - }, - onSuccess: invalidate, - }); - - const setPublished = useMutation({ - mutationFn: async ({ - name, - published, - }: { - name: string; - published: boolean; - }) => { - const response = await fetch( - `/api/components/${encodeURIComponent(name)}/publication`, - { - method: "POST", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ published }), - }, - ); - if (!response.ok) throw new Error("That change could not be saved."); - }, - onSuccess: invalidate, - }); - - const saveDraft = useMutation({ - mutationFn: async ({ - name, - description, - }: { - name: string; - description: string; - }) => { - const response = await fetch( - `/api/components/${encodeURIComponent(name)}/draft`, - { - method: "PUT", - credentials: "include", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ description }), - }, - ); - if (!response.ok) throw new Error("That draft could not be saved."); - }, - onSuccess: invalidate, - }); - - const bots = agents ?? []; - - return ( - - {/* - * ONE CARD EACH, RATHER THAN A ROW EACH. Every other list in admin is `Item` rows, and these - * started as hand-drawn ones — but a component carries per-Bot grants and per-function grants, - * which is a set of switches rather than a line of text. Cramming that into a row would mean - * hiding it behind a menu, and which Bots hold a component is the thing this page exists to - * answer at a glance. - */} - - {isLoading ? Loading… : null} - - {components?.length === 0 && !isLoading ? ( - This deployment ships no components. - ) : null} - -
- {(components ?? []).map((component, index) => ( - - - setFunction.mutate({ - functionName, - granted, - name: component.name, - }) - } - onPublish={(published) => - setPublished.mutate({ name: component.name, published }) - } - onSaveDraft={(description) => - saveDraft.mutate({ name: component.name, description }) - } - onSetGrant={(agentId, granted) => - setGrant.mutate({ agentId, granted, name: component.name }) - } - /> - - ))} -
-
-
- ); -} - -function ComponentRow({ - component, - bots, - dataFunctions, - onSetGrant, - onSetFunction, - onPublish, - onSaveDraft, -}: { - component: ComponentRecord; - bots: { id: string; name: string }[]; - dataFunctions: DataFunctionSummary[]; - onSetGrant: (agentId: string, granted: boolean) => void; - onSetFunction: (functionName: string, granted: boolean) => void; - onPublish: (published: boolean) => void; - onSaveDraft: (description: string) => void; -}) { - const [editing, setEditing] = useState(false); - const [draft, setDraft] = useState(component.draftDescription); - const withheld = new Set(component.withheldFrom); - const heldFunctions = new Set(component.functions); - - return ( -
-
-
-
-

{component.title}

- - {component.name} - - {RENDERABLE_NAMES.has(component.name) ? null : ( - - Not in this build, nothing can draw it - - )} - {component.published ? null : ( - - Unpublished, no Bot may use it - - )} - {component.hasUnpublishedChanges ? ( - - Draft not published - - ) : null} -
-

- {component.publishedDescription ?? - "Nothing is published, so no Bot is told about this."} -

-

- Last changed {new Date(component.updatedAt).toLocaleString()} - {component.updatedBy ? ` by ${component.updatedBy}` : null} -

- {/* - * What granting this actually puts in front of somebody. - * - * Closed by default: this page is a list an administrator scans, and thirteen components - * drawn at once is a page nobody reads. Open, it is the real component, which is the only - * thing that answers "should this Bot have it". - */} - {RENDERABLE_NAMES.has(component.name) ? ( -
- - See it - -
- -
-
- ) : null} -
- -
- - -
-
- - - - - {component.title} - - The draft description is what the model reads when deciding to - call this. It changes nothing until it is published. - - - - - - Draft description - -