From 1169dcdcca7764617e24bd0c313450b2c37be9ed Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:11:53 -0700 Subject: [PATCH 01/29] fix(ux): hide inspector and replace new bot sparkle icon --- app/src/components/agents/new-bot-sheet.tsx | 4 ++-- app/src/lib/copilot/provider.tsx | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/components/agents/new-bot-sheet.tsx b/app/src/components/agents/new-bot-sheet.tsx index 87393f7930..27a1959c40 100644 --- a/app/src/components/agents/new-bot-sheet.tsx +++ b/app/src/components/agents/new-bot-sheet.tsx @@ -1,8 +1,8 @@ import { summaryFor, WEB_HARNESSES } from "@openbot/shared/harnesses"; import { IconCheck, + IconDeviceDesktop, IconLock, - IconSparkles, IconUsers, } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; @@ -752,7 +752,7 @@ function RuntimeStep({ type="button" > - + Run it here diff --git a/app/src/lib/copilot/provider.tsx b/app/src/lib/copilot/provider.tsx index 14842c107d..68afd6e65b 100644 --- a/app/src/lib/copilot/provider.tsx +++ b/app/src/lib/copilot/provider.tsx @@ -34,6 +34,7 @@ export function CopilotProvider({ children }: { children: ReactNode }) { return ( Date: Tue, 27 Oct 2026 15:12:52 -0700 Subject: [PATCH 02/29] fix(ux): simplify new bot details and visibility copy --- app/src/components/agents/new-bot-sheet.tsx | 44 ++++++--------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/app/src/components/agents/new-bot-sheet.tsx b/app/src/components/agents/new-bot-sheet.tsx index 27a1959c40..70f0eefcc5 100644 --- a/app/src/components/agents/new-bot-sheet.tsx +++ b/app/src/components/agents/new-bot-sheet.tsx @@ -157,7 +157,6 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { const [step, setStep] = useState("runtime"); const [direction, setDirection] = useState(1); const [name, setName] = useState(""); - const [title, setTitle] = useState(""); const [instructions, setInstructions] = useState(""); /** Private until said otherwise: a Bot shown to everybody by accident is the worse mistake. */ const [visibility, setVisibility] = useState("private"); @@ -234,7 +233,7 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { const agent = await createAgent.mutateAsync( agentInputFrom({ name: name.trim(), - title: title.trim() || defaultTitle(chosen), + title: defaultTitle(chosen), roleDescription: instructions.trim() || defaultInstructions(name.trim(), chosen), visibility, @@ -375,9 +374,7 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { runtime={runtime ?? { kind: "builtin" }} onInstructions={setInstructions} onName={setName} - onTitle={setTitle} onVisibility={setVisibility} - title={title} visibility={visibility} /> ) : step === "runtime" ? ( @@ -528,22 +525,22 @@ const VISIBILITY_OPTIONS: Array<{ { value: "public", title: "Public", - description: "Everyone in your OpenBot can find and use it.", + description: "Everyone in your company can find and use it.", icon: IconUsers, }, ]; /** * The last step: what to call it, who can see it, and — only if the person wants to be more - * specific — a line describing it and the instructions it follows. + * specific — the instructions it follows. * - * Title and instructions are optional and say so; who can see it always holds an answer, so a Bot + * Instructions are optional; who can see it always holds an answer, so a Bot * named and nothing else is a complete answer, and Create waits for nothing more. * * The instructions field says what it is for the Bot being made. For one that runs here it is the - * prompt it runs on, so it is called Instructions and says how it behaves without them; for one + * prompt it runs on, so it is called Instructions; for one * somebody else runs it is a line added to a prompt written elsewhere, so it is called Role. The - * title's example is the framework's name, which is what it becomes when left blank. + * display subtitle is assigned automatically from the runtime. * * Who can see it was the old wizard's last step. It is here because this step had the room, and a * question that does not change what is being made is cheaper answered alongside the name than as a @@ -557,22 +554,18 @@ function AboutStep({ leftOut, onApp, name, - title, instructions, runtime, visibility, onName, - onTitle, onInstructions, onVisibility, }: { name: string; - title: string; instructions: string; runtime: Runtime; visibility: Visibility; onName: (value: string) => void; - onTitle: (value: string) => void; onInstructions: (value: string) => void; onVisibility: (value: Visibility) => void; apps: GiveableApp[]; @@ -594,21 +587,6 @@ function AboutStep({ value={name} /> - - - Title - Optional - - onTitle(event.target.value)} - placeholder={runsHere ? "Finance Operations" : defaultTitle(runtime)} - value={title} - /> - A few words shown under its name. - {runsHere ? "Instructions" : "Role"} @@ -623,11 +601,11 @@ function AboutStep({ rows={5} value={instructions} /> - - {runsHere - ? "How it should work. Leave it blank and it helps with anything." - : "Added to your agent's own instructions."} - + {!runsHere && ( + + Added to your agent's own instructions. + + )}
Who can see it From 9c194f30a56c17832a43f2cadf6d051a21b769e7 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:16:28 -0700 Subject: [PATCH 03/29] fix(copy): use complete actionable descriptions across OpenBot --- .claude/skills/openbot-copy/SKILL.md | 5 ++- app/src/components/agents/handoff-panel.tsx | 2 +- app/src/components/bot-profile/access.tsx | 2 +- app/src/components/gallery/form.tsx | 2 +- .../components/settings/update-routing.tsx | 4 +-- .../_authed/_app/bots.$agentId.access.tsx | 2 +- .../_authed/_app/bots.$agentId.activity.tsx | 2 +- .../_authed/_app/bots.$agentId.approvals.tsx | 2 +- .../_authed/_app/bots.$agentId.index.tsx | 2 +- .../_authed/_app/bots.$agentId.memory.tsx | 2 +- .../_app/bots.$agentId.responsibilities.tsx | 2 +- .../_authed/_app/bots.$agentId.routines.tsx | 2 +- .../_authed/_app/bots.$agentId.setup.tsx | 2 +- .../_authed/_app/bots.$agentId.sharing.tsx | 2 +- app/src/routes/_authed/_app/bots.index.tsx | 2 +- .../_authed/_app/capabilities.memory.tsx | 2 +- .../routes/_authed/_app/connections.$key.tsx | 2 +- .../routes/_authed/_app/connections.index.tsx | 2 +- app/src/routes/_authed/_app/routines.tsx | 2 +- app/src/routes/_authed/admin/approvals.tsx | 2 +- app/src/routes/_authed/admin/audit.tsx | 2 +- app/src/routes/_authed/admin/boundaries.tsx | 2 +- .../routes/_authed/admin/boundaries_.new.tsx | 2 +- .../routes/_authed/admin/components/$name.tsx | 2 +- .../routes/_authed/admin/components/index.tsx | 2 +- app/src/routes/_authed/admin/credentials.tsx | 2 +- .../routes/_authed/admin/credentials_.new.tsx | 2 +- app/src/routes/_authed/admin/enterprise.tsx | 6 ++-- .../_authed/admin/identity-providers.tsx | 2 +- app/src/routes/_authed/admin/index.tsx | 32 +++++++++---------- app/src/routes/_authed/admin/people.tsx | 2 +- .../routes/_authed/admin/plugins/index.tsx | 2 +- app/src/routes/_authed/admin/skills.tsx | 4 +-- .../settings/components-gallery/index.tsx | 2 +- app/src/routes/_authed/settings/index.tsx | 2 +- .../routes/_authed/settings/notifications.tsx | 2 +- app/src/routes/_authed/settings/passwords.tsx | 2 +- 37 files changed, 59 insertions(+), 56 deletions(-) diff --git a/.claude/skills/openbot-copy/SKILL.md b/.claude/skills/openbot-copy/SKILL.md index 17afc18ddb..0ef475c4cb 100644 --- a/.claude/skills/openbot-copy/SKILL.md +++ b/.claude/skills/openbot-copy/SKILL.md @@ -40,7 +40,8 @@ because the admin is the one who sets them. A page anybody can open may not. 1. **Say what happened, then what to do.** "Gmail isn't connected. Connect it to let Bots read your mail." Not why the screen is built the way it is. -2. **One idea per sentence. Short sentences.** A description is one or two sentences, about 20 words. +2. **Use complete sentences for explanations.** Page descriptions, helper text, notices, and empty-state bodies need a subject and a verb, or a direct instruction ("Connect your accounts"). Avoid fragments such as "The apps your Bots use" or "What this Bot can do." Headings, labels, buttons, and short status labels may be fragments. + **Keep only useful explanation.** State the action, outcome, or constraint the person needs. Remove copy that merely repeats the heading. Use natural sentence lengths; do not chop prose into fragments to meet a word count. 3. **Plain words.** "use", not "hold a grant on". "can't", not "is unable to". Contractions are fine. 4. **No "rather than", no em dashes, no "which is why", no "so that nothing … that may be wrong".** These are the marks of text that is explaining itself. Cut the clause or make it its own sentence. @@ -59,6 +60,7 @@ because the admin is the one who sets them. A page anybody can open may not. | Before | After | |---|---| +| The apps and accounts your Bots use as you. Open one to connect it or change it. | Connect your accounts so Bots can use apps on your behalf. | | Your connected accounts could not be loaded, so nothing is listed here rather than a list that may be wrong. Reload the page, and tell an administrator if it persists. | Couldn't load your connections. Reload the page, or ask an admin if it keeps happening. | | Sent to the coworker it is for. Type @ to choose one yourself. | OpenBot picks the right Bot. Type @ to choose one. | | Gmail is a Composio app, and this deployment has no Composio key. (to a non-admin) | Gmail isn't set up yet. An admin has to turn it on. | @@ -73,5 +75,6 @@ because the admin is the one who sets them. A page anybody can open may not. | "this deployment" on a page anybody can open | OpenBot, or say what is missing | | A sentence over ~25 words | Split it, or cut the clause that justifies the screen | | "rather than", an em dash, "which is" in UI text | Rewrite as two short sentences | +| A description beginning "The apps…", "What…", "Who…", or "Where…" without a main verb | State the action or behavior in a complete sentence; remove redundant explanation | | An env var, OAuth, Composio or "grant" outside Admin | Say what the person sees | | A test asserting long explanatory copy | Assert the short sentence, or the role and the key words | diff --git a/app/src/components/agents/handoff-panel.tsx b/app/src/components/agents/handoff-panel.tsx index 9a26e584d6..6a693c4910 100644 --- a/app/src/components/agents/handoff-panel.tsx +++ b/app/src/components/agents/handoff-panel.tsx @@ -77,7 +77,7 @@ export function HandoffPanel({ agentId }: { agentId: string }) { ) : null } - description="The Bots this one can ask for help. This doesn't change who can ask it. Their answers come back into the same conversation, under their name." + description="Choose which Bots this Bot can ask for help. Their answers appear in the same conversation under their own names. This does not change who can ask this Bot for help." title="Bots it can ask" > {/* Facts about the deployment rather than rows to act on, so said as sentences. */} diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx index 64fe2b5803..6b53a54dce 100644 --- a/app/src/components/bot-profile/access.tsx +++ b/app/src/components/bot-profile/access.tsx @@ -344,7 +344,7 @@ function Granted({ agentId, mine }: { agentId: string; mine: boolean }) { const count = states.length + rest.length + skills.length; return ( {apps.error ? ( diff --git a/app/src/components/gallery/form.tsx b/app/src/components/gallery/form.tsx index b3aa6a2295..01632d8de6 100644 --- a/app/src/components/gallery/form.tsx +++ b/app/src/components/gallery/form.tsx @@ -392,7 +392,7 @@ export const GALLERY: GalleryComponent[] = [ status: "executing", args: { title: "Plan your next project", - description: "A few details to prepare a useful first draft.", + description: "Provide a few details to help prepare your first draft.", fields: [ { id: "team", label: "Team name", type: "text", required: true }, { id: "email", label: "Work email", type: "email", required: true }, diff --git a/app/src/components/settings/update-routing.tsx b/app/src/components/settings/update-routing.tsx index 1cd447e1a8..f0bc1014d7 100644 --- a/app/src/components/settings/update-routing.tsx +++ b/app/src/components/settings/update-routing.tsx @@ -13,12 +13,12 @@ import { import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; import { setUpdateRoutingMutationOptions } from "@/lib/bot-lifecycle/mutations"; -import { deliveryQueryOptions } from "@/lib/delivery"; import { type UpdateKind, type UpdateTransport, updateRoutingQueryOptions, } from "@/lib/bot-lifecycle/queries"; +import { deliveryQueryOptions } from "@/lib/delivery"; import { queryClient } from "@/query-client"; const KIND_LABEL: Record = { @@ -62,7 +62,7 @@ export function UpdateRoutingSection() { reach.data ? reach.data.available[transport] : true; return ( {routing.isPending || reach.isPending ? null : routing.error ? ( diff --git a/app/src/routes/_authed/_app/bots.$agentId.access.tsx b/app/src/routes/_authed/_app/bots.$agentId.access.tsx index c4d80db8e8..d8f7b9685a 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.access.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.access.tsx @@ -11,7 +11,7 @@ function AccessPage() { return ( {(agent) => } diff --git a/app/src/routes/_authed/_app/bots.$agentId.activity.tsx b/app/src/routes/_authed/_app/bots.$agentId.activity.tsx index 6af7f785c2..70c9fc164d 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.activity.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.activity.tsx @@ -16,7 +16,7 @@ function ActivityPage() { return ( {(agent) => } diff --git a/app/src/routes/_authed/_app/bots.$agentId.approvals.tsx b/app/src/routes/_authed/_app/bots.$agentId.approvals.tsx index a8906fa7eb..f50231fa45 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.approvals.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.approvals.tsx @@ -11,7 +11,7 @@ function ApprovalRulesPage() { return ( {(agent) => } diff --git a/app/src/routes/_authed/_app/bots.$agentId.index.tsx b/app/src/routes/_authed/_app/bots.$agentId.index.tsx index e3b1fd7a97..5512c648ea 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.index.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.index.tsx @@ -18,7 +18,7 @@ function BotProfilePage() { return ( {agent.isPending ? null : agent.error ? ( diff --git a/app/src/routes/_authed/_app/bots.$agentId.memory.tsx b/app/src/routes/_authed/_app/bots.$agentId.memory.tsx index ede93cb304..c77fb4fe7d 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.memory.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.memory.tsx @@ -12,7 +12,7 @@ function MemoryPage() { return ( {(agent) => ( diff --git a/app/src/routes/_authed/_app/bots.$agentId.responsibilities.tsx b/app/src/routes/_authed/_app/bots.$agentId.responsibilities.tsx index cc21b2c7d8..40650244b6 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.responsibilities.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.responsibilities.tsx @@ -13,7 +13,7 @@ function ResponsibilitiesPage() { return ( {(agent) => } diff --git a/app/src/routes/_authed/_app/bots.$agentId.routines.tsx b/app/src/routes/_authed/_app/bots.$agentId.routines.tsx index 71cac12735..376a77092a 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.routines.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.routines.tsx @@ -11,7 +11,7 @@ function RoutinesPage() { return ( {(agent) => } diff --git a/app/src/routes/_authed/_app/bots.$agentId.setup.tsx b/app/src/routes/_authed/_app/bots.$agentId.setup.tsx index ab394a041d..dc608e0263 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.setup.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.setup.tsx @@ -11,7 +11,7 @@ function SetupPage() { return ( {(agent) => } diff --git a/app/src/routes/_authed/_app/bots.$agentId.sharing.tsx b/app/src/routes/_authed/_app/bots.$agentId.sharing.tsx index dbacc40ec1..513d9fead8 100644 --- a/app/src/routes/_authed/_app/bots.$agentId.sharing.tsx +++ b/app/src/routes/_authed/_app/bots.$agentId.sharing.tsx @@ -11,7 +11,7 @@ function SharingPage() { return ( {(agent) => } diff --git a/app/src/routes/_authed/_app/bots.index.tsx b/app/src/routes/_authed/_app/bots.index.tsx index be6dfe60f2..74566b460f 100644 --- a/app/src/routes/_authed/_app/bots.index.tsx +++ b/app/src/routes/_authed/_app/bots.index.tsx @@ -32,7 +32,7 @@ function BotsPage() { New Bot } - description="Your Bots and the ones shared with you. See what each is doing and what it needs from you." + description="Manage your Bots and access those shared with you." title="Bots" > diff --git a/app/src/routes/_authed/_app/capabilities.memory.tsx b/app/src/routes/_authed/_app/capabilities.memory.tsx index 00393f5ae3..9728c7ebe0 100644 --- a/app/src/routes/_authed/_app/capabilities.memory.tsx +++ b/app/src/routes/_authed/_app/capabilities.memory.tsx @@ -11,7 +11,7 @@ function MemoryPage() { } - description="What your Bots know about you. Facts you add reach every Bot. What a Bot learns on its own stays with that Bot." + description="Manage what your Bots remember about you. Facts you add are shared with all Bots; learned facts stay with the Bot that learned them." /> ); diff --git a/app/src/routes/_authed/_app/connections.$key.tsx b/app/src/routes/_authed/_app/connections.$key.tsx index cc535ffe1e..48382997ad 100644 --- a/app/src/routes/_authed/_app/connections.$key.tsx +++ b/app/src/routes/_authed/_app/connections.$key.tsx @@ -641,7 +641,7 @@ function AppConnectionPage() { {connection ? ( diff --git a/app/src/routes/_authed/_app/connections.index.tsx b/app/src/routes/_authed/_app/connections.index.tsx index 336821eb3f..fd9144b429 100644 --- a/app/src/routes/_authed/_app/connections.index.tsx +++ b/app/src/routes/_authed/_app/connections.index.tsx @@ -363,7 +363,7 @@ function RouteComponent() { return ( {/* diff --git a/app/src/routes/_authed/_app/routines.tsx b/app/src/routes/_authed/_app/routines.tsx index ba448ed6a8..61172120c6 100644 --- a/app/src/routes/_authed/_app/routines.tsx +++ b/app/src/routes/_authed/_app/routines.tsx @@ -21,7 +21,7 @@ export const Route = createFileRoute("/_authed/_app/routines")({ function RoutinesPage() { return ( diff --git a/app/src/routes/_authed/admin/approvals.tsx b/app/src/routes/_authed/admin/approvals.tsx index e0cdf94f18..1904ab5dcd 100644 --- a/app/src/routes/_authed/admin/approvals.tsx +++ b/app/src/routes/_authed/admin/approvals.tsx @@ -9,7 +9,7 @@ export const Route = createFileRoute("/_authed/admin/approvals")({ function AdminApprovalsPage() { return ( diff --git a/app/src/routes/_authed/admin/audit.tsx b/app/src/routes/_authed/admin/audit.tsx index e254003b9f..96673ee86c 100644 --- a/app/src/routes/_authed/admin/audit.tsx +++ b/app/src/routes/_authed/admin/audit.tsx @@ -133,7 +133,7 @@ function AuditPage() { } - description="Every action a Bot took, and every one this deployment's policy refused." + description="Review Bot actions and actions blocked by this deployment’s policy." title="Audit" width="wide" > diff --git a/app/src/routes/_authed/admin/boundaries.tsx b/app/src/routes/_authed/admin/boundaries.tsx index 2a11ebe259..3f0d536b58 100644 --- a/app/src/routes/_authed/admin/boundaries.tsx +++ b/app/src/routes/_authed/admin/boundaries.tsx @@ -33,7 +33,7 @@ export const Route = createFileRoute("/_authed/admin/boundaries")({ }); 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."; + "Define actions that Bots must never take. OpenBot checks these rules before a Bot acts on its computer or in a connected app."; /** * The deployment's boundaries, read as a list of things Bots may never do. diff --git a/app/src/routes/_authed/admin/boundaries_.new.tsx b/app/src/routes/_authed/admin/boundaries_.new.tsx index 4a89eb4d43..aa3e17582f 100644 --- a/app/src/routes/_authed/admin/boundaries_.new.tsx +++ b/app/src/routes/_authed/admin/boundaries_.new.tsx @@ -140,7 +140,7 @@ function NewRulePage() { return ( diff --git a/app/src/routes/_authed/admin/components/$name.tsx b/app/src/routes/_authed/admin/components/$name.tsx index 5651291600..bce053992e 100644 --- a/app/src/routes/_authed/admin/components/$name.tsx +++ b/app/src/routes/_authed/admin/components/$name.tsx @@ -309,7 +309,7 @@ function ComponentDetail({ diff --git a/app/src/routes/_authed/admin/components/index.tsx b/app/src/routes/_authed/admin/components/index.tsx index 49382e7443..05c17d26fd 100644 --- a/app/src/routes/_authed/admin/components/index.tsx +++ b/app/src/routes/_authed/admin/components/index.tsx @@ -26,7 +26,7 @@ function RouteComponent() { return ( {/* diff --git a/app/src/routes/_authed/admin/credentials.tsx b/app/src/routes/_authed/admin/credentials.tsx index 677218d173..4c167fc0c4 100644 --- a/app/src/routes/_authed/admin/credentials.tsx +++ b/app/src/routes/_authed/admin/credentials.tsx @@ -44,7 +44,7 @@ function CredentialsPage() { Add credential } - description="Keys and tokens for this deployment. OpenBot never shows a secret after it is saved." + description="Manage this deployment’s keys and tokens. Saved secrets cannot be viewed again." title="Credentials" > diff --git a/app/src/routes/_authed/admin/credentials_.new.tsx b/app/src/routes/_authed/admin/credentials_.new.tsx index 6d219d9da8..49ff75064c 100644 --- a/app/src/routes/_authed/admin/credentials_.new.tsx +++ b/app/src/routes/_authed/admin/credentials_.new.tsx @@ -74,7 +74,7 @@ function NewCredentialPage() { return (
{failure ? ( @@ -301,7 +301,7 @@ function EnterpriseSections({ @@ -589,7 +589,7 @@ function EnterpriseSections({ /> ) : null} setModelsOpen(false)} onSave={(models) => diff --git a/app/src/routes/_authed/admin/identity-providers.tsx b/app/src/routes/_authed/admin/identity-providers.tsx index aa82c26ec3..f56edd7677 100644 --- a/app/src/routes/_authed/admin/identity-providers.tsx +++ b/app/src/routes/_authed/admin/identity-providers.tsx @@ -105,7 +105,7 @@ function IdentityProvidersPage() { Add a provider } - description="Your company's own SAML or OpenID Connect provider. People sign in with their email, and its domain picks the provider." + description="Configure your company’s SAML or OpenID Connect provider. Email domains determine which provider people use to sign in." title="Identity providers" > {SECTIONS.map((section) => ( diff --git a/app/src/routes/_authed/admin/people.tsx b/app/src/routes/_authed/admin/people.tsx index 31028e899f..60a1e64b52 100644 --- a/app/src/routes/_authed/admin/people.tsx +++ b/app/src/routes/_authed/admin/people.tsx @@ -95,7 +95,7 @@ function PeoplePage() { return ( {/* Pending, error, empty, rows — pending first, so no sentence asserts anything mid-fetch. */} diff --git a/app/src/routes/_authed/admin/skills.tsx b/app/src/routes/_authed/admin/skills.tsx index 3002a08987..87bb6ca69e 100644 --- a/app/src/routes/_authed/admin/skills.tsx +++ b/app/src/routes/_authed/admin/skills.tsx @@ -94,7 +94,7 @@ function RouteComponent() { Write a skill } - description="Named instructions anybody can run with /. A skill adds no access. It can only ask a Bot to use what it already has." + description="Create reusable instructions people can run with /. Skills use a Bot’s existing permissions and do not grant additional access." title="Skills" > {error ? ( @@ -104,7 +104,7 @@ function RouteComponent() { ) : null} {/* A read that failed says so; `isPending` goes false on it too, and it is not "none". */} diff --git a/app/src/routes/_authed/settings/components-gallery/index.tsx b/app/src/routes/_authed/settings/components-gallery/index.tsx index 5625fb072f..6c49f0293b 100644 --- a/app/src/routes/_authed/settings/components-gallery/index.tsx +++ b/app/src/routes/_authed/settings/components-gallery/index.tsx @@ -29,7 +29,7 @@ function RouteComponent() { return ( {components.isPending ? null : components.error ? ( diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx index 288ed9b779..23d2327538 100644 --- a/app/src/routes/_authed/settings/index.tsx +++ b/app/src/routes/_authed/settings/index.tsx @@ -36,7 +36,7 @@ function RouteComponent() { */ return ( diff --git a/app/src/routes/_authed/settings/notifications.tsx b/app/src/routes/_authed/settings/notifications.tsx index 70b1ece24f..94f59122a7 100644 --- a/app/src/routes/_authed/settings/notifications.tsx +++ b/app/src/routes/_authed/settings/notifications.tsx @@ -11,7 +11,7 @@ export const Route = createFileRoute("/_authed/settings/notifications")({ function NotificationsPage() { return ( diff --git a/app/src/routes/_authed/settings/passwords.tsx b/app/src/routes/_authed/settings/passwords.tsx index a4634fb7ab..2d7479d1fc 100644 --- a/app/src/routes/_authed/settings/passwords.tsx +++ b/app/src/routes/_authed/settings/passwords.tsx @@ -37,7 +37,7 @@ function PasswordsPage() { return ( {logins.isPending ? null : logins.error ? ( From c5d077cc9d512e3bad10254129802027cdf3405c Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:26:26 -0700 Subject: [PATCH 04/29] feat(ux): personalize profile on first run and in settings --- app/src/components/settings/profile.tsx | 111 +++++++++++++++++++++ app/src/lib/auth/queries.ts | 1 + app/src/routes/_authed.tsx | 2 + app/src/routes/_authed/settings/index.tsx | 2 + app/tests/profile.test.tsx | 107 ++++++++++++++++++++ server/src/app.ts | 10 +- server/src/auth/dev-actor.ts | 1 - server/src/index.ts | 22 ++-- server/src/people/profile.ts | 78 +++++++++++++++ server/tests/dev-actor.integration.test.ts | 42 +++++++- server/tests/profile-routes.test.ts | 56 +++++++++++ shared/user-preferences.ts | 2 + 12 files changed, 419 insertions(+), 15 deletions(-) create mode 100644 app/src/components/settings/profile.tsx create mode 100644 app/tests/profile.test.tsx create mode 100644 server/src/people/profile.ts create mode 100644 server/tests/profile-routes.test.ts diff --git a/app/src/components/settings/profile.tsx b/app/src/components/settings/profile.tsx new file mode 100644 index 0000000000..046f52f312 --- /dev/null +++ b/app/src/components/settings/profile.tsx @@ -0,0 +1,111 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { PageSection } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { authKeys, currentUserQueryOptions } from "@/lib/auth/queries"; +import { client } from "@/lib/client"; + +export function ProfileNameForm({ + initialName = "", + firstRun = false, +}: { + initialName?: string; + firstRun?: boolean; +}) { + const id = useId(); + const queryClient = useQueryClient(); + const [name, setName] = useState(initialName); + const save = useMutation({ + mutationFn: (name: string) => + client("/api/me/profile", "profile", { + method: "PATCH", + body: { name }, + fallback: "Your name could not be saved", + }), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: authKeys.currentUser(), + refetchType: "all", + }), + }); + return ( + { + event.preventDefault(); + if (name.trim() && !save.isPending) save.mutate(name.trim()); + }} + > + + { + setName(event.target.value); + save.reset(); + }} + /> + {save.error && ( +

+ {save.error.message} +

+ )} + {save.isSuccess && !firstRun && ( +

+ Your name has been saved. +

+ )} + + + ); +} + +export function ProfileSettings() { + const { data: user } = useQuery(currentUserQueryOptions()); + return ( + + {user && ( + + )} + + ); +} + +export function FirstRunNamePrompt() { + const { data: user } = useQuery(currentUserQueryOptions()); + if (!user?.needsName) return null; + return ( + + + What should we call you? + + You can change your name later in Settings. + + + + + ); +} diff --git a/app/src/lib/auth/queries.ts b/app/src/lib/auth/queries.ts index f4016c075e..a619557f03 100644 --- a/app/src/lib/auth/queries.ts +++ b/app/src/lib/auth/queries.ts @@ -16,6 +16,7 @@ export type AuthenticatedUser = { id: string; email: string; name?: string | null; + needsName?: boolean; image?: string | null; role: "admin" | "user"; /** Null means this deployment does not track onboarding, which reads as nothing to finish. */ diff --git a/app/src/routes/_authed.tsx b/app/src/routes/_authed.tsx index 2e34d837f9..4cc0580479 100644 --- a/app/src/routes/_authed.tsx +++ b/app/src/routes/_authed.tsx @@ -1,4 +1,5 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import { FirstRunNamePrompt } from "../components/settings/profile"; import { currentUserQueryOptions, needsOnboarding } from "../lib/auth/queries"; import { CopilotProvider } from "../lib/copilot/provider"; import { AppHotkeys } from "../lib/hotkeys/app-hotkeys"; @@ -26,6 +27,7 @@ export const Route = createFileRoute("/_authed")({ + ), }); diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx index 23d2327538..ef6309e543 100644 --- a/app/src/routes/_authed/settings/index.tsx +++ b/app/src/routes/_authed/settings/index.tsx @@ -6,6 +6,7 @@ import { PageShell, } from "@/components/layout/page-shell"; import { MessageListPreference } from "@/components/settings/message-list-preference"; +import { ProfileSettings } from "@/components/settings/profile"; import { StandingInstructions } from "@/components/settings/standing-instructions"; import { useTheme } from "@/components/theme-provider"; import { @@ -39,6 +40,7 @@ function RouteComponent() { description="Customize OpenBot’s appearance and message display. Message settings apply to your account; the theme applies only to this browser." title="Preferences" > + diff --git a/app/tests/profile.test.tsx b/app/tests/profile.test.tsx new file mode 100644 index 0000000000..0d21deaa67 --- /dev/null +++ b/app/tests/profile.test.tsx @@ -0,0 +1,107 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + FirstRunNamePrompt, + ProfileNameForm, +} from "@/components/settings/profile"; +import { authKeys } from "@/lib/auth/queries"; +import { settleReactWork } from "./settle-react-work"; + +beforeAll(() => GlobalRegistrator.register()); +const originalFetch = globalThis.fetch; +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; +}); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +function client(needsName = true) { + const query = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + query.setQueryData(authKeys.currentUser(), { + id: "existing-user", + email: "dev@openbot.local", + name: needsName ? "dev@openbot.local" : "Sam", + needsName, + onboarding: { completedAt: "2026-01-01", step: 0 }, + }); + return query; +} + +test("asks existing users without a name even when onboarding is completed", () => { + const view = render( + + + , + ); + expect( + view.getByRole("heading", { name: "What should we call you?" }), + ).toBeTruthy(); + expect( + view.getByRole("button", { name: "Continue" }).hasAttribute("disabled"), + ).toBe(true); +}); + +test("does not ask again when a saved name is present", () => { + const view = render( + + + , + ); + expect(view.queryByRole("dialog")).toBeNull(); +}); + +test("settings saves only the display name and refreshes the current user", async () => { + const query = client(false); + let sent: unknown; + globalThis.fetch = Object.assign( + async (_url: Parameters[0], init?: RequestInit) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ profile: { name: "Samuel", needsName: false } }); + }, + { preconnect: originalFetch.preconnect }, + ); + const view = render( + + + , + ); + const user = userEvent.setup({ document }); + await user.clear(view.getByLabelText("Name")); + await user.type(view.getByLabelText("Name"), " Samuel "); + fireEvent.click(view.getByRole("button", { name: "Save name" })); + await waitFor(() => + expect(view.getByRole("status").textContent).toBe( + "Your name has been saved.", + ), + ); + expect(sent).toEqual({ name: "Samuel" }); + expect(query.getQueryState(authKeys.currentUser())?.isInvalidated).toBe(true); +}); + +test("failed saves show an error and preserve the draft for retry", async () => { + globalThis.fetch = Object.assign( + async () => + Response.json({ error: "Could not save your profile." }, { status: 500 }), + { preconnect: originalFetch.preconnect }, + ); + const view = render( + + + , + ); + fireEvent.click(view.getByRole("button", { name: "Save name" })); + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Could not save your profile.", + ), + ); + expect((view.getByLabelText("Name") as HTMLInputElement).value).toBe("Sam"); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 9d26865911..39876dc092 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,8 +1,8 @@ +import { MAX_IMAGE_BYTES } from "@openbot/shared/attachments"; import type { Hono as HonoApp, MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { bodyLimit } from "hono/body-limit"; import { serveStatic } from "hono/bun"; -import { MAX_IMAGE_BYTES } from "@openbot/shared/attachments"; import { enterpriseControls } from "./admin/controls"; import { createEnterpriseGate } from "./admin/gate"; import { createEnterpriseAdminRoutes } from "./admin/routes"; @@ -25,10 +25,10 @@ import type { ApprovalService } from "./approvals/service"; import { type AuditEventType, type AuditInitiator, - auditEventsToCsv, AuditQueryError, type AuditReader, type AuditStore, + auditEventsToCsv, auditQueryFromUrl, DEPLOYMENT_INITIATOR, readAuditExportRows, @@ -96,10 +96,11 @@ import { parsePageLimit } from "./paging"; import { createPasswordRoutes, createSignInRoutes } from "./passwords/routes"; import type { SignInService } from "./passwords/service"; import type { OnboardingStore } from "./people/onboarding"; +import { createProfileRoutes, type ProfileStore } from "./people/profile"; import { MAX_PAGE, type PeopleStore } from "./people/store"; +import type { BrokerProvider } from "./plugins/access"; import type { AccountModeSwitch } from "./plugins/account-mode"; import type { ComposioBroker } from "./plugins/broker"; -import type { BrokerProvider } from "./plugins/access"; import { createPluginRoutes } from "./plugins/routes"; import { createSharedUseRoutes } from "./plugins/shared-use-routes"; import type { SharedUseStore } from "./plugins/shared-use-store"; @@ -448,6 +449,7 @@ export function createApp( identity?: IdentityStore, /** The GitHub sign-in callback, built by the caller; mounted behind requireUser ahead of /api/identity. */ identityGithubCallback?: Hono<{ Variables: AppVariables }>, + profileStore?: ProfileStore, ) { const app = new Hono<{ Variables: AppVariables }>(); mountDesktopConnectionFailure(app, desktopHostToken); @@ -665,6 +667,7 @@ export function createApp( context.json({ user: { ...context.var.actor, + ...(profileStore ? await profileStore.read(context.var.actor.id) : {}), /* * Read here rather than in the guard, so only this route pays the extra query. Null means * this deployment does not track onboarding, which the app reads as nothing to finish; @@ -676,6 +679,7 @@ export function createApp( }, }), ); + app.route("/api/me/profile", createProfileRoutes(requireUser, profileStore)); app.post("/api/me/onboarding", requireUser, async (context) => { if (!onboardingStore) { return context.json({ error: "Onboarding is not available." }, 503); diff --git a/server/src/auth/dev-actor.ts b/server/src/auth/dev-actor.ts index 6e6ea6afa3..243ad0a07d 100644 --- a/server/src/auth/dev-actor.ts +++ b/server/src/auth/dev-actor.ts @@ -48,7 +48,6 @@ export async function initializeDevActorUser( target: users.id, set: { email: DEV_ACTOR.email, - name, updatedAt: new Date(), }, }); diff --git a/server/src/index.ts b/server/src/index.ts index 519fdbef0b..a2d98570bf 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -160,6 +160,7 @@ import { createIntelligenceClient, observeIntelligenceAuthentication, } from "./intelligence-client"; +import { reportIntelligenceEntitlement } from "./intelligence-entitlement-check"; import { createLearningAutoAssign, probeLearningContainer, @@ -178,33 +179,34 @@ import { createMemoryDefaults } from "./memory/defaults"; import { createMemoryIngestion } from "./memory/ingestion"; import { createIntelligenceMemory } from "./memory/intelligence"; import { createMemoryStore } from "./memory/store"; -import { reportModelAvailability } from "./model-availability-check"; import { memoryTools } from "./memory/tools"; -import { appAccessTools } from "./plugins/app-access-tool"; +import { reportModelAvailability } from "./model-availability-check"; import { createSignInNotifier, createSignInResumer } from "./passwords/resume"; import { createSignInService } from "./passwords/service"; import { createPasswordStore } from "./passwords/store"; import { createOnboardingStore } from "./people/onboarding"; +import { createProfileStore } from "./people/profile"; import { createPeopleStore } from "./people/store"; -import { createAccountModeSwitch } from "./plugins/account-mode"; -import { assertActivatable, providerDecision } from "./plugins/broker"; import { BROKERED_PROFILE } from "./plugins/access"; -import { useRoutineTools } from "./plugins/builtin-routines"; +import { createAccountModeSwitch } from "./plugins/account-mode"; +import { appAccessTools } from "./plugins/app-access-tool"; import { useArcadeClient } from "./plugins/arcade"; import { createArcadeClient } from "./plugins/arcade-adapter"; +import { assertActivatable, providerDecision } from "./plugins/broker"; +import { useRoutineTools } from "./plugins/builtin-routines"; import { useComposioClient } from "./plugins/composio"; import { createComposioClient } from "./plugins/composio-adapter"; import { backfillBrokeredLogos } from "./plugins/logos"; import { redirectUriFor } from "./plugins/oauth"; -import { - createSharedUseGate, - createSharedUseStore, -} from "./plugins/shared-use-store"; import { arcadeVerifierNotice, selectProviderWiring, storedProvider, } from "./plugins/provider-wiring"; +import { + createSharedUseGate, + createSharedUseStore, +} from "./plugins/shared-use-store"; import { createPluginStore } from "./plugins/store"; import { grantedSkills, @@ -249,7 +251,6 @@ import { createRoutineRunner } from "./routines/runner"; import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; -import { reportIntelligenceEntitlement } from "./intelligence-entitlement-check"; import { createSelfHostBanner } from "./self-host-banner"; import { createTeamBots } from "./team-bots/team-bots"; import { @@ -2576,6 +2577,7 @@ const app = createApp( identityStore, identityGithubCallback, + createProfileStore(database), ); startHttpServer({ diff --git a/server/src/people/profile.ts b/server/src/people/profile.ts new file mode 100644 index 0000000000..d005a85d5d --- /dev/null +++ b/server/src/people/profile.ts @@ -0,0 +1,78 @@ +import { eq, sql } from "drizzle-orm"; +import { Hono, type MiddlewareHandler } from "hono"; +import { z } from "zod"; +import type { AppVariables } from "../auth/guards"; +import type { Database } from "../db/client"; +import { users } from "../db/schema"; + +export type Profile = { name: string; needsName: boolean }; +export type ProfileStore = { + read(userId: string): Promise; + save(userId: string, name: string): Promise; +}; + +export function createProfileStore(database: Database): ProfileStore { + const read = async (userId: string): Promise => { + const [user] = await database + .select({ + name: users.name, + email: users.email, + preferences: users.preferences, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + if (!user) throw new Error("Your profile could not be found."); + return { + name: user.name ?? "", + needsName: + user.preferences.profileNameSet !== true && + (!user.name?.trim() || user.name === user.email), + }; + }; + return { + read, + async save(userId, name) { + // Display name only: identity, permissions and conversation ownership never change. + await database + .update(users) + .set({ + name, + updatedAt: new Date(), + preferences: sql`coalesce(${users.preferences}, '{}'::jsonb) || '{"profileNameSet":true}'::jsonb`, + }) + .where(eq(users.id, userId)); + return read(userId); + }, + }; +} + +const profilePatch = z.strictObject({ + name: z.string().trim().min(1).max(100), +}); + +export function createProfileRoutes( + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, + store?: ProfileStore, +) { + const app = new Hono<{ Variables: AppVariables }>(); + app.patch("/", requireUser, async (context) => { + if (!store) + return context.json( + { error: "Profile settings are not available." }, + 503, + ); + const parsed = profilePatch.safeParse( + await context.req.json().catch(() => undefined), + ); + if (!parsed.success) + return context.json( + { error: "Enter a name between 1 and 100 characters." }, + 400, + ); + return context.json({ + profile: await store.save(context.var.actor.id, parsed.data.name), + }); + }); + return app; +} diff --git a/server/tests/dev-actor.integration.test.ts b/server/tests/dev-actor.integration.test.ts index 3147c43aa3..19ef6e31a6 100644 --- a/server/tests/dev-actor.integration.test.ts +++ b/server/tests/dev-actor.integration.test.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { DEV_ACTOR, initializeDevActorUser } from "../src/auth/dev-actor"; import { createDatabase } from "../src/db/client"; import { users } from "../src/db/schema"; +import { createProfileStore } from "../src/people/profile"; import { TEST_POOL, testDatabaseUrl } from "./support/database"; const databaseUrl = testDatabaseUrl(); @@ -74,7 +75,7 @@ describe("development actor persistence", () => { expect(restoredRows[0]).toMatchObject({ id: DEV_ACTOR.id, email: DEV_ACTOR.email, - name: DEV_ACTOR.name ?? DEV_ACTOR.email, + name: "Changed", }); throw rollback; @@ -82,6 +83,45 @@ describe("development actor persistence", () => { ).rejects.toBe(rollback); }); + test("profile edits survive startup without changing identity or onboarding", async () => { + const rollback = new Error("rollback profile test"); + await expect( + database.transaction(async (transaction) => { + await initializeDevActorUser(transaction, true); + const profile = createProfileStore(transaction); + expect((await profile.read(DEV_ACTOR.id)).needsName).toBe(true); + await transaction + .update(users) + .set({ + preferences: { selfHostBannerDismissed: true }, + onboardingCompletedAt: new Date("2026-01-01"), + }) + .where(eq(users.id, DEV_ACTOR.id)); + await profile.save(DEV_ACTOR.id, "Sam"); + await initializeDevActorUser(transaction, true); + expect(await profile.read(DEV_ACTOR.id)).toEqual({ + name: "Sam", + needsName: false, + }); + const [user] = await transaction + .select() + .from(users) + .where(eq(users.id, DEV_ACTOR.id)); + expect(user).toMatchObject({ + id: DEV_ACTOR.id, + email: DEV_ACTOR.email, + preferences: { selfHostBannerDismissed: true, profileNameSet: true }, + onboardingCompletedAt: new Date("2026-01-01"), + }); + // Even deliberately choosing the old placeholder counts as completing the prompt. + await profile.save(DEV_ACTOR.id, DEV_ACTOR.email); + await initializeDevActorUser(transaction, true); + expect((await profile.read(DEV_ACTOR.id)).needsName).toBe(false); + throw rollback; + }), + ).rejects.toBe(rollback); + }); + test("fails loudly when another user owns the development email", async () => { const conflictingUserId = `dev-actor-conflict-${randomUUID()}`; diff --git a/server/tests/profile-routes.test.ts b/server/tests/profile-routes.test.ts new file mode 100644 index 0000000000..4c7d2543f7 --- /dev/null +++ b/server/tests/profile-routes.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { createDevRequireUser, DEV_ACTOR } from "../src/auth/dev-actor"; +import { createProfileRoutes, type ProfileStore } from "../src/people/profile"; + +function setup() { + const writes: { userId: string; name: string }[] = []; + const store: ProfileStore = { + read: async () => ({ name: "", needsName: true }), + save: async (userId, name) => { + writes.push({ userId, name }); + return { name, needsName: false }; + }, + }; + const app = createProfileRoutes(createDevRequireUser(), store); + const patch = (body: unknown) => + app.request("/", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + return { patch, writes }; +} + +describe("profile updates", () => { + test("saves the trimmed name for the authenticated identity", async () => { + const { patch, writes } = setup(); + const response = await patch({ name: " Sam Julien " }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + profile: { name: "Sam Julien", needsName: false }, + }); + expect(writes).toEqual([{ userId: DEV_ACTOR.id, name: "Sam Julien" }]); + }); + test.each([ + { name: " " }, + { name: "x".repeat(101) }, + { name: 7 }, + { name: "Sam", id: "another-user" }, + { name: "Sam", email: "new@example.test" }, + ])("rejects invalid names and identity changes: %j", async (body) => { + const { patch, writes } = setup(); + expect((await patch(body)).status).toBe(400); + expect(writes).toEqual([]); + }); + test("reports unavailable storage without pretending to save", async () => { + const app = createProfileRoutes(createDevRequireUser()); + expect( + ( + await app.request("/", { + method: "PATCH", + body: JSON.stringify({ name: "Sam" }), + }) + ).status, + ).toBe(503); + }); +}); diff --git a/shared/user-preferences.ts b/shared/user-preferences.ts index 80a9e076d5..fcf9fcdfdf 100644 --- a/shared/user-preferences.ts +++ b/shared/user-preferences.ts @@ -1,6 +1,8 @@ export type MessageListEmphasis = "agent" | "thread"; export type UserPreferences = { + /** Set by profile saves so choosing a name finishes the first-run prompt. */ + profileNameSet?: boolean; messageListEmphasis: MessageListEmphasis; /** Whether this person closed the banner offering help self-hosting OpenBot. */ selfHostBannerDismissed: boolean; From 5ab285440cae7738195968b6b8d958cd67afff5a Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:30:45 -0700 Subject: [PATCH 05/29] feat(ux): simplify bot creation and polish welcome prompt --- app/src/components/agents/new-bot-sheet.tsx | 176 ++++++++---------- app/src/components/settings/profile.tsx | 10 +- app/tests/new-bot-sheet.test.tsx | 189 +++++++++----------- app/tests/profile.test.tsx | 2 +- 4 files changed, 172 insertions(+), 205 deletions(-) diff --git a/app/src/components/agents/new-bot-sheet.tsx b/app/src/components/agents/new-bot-sheet.tsx index 70f0eefcc5..1486b5963f 100644 --- a/app/src/components/agents/new-bot-sheet.tsx +++ b/app/src/components/agents/new-bot-sheet.tsx @@ -1,10 +1,5 @@ import { summaryFor, WEB_HARNESSES } from "@openbot/shared/harnesses"; -import { - IconCheck, - IconDeviceDesktop, - IconLock, - IconUsers, -} from "@tabler/icons-react"; +import { IconCheck, IconLock, IconUsers } from "@tabler/icons-react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { MotionConfig, motion, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState } from "react"; @@ -48,18 +43,13 @@ import { } from "@/lib/app-access/queries"; import { isComposing } from "@/lib/composing"; import { pluginKeys } from "@/lib/plugins/queries"; -import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; /** * Where a new Bot is made: a wide sheet floating in from the right, so the roster it is being added * to stays in view behind it. * - * No visible header and no corner ✕ — Cancel, Escape and a click outside all close it. It replaced - * a centred wizard dialog that asked the same questions over four screens, one of them only to say - * who could see the Bot. - * - * The title is still there for screen readers: a dialog with no name is announced as just "dialog". + * New Bot details open first. Cancel, Escape and a click outside close the sheet. */ export function NewBotSheet({ open, @@ -84,7 +74,9 @@ export function NewBotSheet({ side="right" variant="inset" > - New Bot + + New Bot + {/* Inside the popup, so it unmounts on close and a reopened sheet starts empty. */} @@ -92,16 +84,8 @@ export function NewBotSheet({ ); } -/** - * The steps, in the order they are asked. - * - * WHERE IT RUNS COMES FIRST because it decides what the rest of the sheet means. For a Bot that runs - * here, its instructions are the system prompt it runs on; for one somebody else runs, they are a - * line added on top of a prompt written elsewhere. Asking for them before knowing which left the - * field unable to say what it was for. The address is asked only of a Bot somebody else runs, and is - * checked before the person spends time naming it. About it is always last, and always creates. - */ -const STEPS = ["runtime", "endpoint", "about"] as const; +/** Details come first; custom agents then choose a framework and verify their endpoint. */ +const STEPS = ["about", "runtime", "endpoint"] as const; type Step = (typeof STEPS)[number]; /** @@ -154,7 +138,7 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { */ const { data: capabilities } = useQuery(agentCapabilitiesQueryOptions()); const builtInAvailable = capabilities?.builtInAvailable ?? true; - const [step, setStep] = useState("runtime"); + const [step, setStep] = useState("about"); const [direction, setDirection] = useState(1); const [name, setName] = useState(""); const [instructions, setInstructions] = useState(""); @@ -162,6 +146,7 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { const [visibility, setVisibility] = useState("private"); /** Run it here unless the person picks otherwise: the answer that needs nothing set up. */ const [runtime, setRuntime] = useState({ kind: "builtin" }); + const [custom, setCustom] = useState(false); const [endpoint, setEndpoint] = useState(""); const [key, setKey] = useState(""); /** Whether Continue was pressed on the address, which is when its error becomes worth showing. */ @@ -215,9 +200,9 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { const canContinue = !busy && (step === "about" - ? named + ? named && (custom || builtInAvailable) : step === "runtime" - ? runtime !== null + ? runtime?.kind === "managed" : endpointIssue === undefined || !tried); const go = (to: Step) => { @@ -258,25 +243,17 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { const advance = () => { if (step === "runtime" && runtime) { - return go(runtime.kind === "builtin" ? "about" : "endpoint"); + return go("endpoint"); } if (step === "endpoint") return connect(); - if (step === "about" && runtime) return create(runtime); + if (step === "about") + return custom ? go("runtime") : create({ kind: "builtin" }); }; - /** Where Back goes: the address for a Bot somebody else runs, where it runs otherwise. */ - const previous = (): Step => - step === "about" && runtime?.kind === "managed" ? "endpoint" : "runtime"; + /** Back returns to the previous screen without resetting any answers. */ + const previous = (): Step => (step === "endpoint" ? "runtime" : "about"); - /** - * Continue on the address: dial it from the server, and move on only if it answers. - * - * There is no separate Test button. A test that could be skipped let a Bot be made pointed at - * nothing, and one that could not be skipped was a second button doing Continue's job. So Continue - * tests: an agent that answers moves on, and one that does not says why under its address, where - * the fix will be typed. Continue stays pressable after that, because the usual fix is starting the - * agent, not editing what was typed. - */ + /** Verify the endpoint before creating a custom Bot. */ const connect = async () => { const focusAddress = () => // Focus would otherwise fall to the page while the button below is busy or disabled. @@ -296,7 +273,7 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { focusAddress(); return; } - go("about"); + if (runtime?.kind === "managed") await create(runtime); }; if (createdId) { @@ -358,31 +335,63 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { transition={{ duration: 0.2, ease: "easeOut" }} > {step === "about" ? ( - - setLeftOut((current) => { - const next = new Set(current); - if (used) next.delete(app); - else next.add(app); - return next; - }) - } - runtime={runtime ?? { kind: "builtin" }} - onInstructions={setInstructions} - onName={setName} - onVisibility={setVisibility} - visibility={visibility} - /> + <> +
+ + +
+ {!builtInAvailable && ( +

+ An admin must enable built-in hosting before you can run a + Bot here. +

+ )} + + setLeftOut((current) => { + const next = new Set(current); + if (used) next.delete(app); + else next.add(app); + return next; + }) + } + runtime={ + custom + ? runtime?.kind === "managed" + ? runtime + : { kind: "managed", harness: "byo-url" } + : { kind: "builtin" } + } + onInstructions={setInstructions} + onName={setName} + onVisibility={setVisibility} + visibility={visibility} + /> + ) : step === "runtime" ? ( - + ) : ( void }) { - {step === "runtime" ? ( + {step === "about" ? ( } > @@ -439,8 +448,8 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { ? "Checking…" : createAgent.isPending ? "Creating…" - : step === "about" - ? "Create" + : step === "endpoint" || (step === "about" && !custom) + ? "Create Bot" : "Continue"} @@ -700,12 +709,9 @@ const OPTION_CLASSES = * than on the Continue it just pressed, which is still there. */ function RuntimeStep({ - builtInAvailable, runtime, onChoose, }: { - /** Whether this deployment has a Bot of its own to run one here on. */ - builtInAvailable: boolean; runtime: Runtime | null; onChoose: (runtime: Runtime) => void; }) { @@ -719,35 +725,9 @@ function RuntimeStep({ ref={heading} tabIndex={-1} > - Where will your Bot run? + Connect a custom agent - - -
- - or connect one you run - -
- {WEB_HARNESSES.map((harness) => ( - -
- {!builtInAvailable && ( -

- An admin must enable built-in hosting before you can run a - Bot here. -

- )} void }) { onVisibility={setVisibility} visibility={visibility} /> +
+ Where it runs +
+ + +
+ {!builtInAvailable && ( + + An admin must enable built-in hosting before you can run a + Bot here. + + )} +
) : step === "runtime" ? ( diff --git a/app/tests/new-bot-sheet.test.tsx b/app/tests/new-bot-sheet.test.tsx index bc0b61f5bf..a6bbcd3919 100644 --- a/app/tests/new-bot-sheet.test.tsx +++ b/app/tests/new-bot-sheet.test.tsx @@ -132,7 +132,7 @@ async function customFlow() { const result = await opened(); await result.user.type(result.view.getByLabelText("Name"), "Expense Review"); await result.user.click( - result.view.getByRole("button", { name: "Connect a custom agent" }), + result.view.getByRole("button", { name: /^Connect a custom agent/ }), ); await result.user.click( result.view.getByRole("button", { name: "Continue" }), @@ -159,7 +159,9 @@ test("opens on New Bot details and creates locally without another screen", asyn const { view, user } = await opened(); expect(view.getByRole("heading", { name: "New Bot" })).toBeTruthy(); expect( - view.getByRole("button", { name: "Run here" }).getAttribute("aria-pressed"), + view + .getByRole("button", { name: /^Run here/ }) + .getAttribute("aria-pressed"), ).toBe("true"); expect( view.getByRole("button", { name: "Create Bot" }).hasAttribute("disabled"), @@ -175,14 +177,14 @@ test("unavailable hosting still permits the custom path", async () => { const { view, user } = await opened(); await settle(); expect( - view.getByRole("button", { name: "Run here" }).hasAttribute("disabled"), + view.getByRole("button", { name: /^Run here/ }).hasAttribute("disabled"), ).toBe(true); await user.type(view.getByLabelText("Name"), "External"); expect( view.getByRole("button", { name: "Create Bot" }).hasAttribute("disabled"), ).toBe(true); await user.click( - view.getByRole("button", { name: "Connect a custom agent" }), + view.getByRole("button", { name: /^Connect a custom agent/ }), ); await user.click(view.getByRole("button", { name: "Continue" })); expect(view.getByRole("button", { name: /LangGraph/ })).toBeTruthy(); @@ -263,12 +265,12 @@ test("preserves every entry across back navigation and hosting switches", async ); await user.type(view.getByLabelText(/^Role/), "Review carefully"); await user.click(view.getByRole("button", { name: /Public/ })); - await user.click(view.getByRole("button", { name: "Run here" })); + await user.click(view.getByRole("button", { name: /^Run here/ })); expect( (view.getByLabelText(/^Instructions/) as HTMLTextAreaElement).value, ).toBe("Review carefully"); await user.click( - view.getByRole("button", { name: "Connect a custom agent" }), + view.getByRole("button", { name: /^Connect a custom agent/ }), ); await user.click(view.getByRole("button", { name: "Continue" })); await user.click(view.getByRole("button", { name: "Continue" })); @@ -280,7 +282,7 @@ test("preserves every entry across back navigation and hosting switches", async ); await user.click(view.getByRole("button", { name: "Back" })); await user.click(view.getByRole("button", { name: "Back" })); - await user.click(view.getByRole("button", { name: "Run here" })); + await user.click(view.getByRole("button", { name: /^Run here/ })); await user.click(view.getByRole("button", { name: "Create Bot" })); await view.findByText("Expense Review is ready"); expect(creates[0]).toMatchObject({ From 2043af5fe8f0d681a96ee9f68aec683b611c2b81 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:34:19 -0700 Subject: [PATCH 08/29] fix(ux): use default orb for new bot picker action --- app/src/routes/_authed/_app/channel/new.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx index d2a5c1b080..ae9e60d70d 100644 --- a/app/src/routes/_authed/_app/channel/new.tsx +++ b/app/src/routes/_authed/_app/channel/new.tsx @@ -1,8 +1,8 @@ import type { Message } from "@ag-ui/core"; -import { IconPlus } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { useState } from "react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; import { ChannelAvatar } from "@/components/channels/avatar"; import { canSend, @@ -197,7 +197,7 @@ function RouteComponent() { {(item: BotOption) => "createNew" in item ? ( - + New Bot ) : ( From fab2ccb9c4f91652e4f15633865ec78ff0c6a2f3 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:34:59 -0700 Subject: [PATCH 09/29] fix(ux): advance when choosing a custom agent framework --- app/src/components/agents/new-bot-sheet.tsx | 38 +++++++++++++-------- app/tests/new-bot-sheet.test.tsx | 4 +-- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/app/src/components/agents/new-bot-sheet.tsx b/app/src/components/agents/new-bot-sheet.tsx index 6ff1629757..5e190c2727 100644 --- a/app/src/components/agents/new-bot-sheet.tsx +++ b/app/src/components/agents/new-bot-sheet.tsx @@ -416,7 +416,13 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { ) : step === "runtime" ? ( - + { + setRuntime(chosen); + go("endpoint"); + }} + runtime={runtime} + /> ) : ( void }) { Back )} - + {step !== "runtime" && ( + + )} ); diff --git a/app/tests/new-bot-sheet.test.tsx b/app/tests/new-bot-sheet.test.tsx index a6bbcd3919..92ee41e912 100644 --- a/app/tests/new-bot-sheet.test.tsx +++ b/app/tests/new-bot-sheet.test.tsx @@ -141,8 +141,8 @@ async function customFlow() { } async function connected(address = "https://agent.example.com/ag-ui") { const { view, user } = await customFlow(); + expect(view.queryByRole("button", { name: "Continue" })).toBeNull(); await user.click(view.getByRole("button", { name: /LangGraph/ })); - await user.click(view.getByRole("button", { name: "Continue" })); if (address) await user.type(await view.findByLabelText("Agent address"), address); return { view, user }; @@ -273,7 +273,7 @@ test("preserves every entry across back navigation and hosting switches", async view.getByRole("button", { name: /^Connect a custom agent/ }), ); await user.click(view.getByRole("button", { name: "Continue" })); - await user.click(view.getByRole("button", { name: "Continue" })); + await user.click(view.getByRole("button", { name: /LangGraph/ })); expect((view.getByLabelText("Agent address") as HTMLInputElement).value).toBe( "https://agent.example.com/ag-ui", ); From 046a7fa03081893339943ea77feca7f564e69302 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:35:17 -0700 Subject: [PATCH 10/29] fix(ux): describe new bot action in recipient picker --- app/src/routes/_authed/_app/channel/new.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx index ae9e60d70d..aef671a9de 100644 --- a/app/src/routes/_authed/_app/channel/new.tsx +++ b/app/src/routes/_authed/_app/channel/new.tsx @@ -199,6 +199,9 @@ function RouteComponent() { New Bot + + Create a new Bot. + ) : ( From 60b72b86870eb2c12618855b8586ca2cc7ba9dd3 Mon Sep 17 00:00:00 2001 From: Sam Julien Date: Tue, 27 Oct 2026 15:36:48 -0700 Subject: [PATCH 11/29] fix(ux): use consistent instructions label for all bots --- app/src/components/agents/new-bot-sheet.tsx | 17 +---------------- app/tests/new-bot-sheet.test.tsx | 2 +- 2 files changed, 2 insertions(+), 17 deletions(-) diff --git a/app/src/components/agents/new-bot-sheet.tsx b/app/src/components/agents/new-bot-sheet.tsx index 5e190c2727..f0e97de697 100644 --- a/app/src/components/agents/new-bot-sheet.tsx +++ b/app/src/components/agents/new-bot-sheet.tsx @@ -358,13 +358,6 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { return next; }) } - runtime={ - custom - ? runtime?.kind === "managed" - ? runtime - : { kind: "managed", harness: "byo-url" } - : { kind: "builtin" } - } onInstructions={setInstructions} onName={setName} onVisibility={setVisibility} @@ -597,7 +590,6 @@ function AboutStep({ onApp, name, instructions, - runtime, visibility, onName, onInstructions, @@ -605,7 +597,6 @@ function AboutStep({ }: { name: string; instructions: string; - runtime: Runtime; visibility: Visibility; onName: (value: string) => void; onInstructions: (value: string) => void; @@ -614,7 +605,6 @@ function AboutStep({ leftOut: Set; onApp: (app: string, used: boolean) => void; }) { - const runsHere = runtime.kind === "builtin"; return ( @@ -631,7 +621,7 @@ function AboutStep({ - {runsHere ? "Instructions" : "Role"} + Instructions Optional