From b5cf4ada64b3d75533c93398f9778611b0100db3 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Fri, 30 Oct 2026 12:18:06 -0300 Subject: [PATCH 1/4] Float the back button in the corner instead of a band across the page As a full-width band it covered the top of every scrolled page for a control that sits beside the centred column. It now floats over the scroller's corner, only as wide as its buttons, and the column keeps the same starting position through its own padding. --- .claude/skills/openbot-screen-layout/SKILL.md | 5 +++-- app/src/components/layout/page-parts.tsx | 5 +++-- app/src/components/layout/page-shell.tsx | 16 ++++++++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) 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/app/src/components/layout/page-parts.tsx b/app/src/components/layout/page-parts.tsx index 7fa01f8dba..aff5abdd70 100644 --- a/app/src/components/layout/page-parts.tsx +++ b/app/src/components/layout/page-parts.tsx @@ -72,8 +72,9 @@ export function PageColumn({ */ "mx-auto flex w-full flex-col px-4 pb-16", "[&>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..f467bdc9a8 100644 --- a/app/src/components/skills/skill-fields.tsx +++ b/app/src/components/skills/skill-fields.tsx @@ -1,5 +1,17 @@ import { useForm } from "@tanstack/react-form"; -import { SkillTools } from "@/components/skills/skill-tools"; +import { Fragment, useRef, useState } from "react"; +import { + SkillToolApps, + SkillToolStep, + scrollParent, + TOOLS_EXPLAINED, + type ToolSource, +} from "@/components/skills/skill-tools"; +import { + PageCard, + PageRows, + PageSection, +} from "@/components/layout/page-shell"; import { Button } from "@/components/ui/button"; import { Field, @@ -8,6 +20,19 @@ import { FieldLabel, } 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 +40,8 @@ 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, and the demonstration recorder asks them too. */ export function SkillFields({ defaultValues, @@ -25,16 +49,23 @@ export function SkillFields({ onSubmit, error, onCancel, - footer, + beforeActions, + afterForm, slugLocked = false, + layout = "inline", }: { defaultValues: SkillFormValues; submitLabel: string; 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. `page` only. */ + 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. `page` only. + */ + afterForm?: React.ReactNode; /** * Editing, where the command cannot change. * @@ -44,6 +75,12 @@ export function SkillFields({ * field is read-only here and the form says why. */ slugLocked?: boolean; + /** + * `page` when the form is a screen of its own: 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. `inline` + * is one stack of labelled fields with the buttons under them, for a form inside something else. + */ + layout?: "inline" | "page"; }) { const form = useForm({ defaultValues, @@ -53,14 +90,149 @@ 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 page layout's 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 ( + + {slugLocked ? ( + + /{field.state.value} + + ) : ( + + / + + 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 ( + + field.handleChange(event.target.value)} + placeholder="My standup skill" + value={field.state.value} + /> + + ); + }} + + ), + }, + { + key: "summary", + node: ( + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + field.handleChange(event.target.value)} + placeholder="Turns yesterday's work into a standup" + value={field.state.value} + /> + + ); + }} + + ), + }, + ]; + + const fields = ( + <> {(field) => { @@ -191,46 +363,229 @@ export function SkillFields({ {(field) => ( - + )} - {/* - * Rendered by the caller, between the fields and the buttons, so granting sits with the rest - * of the skill rather than after the point where the form is finished with. - * - * IT IS NOT PART OF THE FORM'S STATE. Each toggle saves the moment it is pressed, while - * everything above waits for the submit button — so it must not look like a field, and - * pressing one is never undone by closing the panel without saving. - */} - {footer ?
{footer}
: null} - {error ? (

{error.message}

) : null} + + ); + + const submit = ( + [state.canSubmit, state.isSubmitting]}> + {([canSubmit, isSubmitting]) => ( + + )} + + ); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + /* + * Not from a step. Enter in its search box is an implicit submit, and saving the skill from + * there would save fields the person cannot see, or fail on one they cannot reach. + */ + if (toolStep !== null) return; + form.handleSubmit(); + }; + + const step = + toolStep === null ? null : ( + + {(field) => ( + + )} + + ); + /* + * Both return to the fields, because a tick is already in the form; there is nothing on a step to + * keep or throw away. Done is there so the step ends on a confirmation like every other one. + */ + const leaveStep = () => goTo(null); + + /* + * Hidden rather than unmounted while a step shows, so every field comes back exactly as it was + * left: what was typed, what was touched, which error was showing. + */ + const body = ( + <> + {step} + + + ); + + if (layout === "page") { + const stepping = toolStep !== null; + return ( + <> + + {stepping ? ( + + + + + } + > + {step} + + ) : null} + + {/* + * Hidden rather than unmounted while a step shows, so every field comes back exactly as + * it was left: what was typed, what was touched, which error was showing. + */} +