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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 71 additions & 25 deletions .claude/skills/openbot-screen-layout/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 `<list>_.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 `<form>`, 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"`.
Expand All @@ -142,34 +186,34 @@ 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

- Adding a screen → Procedure 1, then Procedure 2 for each row.
- 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.

Expand All @@ -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` |
Expand All @@ -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
Expand Down
44 changes: 42 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion app/src/components/agents/agent-profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -115,6 +123,12 @@ export function AgentProfile({ agentId }: { agentId: string }) {
Open {profile.name}
</Button>
</div>
{footer ? (
<>
<Separator />
{footer}
</>
) : null}
</div>
);
}
18 changes: 10 additions & 8 deletions app/src/components/agents/handoff-panel.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 : (
<PageEmpty>
Handing work between Bots is turned off. These settings are kept, and
they apply again once an admin turns it back on.
<PageEmpty icon={IconArrowsExchange} title="Handoffs are off">
An admin turned off handing work between Bots. These settings are
kept, and they apply again once it's back on.
</PageEmpty>
)}

{grantable ? null : (
<PageEmpty>
<PageEmpty icon={IconLock} title="Can't hand off work">
This Bot can't hand work to other Bots. Other Bots can still hand work
to it.
</PageEmpty>
Expand All @@ -101,9 +102,8 @@ export function HandoffPanel({ agentId }: { agentId: string }) {
) : null}

{grantable && total === 0 ? (
<PageEmpty>
No other Bots yet. When there are more, you can let this Bot ask them
here.
<PageEmpty icon={IconRobot} title="No other Bots yet">
When there are more, you can let this Bot ask them here.
</PageEmpty>
) : null}
{candidates.length > 0 ? (
Expand Down Expand Up @@ -153,7 +153,9 @@ export function HandoffPanel({ agentId }: { agentId: string }) {
) : null}

{canGrant ? null : (
<PageEmpty>An admin decides which Bots this one can ask.</PageEmpty>
<PageEmpty icon={IconLock} title="Set by an admin">
An admin decides which Bots this one can ask.
</PageEmpty>
)}
</PageSection>
);
Expand Down
Loading
Loading