From 78e86ba3f10f8bbbb499eef72c4e95f1799dc472 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Thu, 20 Aug 2026 15:40:07 -0300 Subject: [PATCH 01/13] Write down how a screen is laid out, so a new one already looks right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two skills that were only on one machine, and so were helping nobody else. openbot-screen-layout is new. It says a configuration screen is PageShell, PageSection, PageRows and Item rows, at prose width, and that this is what a new screen is unless the request says otherwise — somebody who does not work in the frontend should be able to add a page that looks like it belongs without making a visual decision. It carries the rules that are not guessable from the components: the root font size is 15px, so max-w-2xl is 630px and no pixel constant may be copied from the Tailwind docs; an element passed to Item's render must have no children, or the row draws empty; bg-card is invisible inside a dialog because --card and --popover are the same colour; DialogBody has no overflow of its own despite a comment saying it scrolls. openbot-data-access was already written and already untracked. It is committed here rather than left to be rediscovered. --- .claude/skills/openbot-data-access/SKILL.md | 244 ++++++++++++++++++ .claude/skills/openbot-screen-layout/SKILL.md | 211 +++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 .claude/skills/openbot-data-access/SKILL.md create mode 100644 .claude/skills/openbot-screen-layout/SKILL.md diff --git a/.claude/skills/openbot-data-access/SKILL.md b/.claude/skills/openbot-data-access/SKILL.md new file mode 100644 index 0000000..b3f5bb9 --- /dev/null +++ b/.claude/skills/openbot-data-access/SKILL.md @@ -0,0 +1,244 @@ +--- +name: openbot-data-access +description: Governs how the OpenBot browser app reads and writes server data — 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 for CopilotKit runtime traffic under lib/copilot/ which is streamed by the runtime rather than fetched. +--- + +# 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` outside `app/src/lib//`. + +It does not cover server handlers under `server/`, zod form schemas (`lib//form.ts`), page +layout, or the CopilotKit runtime surface under `lib/copilot/`, which streams over AG-UI rather +than fetching. + +## The Shape + +Every entity the browser knows about owns a directory under `app/src/lib/`: + +``` +app/src/lib// + queries.ts # read types, key factory, queryOptions factories + mutations.ts # input type, request helper, mutationOptions factories + form.ts # zod schema (a different skill's territory) +``` + +There are twelve of these today — `agents`, `audit`, `auth`, `channels`, `components`, +`connectors`, `credentials`, `package`, `plugins`, `sandboxed`, and so on. 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`: `fetch` with `credentials: "include"`, check `response.ok`, throw an + `Error` with a sentence a person could read, and **unwrap the envelope** so the caller receives + the payload rather than the wrapper: + + ```ts + if (!response.ok) throw new Error("Could not load coworkers"); + return ((await response.json()) as { agents: AgentProfile[] }).agents; + ``` + +8. Annotate the `queryFn` return type explicitly (`async (): Promise`). It is what + makes the unwrap type-safe. + +### 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. If the file will hold more than one write, add a single module-private request helper that owns + `credentials`, headers, and error extraction. `agentRequest` in `lib/agents/mutations.ts` is the + model. Error extraction surfaces **the server's** message, because that is the one naming the + field or the permission that failed: + + ```ts + const message = await response + .json() + .then((body: { error?: string }) => body.error) + .catch(() => undefined); + throw new Error(message ?? "Coworker operation failed"); + ``` + +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(` in a file under `components/` or `routes/` | The read has no key, so nothing can invalidate it | Move it into `lib//queries.ts` behind a `queryOptions` factory | +| `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 | +| `catch { throw new Error("Something went wrong") }` | Discards the server's message, which is the one naming the field that failed | Extract `body.error` and fall back to a specific sentence | +| A `fetch` without `credentials: "include"` | The session cookie is not sent; it fails as a 401 that reads like a bug | Add it; every request in this app is authenticated | +| `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`**: check `credentials: "include"` first. If present, the session expired — + the `_authed` guard handles the redirect on the next navigation. Do not add per-query redirect + logic. +- **The server returns no `error` field**: keep the `?? "…"` fallback sentence and name the entity + in it ("Could not load coworkers"). Do not print a status code to a person. +- **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} +
+ ); } 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/lib/components/mutations.ts b/app/src/lib/components/mutations.ts new file mode 100644 index 0000000..aa0096a --- /dev/null +++ b/app/src/lib/components/mutations.ts @@ -0,0 +1,118 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +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. + */ +async function componentRequest( + path: string, + init: { method: string; body?: unknown }, +): 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 ?? "Component operation failed"); + } + return response; +} + +/** 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 + ? componentRequest(`${componentPath(variables.name)}/grants`, { + method: "POST", + body: { agentId: variables.agentId }, + }) + : componentRequest( + `${componentPath(variables.name)}/grants/${encodeURIComponent(variables.agentId)}`, + { method: "DELETE" }, + )); + }, + 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 + ? componentRequest(`${componentPath(variables.name)}/functions`, { + method: "POST", + body: { function: variables.functionName }, + }) + : componentRequest( + `${componentPath(variables.name)}/functions/${encodeURIComponent(variables.functionName)}`, + { method: "DELETE" }, + )); + }, + 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 componentRequest(`${componentPath(variables.name)}/publication`, { + method: "POST", + body: { published: variables.published }, + }); + }, + 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 componentRequest(`${componentPath(variables.name)}/draft`, { + method: "PUT", + body: { description: variables.description }, + }); + }, + onSuccess: () => invalidateComponents(queryClient), + }); +} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 3feb12c..e3099b8 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,6 +29,8 @@ 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' const AuthedRoute = AuthedRouteImport.update({ @@ -85,11 +86,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,6 +132,18 @@ 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', @@ -152,7 +160,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 +169,10 @@ 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 '/agents/': typeof AuthedAppAgentsIndexRoute + '/admin/components/': typeof AuthedAdminComponentsIndexRoute } export interface FileRoutesByTo { '/': typeof AuthedAppIndexRoute @@ -172,7 +181,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 +190,10 @@ 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 '/agents': typeof AuthedAppAgentsIndexRoute + '/admin/components': typeof AuthedAdminComponentsIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -196,7 +206,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 +216,10 @@ 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/_app/agents/': typeof AuthedAppAgentsIndexRoute + '/_authed/admin/components/': typeof AuthedAdminComponentsIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -221,7 +232,6 @@ export interface FileRouteTypes { | '/skills' | '/admin/audit' | '/admin/boundaries' - | '/admin/components' | '/admin/computers' | '/admin/connectors' | '/admin/credentials' @@ -231,8 +241,10 @@ export interface FileRouteTypes { | '/settings/' | '/channel/$channelId' | '/channel/new' + | '/admin/components/$name' | '/admin/connectors/google-drive' | '/agents/' + | '/admin/components/' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -241,7 +253,6 @@ export interface FileRouteTypes { | '/skills' | '/admin/audit' | '/admin/boundaries' - | '/admin/components' | '/admin/computers' | '/admin/connectors' | '/admin/credentials' @@ -251,8 +262,10 @@ export interface FileRouteTypes { | '/settings' | '/channel/$channelId' | '/channel/new' + | '/admin/components/$name' | '/admin/connectors/google-drive' | '/agents' + | '/admin/components' id: | '__root__' | '/_authed' @@ -264,7 +277,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 +287,10 @@ export interface FileRouteTypes { | '/_authed/settings/' | '/_authed/_app/channel/$channelId' | '/_authed/_app/channel/new' + | '/_authed/admin/components/$name' | '/_authed/admin/connectors/google-drive' | '/_authed/_app/agents/' + | '/_authed/admin/components/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -363,13 +377,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 +440,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' @@ -459,25 +480,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 = diff --git a/app/src/routes/_authed/admin/components.tsx b/app/src/routes/_authed/admin/components.tsx deleted file mode 100644 index 27c40ea..0000000 --- a/app/src/routes/_authed/admin/components.tsx +++ /dev/null @@ -1,382 +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 { 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} -

-
- -
- - -
-
- - - - - {component.title} - - The draft description is what the model reads when deciding to - call this. It changes nothing until it is published. - - - - - - Draft description - -