diff --git a/app/src/components/gallery/cards.tsx b/app/src/components/gallery/cards.tsx index 24c51f2..8e60b1d 100644 --- a/app/src/components/gallery/cards.tsx +++ b/app/src/components/gallery/cards.tsx @@ -230,6 +230,16 @@ export const GALLERY: GalleryComponent[] = [ "Show one thing and its fields, an order, a person, a ticket. Use instead of describing a record in prose.", parameters: RecordCardProps, Component: RecordCard as GalleryComponent["Component"], + preview: { + title: "Invoice 2043", + subtitle: "Northwind Traders", + status: "Approved", + fields: [ + { label: "Amount", value: "$4,280.00" }, + { label: "Raised", value: "12 March" }, + { label: "Owner", value: "Priya Raman" }, + ], + }, confirmation: "The record is now on screen for the person.", }, { @@ -240,6 +250,24 @@ export const GALLERY: GalleryComponent[] = [ "Show up to six headline figures, each with an optional movement. Use for a summary somebody reads at a glance.", parameters: MetricsCardProps, Component: MetricsCard as GalleryComponent["Component"], + preview: { + title: "This month", + metrics: [ + { + label: "Revenue", + value: "$412k", + change: "+12% on last month", + changeTone: "positive", + }, + { label: "Open deals", value: "38" }, + { + label: "Churn", + value: "1.4%", + change: "+0.3pt", + changeTone: "caution", + }, + ], + }, confirmation: "The figures are now on screen for the person.", }, { @@ -250,6 +278,14 @@ export const GALLERY: GalleryComponent[] = [ "Show a list of things and which are done. Reporting only, the person cannot tick these, so do not use it to ask for anything.", parameters: ChecklistCardProps, Component: ChecklistCard as GalleryComponent["Component"], + preview: { + title: "Before the release", + items: [ + { text: "Migrations applied", done: true }, + { text: "Changelog written", done: true }, + { text: "Load test", done: false, note: "Waiting on staging" }, + ], + }, confirmation: "The checklist is now on screen for the person.", }, { @@ -260,6 +296,12 @@ export const GALLERY: GalleryComponent[] = [ "Show a headline, a short explanation and optional supporting points. Use instead of writing several paragraphs of prose.", parameters: NoticeCardProps, Component: NoticeCard as GalleryComponent["Component"], + preview: { + title: "Certificate expires in 30 days", + body: "The checkout certificate has an owner now, and this is the first of the new alerts.", + tone: "caution", + points: ["Owner: Platform", "Renews automatically once approved"], + }, confirmation: "The notice is now on screen for the person.", }, ]; diff --git a/app/src/components/gallery/charts.tsx b/app/src/components/gallery/charts.tsx index 6ea488e..6a10398 100644 --- a/app/src/components/gallery/charts.tsx +++ b/app/src/components/gallery/charts.tsx @@ -405,6 +405,15 @@ export const GALLERY: GalleryComponent[] = [ "Show values as a bar chart. Use when comparing a handful of named things, teams, months, categories. Not for a trend over time, which is showLineChart.", parameters: BarChartProps, Component: BarChartCard as GalleryComponent["Component"], + preview: { + title: "Revenue by team", + caption: "Sales leads, and Engineering is closing the gap.", + points: [ + { label: "Sales", value: 120 }, + { label: "Engineering", value: 80 }, + { label: "Support", value: 45 }, + ], + }, confirmation: "The bar chart is now on screen for the person.", }, { @@ -415,6 +424,14 @@ export const GALLERY: GalleryComponent[] = [ "Show how a whole is divided, as a donut with a legend. Use only when the parts sum to something meaningful, and prefer a bar chart above about six slices.", parameters: PieChartProps, Component: PieChartCard as GalleryComponent["Component"], + preview: { + title: "Where the month went", + points: [ + { label: "Build", value: 48 }, + { label: "Support", value: 26 }, + { label: "Meetings", value: 26 }, + ], + }, confirmation: "The donut chart is now on screen for the person.", }, { @@ -425,6 +442,12 @@ export const GALLERY: GalleryComponent[] = [ "Show one or more series over an ordered axis, usually time. Every series must have one value per label.", parameters: LineChartProps, Component: LineChartCard as GalleryComponent["Component"], + preview: { + title: "Signups", + caption: "Six weeks, one release.", + labels: ["W1", "W2", "W3", "W4", "W5", "W6"], + series: [{ name: "Signups", values: [120, 180, 160, 240, 300, 420] }], + }, confirmation: "The line chart is now on screen for the person.", }, { @@ -435,6 +458,12 @@ export const GALLERY: GalleryComponent[] = [ "The same as showLineChart with the area under each line filled. Use for volume or accumulation rather than for a rate.", parameters: AreaChartProps, Component: AreaChartCard as GalleryComponent["Component"], + preview: { + title: "Storage used", + caption: "Growing steadily since the migration.", + labels: ["Jan", "Feb", "Mar", "Apr", "May"], + series: [{ name: "TB", values: [12, 19, 26, 31, 44] }], + }, confirmation: "The area chart is now on screen for the person.", }, { @@ -445,6 +474,14 @@ export const GALLERY: GalleryComponent[] = [ "Show values against their targets as progress bars. Use for 'are we there yet' questions, budget spent against budget, done against planned.", parameters: ProgressChartProps, Component: ProgressChartCard as GalleryComponent["Component"], + preview: { + title: "Migration to the new runtime", + caption: "Two services left.", + points: [ + { label: "Services moved", value: 18, target: 20 }, + { label: "Tests ported", value: 240, target: 240 }, + ], + }, confirmation: "The progress chart is now on screen for the person.", }, ]; diff --git a/app/src/components/gallery/decisions.tsx b/app/src/components/gallery/decisions.tsx index 539d18e..208d0f3 100644 --- a/app/src/components/gallery/decisions.tsx +++ b/app/src/components/gallery/decisions.tsx @@ -254,6 +254,23 @@ export const GALLERY: GalleryComponent[] = [ "Ask the person to approve or decline something, and WAIT for their answer. Use before doing anything you cannot undo, spending money, sending a message, changing a record. You are given their decision and any reason they typed.", parameters: ApprovalCardProps, Component: ApprovalCard as GalleryComponent["Component"], + preview: { + // The whole interaction, because that is what this component is handed: it suspends a run, + // so its arguments arrive wrapped in the state of the decision it is waiting on. + status: "executing", + args: { + title: "Refund this order?", + summary: + "The customer was charged twice for the same order and the second charge has not settled.", + details: [ + { label: "Amount", value: "$128.40" }, + { label: "Customer", value: "Northwind Traders" }, + { label: "Order", value: "2043" }, + ], + approveLabel: "Refund", + }, + respond: async () => {}, + }, }, { name: "askChoice", @@ -263,5 +280,25 @@ export const GALLERY: GalleryComponent[] = [ "Ask the person to pick one of several options, and WAIT for their answer. Use when you cannot sensibly guess which one they meant. You are given the id of the option they chose.", parameters: ChoiceCardProps, Component: ChoiceCard as GalleryComponent["Component"], + preview: { + status: "executing", + args: { + title: "Which environment should this go to?", + summary: "The build is green and nothing else is queued.", + options: [ + { + id: "staging", + label: "Staging", + description: "Safe, and reversible", + }, + { + id: "production", + label: "Production", + description: "Live customers", + }, + ], + }, + respond: async () => {}, + }, }, ]; diff --git a/app/src/components/gallery/preview.tsx b/app/src/components/gallery/preview.tsx new file mode 100644 index 0000000..72d260d --- /dev/null +++ b/app/src/components/gallery/preview.tsx @@ -0,0 +1,145 @@ +import { GALLERY_COMPONENTS } from "@/lib/copilot/gallery-registry"; + +/** + * The answers a Bot can give, drawn rather than described. + * + * Somebody meeting this product asks one question first: what does an answer look like? A list of + * names answers it for nobody, and a screenshot ages the moment a component changes. So every tile + * is the real component, rendered from the arguments it ships as its own introduction: the actual + * donut, the actual line chart, the actual card that waits for an answer. + * + * What is shown is what the Bot was granted, not what the build contains. A component can be + * unpublished, or held back from this Bot, and showing it anyway would be advertising something the + * person cannot have. + * + * The tiles are quiet on purpose. Every one of them is a different shape, colour and density, and + * the eye should land on those rather than on frames we drew around them: one hairline, one label, + * and the component left to be the interesting thing. + */ + +/** + * Charts first. + * + * They answer "is any of this visual" in one glance. A rail that opens with two decision cards and + * a paragraph makes somebody scroll to find that out, and most people will not. + */ +const ORDER: Record = { chart: 0, card: 1, decision: 2 }; + +export function GalleryPreview({ + /** + * What this Bot may actually answer with, as the deployment reports it. + * + * Undefined while it is still being asked for, which is not the same as an empty list: one is + * "not yet" and the other is "none", and they must not look alike. + */ + available, + /** + * How many across. One by default, and that is not timidity. + * + * These are conversation-sized components: a record card has a label column and a value column, a + * chart has an axis. Give them half of a narrow rail and the values wrap to one character per + * line, which is not a smaller version of the component but a broken one. Two-up is for a page + * with a page's width. + */ + columns = 1, +}: { + available?: readonly { name: string; description: string }[]; + columns?: 1 | 2; +}) { + if (!available) { + return ( +

Loading what it can draw…

+ ); + } + + const granted = new Map( + available.map((component) => [component.name, component.description]), + ); + const shown = GALLERY_COMPONENTS.filter((component) => + granted.has(component.name), + ).sort((a, b) => (ORDER[a.kind] ?? 9) - (ORDER[b.kind] ?? 9)); + + if (shown.length === 0) { + return ( +

+ This Bot has not been granted any components, so it answers in prose. +

+ ); + } + + return ( + + ); +} + +/** + * One component, drawn from its own sample props. + * + * Separate from the gallery because the two surfaces want different framing: the gallery labels + * every tile, and an admin page already has the name and the description above it. + */ +export function PreviewOf({ name }: { name: string }) { + const component = GALLERY_COMPONENTS.find((entry) => entry.name === name); + if (!component) return null; + if (!component.preview) { + /* + * Said rather than faked. This one draws whatever the deployment's own records hold, and + * inventing numbers for a tile would be the single dishonest thing on a page whose whole + * argument is that answers come from somewhere real. + */ + return ( +

+ Draws this deployment's own records, so it has nothing to show until a + Bot asks for it. +

+ ); + } + return ( + /* + * Not interactive: a decision card here has nothing to decide, and a button that does nothing + * is worse than one that is plainly a picture. + */ +
+ +
+ ); +} + +/** + * The first sentence of what the model is told. + * + * The full description is written for a model deciding whether to call this, and carries the + * comparisons that matter to that decision. A person reading a gallery wants to know what it is. + */ +function firstSentence(description: string): string { + const end = description.indexOf(". "); + return end === -1 ? description : description.slice(0, end + 1); +} diff --git a/app/src/components/gallery/quote.tsx b/app/src/components/gallery/quote.tsx index d912c43..837e68d 100644 --- a/app/src/components/gallery/quote.tsx +++ b/app/src/components/gallery/quote.tsx @@ -55,6 +55,12 @@ export const GALLERY: GalleryComponent[] = [ "Show a quotation with its attribution. Use when the exact words matter, something a person said, or a line from a document you were given.", parameters: QuoteCardProps, Component: QuoteCard as GalleryComponent["Component"], + preview: { + quote: + "Meals under $75 need no receipt. Anything above needs one, and anything above $500 needs your manager before you spend it.", + attribution: "the expense policy", + context: "Last changed in March.", + }, confirmation: "The quotation is now on screen for the person.", }, ]; diff --git a/app/src/lib/copilot/gallery-registry.ts b/app/src/lib/copilot/gallery-registry.ts index 465bd11..152f774 100644 --- a/app/src/lib/copilot/gallery-registry.ts +++ b/app/src/lib/copilot/gallery-registry.ts @@ -28,6 +28,18 @@ export type GalleryComponent = { */ description: string; parameters: ToolParameters; + /** + * The props that show this component at its best, for anywhere it is displayed rather than called. + * + * A gallery of names tells somebody nothing: the question they arrived with is "what does an + * answer from this thing look like", and only the component itself answers that. Kept beside the + * component so a new one cannot be added without deciding how it introduces itself. + * + * 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. + */ + preview?: Record; Component: (props: Record) => ReactElement | null; /** * The line the model is given once it is on screen. Ignored for a `decision`, whose result is the diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx index dd920c2..518ea30 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -141,7 +141,10 @@ function BoundariesPage() { } title="Boundaries" > - +
{(["enforce", "dry-run"] as PolicyMode[]).map((mode) => (
@@ -326,34 +344,63 @@ function ComponentRow({ -
- {bots.length === 0 ? ( -

- There are no Bots yet. -

- ) : null} - {bots.map((bot) => { - const has = !withheld.has(bot.id); - return ( - - ); - })} +
+ {/* + * The row of Bots was unlabelled, so a filled name and a hollow one were a state nobody + * could read: it was not said anywhere that these are the Bots allowed to answer with this, + * nor that clicking one takes it away. + */} +

+ Bots that may answer with it +

+

+ Filled means yes. Switch one off and that Bot is never told the + component exists, so it cannot ask for it and does not apologise for + not having it. +

+
+ {bots.length === 0 ? ( +

+ There are no Bots yet. +

+ ) : null} + {bots.map((bot) => { + const has = !withheld.has(bot.id); + return ( + + ); + })} +
{/* Data-function grants are separate from Bot component grants. */} {dataFunctions.length > 0 ? (
-

May read

+ {/* + * "May read" on its own said nothing. It is a different kind of grant from the row above, + * and the difference is the whole point: the row above decides who may draw this, and this + * decides what it is allowed to go and fetch in order to draw it. Somebody who reads the + * two as one switch will grant a component to every Bot believing that is all they did. + */} +

+ What it may go and read +

+

+ A separate grant, and not implied by the one above. This component + can ask the server for data to fill itself with; until one of these + is on it draws only what the Bot passes it. Each one names what it + reads, and every read is a row in Audit. +

{dataFunctions.map((fn) => { const has = heldFunctions.has(fn.name); diff --git a/app/src/routes/_authed/admin/plugins.tsx b/app/src/routes/_authed/admin/plugins.tsx index 8f1f2b3..f805b4a 100644 --- a/app/src/routes/_authed/admin/plugins.tsx +++ b/app/src/routes/_authed/admin/plugins.tsx @@ -459,6 +459,20 @@ function Yours({ return (
+ {/* + * The two words beside each tool decide how it is judged, and neither said so anywhere. + * "changes things" is not a description of the tool, it is the effect the boundary evaluates, + * and somebody writing a rule about writes has no way to know that from the badge alone. + */} +

+ A Bot with a grant may call that tool; a Bot without one is never told + it exists. Beside each tool is what it does to the far end, which is + what a boundary rule means by mcp.effect. Anything not + positively known to be read-only counts as changes things, + so every tool on a server somebody added by URL is treated as a write + until it is reviewed. Every call is checked against the boundary and + written to Audit whichever way it goes. +

{servers.map((server) => (