From bceaa4e409b1c55926d29b7970bc69a9e6e860fe Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 09:57:17 -0300 Subject: [PATCH 1/9] Fold a run of any undrawn tool calls into one line, not just connector calls An external agent's own tools, such as Antigravity's run_command, drew one line per call because only browser steps and mcp__ calls were foldable. Any tool the app has no component for now folds the same way, counted as "Used N tools" and named in words inside. Which tools the app draws is read from CopilotKit's renderer registry, so charts, approvals and the computer tools keep their own lines. --- .../components/channels/chat-transcript.tsx | 4 +- app/src/components/channels/tool-activity.tsx | 78 +++++++++++++---- app/src/lib/plugins/tool-name.ts | 2 +- app/tests/approvals-rules.test.tsx | 2 + app/tests/browser-activity.test.ts | 14 ++- app/tests/tool-activity.test.tsx | 87 +++++++++++++++++-- 6 files changed, 159 insertions(+), 28 deletions(-) diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx index e4b3e0f845..94a8972d20 100644 --- a/app/src/components/channels/chat-transcript.tsx +++ b/app/src/components/channels/chat-transcript.tsx @@ -65,6 +65,7 @@ import { VOICE_CHAT_ACTIVITY, type VoiceChatEntry } from "@/lib/voice/archive"; import { groupToolSteps, ToolActivity, + useDrawnTool, type TranscriptItem, } from "./tool-activity"; import { @@ -1520,6 +1521,7 @@ export function ChatTranscript({ restoring = false, stopped, }: ChatTranscriptProps) { + const isDrawn = useDrawnTool(); /* * NOT MEMOISED, AND THAT IS DELIBERATE. `useMemo` keyed on `messages` looks obviously right and * silently broke the transcript: the agent hands back the SAME array and mutates it, so the @@ -1530,7 +1532,7 @@ export function ChatTranscript({ * cost was markdown parsing and chart SVGs, and those are skipped by the memoised children below, * which is where the 25x came from. This runs per render and is not worth guarding. */ - const items = groupToolSteps(toVisibleChatItems(messages)); + const items = groupToolSteps(toVisibleChatItems(messages), isDrawn); /* * ONLY WHILE THERE IS NOTHING ELSE TO LOOK AT. Once a reply starts streaming, or a tool line diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx index 7e772e376e..22431443b4 100644 --- a/app/src/components/channels/tool-activity.tsx +++ b/app/src/components/channels/tool-activity.tsx @@ -1,13 +1,16 @@ +import { CopilotKitContext } from "@copilotkit/react-core/v2/context"; import { IconFileText, IconPlug, IconSearch, + IconTool, IconWorld, } from "@tabler/icons-react"; +import { useCallback, useContext, useMemo, useSyncExternalStore } from "react"; import { Streamdown } from "streamdown"; import { PendingApprovals } from "@/components/approvals/inline-approval"; import { markdownComponents } from "@/lib/markdown"; -import { readToolName } from "@/lib/plugins/tool-name"; +import { humanise, readToolName } from "@/lib/plugins/tool-name"; import { asText, forDisplay, @@ -35,16 +38,20 @@ export type TranscriptItem = VisibleChatItem | ToolGroup; * Fold adjacent tool calls into one row, so a reply that searched, read two pages and checked Drive * reads as one thing the Bot did rather than four lines before the answer. * - * Only calls drawn as a plain line are folded: browser steps and connector (MCP) tools. A tool the - * app draws itself, a chart or a request for help, stays where it is, and so does a refusal that - * asks the person to connect their own account, because a question for the person must never sit - * behind a disclosure. A connector call on its own keeps its own line; one browser step is still a - * group, as it always was, so the row does not change shape when the second step arrives. + * Only calls drawn as a plain line are folded: browser steps, connector (MCP) tools, and any other + * tool the app has no component for, such as an external agent's `run_command`. A tool the app draws + * itself, a chart or a request for help, stays where it is, and so does a refusal that asks the + * person to connect their own account, because a question for the person must never sit behind a + * disclosure. A single call on its own keeps its own line; one browser step is still a group, as it + * always was, so the row does not change shape when the second step arrives. + * + * `isDrawn` says which tools the app draws itself; the transcript reads it from `useDrawnTool`. * * The group takes its first step's id, so the row keeps its identity while later steps stream in. */ export function groupToolSteps( items: readonly VisibleChatItem[], + isDrawn: (name: string) => boolean, ): TranscriptItem[] { const grouped: TranscriptItem[] = []; let run: ToolItem[] = []; @@ -56,7 +63,7 @@ export function groupToolSteps( run = []; }; for (const item of items) { - if (item.kind === "tool" && foldable(item)) { + if (item.kind === "tool" && foldable(item, isDrawn)) { run.push(item); continue; } @@ -67,12 +74,44 @@ export function groupToolSteps( return grouped; } -function foldable(item: ToolItem): boolean { +function foldable(item: ToolItem, isDrawn: (name: string) => boolean): boolean { if (isBrowserStep(item)) return true; - if (!item.toolCall.function.name.startsWith("mcp__")) return false; + const name = item.toolCall.function.name; + if (!name.startsWith("mcp__") && isDrawn(name)) return false; return !asksThePerson(item.result); } +const NOTHING_DRAWN: readonly { name: string }[] = []; + +/** + * Whether the app draws a tool itself, read from CopilotKit's own renderer registry. + * + * Read from the registry rather than kept as a list here, because every `useRenderTool` and + * `useFrontendTool` already registers its name there, and a list would fall behind the first tool + * added without it. Taken from the context directly, not through `useCopilotKit`, because that + * throws outside a provider and the transcript is drawn without one in places. With no provider + * nothing is drawn. A `*` renderer draws every tool it is asked about, so nothing outside MCP folds. + */ +export function useDrawnTool(): (name: string) => boolean { + const copilotkit = useContext(CopilotKitContext)?.copilotkit; + const subscribe = useCallback( + (onChange: () => void) => + copilotkit + ? copilotkit.subscribe({ onRenderToolCallsChanged: onChange }) + .unsubscribe + : () => {}, + [copilotkit], + ); + const renderers = useSyncExternalStore( + subscribe, + () => copilotkit?.renderToolCalls ?? NOTHING_DRAWN, + ); + return useMemo(() => { + const names = new Set(renderers.map((renderer) => renderer.name)); + return names.has("*") ? () => true : (name) => names.has(name); + }, [renderers]); +} + /** * Whether this refusal is a question for the person rather than a line about what a Bot did. * @@ -88,7 +127,7 @@ function asksThePerson(result: string | undefined): boolean { return readTeamBotConsent(body) !== null || readConnectPrompt(body) !== null; } -type StepKind = "search" | "fetch" | "browser" | "connector"; +type StepKind = "search" | "fetch" | "browser" | "connector" | "tool"; export type StepDetails = { id: string; @@ -186,22 +225,26 @@ export function stepDetails(step: ToolItem): StepDetails { } const name = step.toolCall.function.name; - const [, server = "", ...rest] = name.split("__"); - const role = researchRoleOfTool(server, rest.join("__")); - const { label, detail } = readToolName(name); const answer = step.result === undefined ? undefined : asText(step.result); const refused = answer?.startsWith(REFUSAL_MARKER) ?? false; const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer; - const urls = role === "fetch" ? urlsOf(args) : []; - const base = { + const outcome = { id: step.id, - label, pending: step.result === undefined, failed: !refused && toolResultFailed(step.result), refused, inputs, ...(body ? { output: body } : {}), }; + // Not a connector: a tool an external agent runs itself. Named in words, never as an identifier. + if (!name.startsWith("mcp__")) + return { ...outcome, kind: "tool", label: humanise(name), pages: 0 }; + + const [, server = "", ...rest] = name.split("__"); + const role = researchRoleOfTool(server, rest.join("__")); + const { label, detail } = readToolName(name); + const urls = role === "fetch" ? urlsOf(args) : []; + const base = { ...outcome, label }; if (role === "search") { const query = firstString( args.query, @@ -276,6 +319,8 @@ export function summarise(steps: readonly StepDetails[]): string { phrases.push( `used ${plural(connectors.length, "connector", "connectors")}`, ); + const tools = of("tool").length; + if (tools) phrases.push(`used ${plural(tools, "tool", "tools")}`); const sentence = joinPhrases(phrases); return sentence.charAt(0).toUpperCase() + sentence.slice(1); } @@ -285,6 +330,7 @@ const KIND_ICONS = { fetch: IconFileText, browser: IconWorld, connector: IconPlug, + tool: IconTool, } as const; function StepIcon({ diff --git a/app/src/lib/plugins/tool-name.ts b/app/src/lib/plugins/tool-name.ts index c39d7b10b9..b7306abbbb 100644 --- a/app/src/lib/plugins/tool-name.ts +++ b/app/src/lib/plugins/tool-name.ts @@ -114,7 +114,7 @@ function singular(word: string): string { * is that the first word is a verb. Splitting on both and sentence-casing the result gets a phrase * that reads as an action without anybody maintaining a table of names. */ -function humanise(tool: string): string { +export function humanise(tool: string): string { const words = wordsOf(tool).join(" "); if (words.length === 0) return tool; return words.charAt(0).toUpperCase() + words.slice(1); diff --git a/app/tests/approvals-rules.test.tsx b/app/tests/approvals-rules.test.tsx index 3b55210089..a4f3833c9c 100644 --- a/app/tests/approvals-rules.test.tsx +++ b/app/tests/approvals-rules.test.tsx @@ -415,6 +415,8 @@ test("in the real transcript shape a waiting click is folded into browser activi content: '{"ok":true,"url":"https://httpbin.org/forms/post"}', }, ] as never), + // Browser steps fold whatever the app draws, so nothing else needs naming here. + () => false, ); const group = items.find((item) => item.kind === "tools"); if (group?.kind !== "tools") throw new Error("no tool group"); diff --git a/app/tests/browser-activity.test.ts b/app/tests/browser-activity.test.ts index 8b6ea5852e..b9a093e0e7 100644 --- a/app/tests/browser-activity.test.ts +++ b/app/tests/browser-activity.test.ts @@ -20,14 +20,20 @@ function step( }; } +// The computer tools the app draws itself, as CopilotKit's renderer registry would list them. +const drawn = (name: string) => + name.startsWith("computer_request_") || name === "computer_run_command"; + test("navigation and intervening browser actions become one stable row as results stream", () => { const first = step("one"); const items = [first, step("read", "computer_read"), step("two")]; - const grouped = groupToolSteps(items); + const grouped = groupToolSteps(items, drawn); expect(grouped).toEqual([{ kind: "tools", id: "one", steps: items }]); expect( - groupToolSteps([{ ...first, result: '{"ok":true}' }, ...items.slice(1)])[0] - ?.id, + groupToolSteps( + [{ ...first, result: '{"ok":true}' }, ...items.slice(1)], + drawn, + )[0]?.id, ).toBe("one"); expect(items).toHaveLength(3); }); @@ -46,7 +52,7 @@ test("grouping keeps messages, other tools, and human assistance requests in the step("shell", "computer_run_command"), ]; for (const boundary of boundaries) { - const grouped = groupToolSteps([step("one"), boundary, step("two")]); + const grouped = groupToolSteps([step("one"), boundary, step("two")], drawn); expect(grouped.map((item) => item.id)).toEqual(["one", boundary.id, "two"]); expect(grouped[1]).toBe(boundary); } diff --git a/app/tests/tool-activity.test.tsx b/app/tests/tool-activity.test.tsx index 041ac9f504..f395a81126 100644 --- a/app/tests/tool-activity.test.tsx +++ b/app/tests/tool-activity.test.tsx @@ -1,7 +1,9 @@ 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 } from "@testing-library/react"; +import { cleanup, fireEvent, render, renderHook } from "@testing-library/react"; +import { CopilotKitContext } from "@copilotkit/react-core/v2/context"; +import type { ReactNode } from "react"; import type { VisibleChatItem } from "../src/components/channels/chat-messages"; import { groupToolSteps, @@ -9,6 +11,7 @@ import { summarise, ToolActivity, type ToolGroup, + useDrawnTool, } from "../src/components/channels/tool-activity"; beforeAll(() => GlobalRegistrator.register()); @@ -39,6 +42,9 @@ const search = (id = "search", result = '"Results"') => call(id, "mcp__exa__web_search_exa", { query: "top 5 GDP 2025" }, result); const fetch = (urls: string[], id = "fetch") => call(id, "mcp__exa__web_fetch_exa", { urls }, '"Page text"'); +// The names this app draws itself, as CopilotKit's renderer registry would list them. +const drawn = (name: string) => + name === "showBarChart" || name === "computer_request_help"; const text = (id: string): VisibleChatItem => ({ kind: "text", id, @@ -53,21 +59,90 @@ test("adjacent connector calls fold into one row named after the first", () => { call("drive", "mcp__google-drive__search_files", { q: "budget" }, '"x"'), text("answer"), ]; - const grouped = groupToolSteps(items); + const grouped = groupToolSteps(items, drawn); expect(grouped.map((item) => item.kind)).toEqual(["tools", "text"]); expect(grouped[0]).toMatchObject({ id: "search", steps: items.slice(0, 3) }); }); test("one connector call keeps its own line, and anything the app draws stays where it is", () => { - expect(groupToolSteps([search()])).toEqual([search()]); + expect(groupToolSteps([search()], drawn)).toEqual([search()]); const chart = call("chart", "showBarChart"); const help = call("help", "computer_request_help"); for (const boundary of [chart, help, text("between")]) { - const grouped = groupToolSteps([search("a"), boundary, search("b")]); + const grouped = groupToolSteps([search("a"), boundary, search("b")], drawn); expect(grouped.map((item) => item.id)).toEqual(["a", boundary.id, "b"]); } }); +test("a run of tools the app does not draw folds like connector calls do", () => { + const commands = ["1", "2", "3", "4", "5"].map((n) => + call(`cmd${n}`, "run_command", { command: "echo 5" }, '"5"'), + ); + const grouped = groupToolSteps([...commands, text("answer")], drawn); + expect(grouped.map((item) => item.kind)).toEqual(["tools", "text"]); + expect(grouped[0]).toMatchObject({ id: "cmd1", steps: commands }); + // On its own it keeps its own line, as a lone connector call does. + expect(groupToolSteps([commands[0] as ToolItem], drawn)).toEqual([ + commands[0] as ToolItem, + ]); + // And it joins a run of connector calls rather than splitting it. + const mixed = groupToolSteps([search("a"), commands[0] as ToolItem], drawn); + expect(mixed.map((item) => item.kind)).toEqual(["tools"]); +}); + +test("a tool the app draws ends a run, whatever it is called", () => { + const grouped = groupToolSteps( + [ + call("a", "run_command", {}, '"x"'), + call("b", "run_command", {}, '"x"'), + call("chart", "showBarChart"), + call("c", "run_command", {}, '"x"'), + ], + drawn, + ); + expect(grouped.map((item) => item.id)).toEqual(["a", "chart", "c"]); +}); + +test("a tool step is named in words and counted as tools", () => { + const steps = ["1", "2", "3", "4", "5"] + .map((n) => call(n, "run_command", { command: "echo 5" }, '"5"')) + .map(stepDetails); + expect(steps[0]).toMatchObject({ kind: "tool", label: "Run command" }); + expect(summarise(steps)).toBe("Used 5 tools"); + expect( + summarise( + [ + search(), + call("t", "runCommand", {}, '"x"'), + call("u", "read_file", {}, '"x"'), + ].map(stepDetails), + ), + ).toBe("Searched the web and used 2 tools"); +}); + +test("the tools CopilotKit has a renderer for are the ones the app draws", () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + ({ unsubscribe() {} }), + }, + } as never + } + > + {children} + + ); + const { result } = renderHook(() => useDrawnTool(), { wrapper }); + expect(result.current("showBarChart")).toBe(true); + expect(result.current("run_command")).toBe(false); + // Outside a provider nothing is drawn, so nothing is held back from a group. + const bare = renderHook(() => useDrawnTool()); + expect(bare.result.current("showBarChart")).toBe(false); +}); + test("a request to connect the person's own account is never folded away", () => { const consent = call( "consent", @@ -77,7 +152,7 @@ test("a request to connect the person's own account is never folded away", () => 'Refused. [team-bot-consent]{"botId":"b","serverId":"gmail"} Use your own Gmail?', ), ); - const grouped = groupToolSteps([search("a"), consent, search("b")]); + const grouped = groupToolSteps([search("a"), consent, search("b")], drawn); expect(grouped.map((item) => item.id)).toContain("consent"); }); @@ -90,7 +165,7 @@ test("a request to connect an app at all is never folded away either", () => { 'Refused. [connect-app]{"serverId":"srv_notion","title":"Notion"} You have not connected your Notion account. Connect it and ask again.', ), ); - const grouped = groupToolSteps([search("a"), prompt, search("b")]); + const grouped = groupToolSteps([search("a"), prompt, search("b")], drawn); expect(grouped.map((item) => item.id)).toContain("connect"); }); From fb5f57b198a80c82482ff0e32d20c64694ca911e Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 10:29:46 -0300 Subject: [PATCH 2/9] Frame onboarding's pictures so nobody mistakes them for controls The Welcome composer and the roster of Bot cards looked live and did nothing when clicked. Both now sit in the same muted frame as the computer illustration, inert, so neither the mouse, Tab nor a screen reader reaches the controls drawn inside; each frame is read as one labelled picture. --- .../onboarding/illustration-frame.tsx | 38 +++++++++++ app/src/routes/_authed/onboarding.tsx | 64 +++++++++++-------- 2 files changed, 75 insertions(+), 27 deletions(-) create mode 100644 app/src/components/onboarding/illustration-frame.tsx diff --git a/app/src/components/onboarding/illustration-frame.tsx b/app/src/components/onboarding/illustration-frame.tsx new file mode 100644 index 0000000000..42410fe6e4 --- /dev/null +++ b/app/src/components/onboarding/illustration-frame.tsx @@ -0,0 +1,38 @@ +import type * as React from "react"; + +/** + * The muted frame every picture in onboarding sits in, the same one the computer illustration has. + * + * Without it a picture of a composer or of Bot cards reads as the real thing, and people click it + * and nothing happens. The frame says "this is an illustration" before anybody tries. + * + * NOTHING INSIDE IS REACHABLE, BY ANY ROUTE. `pointer-events-none` stops the mouse but not the + * keyboard: the composer drawn on the Welcome slide still put its editor, Dictate and Send in the Tab + * order, and a screen reader announced an editable message box. `inert` takes the whole subtree out + * of focus and out of the accessibility tree, and the outer `role="img"` with its label is what is + * read in its place: one picture, described once. + * + * The label sits on a wrapper rather than on the inert element because an inert element is removed + * from the accessibility tree along with everything in it, its own name included. + */ +export function IllustrationFrame({ + label, + children, + className = "", +}: { + /** What the picture shows, read in place of the controls drawn inside it. */ + label: string; + children: React.ReactNode; + className?: string; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index e1d58b98b3..2626754ee5 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -8,6 +8,7 @@ import AgentOrb from "@/components/agents/orb/agent-orb"; import { Composer } from "@/components/channels/composer"; import { DesktopIllustration } from "@/components/computer/desktop-illustration"; import { ComputerPlaceholder } from "@/components/computer/placeholder"; +import { IllustrationFrame } from "@/components/onboarding/illustration-frame"; import { Button } from "@/components/ui/button"; import { type AgentProfile, agentListQueryOptions } from "@/lib/agents/queries"; import { currentUserQueryOptions, needsOnboarding } from "@/lib/auth/queries"; @@ -34,31 +35,37 @@ function WelcomeStep() {

Welcome to {appConfig.brand.productName}

-
- - {/* - * A POSTER OF A COMPOSER, AND `pointer-events-none` IS WHAT MAKES IT ONE. Nothing here is - * meant to be typed in, clicked or dropped on: it is a picture of the thing the person is - * about to get, shown while they read a sentence about it. - * - * THE DROP THAT PASSES STRAIGHT THROUGH IT IS SOMEBODY ELSE'S TO CATCH, WHICH IS WORTH - * SAYING OUT LOUD. The composer guards its own form against a dropped file navigating the - * whole app away (`refuseDragOver` in `composer.tsx`), and that guard cannot fire here: an - * element with no pointer events is never the target of the drop, so the event goes past it - * to the document as if this composer were not on the page. What catches it is - * `useUnclaimedDropGuard` in `routes/__root.tsx`, which refuses every drop nobody claimed — - * the reason that guard lives at the root rather than in the composer, and the reason this - * wrapper does not need to change to be safe. Taking `pointer-events-none` off to "fix" the - * drop would turn the poster back into a live composer with nowhere to upload to. - */} -
- -
+
+ + + {/* + * A POSTER OF A COMPOSER, AND THE FRAME AROUND IT IS WHAT MAKES IT ONE: `IllustrationFrame` is + * `pointer-events-none` and `inert`. Nothing here is + * meant to be typed in, clicked or dropped on: it is a picture of the thing the person is + * about to get, shown while they read a sentence about it. + * + * THE DROP THAT PASSES STRAIGHT THROUGH IT IS SOMEBODY ELSE'S TO CATCH, WHICH IS WORTH + * SAYING OUT LOUD. The composer guards its own form against a dropped file navigating the + * whole app away (`refuseDragOver` in `composer.tsx`), and that guard cannot fire here: an + * element with no pointer events is never the target of the drop, so the event goes past it + * to the document as if this composer were not on the page. What catches it is + * `useUnclaimedDropGuard` in `routes/__root.tsx`, which refuses every drop nobody claimed — + * the reason that guard lives at the root rather than in the composer, and the reason this + * wrapper does not need to change to be safe. Taking `pointer-events-none` off to "fix" the + * drop would turn the poster back into a live composer with nowhere to upload to. + */} +
+ +
+
); } @@ -119,7 +126,10 @@ function RosterStep() { Choose from a variety of agents or create your own
-
+ a.name).join(", ")}, and one of your own`} + > {roster.map((a) => { return (
-
+
); } From 5e5dfc294df7aa2e127a67fa5f0e6629d3d92b1b Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 10:36:59 -0300 Subject: [PATCH 3/9] Give local evaluators a last onboarding slide, and tell the browser when it is one /api/capabilities answers localEvaluation, which is single-user mode: no identity provider and no address anybody else can reach, so never an organization's deployment. Onboarding adds an empty slide after the roster when it is true, the slot for explaining where OpenBot can run (OB-02). It is not gated on selfHostBanner too, because the stack `copilotkit local` installs runs on an enterprise evaluation licence that hides the banner. --- app/src/lib/deployment/queries.ts | 7 +++++++ app/src/routes/_authed/onboarding.tsx | 29 +++++++++++++++++++++++++-- server/src/app.ts | 10 +++++++++ server/tests/health.test.ts | 3 +++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/src/lib/deployment/queries.ts b/app/src/lib/deployment/queries.ts index 7e96876875..d50eb949d7 100644 --- a/app/src/lib/deployment/queries.ts +++ b/app/src/lib/deployment/queries.ts @@ -24,6 +24,11 @@ export type DeploymentCapabilities = { * for a deployment on a paid Intelligence plan. */ selfHostBanner: boolean; + /** + * Whether this is somebody trying OpenBot on their own machine rather than an organization's + * deployment: single-user mode on the server, which only a local, unreachable install can be in. + */ + localEvaluation: boolean; transcription?: boolean; voice?: boolean; }; @@ -62,6 +67,7 @@ export function deploymentCapabilitiesQueryOptions() { ).json()) as { generativeUi?: boolean; selfHostBanner?: boolean; + localEvaluation?: boolean; transcription?: boolean; voice?: boolean; }; @@ -69,6 +75,7 @@ export function deploymentCapabilitiesQueryOptions() { return { generativeUi: body.generativeUi === true, selfHostBanner: body.selfHostBanner === true, + localEvaluation: body.localEvaluation === true, transcription: body.transcription === true, voice: body.voice === true, }; diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 2626754ee5..64fa1b247d 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -12,6 +12,7 @@ import { IllustrationFrame } from "@/components/onboarding/illustration-frame"; import { Button } from "@/components/ui/button"; import { type AgentProfile, agentListQueryOptions } from "@/lib/agents/queries"; import { currentUserQueryOptions, needsOnboarding } from "@/lib/auth/queries"; +import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries"; import { appConfig } from "@/lib/generated/application-config"; import { completeOnboardingMutationOptions } from "@/lib/onboarding/mutations"; import { queryClient } from "@/query-client"; @@ -160,6 +161,16 @@ function RosterStep() { ); } +/** + * Where OpenBot can run and what can be made its own, for somebody trying it on their own machine. + * + * Empty while its content is being written (OB-02 in the product backlog). The slot and the rule for + * when it appears are what exist so far. + */ +function LocalPathsStep() { + return
; +} + const STEPS: Array<() => React.ReactNode> = [ () => , () => , @@ -184,7 +195,21 @@ function RouteComponent() { const [leaving, setLeaving] = React.useState(false); const [ref, bounds] = useMeasure(); - const last = step === STEPS.length - 1; + /* + * The last slide is for somebody evaluating OpenBot locally, never for an organization's people: + * the server says single-user mode, which an organization's deployment, always behind sign-in, + * cannot be in. Until the answer arrives the slide is left out, so a slow or failed request never + * shows an organization's employees a page about deploying OpenBot themselves. + * + * NOT GATED ON `selfHostBanner` AS WELL. That flag is off for any deployment with a paid + * Intelligence entitlement, and the stack `copilotkit local` installs runs on an enterprise + * evaluation licence, so every CLI evaluator, the person this slide is for, would never see it. + */ + const { data: capabilities } = useQuery(deploymentCapabilitiesQueryOptions()); + const steps = capabilities?.localEvaluation + ? [...STEPS, () => ] + : STEPS; + const last = step === steps.length - 1; const go = (to: number) => { setDirection(to > step ? 1 : -1); @@ -228,7 +253,7 @@ function RouteComponent() { key={step} variants={variants} > - {STEPS[step]()} + {steps[step]?.()} diff --git a/server/src/app.ts b/server/src/app.ts index b30d0dc2b9..ea2ac33575 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -483,6 +483,16 @@ export function createApp( selfHostBanner: selfHostBanner ? await selfHostBanner.shown() : config.selfHostBanner, + /* + * Whether this is somebody trying OpenBot on their own machine, not an organization's + * deployment. Onboarding reads it to explain where OpenBot can run, which only the person + * evaluating it needs and an organization's employees should never be pitched. + * + * Single-user mode, not a new switch: it is already true only with no identity provider and + * no address anybody else can reach (see `singleUserAllowed` in config.ts), which is exactly + * a local trial, and an operator cannot forget to set it. + */ + localEvaluation: config.singleUser, transcription: Boolean(config.transcription), voice: Boolean(config.voice), /* diff --git a/server/tests/health.test.ts b/server/tests/health.test.ts index 851d40a5c7..dadb327397 100644 --- a/server/tests/health.test.ts +++ b/server/tests/health.test.ts @@ -47,6 +47,8 @@ describe("runtime capabilities", () => { generativeUi: true, // Default-on: a fresh clone is somebody evaluating the template. selfHostBanner: true, + // Off: this deployment has an identity provider, so it is not somebody's local trial. + localEvaluation: false, transcription: false, voice: false, // Names only. The sign-in screen reads this to know which buttons to draw. @@ -73,6 +75,7 @@ describe("runtime capabilities", () => { "durableHistory", "generativeUi", "selfHostBanner", + "localEvaluation", "transcription", "voice", "authProviders", From b26f07b8892de2a4fdc4cfccca15bc5138e2961c Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 15:23:31 -0300 Subject: [PATCH 4/9] Fill the local evaluator's onboarding: their data stays here, their agents come along, their organization can follow Three slides where there was one empty one, shown only on a single-user install. A laptop that opens onto the product for where data lives, the frameworks by mark for bringing the agents somebody already runs, and a sign-in-to-outcomes flow with a link to an engineer for deploying it for an organization. --- .../onboarding/laptop-illustration.tsx | 46 +++++ .../onboarding/organization-flow.tsx | 157 ++++++++++++++++++ app/src/routes/_authed/onboarding.tsx | 123 ++++++++++++-- 3 files changed, 314 insertions(+), 12 deletions(-) create mode 100644 app/src/components/onboarding/laptop-illustration.tsx create mode 100644 app/src/components/onboarding/organization-flow.tsx diff --git a/app/src/components/onboarding/laptop-illustration.tsx b/app/src/components/onboarding/laptop-illustration.tsx new file mode 100644 index 0000000000..cd648a6e94 --- /dev/null +++ b/app/src/components/onboarding/laptop-illustration.tsx @@ -0,0 +1,46 @@ +import { motion, useReducedMotion } from "motion/react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import { appConfig } from "@/lib/generated/application-config"; + +/** + * A laptop whose lid swings open, then whose screen lights up with the product: the orb the Welcome + * slide opens on, and the product's name from the brand config, so a white-label build shows its own. + * + * Drawn here rather than loaded: a local install should not fetch artwork from somebody else's + * server to say its data stays home. There is no logo file to use instead, so the orb stands in. + * + * The lid starts tilted back on mount and the screen fades in after it, so it plays each time the + * slide arrives. Somebody who asks for reduced motion gets the laptop already open. + */ +export function LaptopIllustration() { + const reduceMotion = useReducedMotion(); + return ( +
+ +
+
+ + + + {appConfig.brand.productName} + + +
+ +
+
+
+
+ ); +} diff --git a/app/src/components/onboarding/organization-flow.tsx b/app/src/components/onboarding/organization-flow.tsx new file mode 100644 index 0000000000..37014dbd1e --- /dev/null +++ b/app/src/components/onboarding/organization-flow.tsx @@ -0,0 +1,157 @@ +import { + IconBrandGoogle, + IconBrandWindows, + IconKey, + IconListCheck, + IconRobot, + IconShieldCheck, +} from "@tabler/icons-react"; +import { useReducedMotion } from "motion/react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; + +/* + * Drawn on one fixed grid so the connectors and the pills always meet: the SVG and the pills share + * the same coordinate box, and each pill is centred on the point its line ends at. + */ +const WIDTH = 360; +const HEIGHT = 210; +const HUB = { x: WIDTH / 2, y: HEIGHT / 2 }; +const COLUMNS = [60, WIDTH / 2, WIDTH - 60]; +const TOP_Y = 18; +const BOTTOM_Y = HEIGHT - 18; + +type Node = { + label: string; + icon: typeof IconKey; + /** Tailwind classes for the small square behind the icon. */ + tone: string; +}; + +/** Who signs in: the identity providers the slide's sentence names. Okta has no glyph, so a key. */ +const SIGN_IN: Node[] = [ + { + label: "Google", + icon: IconBrandGoogle, + tone: "bg-sky-500/15 text-sky-600 dark:text-sky-400", + }, + { + label: "Microsoft", + icon: IconBrandWindows, + tone: "bg-blue-500/15 text-blue-600 dark:text-blue-400", + }, + { + label: "Okta", + icon: IconKey, + tone: "bg-indigo-500/15 text-indigo-600 dark:text-indigo-400", + }, +]; + +/** What an organization gets once its people are in. */ +const OUTCOMES: Node[] = [ + { + label: "Your rules", + icon: IconShieldCheck, + tone: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400", + }, + { + label: "Team Bots", + icon: IconRobot, + tone: "bg-amber-500/15 text-amber-600 dark:text-amber-400", + }, + { + label: "Audit trail", + icon: IconListCheck, + tone: "bg-violet-500/15 text-violet-600 dark:text-violet-400", + }, +]; + +/** A gentle S-curve from a pill into the hub's top edge, or out of its bottom edge. */ +function inbound(x: number) { + return `M ${x} ${TOP_Y + 12} C ${x} ${TOP_Y + 52}, ${HUB.x} ${HUB.y - 62}, ${HUB.x} ${HUB.y - 22}`; +} +function outbound(x: number) { + return `M ${HUB.x} ${HUB.y + 22} C ${HUB.x} ${HUB.y + 62}, ${x} ${BOTTOM_Y - 52}, ${x} ${BOTTOM_Y - 12}`; +} + +function Pill({ node, x, y }: { node: Node; x: number; y: number }) { + const Icon = node.icon; + return ( +
+ + + + {node.label} +
+ ); +} + +/** + * How OpenBot sits inside an organization: its people sign in through the organization's own + * identity provider, and what comes out is its own rules, its own Bots and a record of every action. + * + * Built here from Tailwind and SVG, in the visual language of node-and-connector diagrams, rather + * than copied from a commercial illustration set. A dot runs each connector into and out of the hub; + * somebody who asks for reduced motion gets the diagram still. + */ +export function OrganizationFlow() { + const reduceMotion = useReducedMotion(); + const paths = [...COLUMNS.map(inbound), ...COLUMNS.map(outbound)]; + return ( +
+ + {paths.map((d) => ( + + ))} + {reduceMotion + ? null + : paths.map((d, index) => ( + + {/* Inbound dots first, outbound after, so the flow reads top to bottom. */} + + + ))} + + + {SIGN_IN.map((node, index) => ( + + ))} + +
+ +
+ + {OUTCOMES.map((node, index) => ( + + ))} +
+ ); +} diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 64fa1b247d..48645e540d 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -1,20 +1,26 @@ +import { WEB_HARNESSES } from "@openbot/shared/harnesses"; import { useMutation, useQuery } from "@tanstack/react-query"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import Avatar from "boring-avatars"; import { AnimatePresence, MotionConfig, motion } from "motion/react"; import * as React from "react"; import useMeasure from "react-use-measure"; +import { HarnessMark } from "@/components/agents/harness-mark"; import AgentOrb from "@/components/agents/orb/agent-orb"; import { Composer } from "@/components/channels/composer"; import { DesktopIllustration } from "@/components/computer/desktop-illustration"; import { ComputerPlaceholder } from "@/components/computer/placeholder"; +import { SELF_HOST_URL } from "@/components/layout/self-host-banner"; import { IllustrationFrame } from "@/components/onboarding/illustration-frame"; -import { Button } from "@/components/ui/button"; +import { LaptopIllustration } from "@/components/onboarding/laptop-illustration"; +import { OrganizationFlow } from "@/components/onboarding/organization-flow"; +import { Button, buttonVariants } from "@/components/ui/button"; import { type AgentProfile, agentListQueryOptions } from "@/lib/agents/queries"; import { currentUserQueryOptions, needsOnboarding } from "@/lib/auth/queries"; import { deploymentCapabilitiesQueryOptions } from "@/lib/deployment/queries"; import { appConfig } from "@/lib/generated/application-config"; import { completeOnboardingMutationOptions } from "@/lib/onboarding/mutations"; +import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; export const Route = createFileRoute("/_authed/onboarding")({ @@ -161,14 +167,102 @@ function RosterStep() { ); } -/** - * Where OpenBot can run and what can be made its own, for somebody trying it on their own machine. - * - * Empty while its content is being written (OB-02 in the product backlog). The slot and the rule for - * when it appears are what exist so far. +/* + * THE THREE SLIDES BELOW ARE FOR SOMEBODY TRYING OPENBOT ON THEIR OWN MACHINE, and only they see them + * (see `localEvaluation` in RouteComponent). They answer what that person is deciding: where their + * data is, whether their own agents fit, and what taking it to their organization involves (OB-02 in + * the product backlog). None of it is said to an organization's own people, who are past all three. */ -function LocalPathsStep() { - return
; + +function LocalStep() { + return ( +
+

+ It runs on your machine +

+ {/* + * "Stored", and the model named, because "your data never leaves" would be untrue: every turn + * goes to the model provider this install was set up with. What stays is everything kept. No + * word about sign-in either: activating a local install signs you in to CopilotKit. + */} +

+ Your conversations, files and Bots are stored on this computer. Only + what you send to your model leaves it. +

+
+ + + +
+ ); +} + +/** Every framework the New Bot wizard offers by name; "an agent you already run" is the sentence. */ +const FRAMEWORKS = WEB_HARNESSES.filter((harness) => harness.id !== "byo-url"); + +function AgentsStep() { + return ( +
+

+ Bring the agents you already have +

+

+ Any agent that speaks AG-UI joins as a Bot. Add one any time from Bots, + then New Bot. +

+
+ harness.name).join(", ")}`} + > + {FRAMEWORKS.map((harness) => ( + + + {harness.name} + + ))} + +
+ ); +} + +function OrganizationStep() { + return ( +
+

+ Deploy it for your organization +

+

+ One container image, or a Helm chart for EKS, GKE and AKS, with sign-in + through Google, Microsoft or Okta. +

+
+ + + + {/* + * The one real control on these slides, so it sits outside the frame: a frame means picture. + * A new tab, because following it must not cost this person their place in onboarding. + */} + + Talk to an engineer + +
+ ); } const STEPS: Array<() => React.ReactNode> = [ @@ -196,18 +290,23 @@ function RouteComponent() { const [ref, bounds] = useMeasure(); /* - * The last slide is for somebody evaluating OpenBot locally, never for an organization's people: + * The last three slides are for somebody evaluating OpenBot locally, never for an organization's people: * the server says single-user mode, which an organization's deployment, always behind sign-in, - * cannot be in. Until the answer arrives the slide is left out, so a slow or failed request never + * cannot be in. Until the answer arrives they are left out, so a slow or failed request never * shows an organization's employees a page about deploying OpenBot themselves. * * NOT GATED ON `selfHostBanner` AS WELL. That flag is off for any deployment with a paid * Intelligence entitlement, and the stack `copilotkit local` installs runs on an enterprise - * evaluation licence, so every CLI evaluator, the person this slide is for, would never see it. + * evaluation licence, so every CLI evaluator, the person these slides are for, would never see them. */ const { data: capabilities } = useQuery(deploymentCapabilitiesQueryOptions()); const steps = capabilities?.localEvaluation - ? [...STEPS, () => ] + ? [ + ...STEPS, + () => , + () => , + () => , + ] : STEPS; const last = step === steps.length - 1; From 96211c536803c427af465063502c0210741fee25 Mon Sep 17 00:00:00 2001 From: Guido Vizoso Date: Tue, 20 Oct 2026 15:23:31 -0300 Subject: [PATCH 5/9] Give AG2, Agno and Langroid their marks, and keep every mark legible in dark mode AG2's dark-on-transparent favicon, Agno's square icon and Langroid's logo, each from the brand's own repository, so only the bring-your-own row is left without one. A mark drawn in the brand's dark colour vanished on a dark background, so in dark mode every mark sits on a small white tile; a row with no mark keeps the tile's width so names still line up. --- app/src/components/agents/harness-mark.tsx | 28 +++++++++++++++------- app/tests/harness-mark.test.tsx | 23 ++++++++++-------- app/tests/preload.ts | 2 +- desktop/src/marks.ts | 4 ++-- shared/harnesses.json | 8 +++---- shared/harnesses.test.ts | 2 +- shared/marks/README.md | 23 ++++++++++++++---- shared/marks/ag2.svg | 7 ++++++ shared/marks/agno.svg | 1 + shared/marks/langroid.svg | 1 + 10 files changed, 67 insertions(+), 32 deletions(-) create mode 100644 shared/marks/ag2.svg create mode 100644 shared/marks/agno.svg create mode 100644 shared/marks/langroid.svg diff --git a/app/src/components/agents/harness-mark.tsx b/app/src/components/agents/harness-mark.tsx index c6c32a39a1..4684d32ff5 100644 --- a/app/src/components/agents/harness-mark.tsx +++ b/app/src/components/agents/harness-mark.tsx @@ -3,28 +3,38 @@ import { markFor } from "@/lib/marks"; /** * A framework's own mark, beside its name. * - * Draws nothing when the brand has no mark. Three of the frameworks have none in any maintained - * set, and `shared/marks/README.md` is explicit about why nothing is drawn to fill the gap: a - * monogram we drew would read as the vendor's own, which is the thing a trade mark holder actually - * objects to. The caller draws the name either way, so a row without a mark is not a lesser one. + * Draws nothing when the brand has no mark, and `shared/marks/README.md` is explicit about why + * nothing is drawn to fill the gap: a monogram we drew would read as the vendor's own, which is the + * thing a trade mark holder actually objects to. The caller draws the name either way, so a row + * without a mark is not a lesser one. * * Decorative, always: `alt=""` and `aria-hidden`, because the name is already there in text and a * screen reader announcing "LangGraph LangGraph" is worse than one announcing it once. */ +/** The column every row reserves, marked or not, so the names beside it line up. */ +export const MARK_TILE = "size-5 shrink-0"; + export function HarnessMark({ id, name }: { id: string | null; name: string }) { const uri = markFor(id); /* - * An empty box of the same size, not nothing. Four of the twelve rows have no mark, and a - * collapsed box started their names a tile's width to the left of the rest — a ragged list rather - * than four brands without artwork. Reserving the column draws no mark and invents none, which is + * An empty box of the same size, not nothing. A row with no mark ("An agent you already run", or + * a brand that publishes none) in a collapsed box started its name a tile's width to the left of + * the rest — a ragged list rather than a row without artwork. Reserving the column draws no mark and invents none, which is * what the README forbids; it only keeps the names in line. */ - if (!uri) return