diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md index 20cd104d6f..7025349711 100644 --- a/.claude/skills/openbot-screen-layout/SKILL.md +++ b/.claude/skills/openbot-screen-layout/SKILL.md @@ -64,7 +64,7 @@ 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`) | +| Page edge (top / sides / bottom) | 48 / 16 / 64px | `PageShell` column (`pt-12`, or `pt-22` under the bar: its 56px plus 32px, `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+*]` | @@ -96,7 +96,8 @@ the action is right there. A search with no results uses `IconSearch` and "No ma 1. Open with `PageShell`, giving it `title` and `description`. Leave `width` alone. 2. On a detail screen reached from a list, pass `backButton={{ label, linkProps }}` — it draws a - chevron-left bar above the header. Do not put a Back button in `action`; `action` is for the + chevron-left button floating in the pane's top-left corner, only as wide as itself, so the page + scrolls under it rather than under a full-width band. Do not put a Back button in `action`; `action` is for the page's one primary verb, on the title's baseline. 3. Group the page's decisions into `PageSection`s, each with a `title` and, where the grouping is not self-evident, a `description`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f9d3f16f4..fae183b0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `COMPUTER_MEMORY_HIGH_PERCENT=0` and `COMPUTER_TAB_IDLE_MS=0` (Helm: `computers.memoryHighPercent` and `computers.tabIdleMs`) keep the old behaviour. +### Writing a skill is a screen of its own, and lists keep their place + +New skill and each skill's page open as screens instead of dialogs, laid out a section at a time: +details, instructions, tools, the Bots it is on, and on an existing skill, Delete. Tools are picked +one app at a time, and a new skill can be put on Bots before it is created. Each skill in the list +is a card that opens it and shows how many Bots it is on and how many tools it needs. Coming back +from any detail screen returns a list to where it was scrolled, and the back button no longer +covers the top of the page. + ### A computer closes browsers nobody is using when its memory runs high A computer runs one browser for each Bot that is browsing, and the only limits were a count and half diff --git a/app/src/components/layout/page-parts.tsx b/app/src/components/layout/page-parts.tsx index 7fa01f8dba..d1fbee7065 100644 --- a/app/src/components/layout/page-parts.tsx +++ b/app/src/components/layout/page-parts.tsx @@ -28,6 +28,9 @@ export type ShellWidth = "prose" | "full"; * bar needs the router, so `PageShell` draws it and passes `bar` to say whether it did; this file * imports nothing from the router so the extension can render the same column. */ +/** The name the router restores a page's scroll position by. See `router.tsx`. */ +export const PAGE_SCROLLER = "page"; + export function PageColumn({ bar, children, @@ -58,7 +61,10 @@ export function PageColumn({ * Separate from the centred column below so the scrollbar rides the pane's edge instead of * 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", + // Under the bar: its 56px, then the 32px the heading keeps below it. Without one, the + // heading keeps the full original space. + bar ? "pt-22" : "pt-12", "max-w-3xl", className, )} diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 073ed9e42e..8dbca6b55d 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -66,7 +66,7 @@ export function PageShell({ ); const barRow = bar ? ( -
+
{showToggle ? : null} {!!backButton && ( - ); - })} -
-

- Bots you pick here offer /{slug} when you type{" "} - /. -

+ Typing /{slug} on these Bots uses this skill. Saved as + you switch them. - )} + } + title="Bots" + > + + grant.mutate({ agentId, granted: on, kind: "skill", ref: slug }) + } + /> {grant.error ? ( -

+

{grant.error.message}

) : null} -
+ + ); +} + +/** + * The switches, for a skill not written yet: the picks are held here and put on once it is created, + * so a new skill does not start out on no Bot, where it does nothing. + */ +export function SkillBotPicks({ + picked, + onChange, +}: { + picked: string[]; + onChange: (agentIds: string[]) => void; +}) { + return ( + + picked.includes(agentId)} + onSwitch={(agentId, on) => + onChange( + on + ? [...picked, agentId] + : picked.filter((each) => each !== agentId), + ) + } + /> + + ); +} + +/** Every Bot this person owns, each with a switch; or, with none, the way to make one. */ +function BotSwitches({ + isOn, + onSwitch, + busy, +}: { + isOn: (agentId: string) => boolean; + onSwitch: (agentId: string, on: boolean) => void; + /** The Bot whose switch is being saved, held still until it is. */ + busy?: string; +}) { + const { data: agents, isPending } = useQuery(agentListQueryOptions()); + const mine = (agents ?? []).filter((agent) => agent.mine); + + if (isPending) return null; + + if (mine.length === 0) { + return ( + ( + + )} + size="sm" + variant="outline" + > + + New Bot + + } + icon={IconRobot} + title="No Bots of your own yet" + > + A skill goes on Bots you own. Make one, then switch this skill on for + it. + + ); + } + + return ( + + {mine.map((agent, index) => { + const id = `skill-bot-${agent.id}`; + return ( + + {index > 0 ? : null} + + + + + {/* The text is the switch's label, so the name toggles it too. */} + + + onSwitch(agent.id, checked)} + /> + + + + ); + })} + ); } diff --git a/app/src/components/skills/skill-fields.tsx b/app/src/components/skills/skill-fields.tsx index 651cb2d16e..4151edd8fc 100644 --- a/app/src/components/skills/skill-fields.tsx +++ b/app/src/components/skills/skill-fields.tsx @@ -1,13 +1,33 @@ import { useForm } from "@tanstack/react-form"; -import { SkillTools } from "@/components/skills/skill-tools"; -import { Button } from "@/components/ui/button"; +import { Fragment, useRef, useState } from "react"; +import { + PageCard, + PageRows, + PageSection, +} from "@/components/layout/page-shell"; import { - Field, - FieldError, - FieldGroup, - FieldLabel, -} from "@/components/ui/field"; + SkillToolApps, + SkillToolStep, + scrollParent, + TOOLS_EXPLAINED, + type ToolSource, +} from "@/components/skills/skill-tools"; +import { Button } from "@/components/ui/button"; +import { FieldError } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/ui/input-group"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { type SkillFormValues, skillFormSchema } from "@/lib/skills/form"; @@ -15,9 +35,9 @@ import { type SkillFormValues, skillFormSchema } from "@/lib/skills/form"; * The four things a person decides about a skill: what they type, what it is called, what it is * for, and what the Bot is actually told. * - * Written as its own component rather than inline in the panel because creating and editing a skill - * are the same four fields, and the second one is coming — a skill whose instructions can only be - * set once is a skill nobody will correct. + * Written as its own component rather than inline in a screen because creating and editing a skill + * are the same four fields. Laid out a section per part of the skill, each in its own container, the + * way Linear lays out a settings screen, with the buttons under the last. */ export function SkillFields({ defaultValues, @@ -25,7 +45,8 @@ export function SkillFields({ onSubmit, error, onCancel, - footer, + beforeActions, + afterForm, slugLocked = false, }: { defaultValues: SkillFormValues; @@ -33,8 +54,13 @@ export function SkillFields({ onSubmit: (values: SkillFormValues) => Promise; error?: Error | null; onCancel?: () => void; - /** Anything that belongs with the skill but saves on its own, shown above the submit button. */ - footer?: React.ReactNode; + /** Sections that belong to the form and come before its buttons. */ + beforeActions?: React.ReactNode; + /** + * Sections after the form and its buttons, for anything that saves on its own — granting, deleting. + * Outside the form on purpose: what sits after Save is visibly not waiting for it. + */ + afterForm?: React.ReactNode; /** * Editing, where the command cannot change. * @@ -53,69 +79,102 @@ export function SkillFields({ }, }); - return ( -
{ - event.preventDefault(); - form.handleSubmit(); - }} - > - + /* + * The app whose tools are showing, in place of the fields. A step inside the form rather than a + * route of its own, so nothing typed above has to survive a navigation, and rather than a dialog, + * because one app can offer eighty tools and a dialog is the wrong size for a list that long. + */ + const [toolStep, setToolStep] = useState(null); + const formRef = useRef(null); + const fieldsScroll = useRef(0); + + /* + * A step starts with the page at its top, where its list is sized to fit, and the fields come back + * scrolled to where the list of apps was, so the person returns to the row they opened rather than + * to the Command field. + */ + const goTo = (next: ToolSource | null) => { + const scroller = scrollParent(formRef.current); + if (toolStep === null) fieldsScroll.current = scroller?.scrollTop ?? 0; + setToolStep(next); + requestAnimationFrame(() => { + if (scroller) + scroller.scrollTop = next === null ? fieldsScroll.current : 0; + }); + }; + + /* + * The Details card: one settings row each for what you type, what it is called and + * what it is for, the label and its hint on the left and the control on the right, as Linear draws + * a settings screen. A field's error takes the hint's place, so a row never grows a third line. + */ + const settingsRows = [ + { + key: "slug", + node: ( {(field) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - - Command - - field.handleChange(event.target.value.toLowerCase()) - } - placeholder="standup" - value={field.state.value} - /> - {isInvalid ? ( - + + {slugLocked ? ( + + /{field.state.value} + ) : ( -

- {slugLocked ? ( - "You can't change a command. To rename this skill, write a new one and delete this one." - ) : ( - <> - What you type after a slash.{" "} - - /{"{"}command{"}"} - - - )} -

+ + / + + field.handleChange(event.target.value.toLowerCase()) + } + placeholder="standup" + value={field.state.value} + /> + )} -
+ ); }}
- + ), + }, + { + key: "title", + node: ( {(field) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - - Title + - {isInvalid ? ( - - ) : null} - + ); }} - + ), + }, + { + key: "summary", + node: ( {(field) => { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - - One-liner + field.handleChange(event.target.value)} - placeholder="Turns yesterday's work into a standup update" + placeholder="Turns yesterday's work into a standup" value={field.state.value} /> - {isInvalid ? ( - - ) : ( -

- Shown beside the command in this list and in the{" "} - / menu. Optional. -

- )} -
+ ); }}
+ ), + }, + ]; - - {(field) => { - const isInvalid = - field.state.meta.isTouched && !field.state.meta.isValid; - return ( - - Instructions -