diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md index e27a721a4f..20cd104d6f 100644 --- a/.claude/skills/openbot-screen-layout/SKILL.md +++ b/.claude/skills/openbot-screen-layout/SKILL.md @@ -51,14 +51,44 @@ PageShell the page: header (title, description, action, backButton) then PageEmpty what a section says when it has nothing to list ``` -`PageShell` takes a `width`: `prose` (`max-w-3xl`, 720px here) is the default, and every +`PageShell` takes a `width`: `prose` (`max-w-3xl`, 768px) is the default, and every configuration screen uses it so no screen is wider than another. Sentences inside it are held to `max-w-prose`, so the column has room for a label beside its control without stretching the text. `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. +## The Rhythm + +Spacing comes from the frame, not from the screen. These are the gaps every screen gets by using the +components above; a screen that sets its own is drifting. + +| Between | Gap | Where it lives | +|---------|-----|----------------| +| Page edge (top / sides / bottom) | 48 / 16 / 64px | `PageShell` column (`pt-12` or `pt-8` under the bar, `px-4`, `pb-16`) | +| Title → description | 8px | `PageShell` header | +| Title block → first content | 32px | `PageShell`: `[&>header+*]:mt-8` | +| Title → tabs, tabs → content | 24px, then 32px | `PageShell`: `[&>header+nav]`, `[&>header+nav+*]` | +| Section → section | 48px | `PageSection` (`mt-12`) | +| Section heading → description | 4px | `PageSection` | +| Heading block → section content | 16px | `PageRows` / `PageEmpty` (`mt-4`) | +| Controls stacked inside a section | 12px | the screen, with `gap-3` | +| A row in `PageRows` | 65px with a description, never under 56px | `Item` (`px-4 py-3.5 gap-3`), `PageRows` (`min-h-14`) | +| Divider between rows | inset 16px from both edges | `PageRows`, on every horizontal `Separator` inside it | + +The 32px under the title is deliberately tighter than the 48px between sections: the title belongs +to the page it introduces. Those `PageShell` selectors outrank the first child's own `mt-*`, so a +section or notice keeps its usual margin everywhere else and loses it only at the top. + +A page with one section gives that section no `title`: the page title already names it, and a second +heading 32px below the first says the same thing twice. Put the description on `PageShell` instead +(`connections.index.tsx`). A count or filter that belongs to a search sits on the search's row, not +on a line of its own. + +`PageEmpty` is shadcn's empty state: a bordered box where the rows would be, with an `icon` for +the kind of thing, a `title` ("No rules yet", "No matches", "Background research is off"), one +sentence of description saying how the first one arrives, and an `action` — the same Add button the +page already has — when there is one to offer. Don't repeat "Add one with X" in the description when +the action is right there. A search with no results uses `IconSearch` and "No matches". ## Procedures @@ -79,8 +109,10 @@ here yet" is a fact, and the section heading already said what the section is fo ### 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. +1. `ItemMedia variant="icon"` holds a leading icon from `@tabler/icons-react`, drawn as a 16px glyph + on a 32px muted tile. It is what makes a card of rows scannable; a row without one reads as a + paragraph. Pass the bare icon — the variant sizes and colours it — and use plain `ItemMedia` only + for something that is its own picture, such as a Bot's avatar. 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 @@ -127,7 +159,19 @@ itself when the dialog changes something. 4. `bg-card` is invisible inside a dialog. `--card` and `--popover` resolve to the same colour in both themes, so a card-coloured row against a popover is no row at all. Use `muted`. -### Procedure 5: Lay out a dialog +### Procedure 5: Add something from a list + +1. Creating a thing that takes several fields — a credential, a key — is its own screen, not a dialog + over the list. Put it at `_.new.tsx` (the `_` keeps it from nesting inside the list's route), + and make the list's `action` a `Button` rendering a `Link` to it. +2. `PageShell` with `backButton` pointing at the list, a title that is the verb ("Add credential"), + and a description saying what happens on save. +3. The fields go in one `PageCard` inside a `
`, stacked in a single column, each a `Field` + with `FieldLabel`, an optional `FieldDescription`, then the control. `PageCard`'s `footer` takes + Cancel (`variant="outline"`, a `Link` back) and the submit button; on success, navigate to the list. +4. `admin/credentials_.new.tsx` is the reference. + +### Procedure 6: Lay out a dialog 1. `DialogContent` defaults to `max-w-lg` and `max-h-[85svh]` with `p-5`. Header, body and footer go in as `DialogHeader` / `DialogBody className="mt-4"` / `DialogFooter className="mt-4"`. @@ -142,21 +186,17 @@ itself when the dialog changes something. its own backing — the built-in one is a ghost button in the foreground colour and disappears over a pale image. -## The rem Trap - -`app/src/styles.css` sets `html { font-size: 15px }`. Every rem-based Tailwind token is therefore -**0.9375×** its documented pixel value: +## Units -| Token | Documented | Actual here | -|-------|-----------|-------------| -| `max-w-2xl` | 672px | **630px** | -| `max-w-3xl` | 768px | **720px** | -| `max-w-5xl` | 1024px | **960px** | +The root is the browser's 16px, so Tailwind's spacing, sizes and widths are their documented values +(`p-4` is 16px, `max-w-3xl` is 768px) and land on its 4px grid. Text is the exception: `styles.css` +sets every `--text-*` to Tailwind's default times 15/16, so `text-sm` is 13.125px and body text is +15px. That is the scale this app has always read at; do not size text in px or rem by hand to match +the docs. -Never hardcode a pixel constant derived from a Tailwind rem token, and never compute a ratio from -one. Measure the element — `offsetWidth`, `clientWidth`, a `ResizeObserver` — and scale from what is -actually on screen. A constant taken from the Tailwind docs is wrong by 6.25% here, which is enough -to sit a centred element visibly off centre or clip a card's corners against its container. +Even so, never hardcode a pixel constant derived from a Tailwind token, and never compute a ratio +from one. Measure the element — `offsetWidth`, `clientWidth`, a `ResizeObserver` — and scale from what +is actually on screen. The root was once 15px, and every constant taken from the docs sat 6.25% off. ## Decision Tree @@ -164,12 +204,16 @@ to sit a centred element visibly off centre or clip a card's corners against its - Adding a row to an existing screen → Procedure 2, then Procedure 3 to pick its kind. - The row toggles one thing → Procedure 3, kind 1: a `Switch`, no save. - The row edits text, or a set of grants, or more than one value → Procedure 3, kind 2: a summary, a - chevron, and a dialog. Then Procedure 5. + chevron, and a dialog. Then Procedure 6. +- Adding a new thing that takes several fields → Procedure 5: its own screen with a back button. - The row only states something → Procedure 3, kind 3: a value and no chevron. - Unsure which `Item` size, `Button` size, or `variant` → Procedure 4. -- A width, ratio or scale needs a number in pixels → the rem Trap section. Measure, do not assume. +- A width, ratio or scale needs a number in pixels → the Units section. Measure, do not assume. +- Choosing a margin or gap between blocks → the Rhythm table. If the frame already gives it, set none. - The screen genuinely does not fit this shape → say so in the response and comment the reason in the - code. A table is the one accepted precedent (`admin/audit.tsx`, `width="wide"`). + code. There are two accepted precedents: a table (`admin/audit.tsx`, `width="wide"`), and the + Connections tiles (`connections.index.tsx`), a grid of separate `variant="outline"` cards, 56px + tall and 12px apart, because apps are picked out by their mark rather than read down as a list. - The question is which states the screen renders while loading or failing → not this skill; `openbot-data-access`, Procedure 3. @@ -184,7 +228,9 @@ to sit a centred element visibly off centre or clip a card's corners against its | Children on an element passed to `Item`'s `render` | They replace the row's own children; the row draws empty | Pass a childless element | | `bg-card` on a row inside a dialog | `--card` equals `--popover`; the row is invisible | `variant="muted"` | | `Item variant="outline"` and `variant="muted"` on one screen | Reads as two kinds of list and invites a distinction that is not there | One variant per screen | -| A pixel constant matching a Tailwind rem token (672, 768, 1024) | Assumes a 16px root; the root here is 15px | Measure the element | +| A pixel constant matching a Tailwind rem token (672, 768, 1024) | Breaks the day a width or the root changes | Measure the element | +| An `mt-*` on a screen's first block, or a gap between blocks not in the Rhythm table | A second spacing scale, which is what made every screen feel slightly off | Let `PageShell` and `PageSection` space it | +| A one-section page whose section has a `title` | The heading repeats the page title | Drop the section title; description on `PageShell` | | A `Switch` on a row that needs two or more values | Half the change happens in place and half in a dialog | A summary, a chevron, and a dialog | | A chevron on a row that opens nothing | Announces a destination that does not exist | Value in `ItemActions`, no chevron | | A set of chips or toggles crammed into `ItemActions` | It competes with the label for width and wraps badly | `ItemFooter`, which is `basis-full` | @@ -195,8 +241,8 @@ to sit a centred element visibly off centre or clip a card's corners against its - **A row renders empty — media, title and actions all missing**: children were passed on the element given to `Item`'s `render`. Remove them. - **A row's content sits visibly off centre, or a card's corners are clipped by its container**: a - pixel constant was taken from the Tailwind docs and the root font size is 15px. Measure the drawn - element instead of the box it was given. + pixel constant was taken from the Tailwind docs rather than measured. Measure the drawn element + instead of the box it was given. - **A dialog's footer has body content painted over it**: `DialogBody` is missing `overflow-y-auto`. It has `flex-1 min-h-0` and no overflow of its own. - **A dialog stops scrolling after a refactor**: `DialogBody` is no longer a direct child of diff --git a/CHANGELOG.md b/CHANGELOG.md index ea630edc5a..85bcf0b573 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -**Before upgrading.** Six things change for an existing deployment: +**Before upgrading.** Eight things change for an existing deployment: - A hosted computer's browser is now headed by default. It uses a little more memory than headless did; set `COMPUTER_BROWSER_MODE=headless` (Helm: `computers.browserMode: headless`) to keep the old behaviour. @@ -34,6 +34,46 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. - Migration `0060_app_access_requests` adds one table, `app_access_requests`, where a person's request for an administrator to switch on a work app, grant it to a Bot, or connect a Shared app's team account is kept, with who answered it and when. It starts empty and nothing is backfilled. +- Migration `0061_suggestion_superseded` widens the check on `proactive_suggestions.status` to allow + `superseded` (below). Open suggestions from before the upgrade stay open until that Bot's next + research run replaces them. +- Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to + empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them. + +### Screens share one spacing, row and empty-state design + +Every configuration screen now takes its spacing from the page frame: 32px under the title, 48px +between sections, rows at least 56px tall with dividers inset to the text. Rows draw their icon as a +glyph on a small tile, and a section with nothing in it says so with an icon, a title and, where +there is one, the button that adds the first thing. The root font size is the browser's 16px again, +with text kept on its 15px scale, so Tailwind sizes are their documented values. + +### Adding a credential and a boundary rule are screens of their own + +Add credential opens a page with a back button instead of a dialog. Boundaries is now a list of +named rules ("Bots may never…"); adding one starts from a template or a blank rule and can be tested +against recent activity before it applies, and each rule opens to what it has stopped lately. + +### A Bot's page is shorter + +Its In progress, Scheduled and Completed lists move to their own Activity page. The Bot's page keeps +what is running or scheduled, three at most, with a dot on anything under way, and shows its newest +three suggested next steps with the rest behind "Show more". + +### Suggested next steps no longer pile up + +Each research run replaces the open suggestions from the one before, and one with the same title as +a suggestion that is already open, or was dismissed in the last 30 days, is not suggested again. The +research is asked to suggest nothing when nothing needs doing, and not to name the Bot's tools. + +### Pin or delete a conversation from its Bot panel + +The gear panel in a conversation ends with Pin and Delete for that conversation. + +### The @ menu shows each Bot's avatar, at the caret + +On the home screen the @ menu opened away from the text, partly off the window; it now opens at the +caret. It is wider and shows each Bot's avatar beside its name. ### Filling in a form is no longer mistaken for paying @@ -665,7 +705,7 @@ A call that is refused anyway, from a schema offered earlier, is still refused a ## 0.1.0 -**Before upgrading.** Six things change for an existing deployment: +**Before upgrading.** Eight things change for an existing deployment: - Automatic Learning is on unless an administrator saved it off. It does nothing until a Learning container is assigned; see below. - A Bot's computer refuses the network until the server pushes its policy. A computer run without diff --git a/app/src/components/agents/agent-profile.tsx b/app/src/components/agents/agent-profile.tsx index a7b87d77a6..2197c09c3a 100644 --- a/app/src/components/agents/agent-profile.tsx +++ b/app/src/components/agents/agent-profile.tsx @@ -3,6 +3,7 @@ import { Link, useNavigate } from "@tanstack/react-router"; import type { ReactNode } from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { agentQueryOptions } from "@/lib/agents/queries"; @@ -46,7 +47,14 @@ function ProfileSkeleton() { * It used to duplicate that dialog's whole surface (edit form, tokens, grants, delete), which made * two places to maintain and a sidebar that scrolled past the conversation it sat beside. */ -export function AgentProfile({ agentId }: { agentId: string }) { +export function AgentProfile({ + agentId, + footer, +}: { + agentId: string; + /** What the panel offers about the conversation rather than the Bot, below a separator. */ + footer?: ReactNode; +}) { /** The full dialog, opened over the chat rather than navigating away from it. */ const navigate = useNavigate(); const agent = useQuery(agentQueryOptions(agentId)); @@ -115,6 +123,12 @@ export function AgentProfile({ agentId }: { agentId: string }) { Open {profile.name} + {footer ? ( + <> + + {footer} + + ) : null} ); } diff --git a/app/src/components/agents/handoff-panel.tsx b/app/src/components/agents/handoff-panel.tsx index d9d9b008f3..9a26e584d6 100644 --- a/app/src/components/agents/handoff-panel.tsx +++ b/app/src/components/agents/handoff-panel.tsx @@ -1,3 +1,4 @@ +import { IconArrowsExchange, IconLock, IconRobot } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Fragment } from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; @@ -81,14 +82,14 @@ export function HandoffPanel({ agentId }: { agentId: string }) { > {/* Facts about the deployment rather than rows to act on, so said as sentences. */} {enabled ? null : ( - - Handing work between Bots is turned off. These settings are kept, and - they apply again once an admin turns it back on. + + An admin turned off handing work between Bots. These settings are + kept, and they apply again once it's back on. )} {grantable ? null : ( - + This Bot can't hand work to other Bots. Other Bots can still hand work to it. @@ -101,9 +102,8 @@ export function HandoffPanel({ agentId }: { agentId: string }) { ) : null} {grantable && total === 0 ? ( - - No other Bots yet. When there are more, you can let this Bot ask them - here. + + When there are more, you can let this Bot ask them here. ) : null} {candidates.length > 0 ? ( @@ -153,7 +153,9 @@ export function HandoffPanel({ agentId }: { agentId: string }) { ) : null} {canGrant ? null : ( - An admin decides which Bots this one can ask. + + An admin decides which Bots this one can ask. + )} ); diff --git a/app/src/components/app-sidebar/channel.tsx b/app/src/components/app-sidebar/channel.tsx index 6532c90f7f..b043543926 100644 --- a/app/src/components/app-sidebar/channel.tsx +++ b/app/src/components/app-sidebar/channel.tsx @@ -2,25 +2,14 @@ import { IconPin, IconPinnedOff, IconTrash } from "@tabler/icons-react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { memo, useState } from "react"; -import { Button } from "@/components/ui/button"; +import { DeleteConversationDialog } from "@/components/channels/delete-conversation-dialog"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { - deleteChannelMutationOptions, - setChannelPinnedMutationOptions, -} from "@/lib/channels/mutations"; +import { setChannelPinnedMutationOptions } from "@/lib/channels/mutations"; import type { MessageListEmphasis } from "@/lib/settings/message-list"; import { useTypedReveal } from "@/lib/typed-reveal"; import { ChannelItemContent } from "./channel-item-content"; @@ -70,7 +59,6 @@ export const Channel = memo(function Channel({ const revealed = useTypedReveal(summary); const threadTitle = revealed.text || lastMessage || "New conversation"; const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient)); - const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient)); const [confirming, setConfirming] = useState(false); /** * Why a pin did not take, said on the row it was asked of. @@ -82,28 +70,6 @@ export const Channel = memo(function Channel({ */ const [pinProblem, setPinProblem] = useState(null); - const confirmDelete = async () => { - /* - * Away first when this row's channel is the one on screen. - * - * The roster invalidates the moment the delete lands, so this row — and the dialog living inside - * it — unmounts while the rest of this function is still owed. Navigating after the mutation - * therefore ran in a component that was already gone, leaving somebody looking at a conversation - * that no longer exists. Leaving before asking is safe in the other direction: a refused delete - * puts them on the roster with the channel still in it, and says why in the dialog. - */ - if (isOpen) { - await navigate({ to: "/" }); - } - try { - await deleteChannel.mutateAsync(channelId); - } catch { - // The error is on the mutation and rendered in the dialog; leaving it open says "not done". - return; - } - setConfirming(false); - }; - return ( <> @@ -151,11 +117,7 @@ export const Channel = memo(function Channel({ { - // A refusal from a previous attempt is not news about this one. - deleteChannel.reset(); - setConfirming(true); - }} + onClick={() => setConfirming(true)} > Delete conversation… @@ -167,45 +129,14 @@ export const Channel = memo(function Channel({ {pinProblem}

) : null} - { - if (!open) setConfirming(false); - }} + navigate({ to: "/" }) : undefined} + name={name} + onOpenChange={setConfirming} open={confirming} - > - - - Delete {name}? - - It disappears for everyone in the conversation. - - - {deleteChannel.error ? ( -

- {deleteChannel.error.message} -

- ) : null} - - - - -
-
+ /> ); }); diff --git a/app/src/components/approvals/bot-rules.tsx b/app/src/components/approvals/bot-rules.tsx index 3dfae60572..18790274c2 100644 --- a/app/src/components/approvals/bot-rules.tsx +++ b/app/src/components/approvals/bot-rules.tsx @@ -1,3 +1,4 @@ +import { IconChecklist } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { Fragment } from "react"; @@ -67,7 +68,9 @@ export function BotApprovalRules({ agentId }: { agentId: string }) { {error.message}

) : null} - {saved === 0 ? No rules for this Bot. : null} + {saved === 0 ? ( + + ) : null} {saved > 0 || canAdd ? ( {team.map((rule, index) => ( diff --git a/app/src/components/approvals/settings.tsx b/app/src/components/approvals/settings.tsx index 1f6a73033e..608472641f 100644 --- a/app/src/components/approvals/settings.tsx +++ b/app/src/components/approvals/settings.tsx @@ -1,4 +1,9 @@ -import { IconEyeCheck, IconHandStop, IconTerminal2 } from "@tabler/icons-react"; +import { + IconChecklist, + IconEyeCheck, + IconHandStop, + IconTerminal2, +} from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { Fragment } from "react"; @@ -17,6 +22,7 @@ import { } from "@/components/ui/item"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; +import { agentListQueryOptions } from "@/lib/agents/queries"; import { approvalInboxOptions, approvalPreferencesMutationOptions, @@ -25,7 +31,6 @@ import { ruleCoversBot, updateApprovalRuleMutationOptions, } from "@/lib/approvals"; -import { agentListQueryOptions } from "@/lib/agents/queries"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { queryClient } from "@/query-client"; import { HOST_LABELS, LabelSelect, RuleForm, RuleRow } from "./rules"; @@ -191,7 +196,9 @@ export function ApprovalSettings() { } title="Rules for every Bot" > - {saved === 0 ? No rules yet. : null} + {saved === 0 ? ( + + ) : null} {saved > 0 || canAdd ? ( {teamRules.map((rule, index) => ( diff --git a/app/src/components/approvals/team-settings.tsx b/app/src/components/approvals/team-settings.tsx index 0095531d83..9740ceae22 100644 --- a/app/src/components/approvals/team-settings.tsx +++ b/app/src/components/approvals/team-settings.tsx @@ -1,4 +1,9 @@ -import { IconEyeCheck, IconTerminal2, IconUserCog } from "@tabler/icons-react"; +import { + IconChecklist, + IconEyeCheck, + IconTerminal2, + IconUserCog, +} from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Fragment } from "react"; import { @@ -131,7 +136,9 @@ export function TeamApprovalSettings() { description="These apply to every member's Bots. Members can't change them." title="Team rules" > - {teamRules.length === 0 ? No team rules. : null} + {teamRules.length === 0 ? ( + + ) : null} {teamRules.map((rule) => ( diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx index 2154222568..f6b22ef8ef 100644 --- a/app/src/components/bot-profile/access.tsx +++ b/app/src/components/bot-profile/access.tsx @@ -1,19 +1,24 @@ -import { IconApps, IconBox, IconChevronRight } from "@tabler/icons-react"; +import { + IconApps, + IconBox, + IconChevronRight, + IconPlus, +} from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { Fragment } from "react"; import { HandoffPanel } from "@/components/agents/handoff-panel"; +import { + PageEmpty, + PageRows, + PageSection, +} from "@/components/layout/page-shell"; import { AppAccessAction, appAccessSentence, } from "@/components/plugins/app-access-action"; import { PluginLogo } from "@/components/plugins/plugin-logo"; import { Button } from "@/components/ui/button"; -import { - PageEmpty, - PageRows, - PageSection, -} from "@/components/layout/page-shell"; import { Item, ItemActions, @@ -114,15 +119,21 @@ function YourSkills({ agentId }: { agentId: string }) { Couldn't load your skills. Reload the page.

) : skills.length === 0 ? ( - - No skills yet.{" "} - - Write one - - . + } + size="sm" + variant="outline" + > + + Write a skill + + } + icon={IconBox} + title="No skills yet" + > + Skills you write show up here. ) : ( @@ -212,9 +223,8 @@ function Granted({ agentId }: { agentId: string }) {

) : null} {count === 0 ? ( - - No apps yet. An admin has to add them. Until then this Bot can only - talk with you. + + An admin has to add them. Until then this Bot can only talk with you. ) : ( diff --git a/app/src/components/bot-profile/activity.tsx b/app/src/components/bot-profile/activity.tsx index b390effebc..91c0fad637 100644 --- a/app/src/components/bot-profile/activity.tsx +++ b/app/src/components/bot-profile/activity.tsx @@ -3,8 +3,10 @@ import { type IconBell, IconCalendarRepeat, IconChecks, + IconChevronRight, IconClockPlay, IconHelpCircle, + IconHistory, IconTargetArrow, } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -32,9 +34,11 @@ import { import { type ActivityItem, type ActivityKind, + type BotActivity, botActivityQueryOptions, } from "@/lib/bot-lifecycle/queries"; import { relativeTime } from "@/lib/relative-time"; +import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; const ICONS: Record = { @@ -77,9 +81,12 @@ function linkFor(item: ActivityItem, agentId: string) { function ActivityRow({ agentId, item, + live = false, }: { agentId: string; item: ActivityItem; + /** Under way now: a pulsing dot on its icon, amber when it is waiting on the person. */ + live?: boolean; }) { const stop = useMutation(stopHandoffMutationOptions(queryClient)); const cancel = useMutation(cancelFollowUpMutationOptions(queryClient)); @@ -107,8 +114,17 @@ function ActivityRow({ ) : null; const content = ( <> - + + {live ? ( + {item.title} @@ -151,23 +167,25 @@ function ActivitySection({ agentId, empty, items, + live = false, title, }: { agentId: string; empty: string; items: ActivityItem[]; + live?: boolean; title: string; }) { return ( {items.length === 0 ? ( - {empty} + {empty} ) : ( {items.map((item, index) => ( {index > 0 ? : null} - + ))} @@ -176,6 +194,77 @@ function ActivitySection({ ); } +/** How many running or scheduled items the Bot's page shows before "All activity". */ +const GLANCE = 3; + +/** + * The Bot page's view of its activity: what is under way and what is lined up, so either is seen + * without opening anything, then a row to the full lists. Running work comes first, with its dot. + * + * The full lists used to sit here and ran on for screens; finished work is the long part and the + * least urgent, so it lives only on the Activity page. + */ +export function BotActivityGlance({ agentId }: { agentId: string }) { + const activity = useQuery(botActivityQueryOptions(agentId)); + if (activity.isPending) return null; + const upcoming = activity.data + ? [ + ...activity.data.inProgress.map((item) => ({ item, live: true })), + ...activity.data.scheduled.map((item) => ({ item, live: false })), + ].slice(0, GLANCE) + : []; + return ( + + + {upcoming.map(({ item, live }) => ( + + + + + ))} + } + size="sm" + > + {upcoming.length ? null : ( + + + + )} + + All activity + + {activity.data + ? activitySummary(activity.data) + : "Couldn't load this Bot's activity."} + + + + + + + + + ); +} + +/** + * The Bot page's one line about its activity: what is running and what is lined up, the two that + * might want a look. Finished work is only counted when there is nothing else to say. + */ +export function activitySummary(activity: BotActivity): string { + const parts = [ + activity.inProgress.length + ? `${activity.inProgress.length} in progress` + : null, + activity.scheduled.length ? `${activity.scheduled.length} scheduled` : null, + ].filter(Boolean); + if (parts.length) return parts.join(" · "); + return activity.completed.length + ? "Nothing running or scheduled" + : "Nothing yet"; +} + /** In progress, Scheduled and Completed, read from the ledgers the work already lives in. */ export function BotActivitySections({ agentId }: { agentId: string }) { const activity = useQuery(botActivityQueryOptions(agentId)); @@ -194,6 +283,7 @@ export function BotActivitySections({ agentId }: { agentId: string }) { agentId={agentId} empty="Nothing is running or waiting on you." items={activity.data.inProgress} + live title="In progress" /> = { @@ -222,7 +222,7 @@ export function BotProfile({ )} - + {settings} {manage} diff --git a/app/src/components/bot-profile/sharing.tsx b/app/src/components/bot-profile/sharing.tsx index 16a69214f4..103f7c4a9a 100644 --- a/app/src/components/bot-profile/sharing.tsx +++ b/app/src/components/bot-profile/sharing.tsx @@ -2,6 +2,7 @@ import { IconChevronRight, IconLink, IconPlus, + IconShare, IconUsers, IconUsersGroup, } from "@tabler/icons-react"; @@ -148,12 +149,19 @@ export function SharingSections({ agent }: { agent: AgentProfile }) { ) : ( - + {agent.visibility === "public" - ? "Not published to the team. It's public, so everyone on OpenBot can already use it." + ? "It's public, so everyone on OpenBot can already use it." : agent.mine - ? "Not published. Only you can use it." - : "Not published."} + ? "Only you can use it." + : null} )} @@ -365,7 +373,7 @@ function Assignments({ bot }: { bot: TeamBot }) {

) : null} {assigned.length === 0 ? ( - Not assigned to any group. + ) : null} {assigned.map((name) => ( diff --git a/app/src/components/bots/bot-roster.tsx b/app/src/components/bots/bot-roster.tsx index c13668f378..c801d12f9e 100644 --- a/app/src/components/bots/bot-roster.tsx +++ b/app/src/components/bots/bot-roster.tsx @@ -1,4 +1,9 @@ -import { IconChevronRight } from "@tabler/icons-react"; +import { + IconChevronRight, + IconPlus, + IconRobot, + IconShare, +} from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import { Fragment, useState } from "react"; @@ -76,12 +81,35 @@ export function BotRoster() { {yours.length ? ( ) : ( - + ( + + )} + size="sm" + variant="outline" + > + + New Bot + + ) + } + icon={IconRobot} + title={ + yoursPinned + ? "All pinned" + : yoursWaiting + ? "All waiting on you" + : "No Bots yet" + } + > {yoursPinned ? "Your Bots are all pinned above." : yoursWaiting ? "Your Bots are all under Needs you." - : "You don't have any Bots yet. Create one with New Bot."} + : "Bots you create show up here."} )} @@ -89,7 +117,9 @@ export function BotRoster() { {shared.length ? ( ) : ( - Nobody has shared a Bot with you yet. + + Bots somebody shares with you show up here. + )} {hidden.data?.length ? ( diff --git a/app/src/components/boundaries/dry-run-changes.tsx b/app/src/components/boundaries/dry-run-changes.tsx new file mode 100644 index 0000000000..38bf0fc68b --- /dev/null +++ b/app/src/components/boundaries/dry-run-changes.tsx @@ -0,0 +1,48 @@ +import type { DryRunChange } from "@/lib/computers/queries"; + +/** + * Actions already on the trail that a rule decides differently, one line each. + * + * `total` is the count over everything scanned. The list is capped by the server, and a reader who + * stops at the rows should not believe the rows are the whole answer, so the gap is said under them. + */ +export function DryRunChanges({ + changes, + total, +}: { + changes: DryRunChange[]; + total: number; +}) { + if (changes.length === 0) return null; + return ( + <> +
    + {changes.map((change) => ( +
  • +

    + {change.action} + {change.element?.name ? <> on “{change.element.name}” : null} + {change.command ? ( + <> + {" "} + running {change.command} + + ) : null} + {change.file ? <> touching {change.file} : null} +

    +

    + {change.bot} + {change.page ? <> · {change.page} : null} ·{" "} + {new Date(change.createdAt).toLocaleString()} +

    +
  • + ))} +
+ {total > changes.length ? ( +

+ Showing the first {changes.length} of {total}. +

+ ) : null} + + ); +} diff --git a/app/src/components/channels/composer/attachment-strip.tsx b/app/src/components/channels/composer/attachment-strip.tsx index c14f769370..90ba773333 100644 --- a/app/src/components/channels/composer/attachment-strip.tsx +++ b/app/src/components/channels/composer/attachment-strip.tsx @@ -143,9 +143,9 @@ export function AttachmentStrip({ /* * ON THE SIZE LINE, NOT UNDER IT, AND THAT IS A LAYOUT CONSTRAINT RATHER THAN A * PREFERENCE. The tile's height is fixed so that a file and a thumbnail read as - * one row. A third line does not fit: measured in Chromium at this repository's - * 15px root, the two in-flow children need 63.75px inside a 58px box. Something - * has to give, and the only choices are the icon shrinking to 13px or the text + * one row. A third line does not fit: with three lines the two in-flow children + * need 65px (a 20px icon and three 15px lines) inside a 62px box. Something + * has to give, and the only choices are the icon shrinking to 17px or the text * spilling past the border. Folding the warning onto this line keeps two. * * `truncate` is the guarantee, not the wording: `formatBytes` is unbounded, so a diff --git a/app/src/components/channels/composer/dictation-controls.tsx b/app/src/components/channels/composer/dictation-controls.tsx index a1de0b6b86..07536e098f 100644 --- a/app/src/components/channels/composer/dictation-controls.tsx +++ b/app/src/components/channels/composer/dictation-controls.tsx @@ -62,10 +62,20 @@ export function DictationSurface({ aria-hidden={dictation.busy || undefined} inert={dictation.busy} className={cn( - "min-w-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transform-none motion-reduce:transition-none", + /* + * NO TRANSLATE AT REST, NOT EVEN ZERO. Tailwind's `translate-y-0` sets the `translate` + * property to `0px`, which is not `none`, and any translate makes this box the containing + * block for `position: fixed` descendants. The @ menu is one: it is placed at the caret in + * viewport coordinates, and was drawn that far again from this box's corner, off the bottom + * of the screen. The idle state therefore names no translate at all. + * + * `translate` in the transition list, not `transform`: Tailwind v4's translate utilities + * set the `translate` property, so a transition on `transform` never animated the lift. + */ + "min-w-0 transition-[opacity,translate] duration-200 ease-out motion-reduce:transition-none motion-reduce:translate-none", dictation.busy ? "pointer-events-none -translate-y-1 opacity-0" - : "translate-y-0 opacity-100", + : "opacity-100", )} > {children} diff --git a/app/src/components/channels/composer/triggers.ts b/app/src/components/channels/composer/triggers.tsx similarity index 81% rename from app/src/components/channels/composer/triggers.ts rename to app/src/components/channels/composer/triggers.tsx index f2cbef110c..2dc0021b4a 100644 --- a/app/src/components/channels/composer/triggers.ts +++ b/app/src/components/channels/composer/triggers.tsx @@ -1,5 +1,6 @@ import type { TriggerConfig, TriggerSuggestion } from "prompt-area/helpers"; import { commandTrigger, mentionTrigger } from "prompt-area/helpers"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { AGENT_TRIGGER, COMMAND_TRIGGER, type CommandOption } from "./draft"; /** @@ -13,6 +14,7 @@ export type AgentOption = { id: string; name: string; description?: string; + avatarSeed: string; }; /** @@ -22,7 +24,14 @@ export type AgentOption = { * Takes a structural shape so the composer stays independent of the queries module. */ export function toAgentOptions( - profiles: readonly { id: string; name: string; title?: string }[] | undefined, + profiles: + | readonly { + id: string; + name: string; + title?: string; + avatarSeed?: string; + }[] + | undefined, permittedIds?: readonly string[], ): AgentOption[] { if (!profiles) { @@ -35,6 +44,8 @@ export function toAgentOptions( id: profile.id, name: profile.name, description: profile.title, + // A group's roster carries no seed, and draws each Bot from its id; this matches it. + avatarSeed: profile.avatarSeed ?? profile.id, })); } @@ -64,6 +75,16 @@ export function agentTrigger(agents: readonly AgentOption[]): TriggerConfig { value: agent.id, label: agent.name, description: agent.description, + // Hidden: the option is already announced by the Bot's name. + icon: ( + + ), })), onSelect: (suggestion) => suggestion.label, }); diff --git a/app/src/components/channels/conversation-actions.tsx b/app/src/components/channels/conversation-actions.tsx new file mode 100644 index 0000000000..9d8097387b --- /dev/null +++ b/app/src/components/channels/conversation-actions.tsx @@ -0,0 +1,89 @@ +import { IconPin, IconPinnedOff, IconTrash } from "@tabler/icons-react"; +import { + useInfiniteQuery, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { setChannelPinnedMutationOptions } from "@/lib/channels/mutations"; +import { channelListQueryOptions } from "@/lib/channels/queries"; +import { DeleteConversationDialog } from "./delete-conversation-dialog"; + +/** + * Pin and Delete for the conversation that is open, in the Bot panel beside it. + * + * The same two actions as the sidebar row's right-click menu, which nobody finds without being told + * it is there. Navigation is the caller's: `leave` takes the person off the conversation before it + * is deleted from under them. + * + * Whether it is pinned comes from the sidebar's list, which is already loaded, because the + * conversation's own read does not carry the member's pin. The list's first page always holds every + * pinned conversation, so a conversation missing from the pages loaded so far is not pinned. + */ +export function ConversationActions({ + channelId, + leave, + name, +}: { + channelId: string; + leave: () => Promise; + name: string; +}) { + const queryClient = useQueryClient(); + const pinned = useInfiniteQuery({ + ...channelListQueryOptions(), + select: (data) => + data.pages.some((page) => + page.channels.some( + (channel) => channel.id === channelId && channel.pinned, + ), + ), + }).data; + const setPinned = useMutation(setChannelPinnedMutationOptions(queryClient)); + const [confirming, setConfirming] = useState(false); + + return ( +
+ {/* Headed like the profile's own sections, so it reads as about the conversation, not the Bot. */} +

+ This conversation +

+
+ {/* The heading names the conversation, so the labels drop it; screen readers still hear it. */} + + +
+ {/* No toast in this app: why a pin did not take is said where it was asked for. */} + {setPinned.error ? ( +

+ {setPinned.error.message} +

+ ) : null} + +
+ ); +} diff --git a/app/src/components/channels/delete-conversation-dialog.tsx b/app/src/components/channels/delete-conversation-dialog.tsx new file mode 100644 index 0000000000..642dfeb084 --- /dev/null +++ b/app/src/components/channels/delete-conversation-dialog.tsx @@ -0,0 +1,90 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { deleteChannelMutationOptions } from "@/lib/channels/mutations"; + +/** + * Confirms deleting a conversation, naming it, and deletes it. + * + * Shared by the sidebar row's menu and the Bot panel beside an open conversation, so both ask the + * same question and fail the same way. `leave` runs before the delete when the conversation is the + * one on screen: the roster invalidates the moment the delete lands, and whatever drew this dialog + * can unmount while the rest of the work is still owed, leaving somebody looking at a conversation + * that no longer exists. Leaving first is safe in the other direction: a refused delete puts them on + * the roster with the conversation still in it, and the dialog says why. + */ +export function DeleteConversationDialog({ + channelId, + leave, + name, + onOpenChange, + open, +}: { + channelId: string; + /** Navigates away from the conversation. Only passed where it is the one on screen. */ + leave?: () => Promise; + name: string; + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const queryClient = useQueryClient(); + const deleteChannel = useMutation(deleteChannelMutationOptions(queryClient)); + const { reset } = deleteChannel; + + // A refusal from a previous attempt is not news about this one. + useEffect(() => { + if (open) reset(); + }, [open, reset]); + + const confirm = async () => { + if (leave) await leave(); + try { + await deleteChannel.mutateAsync(channelId); + } catch { + // The error is on the mutation and rendered below; leaving it open says "not done". + return; + } + onOpenChange(false); + }; + + return ( + + + + Delete {name}? + + It disappears for everyone in the conversation. + + + {deleteChannel.error ? ( +

+ {deleteChannel.error.message} +

+ ) : null} + + + + +
+
+ ); +} diff --git a/app/src/components/component-preview.tsx b/app/src/components/component-preview.tsx index 9d55d0d1f9..57d991c949 100644 --- a/app/src/components/component-preview.tsx +++ b/app/src/components/component-preview.tsx @@ -112,9 +112,9 @@ export function ComponentPreview({ * column: previewing at the tile's own width would be a different component. * * Only an upper bound, and deliberately not read as the component's width. `GalleryFrame` caps - * itself at `max-w-2xl`, which is 42rem — 630px against this app's 15px root, not the 672px a 16px - * root would give. Assuming that number left every preview sitting half the difference off centre, - * so the drawn width is measured instead. + * itself at `max-w-2xl`, which is 42rem, or 672px against the app's 16px root. The drawn width is + * still measured rather than assumed: a root that once differed left every preview sitting half the + * difference off centre. */ const PREVIEW_LAYOUT_WIDTH = 672; diff --git a/app/src/components/delivery/reaching-you.tsx b/app/src/components/delivery/reaching-you.tsx index 4a6a7f00c7..3cc9c6d0a4 100644 --- a/app/src/components/delivery/reaching-you.tsx +++ b/app/src/components/delivery/reaching-you.tsx @@ -3,6 +3,7 @@ import { IconBrandTeams, IconChevronRight, IconDeviceMobileMessage, + IconSend, } from "@tabler/icons-react"; import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; @@ -189,7 +190,7 @@ export function ReachingYou({ agentId: fixedBot }: { agentId?: string }) {

) : null} {reach.isPending || !destinations ? null : destinations.length === 0 ? ( - No destinations connected. + ) : ( {destinations.map((binding, index) => { diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 6748f66b03..519c963c26 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -3,6 +3,14 @@ import { Link, type LinkProps } from "@tanstack/react-router"; import type * as React from "react"; import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../ui/empty"; import { SidebarToggle, useSidebarToggleVisible } from "./sidebar-toggle"; /** @@ -23,7 +31,7 @@ import { SidebarToggle, useSidebarToggleVisible } from "./sidebar-toggle"; /** * `prose` is the default because configuration is mostly reading, and every screen uses it so none is - * wider than another. It is `max-w-3xl`, 720px against this app's 15px root: room for a row's label + * wider than another. It is `max-w-3xl`, 768px: room for a row's label * and its control side by side. Sentences are held to `max-w-prose` (around 65 characters) inside it, * which is where reading stays comfortable, so the column widening does not stretch them. * @@ -109,12 +117,22 @@ export function PageShell({ * eighteen screens are unaffected. * * Separate from the centred column below so the scrollbar rides the pane's edge instead of - * appearing inside a 630px measure with content either side of it. + * appearing inside the measure with content either side of it. */}
header+*]:mt-8 [&>header+nav]:mt-6 [&>header+nav+*]:mt-8", // Without a bar above it the heading keeps the full original space. bar ? "pt-8" : "pt-12", WIDTHS[width], @@ -196,11 +214,18 @@ export function PageRows({ * 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. + * + * Every row is at least 56px, whatever it holds. A title over a description is 65px; a lone + * line would be 48px and look stunted beside it, so it is held up to the floor. A description + * that wraps to a second line still grows the row rather than being clipped. */
@@ -210,11 +235,70 @@ export function PageRows({ } /** - * What a section says when it has nothing to list. + * The card a page-sized form sits in: the fields, then its buttons on the right. * - * Said in a sentence rather than drawn as an illustration with a heading: on a configuration screen - * "nothing here yet" is a fact, not an event, and the page already told you what the section is for. + * For a screen that exists to fill something in, reached from a list by its Add button, with + * `PageShell`'s back button returning to it. The same border and fill as `PageRows`, so a form and + * a list read as the same kind of surface, padded rather than divided because it holds fields. */ -export function PageEmpty({ children }: { children: React.ReactNode }) { - return

{children}

; +export function PageCard({ + children, + className, + footer, +}: { + children: React.ReactNode; + className?: string; + /** The form's buttons, Cancel then the primary action. */ + footer?: React.ReactNode; +}) { + return ( +
+ {children} + {footer ? ( +
{footer}
+ ) : null} +
+ ); +} + +/** + * What a section says when it has nothing to list, drawn as shadcn's empty state. + * + * A dashed box where the rows would be, so the section keeps its shape: the icon says what kind of + * thing would be here, the title says that there is none, the description says how one arrives, and + * `action` is the button that adds the first one when the page has one to offer. + * + * Only the description is required. A section that is empty for a reason rather than because + * nothing was added yet (a feature turned off, a search with no match) can say just that. + */ +export function PageEmpty({ + action, + children, + icon: Icon, + title, +}: { + action?: React.ReactNode; + children?: React.ReactNode; + icon?: React.ComponentType<{ className?: string }>; + title?: string; +}) { + return ( + + + {Icon ? ( + + + + ) : null} + {title ? {title} : null} + {children ? {children} : null} + + {action ? {action} : null} + + ); } diff --git a/app/src/components/memory/memories.tsx b/app/src/components/memory/memories.tsx index f19d725852..01bf7e800e 100644 --- a/app/src/components/memory/memories.tsx +++ b/app/src/components/memory/memories.tsx @@ -1,4 +1,4 @@ -import { IconPlus, IconRobot, IconUser } from "@tabler/icons-react"; +import { IconBrain, IconPlus, IconRobot, IconUser } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Fragment, type ReactNode, useId, useState } from "react"; import { @@ -163,7 +163,9 @@ export function MemoryList({ ))} ) : ( - No memories yet. Add one with Remember a fact. + + Add one with Remember a fact. + )} ); diff --git a/app/src/components/memory/sources.tsx b/app/src/components/memory/sources.tsx index a789a508b2..5a5875a2e9 100644 --- a/app/src/components/memory/sources.tsx +++ b/app/src/components/memory/sources.tsx @@ -122,7 +122,27 @@ export function MemorySources({ agentId: fixedBot }: { agentId?: string }) { {sources.error.message}

) : shown.length === 0 ? ( - No sources yet. Add one with Add source. + { + // The draft is kept on purpose; the last attempt's failure is not. + setError(""); + add.reset(); + setAdding(true); + }} + size="sm" + variant="outline" + > + + Add source + + } + icon={IconPlugConnected} + title="No sources yet" + > + Apps this Bot can read from show up here. + ) : ( {shown.map((source, index) => ( diff --git a/app/src/components/plugins/broker-directory.tsx b/app/src/components/plugins/broker-directory.tsx index 0af0e26479..7137bb66e3 100644 --- a/app/src/components/plugins/broker-directory.tsx +++ b/app/src/components/plugins/broker-directory.tsx @@ -1,4 +1,4 @@ -import { IconPlug } from "@tabler/icons-react"; +import { IconPlug, IconSearch } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import * as React from "react"; import { @@ -158,7 +158,10 @@ export function BrokerDirectory({ provider }: { provider: PluginsProvider }) { {apps.error.message || directoryReadFailure(provider)}

) : listed.length === 0 ? ( - + {query ? `No ${name} app matches "${query}".` : `${name} returned no apps.`} diff --git a/app/src/components/responsibilities/responsibilities.tsx b/app/src/components/responsibilities/responsibilities.tsx index e603809554..a9ad2cac12 100644 --- a/app/src/components/responsibilities/responsibilities.tsx +++ b/app/src/components/responsibilities/responsibilities.tsx @@ -1,4 +1,4 @@ -import { IconChevronRight, IconPlus } from "@tabler/icons-react"; +import { IconChecklist, IconChevronRight, IconPlus } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { Fragment, useState } from "react"; import { @@ -62,10 +62,23 @@ export function BotResponsibilities({ agentId }: { agentId?: string }) { {goals.error.message}

) : shown.length === 0 ? ( - + setCreating(true)} + size="sm" + variant="outline" + > + + New responsibility + + } + icon={IconChecklist} + title="No responsibilities yet" + > {agentId - ? "No responsibilities for this Bot yet. Add one with New responsibility." - : "No responsibilities yet. Add one with New responsibility."} + ? "Work you give this Bot to keep doing shows up here." + : "Work you give your Bots to keep doing shows up here."} ) : ( diff --git a/app/src/components/routines/routines-list.tsx b/app/src/components/routines/routines-list.tsx index d1f5342eaa..e75073f9c6 100644 --- a/app/src/components/routines/routines-list.tsx +++ b/app/src/components/routines/routines-list.tsx @@ -273,10 +273,13 @@ export function RoutinesList({ Couldn't load your routines. Reload the page.

) : rows.length === 0 ? ( - + {agentId - ? 'No routines for this Bot yet. Ask it something like "every weekday at 9, send me…" and it shows up here.' - : 'No routines yet. Ask a Bot something like "every weekday at 9, send me…" and it shows up here.'} + ? 'Ask it something like "every weekday at 9, send me…" and it shows up here.' + : 'Ask a Bot something like "every weekday at 9, send me…" and it shows up here.'} ) : ( diff --git a/app/src/components/settings/devices.tsx b/app/src/components/settings/devices.tsx index 4419a9f5c0..0611b4665e 100644 --- a/app/src/components/settings/devices.tsx +++ b/app/src/components/settings/devices.tsx @@ -1,4 +1,4 @@ -import { IconDeviceMobile, IconSend } from "@tabler/icons-react"; +import { IconDeviceMobile, IconDevices, IconSend } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Fragment } from "react"; import { @@ -44,7 +44,9 @@ export function NativeDevices() { ) : null} {reach.isPending || !reach.data ? null : reach.data.devices.length === 0 ? ( - No devices yet. + + Phones and computers you sign in on show up here. + ) : ( {reach.data.devices.map((device, index) => ( @@ -88,7 +90,9 @@ export function RecentDeliveries() { ) : null} {reach.isPending || !reach.data ? null : reach.data.deliveries.length === 0 ? ( - No deliveries yet. + + Notifications sent to your devices show up here. + ) : ( {reach.data.deliveries.map((delivery, index) => ( diff --git a/app/src/components/settings/github-sources.tsx b/app/src/components/settings/github-sources.tsx index 6d6f5483e5..10f64d7e9f 100644 --- a/app/src/components/settings/github-sources.tsx +++ b/app/src/components/settings/github-sources.tsx @@ -64,8 +64,17 @@ export function GithubSources() { ) : null} {bindings.isPending || !bindings.data ? null : bindings.data.length === 0 ? ( - - No repositories yet. Add one with Add a repository. + setAdding(true)} size="sm" variant="outline"> + + Add a repository + + } + icon={IconBrandGithub} + title="No repositories yet" + > + Repositories you add show up here with their webhook address. ) : ( diff --git a/app/src/components/settings/linked-accounts.tsx b/app/src/components/settings/linked-accounts.tsx index e16e88d71f..23c4808a48 100644 --- a/app/src/components/settings/linked-accounts.tsx +++ b/app/src/components/settings/linked-accounts.tsx @@ -202,7 +202,7 @@ export function LinkedAccountSection({ if (!state) { return ( - + You can't link a {linkableProviderTitle(provider)} account yet. An admin has to set it up first. diff --git a/app/src/components/suggestions/proactive-panel.tsx b/app/src/components/suggestions/proactive-panel.tsx index a8b171cf89..fa428f7eda 100644 --- a/app/src/components/suggestions/proactive-panel.tsx +++ b/app/src/components/suggestions/proactive-panel.tsx @@ -1,5 +1,6 @@ import { IconBulb, + IconChevronDown, IconChevronRight, IconClock, IconPlus, @@ -69,6 +70,9 @@ const intervalLabel = (minutes: number) => intervals.find((interval) => interval.minutes === minutes)?.label ?? `Every ${minutes} minutes`; /** Next steps a Bot proposed from background research: start one as a task, or dismiss it. */ +/** How many suggestions show before the rest fold behind "Show more". */ +const SUGGESTIONS_SHOWN = 3; + export function SuggestionsInbox({ agentId, }: { @@ -81,9 +85,17 @@ export function SuggestionsInbox({ ); const bots = useQuery(agentListQueryOptions()); const act = useMutation(resolveSuggestionMutationOptions(queryClient)); + const [expanded, setExpanded] = useState(false); const botName = (id: string) => bots.data?.find((bot) => bot.id === id)?.name ?? "Your Bot"; if (agentId !== undefined && shown.length === 0) return null; + /* + * The newest few, the rest a click away. Each suggestion is a paragraph with two buttons, and a + * Bot that had piled up sixty of them made its own page 8,000px of suggestions with its settings + * somewhere underneath. The list arrives newest first. + */ + const hidden = expanded ? 0 : Math.max(0, shown.length - SUGGESTIONS_SHOWN); + const visible = hidden ? shown.slice(0, SUGGESTIONS_SHOWN) : shown; return ( {suggestions.isPending ? null : suggestions.error ? ( @@ -92,7 +104,7 @@ export function SuggestionsInbox({

) : shown.length ? ( - {shown.map((suggestion, index) => ( + {visible.map((suggestion, index) => ( {index > 0 ? : null} @@ -157,11 +169,29 @@ export function SuggestionsInbox({ ))} + {hidden ? ( + <> + + } + onClick={() => setExpanded(true)} + size="sm" + > + + + Show {hidden} more + + + + + + + + ) : null} ) : ( - - No suggestions right now. Bots with background research on will - suggest next steps here. + + Bots with background research on will suggest next steps here. )} {act.error && ( @@ -207,7 +237,16 @@ export function ProactiveResearchSettings({ {settings.error.message}

) : shown.length === 0 ? ( - Background research is off. + setAdding(true)} size="sm" variant="outline"> + + Turn on research + + } + icon={IconRadar} + title="Background research is off" + /> ) : ( {shown.map((setting, index) => ( diff --git a/app/src/components/ui/item.tsx b/app/src/components/ui/item.tsx index a8e2852a21..0f68d1c1bf 100644 --- a/app/src/components/ui/item.tsx +++ b/app/src/components/ui/item.tsx @@ -1,10 +1,9 @@ -import type * as React from "react"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; +import type * as React from "react"; import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -43,9 +42,13 @@ const itemVariants = cva( outline: "border-border", muted: "border-transparent bg-muted/50", }, + /* + * Linear's settings row: 16px in from every edge and 12px between media, text and actions. + * A title over a one-line description comes to 65px, a single line to 48px. + */ size: { - default: "gap-2.5 px-3 py-2.5", - sm: "gap-2.5 px-3 py-2.5", + default: "gap-3 px-4 py-3.5", + sm: "gap-3 px-4 py-3.5", xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0", }, }, @@ -86,7 +89,8 @@ const itemMediaVariants = cva( variants: { variant: { default: "bg-transparent", - icon: "[&_svg:not([class*='size-'])]:size-4", + // A 32px tile behind a 16px glyph, so a column of rows reads as a column of marks. + icon: "size-8 rounded-md bg-muted text-muted-foreground group-data-[size=xs]/item:size-6 [&_svg:not([class*='size-'])]:size-4", image: "size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover", }, @@ -117,7 +121,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
) {

a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", + // A size below the title, so the two lines read as a label and its detail rather than two labels. + "line-clamp-2 text-left text-xs font-normal text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary", className, )} {...props} @@ -189,13 +194,13 @@ function ItemFooter({ className, ...props }: React.ComponentProps<"div">) { export { Item, - ItemMedia, - ItemContent, ItemActions, + ItemContent, + ItemDescription, + ItemFooter, ItemGroup, + ItemHeader, + ItemMedia, ItemSeparator, ItemTitle, - ItemDescription, - ItemHeader, - ItemFooter, }; diff --git a/app/src/lib/boundaries/rules.ts b/app/src/lib/boundaries/rules.ts new file mode 100644 index 0000000000..fb41c3502f --- /dev/null +++ b/app/src/lib/boundaries/rules.ts @@ -0,0 +1,88 @@ +import type { ActionPolicy, RuleLabel } from "@/lib/computers/queries"; + +/** + * A rule an administrator can start from, in words first and CEL underneath. + * + * `cost` is what the rule also catches, or misses, that its name does not say. Each one is a real + * edge of the rule as written, so it is shown before the rule is added rather than discovered from + * the refusals it produces. + */ +export type RuleTemplate = RuleLabel & { rule: string; cost: string }; + +export const RULE_TEMPLATES: RuleTemplate[] = [ + { + name: "Never submit a form", + description: "Stops a Bot pressing a submit button, or Enter while typing.", + // `key` is guarded by tool name so the clause short-circuits before it on actions that have no + // keypress in them. Both tools that can press Enter are named: `computer_type` takes a `submit` + // flag that presses it once the text is in, and a rule naming only `computer_key` left that door + // open. + rule: '(intent == "activate" && contains(element.name, "submit")) || ((tool.name == "computer_key" || tool.name == "computer_type") && key == "Enter")', + cost: "Also stops the Bot pressing Enter anywhere else. A form can submit from Enter in any field.", + }, + { + name: "Never type into a password field", + description: "Stops a Bot typing into a field labelled as a password.", + rule: 'intent == "type" && contains(element.name, "password")', + cost: "A password box with a different label isn't covered. The rule matches the label.", + }, + { + name: "Stay off social media", + description: "Stops a Bot opening Facebook or X.", + rule: 'intent == "navigate" && (contains(page.host, "facebook.com") || contains(page.host, "x.com"))', + cost: "Only these two sites. A link that redirects there from elsewhere is allowed.", + }, +]; + +/** What a rule can be written against, in the words an administrator would use. */ +export const RULE_ATTRIBUTES: { name: string; meaning: string }[] = [ + { + name: "tool.name", + meaning: + "The action, such as computer_click, computer_type or computer_key.", + }, + { + name: "intent", + meaning: "What the action is for: activate, type or navigate.", + }, + { name: "bot.id", meaning: "The Bot taking the action." }, + { name: "actor.id", meaning: "The person the Bot is acting for." }, + { + name: "initiator.kind", + meaning: + "What started the run: person, routine or handoff. A scheduled run acts as its owner, so actor.id alone can't tell you.", + }, + { name: "page.url, page.host", meaning: "The page the Bot is on." }, + { + name: "element.name, element.role", + meaning: "The button, link or field being clicked or typed into.", + }, + { name: "key", meaning: "The key pressed, such as Enter." }, + { name: "file", meaning: "The file being read or written." }, + { name: "command", meaning: "The command being run." }, + { + name: "mcp.server, mcp.tool, mcp.effect", + meaning: + "For a connected app's tool: which app, which tool, and read or write.", + }, +]; + +/** The name a rule is shown by: its label, a template's name if it is one, or plainly "Custom rule". */ +export function ruleLabel(policy: ActionPolicy, rule: string): RuleLabel { + const label = policy.labels?.[rule]; + if (label) return label; + const template = RULE_TEMPLATES.find((one) => one.rule === rule); + if (template) + return { name: template.name, description: template.description }; + return { name: "Custom rule", description: "" }; +} + +/** The policy without one rule, and without that rule's label. */ +export function withoutRule(policy: ActionPolicy, rule: string): ActionPolicy { + const { [rule]: _removed, ...labels } = policy.labels ?? {}; + return { + ...policy, + deny: policy.deny.filter((one) => one !== rule), + labels, + }; +} diff --git a/app/src/lib/computers/queries.ts b/app/src/lib/computers/queries.ts index 85d1c3692b..7d8d8dcffa 100644 --- a/app/src/lib/computers/queries.ts +++ b/app/src/lib/computers/queries.ts @@ -34,8 +34,12 @@ export type ActionPolicy = { mode: PolicyMode; deny: string[]; allow: string[]; + /** Each rule's name and what it stops, keyed by the rule's text. Display only. */ + labels?: Record; }; +export type RuleLabel = { name: string; description: string }; + export const computerKeys = { all: ["computers"] as const, fleet: () => ["computers", "fleet"] as const, diff --git a/app/src/lib/copilot/a2ui.css b/app/src/lib/copilot/a2ui.css index c491f6ad7d..2f790d786f 100644 --- a/app/src/lib/copilot/a2ui.css +++ b/app/src/lib/copilot/a2ui.css @@ -5,7 +5,7 @@ --a2ui-primary-color: var(--primary); color: inherit; font-family: var(--font-sans, Inter, ui-sans-serif, system-ui, sans-serif); - font-size: 0.9333rem; + font-size: 0.875rem; line-height: 1.5; } @@ -57,7 +57,7 @@ } [data-openbot-a2ui="Text"] :is(h1, h2, h3, h4, h5, h6) { - font-size: 1.067rem !important; + font-size: 1rem !important; font-weight: 600 !important; letter-spacing: -0.01em; } diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 6aff6bc45f..87fdb447a2 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -54,8 +54,11 @@ import { Route as AuthedAppConnectionsIndexRouteImport } from './routes/_authed/ import { Route as AuthedAppConnectionsKeyRouteImport } from './routes/_authed/_app/connections.$key' import { Route as AuthedAppGroupChannelIdRouteImport } from './routes/_authed/_app/group/$channelId' import { Route as AuthedAppGroupNewRouteImport } from './routes/_authed/_app/group/new' +import { Route as AuthedAdminBoundariesIndexRouteImport } from './routes/_authed/admin/boundaries_.$index' +import { Route as AuthedAdminBoundariesNewRouteImport } from './routes/_authed/admin/boundaries_.new' import { Route as AuthedAdminComponentsIndexRouteImport } from './routes/_authed/admin/components/index' import { Route as AuthedAdminComponentsNameRouteImport } from './routes/_authed/admin/components/$name' +import { Route as AuthedAdminCredentialsNewRouteImport } from './routes/_authed/admin/credentials_.new' import { Route as AuthedAdminPluginsIndexRouteImport } from './routes/_authed/admin/plugins/index' import { Route as AuthedAdminPluginsKeyRouteImport } from './routes/_authed/admin/plugins/$key' import { Route as AuthedAdminPluginsArcadeRouteImport } from './routes/_authed/admin/plugins/arcade' @@ -66,6 +69,7 @@ import { Route as AuthedSettingsConnectedAccountsIndexRouteImport } from './rout import { Route as AuthedSettingsConnectedAccountsKeyRouteImport } from './routes/_authed/settings/connected-accounts/$key' import { Route as AuthedAppBotsAgentIdIndexRouteImport } from './routes/_authed/_app/bots.$agentId.index' import { Route as AuthedAppBotsAgentIdAccessRouteImport } from './routes/_authed/_app/bots.$agentId.access' +import { Route as AuthedAppBotsAgentIdActivityRouteImport } from './routes/_authed/_app/bots.$agentId.activity' import { Route as AuthedAppBotsAgentIdApprovalsRouteImport } from './routes/_authed/_app/bots.$agentId.approvals' import { Route as AuthedAppBotsAgentIdMemoryRouteImport } from './routes/_authed/_app/bots.$agentId.memory' import { Route as AuthedAppBotsAgentIdReachRouteImport } from './routes/_authed/_app/bots.$agentId.reach' @@ -307,6 +311,18 @@ const AuthedAppGroupNewRoute = AuthedAppGroupNewRouteImport.update({ path: '/group/new', getParentRoute: () => AuthedAppRoute, } as any) +const AuthedAdminBoundariesIndexRoute = + AuthedAdminBoundariesIndexRouteImport.update({ + id: '/boundaries_/$index', + path: '/boundaries/$index', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) +const AuthedAdminBoundariesNewRoute = + AuthedAdminBoundariesNewRouteImport.update({ + id: '/boundaries_/new', + path: '/boundaries/new', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedAdminComponentsIndexRoute = AuthedAdminComponentsIndexRouteImport.update({ id: '/components/', @@ -319,6 +335,12 @@ const AuthedAdminComponentsNameRoute = path: '/components/$name', getParentRoute: () => AuthedAdminRouteRoute, } as any) +const AuthedAdminCredentialsNewRoute = + AuthedAdminCredentialsNewRouteImport.update({ + id: '/credentials_/new', + path: '/credentials/new', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) const AuthedAdminPluginsIndexRoute = AuthedAdminPluginsIndexRouteImport.update({ id: '/plugins/', path: '/plugins/', @@ -377,6 +399,12 @@ const AuthedAppBotsAgentIdAccessRoute = path: '/bots/$agentId/access', getParentRoute: () => AuthedAppRoute, } as any) +const AuthedAppBotsAgentIdActivityRoute = + AuthedAppBotsAgentIdActivityRouteImport.update({ + id: '/bots/$agentId/activity', + path: '/bots/$agentId/activity', + getParentRoute: () => AuthedAppRoute, + } as any) const AuthedAppBotsAgentIdApprovalsRoute = AuthedAppBotsAgentIdApprovalsRouteImport.update({ id: '/bots/$agentId/approvals', @@ -472,7 +500,10 @@ export interface FileRoutesByFullPath { '/connections/$key': typeof AuthedAppConnectionsKeyRoute '/group/$channelId': typeof AuthedAppGroupChannelIdRoute '/group/new': typeof AuthedAppGroupNewRoute + '/admin/boundaries/$index': typeof AuthedAdminBoundariesIndexRoute + '/admin/boundaries/new': typeof AuthedAdminBoundariesNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute + '/admin/credentials/new': typeof AuthedAdminCredentialsNewRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute '/admin/plugins/arcade': typeof AuthedAdminPluginsArcadeRoute '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute @@ -487,6 +518,7 @@ export interface FileRoutesByFullPath { '/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute '/bots/$agentId/access': typeof AuthedAppBotsAgentIdAccessRoute + '/bots/$agentId/activity': typeof AuthedAppBotsAgentIdActivityRoute '/bots/$agentId/approvals': typeof AuthedAppBotsAgentIdApprovalsRoute '/bots/$agentId/memory': typeof AuthedAppBotsAgentIdMemoryRoute '/bots/$agentId/reach': typeof AuthedAppBotsAgentIdReachRoute @@ -535,7 +567,10 @@ export interface FileRoutesByTo { '/connections/$key': typeof AuthedAppConnectionsKeyRoute '/group/$channelId': typeof AuthedAppGroupChannelIdRoute '/group/new': typeof AuthedAppGroupNewRoute + '/admin/boundaries/$index': typeof AuthedAdminBoundariesIndexRoute + '/admin/boundaries/new': typeof AuthedAdminBoundariesNewRoute '/admin/components/$name': typeof AuthedAdminComponentsNameRoute + '/admin/credentials/new': typeof AuthedAdminCredentialsNewRoute '/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute '/admin/plugins/arcade': typeof AuthedAdminPluginsArcadeRoute '/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute @@ -550,6 +585,7 @@ export interface FileRoutesByTo { '/settings/components-gallery': typeof AuthedSettingsComponentsGalleryIndexRoute '/settings/connected-accounts': typeof AuthedSettingsConnectedAccountsIndexRoute '/bots/$agentId/access': typeof AuthedAppBotsAgentIdAccessRoute + '/bots/$agentId/activity': typeof AuthedAppBotsAgentIdActivityRoute '/bots/$agentId/approvals': typeof AuthedAppBotsAgentIdApprovalsRoute '/bots/$agentId/memory': typeof AuthedAppBotsAgentIdMemoryRoute '/bots/$agentId/reach': typeof AuthedAppBotsAgentIdReachRoute @@ -604,7 +640,10 @@ export interface FileRoutesById { '/_authed/_app/connections/$key': typeof AuthedAppConnectionsKeyRoute '/_authed/_app/group/$channelId': typeof AuthedAppGroupChannelIdRoute '/_authed/_app/group/new': typeof AuthedAppGroupNewRoute + '/_authed/admin/boundaries_/$index': typeof AuthedAdminBoundariesIndexRoute + '/_authed/admin/boundaries_/new': typeof AuthedAdminBoundariesNewRoute '/_authed/admin/components/$name': typeof AuthedAdminComponentsNameRoute + '/_authed/admin/credentials_/new': typeof AuthedAdminCredentialsNewRoute '/_authed/admin/plugins/$key': typeof AuthedAdminPluginsKeyRoute '/_authed/admin/plugins/arcade': typeof AuthedAdminPluginsArcadeRoute '/_authed/admin/plugins/composio': typeof AuthedAdminPluginsComposioRoute @@ -619,6 +658,7 @@ export interface FileRoutesById { '/_authed/settings/components-gallery/': typeof AuthedSettingsComponentsGalleryIndexRoute '/_authed/settings/connected-accounts/': typeof AuthedSettingsConnectedAccountsIndexRoute '/_authed/_app/bots/$agentId/access': typeof AuthedAppBotsAgentIdAccessRoute + '/_authed/_app/bots/$agentId/activity': typeof AuthedAppBotsAgentIdActivityRoute '/_authed/_app/bots/$agentId/approvals': typeof AuthedAppBotsAgentIdApprovalsRoute '/_authed/_app/bots/$agentId/memory': typeof AuthedAppBotsAgentIdMemoryRoute '/_authed/_app/bots/$agentId/reach': typeof AuthedAppBotsAgentIdReachRoute @@ -672,7 +712,10 @@ export interface FileRouteTypes { | '/connections/$key' | '/group/$channelId' | '/group/new' + | '/admin/boundaries/$index' + | '/admin/boundaries/new' | '/admin/components/$name' + | '/admin/credentials/new' | '/admin/plugins/$key' | '/admin/plugins/arcade' | '/admin/plugins/composio' @@ -687,6 +730,7 @@ export interface FileRouteTypes { | '/settings/components-gallery/' | '/settings/connected-accounts/' | '/bots/$agentId/access' + | '/bots/$agentId/activity' | '/bots/$agentId/approvals' | '/bots/$agentId/memory' | '/bots/$agentId/reach' @@ -735,7 +779,10 @@ export interface FileRouteTypes { | '/connections/$key' | '/group/$channelId' | '/group/new' + | '/admin/boundaries/$index' + | '/admin/boundaries/new' | '/admin/components/$name' + | '/admin/credentials/new' | '/admin/plugins/$key' | '/admin/plugins/arcade' | '/admin/plugins/composio' @@ -750,6 +797,7 @@ export interface FileRouteTypes { | '/settings/components-gallery' | '/settings/connected-accounts' | '/bots/$agentId/access' + | '/bots/$agentId/activity' | '/bots/$agentId/approvals' | '/bots/$agentId/memory' | '/bots/$agentId/reach' @@ -803,7 +851,10 @@ export interface FileRouteTypes { | '/_authed/_app/connections/$key' | '/_authed/_app/group/$channelId' | '/_authed/_app/group/new' + | '/_authed/admin/boundaries_/$index' + | '/_authed/admin/boundaries_/new' | '/_authed/admin/components/$name' + | '/_authed/admin/credentials_/new' | '/_authed/admin/plugins/$key' | '/_authed/admin/plugins/arcade' | '/_authed/admin/plugins/composio' @@ -818,6 +869,7 @@ export interface FileRouteTypes { | '/_authed/settings/components-gallery/' | '/_authed/settings/connected-accounts/' | '/_authed/_app/bots/$agentId/access' + | '/_authed/_app/bots/$agentId/activity' | '/_authed/_app/bots/$agentId/approvals' | '/_authed/_app/bots/$agentId/memory' | '/_authed/_app/bots/$agentId/reach' @@ -1152,6 +1204,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAppGroupNewRouteImport parentRoute: typeof AuthedAppRoute } + '/_authed/admin/boundaries_/$index': { + id: '/_authed/admin/boundaries_/$index' + path: '/boundaries/$index' + fullPath: '/admin/boundaries/$index' + preLoaderRoute: typeof AuthedAdminBoundariesIndexRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } + '/_authed/admin/boundaries_/new': { + id: '/_authed/admin/boundaries_/new' + path: '/boundaries/new' + fullPath: '/admin/boundaries/new' + preLoaderRoute: typeof AuthedAdminBoundariesNewRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/admin/components/': { id: '/_authed/admin/components/' path: '/components' @@ -1166,6 +1232,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminComponentsNameRouteImport parentRoute: typeof AuthedAdminRouteRoute } + '/_authed/admin/credentials_/new': { + id: '/_authed/admin/credentials_/new' + path: '/credentials/new' + fullPath: '/admin/credentials/new' + preLoaderRoute: typeof AuthedAdminCredentialsNewRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/admin/plugins/': { id: '/_authed/admin/plugins/' path: '/plugins' @@ -1236,6 +1309,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAppBotsAgentIdAccessRouteImport parentRoute: typeof AuthedAppRoute } + '/_authed/_app/bots/$agentId/activity': { + id: '/_authed/_app/bots/$agentId/activity' + path: '/bots/$agentId/activity' + fullPath: '/bots/$agentId/activity' + preLoaderRoute: typeof AuthedAppBotsAgentIdActivityRouteImport + parentRoute: typeof AuthedAppRoute + } '/_authed/_app/bots/$agentId/approvals': { id: '/_authed/_app/bots/$agentId/approvals' path: '/bots/$agentId/approvals' @@ -1315,7 +1395,10 @@ interface AuthedAdminRouteRouteChildren { AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute AuthedAdminSkillsRoute: typeof AuthedAdminSkillsRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute + AuthedAdminBoundariesIndexRoute: typeof AuthedAdminBoundariesIndexRoute + AuthedAdminBoundariesNewRoute: typeof AuthedAdminBoundariesNewRoute AuthedAdminComponentsNameRoute: typeof AuthedAdminComponentsNameRoute + AuthedAdminCredentialsNewRoute: typeof AuthedAdminCredentialsNewRoute AuthedAdminPluginsKeyRoute: typeof AuthedAdminPluginsKeyRoute AuthedAdminPluginsArcadeRoute: typeof AuthedAdminPluginsArcadeRoute AuthedAdminPluginsComposioRoute: typeof AuthedAdminPluginsComposioRoute @@ -1338,7 +1421,10 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute, AuthedAdminSkillsRoute: AuthedAdminSkillsRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, + AuthedAdminBoundariesIndexRoute: AuthedAdminBoundariesIndexRoute, + AuthedAdminBoundariesNewRoute: AuthedAdminBoundariesNewRoute, AuthedAdminComponentsNameRoute: AuthedAdminComponentsNameRoute, + AuthedAdminCredentialsNewRoute: AuthedAdminCredentialsNewRoute, AuthedAdminPluginsKeyRoute: AuthedAdminPluginsKeyRoute, AuthedAdminPluginsArcadeRoute: AuthedAdminPluginsArcadeRoute, AuthedAdminPluginsComposioRoute: AuthedAdminPluginsComposioRoute, @@ -1419,6 +1505,7 @@ interface AuthedAppRouteChildren { AuthedAppBotsIndexRoute: typeof AuthedAppBotsIndexRoute AuthedAppConnectionsIndexRoute: typeof AuthedAppConnectionsIndexRoute AuthedAppBotsAgentIdAccessRoute: typeof AuthedAppBotsAgentIdAccessRoute + AuthedAppBotsAgentIdActivityRoute: typeof AuthedAppBotsAgentIdActivityRoute AuthedAppBotsAgentIdApprovalsRoute: typeof AuthedAppBotsAgentIdApprovalsRoute AuthedAppBotsAgentIdMemoryRoute: typeof AuthedAppBotsAgentIdMemoryRoute AuthedAppBotsAgentIdReachRoute: typeof AuthedAppBotsAgentIdReachRoute @@ -1449,6 +1536,7 @@ const AuthedAppRouteChildren: AuthedAppRouteChildren = { AuthedAppBotsIndexRoute: AuthedAppBotsIndexRoute, AuthedAppConnectionsIndexRoute: AuthedAppConnectionsIndexRoute, AuthedAppBotsAgentIdAccessRoute: AuthedAppBotsAgentIdAccessRoute, + AuthedAppBotsAgentIdActivityRoute: AuthedAppBotsAgentIdActivityRoute, AuthedAppBotsAgentIdApprovalsRoute: AuthedAppBotsAgentIdApprovalsRoute, AuthedAppBotsAgentIdMemoryRoute: AuthedAppBotsAgentIdMemoryRoute, AuthedAppBotsAgentIdReachRoute: AuthedAppBotsAgentIdReachRoute, diff --git a/app/src/routes/_authed/_app/bots.$agentId.activity.tsx b/app/src/routes/_authed/_app/bots.$agentId.activity.tsx new file mode 100644 index 0000000000..6af7f785c2 --- /dev/null +++ b/app/src/routes/_authed/_app/bots.$agentId.activity.tsx @@ -0,0 +1,25 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { BotActivitySections } from "@/components/bot-profile/activity"; +import { BotSubpage } from "@/components/bot-profile/bot-subpage"; + +export const Route = createFileRoute("/_authed/_app/bots/$agentId/activity")({ + component: ActivityPage, +}); + +/** + * Everything this Bot is doing, has lined up and has finished, on a page of its own. On the Bot's + * own page the three lists ran on for screens and pushed its settings out of reach; there, one row + * counts them and opens this. + */ +function ActivityPage() { + const { agentId } = Route.useParams(); + return ( + + {(agent) => } + + ); +} diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 727c1b8871..7ef1c5fd64 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -7,6 +7,7 @@ import { AgentProfile } from "@/components/agents/agent-profile"; import { BotPausedBanner } from "@/components/bot-profile/pause-banner"; import { ChannelAvatar } from "@/components/channels/avatar"; import { ChannelChat } from "@/components/channels/channel-chat"; +import { ConversationActions } from "@/components/channels/conversation-actions"; import { ComputerChatControls } from "@/components/computer/computer-controls"; import { ComputerViewPanel } from "@/components/computer/computer-panel"; import { useAutoWatchScreen } from "@/components/computer/use-auto-watch-screen"; @@ -89,7 +90,18 @@ function RouteComponent() { // Manual watch remains active even when there is no current browser action. ) : ( - + navigate({ to: "/" })} + name={channel.data.name} + /> + ) : null + } + /> ) } > diff --git a/app/src/routes/_authed/_app/connections.$key.tsx b/app/src/routes/_authed/_app/connections.$key.tsx index f28624f783..cc535ffe1e 100644 --- a/app/src/routes/_authed/_app/connections.$key.tsx +++ b/app/src/routes/_authed/_app/connections.$key.tsx @@ -1,4 +1,10 @@ -import { IconArrowUpRight, IconChevronDown } from "@tabler/icons-react"; +import { + IconAppsOff, + IconArrowUpRight, + IconChevronDown, + IconPlugOff, + IconUsers, +} from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, useParams } from "@tanstack/react-router"; import { useEffect, useState } from "react"; @@ -370,9 +376,8 @@ function AppConnectionPage() { if (server?.provenance === "composio" && !plugins.data.composioConfigured) { return ( - - {server.title} isn't set up yet. An admin has to turn it on before - anyone can connect it. + + An admin has to turn on {server.title} before anyone can connect it. ); @@ -521,7 +526,10 @@ function AppConnectionPage() { description="You don't connect this app yourself." title={entry?.title ?? key} > - + {entry ? "Bots use one shared account for this app, the same for everybody." : "OpenBot has no app by that name."} diff --git a/app/src/routes/_authed/_app/connections.index.tsx b/app/src/routes/_authed/_app/connections.index.tsx index 4dcc9de07a..336821eb3f 100644 --- a/app/src/routes/_authed/_app/connections.index.tsx +++ b/app/src/routes/_authed/_app/connections.index.tsx @@ -361,7 +361,11 @@ function RouteComponent() { providers.isPending; return ( - + {/* * For `?connected=` only the failure is worth saying. A success needs no sentence: the row it * came back to now reads "Connected", which is the same news told by the thing it is news @@ -393,10 +397,11 @@ function RouteComponent() { {arrival.authorizationNotice}

) : null} - + {/* + * One section, so it carries no heading of its own: "Your connections" under "Connections" + * said the same thing twice, 48px apart, and the description now sits under the page title. + */} + {/* * BOTH APP READS DECIDE THIS. A `/api/plugins` that succeeded beside a * `/api/plugins/connections` that failed would leave the `connected` set empty and every row @@ -409,32 +414,38 @@ function RouteComponent() { it keeps happening.

) : rows.length === 0 ? ( - - Nothing to connect yet. Apps show up here once an admin turns them - on. + + Apps show up here once an admin turns them on. ) : ( <> -

- {inUse} in use · {rows.length} in all -

- - - - setSearch(event.target.value)} - /> - +
+ + + + setSearch(event.target.value)} + /> + +

+ {inUse} in use · {rows.length} in all +

+
{matching.length === 0 ? ( -

+

Nothing matches “{search.trim()}”.

) : ( -
+ /* + * A grid of separate cards, where every other list is one `PageRows` card of divided + * rows. The accepted exception: connections are apps you pick out by their mark, so + * they are laid out as tiles two across rather than read down as a list. + */ +
{matching.map((row) => ( ))} @@ -457,10 +468,11 @@ function RouteComponent() { function ConnectionItem({ row }: { row: ConnectionRow }) { return ( } size="sm" + variant="outline" > diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx index ec27d4faed..e254003b9f 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -1,4 +1,4 @@ -import { IconDownload, IconRefresh } from "@tabler/icons-react"; +import { IconDownload, IconRefresh, IconSearch } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; @@ -169,7 +169,9 @@ function AuditPage() { Couldn't load the audit log. Reload the page.

) : rows.length === 0 ? ( - No events match this filter yet. + + No events match this filter yet. + ) : (
diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx index 8906302fde..2a11ebe259 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -1,377 +1,214 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { + IconChevronRight, + IconListSearch, + IconPlus, + IconShieldX, +} from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { useState } from "react"; -import { PageSection, PageShell } from "@/components/layout/page-shell"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { StaggerItem } from "@/components/layout/stagger"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { isComposing } from "@/lib/composing"; -import { saveActionPolicyMutationOptions } from "@/lib/computers/mutations"; import { - type ActionPolicy, - actionPolicyQueryOptions, - type DryRunReport, - dryRunActionPolicy, - type PolicyMode, -} from "@/lib/computers/queries"; -import { queryClient } from "@/query-client"; - -/** - * CEL computer-action boundary editor. Rules are shown as the gateway evaluates them, and denied - * actions are recorded in Audit with the matching rule. - */ - -/** - * Presets are concrete CEL rules, not a separate policy language. - */ -const PRESETS: { label: string; rule: string; cost?: string }[] = [ - { - label: "Never submit a form", - // `key` is guarded by tool name so the clause short-circuits before it on actions that have no - // keypress in them. Both tools that can press Enter are named: `computer_type` takes a `submit` - // flag that presses it once the text is in, and a rule naming only `computer_key` left that door - // open. - rule: '(intent == "activate" && contains(element.name, "submit")) || ((tool.name == "computer_key" || tool.name == "computer_type") && key == "Enter")', - cost: "Also stops the Bot pressing Enter anywhere else. A form can submit from Enter in any field.", - }, - { - label: "Never type into a password field", - rule: 'intent == "type" && contains(element.name, "password")', - cost: "A password box with a different label isn't covered. The rule matches the label.", - }, - { - label: "Stay off social media", - rule: 'intent == "navigate" && (contains(page.host, "facebook.com") || contains(page.host, "x.com"))', - cost: "Only these two hosts. A link that redirects there from elsewhere is allowed.", - }, -]; + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { ruleLabel } from "@/lib/boundaries/rules"; +import { saveActionPolicyMutationOptions } from "@/lib/computers/mutations"; +import { actionPolicyQueryOptions } from "@/lib/computers/queries"; export const Route = createFileRoute("/_authed/admin/boundaries")({ component: BoundariesPage, }); -function BoundariesPage() { - const [problem, setProblem] = useState(null); - const [saved, setSaved] = useState(false); - const [draft, setDraft] = useState(""); - - const [tested, setTested] = useState<{ - rule: string; - report: DryRunReport; - } | null>(null); - const [testing, setTesting] = useState(false); +const DESCRIPTION = + "Things no Bot may do, whatever it's asked. Every action a Bot takes on its computer or in a connected app is checked against these rules before it happens."; +/** + * The deployment's boundaries, read as a list of things Bots may never do. + * + * It used to be one screen that was also the editor: the rule language explained in a paragraph of + * attribute names, rules shown as raw CEL, and ready-made rules drawn as buttons. Nobody could tell + * from it what the page was for. Now each rule is a row with a name, adding one is its own screen + * (`boundaries_.new.tsx`) and a rule opens to what it has stopped (`boundaries_.$index.tsx`). + */ +function BoundariesPage() { + const queryClient = useQueryClient(); const stored = useQuery(actionPolicyQueryOptions()); const savePolicy = useMutation(saveActionPolicyMutationOptions(queryClient)); - - /* - * 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. - */ + /* What the server stored wins once a save lands: it normalises what it keeps. */ const policy = savePolicy.data ?? stored.data ?? null; - const saving = savePolicy.isPending; - const save = (next: ActionPolicy) => { - setSaved(false); - setProblem(null); - savePolicy.mutate(next, { - onError: (thrown: Error) => setProblem(thrown.message), - onSuccess: () => setSaved(true), - }); - }; + const addRule = ( + + ); - /* - * A read that failed says so. This screen used to set "The boundary could not be read." itself; - * since the read became a query, that sentence (or the server's own) is the query's error, and - * without this the page sat on its title with nothing under it for as long as it was open. - */ - const unreadable = problem ?? stored.error?.message ?? null; - if (unreadable && !policy) { + if (!policy) { return ( - -

- {unreadable} -

+ + {/* Nothing until the policy is known: a rule list that guesses is worse than a blank. */} + {stored.error ? ( +

+ {stored.error.message} +

+ ) : null}
); } - /* Nothing until the policy is known: a rule list that guesses is worse than a blank. */ - if (!policy) { - return {null}; - } - - const addRule = (rule: string) => { - const trimmed = rule.trim(); - if (!trimmed || policy.deny.includes(trimmed)) return; - void save({ ...policy, deny: [...policy.deny, trimmed] }); - setDraft(""); - setTested(null); - }; - - /* - * The rule as it would be in force — the current policy plus this draft — replayed over recent - * recorded actions. Nothing is saved and nothing is decided; the reply names the actions the - * addition would have decided differently, so the rule's real reach is known before it starts - * refusing anybody. - */ - const testRule = async (rule: string) => { - const trimmed = rule.trim(); - if (!trimmed) return; - setProblem(null); - setTesting(true); - try { - const report = await dryRunActionPolicy({ - ...policy, - deny: [...policy.deny, trimmed], - }); - setTested({ rule: trimmed, report }); - } catch (thrown) { - setProblem((thrown as Error).message); - } finally { - setTesting(false); - } - }; + const trial = policy.mode === "dry-run"; + const allowsEverythingElse = + policy.allow.length === 1 && policy.allow[0] === "true"; return ( - - What a Bot can and can't do with its computer. Every action is checked - before it happens. Refusals show in{" "} - - Audit - {" "} - with the rule that refused them. - - } - title="Boundaries" - > - -
- {(["enforce", "dry-run"] as PolicyMode[]).map((mode) => ( - - ))} -
-

- {policy.mode === "enforce" - ? "The Bot is stopped and told which rule refused it." - : "Nothing is stopped. Each match is recorded as if it had been refused."} -

-
- - - Checked first. A match stops the action, and the Bot is told which - rule refused it. Rules are CEL. They can check{" "} - tool.name, intent, bot.id,{" "} - actor.id, page.url, page.host - , the element, the key pressed, the file, the{" "} - command run, and mcp.server,{" "} - mcp.tool and mcp.effect for outside tools.{" "} - initiator.kind says what started the run:{" "} - person, routine or handoff. A - scheduled run acts as its owner, so actor.id alone - can't tell you that. A rule that can't be evaluated counts as a - match. A typo never lets an action through. - - } - title="Bots may never" - > + + {policy.deny.length === 0 ? ( -

- No rules. Every action is allowed and recorded. -

- ) : ( -
    - {policy.deny.map((rule) => ( -
  • - - {rule} - - -
  • - ))} -
- )} - -
- { - setDraft(event.target.value); - setSaved(false); - setTested(null); - }} - onKeyDown={(event) => { - // Not the Enter that confirms a composed character, which would put a half-typed - // rule into the policy in force. - if (event.key === "Enter" && !isComposing(event)) addRule(draft); - }} - placeholder='tool.name == "computer_click" && contains(element.name, "submit")' - value={draft} - /> - - -
- - {tested ? : null} - -
    - {PRESETS.map((preset) => ( -
  • + addRule(preset.rule)} + render={(props) => ( + + )} size="sm" variant="outline" > - {preset.label} + + Add rule - {preset.cost ? ( - - {preset.cost} - - ) : null} -
  • - ))} -
+ } + icon={IconShieldX} + title="No rules yet" + > + Bots can do anything their own permissions allow. + + ) : ( + + {policy.deny.map((rule, index) => { + const label = ruleLabel(policy, rule); + return ( + + ( + + )} + size="sm" + > + + + + + {label.name} + {label.description ? ( + {label.description} + ) : ( + // A rule nobody named is still told apart from the next by what it says. + + {rule} + + )} + + + + {index !== policy.deny.length - 1 && } + + ); + })} + + )}
- -
    - {policy.allow.map((rule) => ( -
  • - {rule === "true" ? "true, anything not refused above" : rule} -
  • - ))} -
+ + + + + Trial mode + + {trial + ? "On. A matching action goes ahead and is recorded in Audit, so you can see what a rule catches before it stops anything." + : "Off. A matching action is stopped, and the Bot is told which rule stopped it."} + + + + + savePolicy.mutate({ + ...policy, + mode: checked ? "dry-run" : "enforce", + }) + } + /> + + + + } + size="sm" + > + + + + + Stopped actions + + In Audit, choose Blocked to see every action a rule stopped and + which rule it was. + + + + + + {/* + * Read-only, and only interesting when it is not the default. Allow rules are checked after + * the list above and an empty list refuses everything, so a deployment that narrowed them + * in configuration needs to see that here rather than wonder why every action is refused. + */} + + + Everything else + + {allowsEverythingElse + ? "Allowed. A Bot can do anything no rule above stops, within its own permissions." + : policy.allow.length === 0 + ? "Nothing else is allowed. Every action is refused." + : `Only what these allow: ${policy.allow.join(" · ")}`} + + + + + {savePolicy.error ? ( +

+ {savePolicy.error.message} +

+ ) : null}
- -

- {problem ? ( - - {problem} - - ) : saved ? ( - "Saved. It applies to the next action any Bot takes." - ) : ( - "Changes apply to the next action any Bot takes. They stay after a restart." - )} -

); } - -/** - * What the tested rule would have done to actions already on the trail. - * - * Says the number over everything scanned first, because the list below it is capped and a reader - * who stops at the rows should not believe the rows are the whole answer. - */ -function DryRunResult({ report }: { report: DryRunReport }) { - if (report.scanned === 0) { - return ( -

- No recorded computer actions to test against yet. The rule is valid. - You'll see what it matches once Bots have acted. -

- ); - } - - return ( -
-

- {report.wouldRefuse === 0 - ? `Tested on the last ${report.scanned} recorded actions. This rule would have refused none. It may still match future ones.` - : `Tested on the last ${report.scanned} recorded actions. This rule would have refused ${report.wouldRefuse}.`} -

- {report.changes.length > 0 ? ( -
    - {report.changes.map((change) => ( -
  • -

    - - {change.would === "refused" - ? "Would refuse" - : "Would now allow"} - {" "} - {change.action} - {change.element?.name ? <> on “{change.element.name}” : null} - {change.command ? ( - <> - {" "} - running {change.command} - - ) : null} - {change.file ? <> touching {change.file} : null} -

    -

    - {change.bot} - {change.page ? <> · {change.page} : null} ·{" "} - {new Date(change.createdAt).toLocaleString()} -

    -
  • - ))} -
- ) : null} - {report.wouldRefuse > report.changes.length ? ( -

- Showing the first {report.changes.length}. The count above covers - everything checked. -

- ) : null} -
- ); -} diff --git a/app/src/routes/_authed/admin/boundaries_.$index.tsx b/app/src/routes/_authed/admin/boundaries_.$index.tsx new file mode 100644 index 0000000000..013c30e4ec --- /dev/null +++ b/app/src/routes/_authed/admin/boundaries_.$index.tsx @@ -0,0 +1,190 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { DryRunChanges } from "@/components/boundaries/dry-run-changes"; +import { + PageCard, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ruleLabel, withoutRule } from "@/lib/boundaries/rules"; +import { saveActionPolicyMutationOptions } from "@/lib/computers/mutations"; +import { + type ActionPolicy, + actionPolicyQueryOptions, + dryRunActionPolicy, +} from "@/lib/computers/queries"; + +export const Route = createFileRoute("/_authed/admin/boundaries_/$index")({ + component: RulePage, +}); + +const BACK = { + label: "Boundaries", + linkProps: { to: "/admin/boundaries" as const }, +}; + +/** + * One rule: what it is called, what it is made of, and what it has stopped lately. + * + * Addressed by position because a rule has no id of its own, only its text. A position that no + * longer holds a rule, because it was removed elsewhere, says so rather than showing a neighbour. + */ +function RulePage() { + const { index } = Route.useParams(); + const stored = useQuery(actionPolicyQueryOptions()); + const policy = stored.data; + const rule = policy?.deny[Number(index)]; + + if (!policy) { + return ( + + {stored.error ? ( +

+ {stored.error.message} +

+ ) : null} +
+ ); + } + if (rule === undefined) { + return ( + +

+ This rule isn't in place anymore. It may have been removed. +

+
+ ); + } + return ; +} + +function RuleDetail({ policy, rule }: { policy: ActionPolicy; rule: string }) { + const navigate = Route.useNavigate(); + const queryClient = useQueryClient(); + const savePolicy = useMutation(saveActionPolicyMutationOptions(queryClient)); + const [removing, setRemoving] = useState(false); + const label = ruleLabel(policy, rule); + + /* + * What this rule has stopped, read as the difference it makes: the policy without it, replayed + * over recent actions, would have allowed exactly the ones it stopped. + */ + const reach = useQuery({ + queryKey: ["computers", "policy", "reach", rule], + queryFn: () => dryRunActionPolicy(withoutRule(policy, rule)), + }); + + const remove = () => + savePolicy.mutate(withoutRule(policy, rule), { + onSuccess: () => void navigate({ to: "/admin/boundaries" }), + }); + + return ( + + {policy.mode === "dry-run" ? ( +

+ Trial mode is on, so this rule records matching actions without + stopping them. +

+ ) : null} + + + + + {rule} + + + + + + {reach.isPending ? null : reach.error ? ( +

+ {reach.error.message} +

+ ) : reach.data.scanned === 0 ? ( +

+ No recorded actions yet. What this rule stops will show here once + Bots have acted. +

+ ) : ( + <> +

+ {reach.data.wouldAllow === 0 + ? `It stopped none of the last ${reach.data.scanned} recorded actions.` + : `It stopped ${reach.data.wouldAllow} of the last ${reach.data.scanned} recorded actions.`} +

+ change.would === "allowed", + )} + total={reach.data.wouldAllow} + /> + + )} +
+ + + + + + + + + Remove “{label.name}”? + + Every Bot can do this again from its next action. + + + {savePolicy.error ? ( +

+ {savePolicy.error.message} +

+ ) : null} + + + + +
+
+
+ ); +} diff --git a/app/src/routes/_authed/admin/boundaries_.new.tsx b/app/src/routes/_authed/admin/boundaries_.new.tsx new file mode 100644 index 0000000000..4a89eb4d43 --- /dev/null +++ b/app/src/routes/_authed/admin/boundaries_.new.tsx @@ -0,0 +1,367 @@ +import { IconCheck, IconCode, IconShieldX } from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { DryRunChanges } from "@/components/boundaries/dry-run-changes"; +import { + PageCard, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { + RULE_ATTRIBUTES, + RULE_TEMPLATES, + type RuleTemplate, +} from "@/lib/boundaries/rules"; +import { isComposing } from "@/lib/composing"; +import { saveActionPolicyMutationOptions } from "@/lib/computers/mutations"; +import { + actionPolicyQueryOptions, + type DryRunReport, + dryRunActionPolicy, +} from "@/lib/computers/queries"; + +export const Route = createFileRoute("/_authed/admin/boundaries_/new")({ + component: NewRulePage, +}); + +const BACK = { + label: "Boundaries", + linkProps: { to: "/admin/boundaries" as const }, +}; + +/** + * Adding a rule: pick a template or write one, see what it would have stopped, then add it. + * + * A template fills the form rather than adding itself, so its name, its cost and the rule it is + * made of are all read before it applies to every Bot. + */ +function NewRulePage() { + const navigate = Route.useNavigate(); + const queryClient = useQueryClient(); + const stored = useQuery(actionPolicyQueryOptions()); + const savePolicy = useMutation(saveActionPolicyMutationOptions(queryClient)); + const [chosen, setChosen] = useState(null); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [rule, setRule] = useState(""); + const [tested, setTested] = useState(null); + const [testing, setTesting] = useState(false); + const [problem, setProblem] = useState(null); + + const policy = stored.data; + const trimmed = rule.trim(); + const duplicate = Boolean(policy?.deny.includes(trimmed)); + const ready = Boolean(policy && name.trim() && trimmed && !duplicate); + + const choose = (next: RuleTemplate | "custom") => { + setChosen(next); + setTested(null); + setProblem(null); + if (next === "custom") { + setName(""); + setDescription(""); + setRule(""); + } else { + setName(next.name); + setDescription(next.description); + setRule(next.rule); + } + }; + + /* + * The policy in force plus this rule, replayed over recent recorded actions. Nothing is saved; + * the reply says what the addition would have stopped, so its reach is known before it applies. + */ + const test = async () => { + if (!policy || !trimmed) return; + setProblem(null); + setTesting(true); + try { + setTested( + await dryRunActionPolicy({ + ...policy, + deny: [...policy.deny, trimmed], + }), + ); + } catch (thrown) { + setProblem((thrown as Error).message); + } finally { + setTesting(false); + } + }; + + const add = () => { + if (!policy || !ready) return; + setProblem(null); + savePolicy.mutate( + { + ...policy, + deny: [...policy.deny, trimmed], + labels: { + ...policy.labels, + [trimmed]: { name: name.trim(), description: description.trim() }, + }, + }, + { + onError: (thrown: Error) => setProblem(thrown.message), + onSuccess: () => void navigate({ to: "/admin/boundaries" }), + }, + ); + }; + + const options: { key: string; value: RuleTemplate | "custom" }[] = [ + ...RULE_TEMPLATES.map((template) => ({ + key: template.rule, + value: template, + })), + { key: "custom", value: "custom" }, + ]; + + return ( + + + + {options.map((option, index) => { + const custom = option.value === "custom"; + const template = custom ? null : (option.value as RuleTemplate); + const selected = chosen === option.value; + const taken = template + ? policy?.deny.includes(template.rule) + : false; + return ( +
+ } + onClick={() => choose(option.value)} + size="sm" + > + + {custom ? : } + + + + {template ? template.name : "Write your own"} + + + {template + ? taken + ? "Already one of your rules." + : template.description + : "Describe the action yourself, in CEL."} + + + {selected ? ( + + + + ) : null} + + {index !== options.length - 1 && } +
+ ); + })} +
+
+ + {chosen ? ( + + { + // Enter in a field submits the form, but not the Enter that confirms a composed + // character: that would add a rule whose name is still half typed. + if (event.key === "Enter" && isComposing(event)) + event.preventDefault(); + }} + onSubmit={(event) => { + event.preventDefault(); + add(); + }} + > + + + + + } + > + + + Name + setName(event.target.value)} + placeholder="Never delete files" + value={name} + /> + + + + What it stops + + + One sentence, shown under the name in the list. + + setDescription(event.target.value)} + value={description} + /> + + + Rule + + Written in CEL. A rule that can't be evaluated counts as a + match, so a typo stops actions rather than letting them + through. + +