From 666c1ff417e021304f6afececb90c97bbaea3b34 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:52:49 -0700 Subject: [PATCH 01/47] feat(theme): follow the system's dark mode, with an Appearance setting --- app/index.html | 24 +- app/src/components/theme-provider.tsx | 66 ++++- app/src/lib/theme.ts | 68 ++++- app/src/routes/_authed/settings/index.tsx | 44 +++- app/tests/settings-appearance.test.tsx | 101 ++++++++ app/tests/theme-preference.test.ts | 291 ++++++++++++++++++++-- 6 files changed, 543 insertions(+), 51 deletions(-) create mode 100644 app/tests/settings-appearance.test.tsx diff --git a/app/index.html b/app/index.html index a839d57c50..b35c822cb2 100644 --- a/app/index.html +++ b/app/index.html @@ -5,15 +5,27 @@ OpenBot + diff --git a/app/src/components/theme-provider.tsx b/app/src/components/theme-provider.tsx index 9806bb8119..1a5fd3dbed 100644 --- a/app/src/components/theme-provider.tsx +++ b/app/src/components/theme-provider.tsx @@ -1,41 +1,87 @@ import { createContext, type ReactNode, + useCallback, useContext, useEffect, useState, } from "react"; import { - applyDarkTheme, - parseStoredDarkTheme, + applyTheme, + LEGACY_THEME_STORAGE_KEY, + parseThemePreference, + type ResolvedTheme, + resolveTheme, + SYSTEM_DARK_QUERY, THEME_STORAGE_KEY, + type ThemePreference, } from "@/lib/theme"; type ThemeContextValue = { - dark: boolean; - setDark: (dark: boolean) => void; + /** What the person chose: follow the system, or always light or dark. */ + preference: ThemePreference; + /** What is drawn right now. */ + resolved: ResolvedTheme; + setPreference: (preference: ThemePreference) => void; }; const ThemeContext = createContext(null); +/** Storage throws in some privacy modes; a theme is not worth taking the app down for. */ +function readStored(key: string): string | null { + try { + return window.localStorage.getItem(key); + } catch { + return null; + } +} + +function systemDarkNow(): boolean { + return window.matchMedia?.(SYSTEM_DARK_QUERY).matches ?? false; +} + export function ThemeProvider({ children }: { children: ReactNode }) { - const [dark, setDark] = useState(() => - parseStoredDarkTheme(window.localStorage.getItem(THEME_STORAGE_KEY)), + const [preference, setPreferenceState] = useState(() => + parseThemePreference( + readStored(THEME_STORAGE_KEY), + readStored(LEGACY_THEME_STORAGE_KEY), + ), ); + const [systemDark, setSystemDark] = useState(systemDarkNow); + + // The system's setting, live: it changes at sunset without anybody reloading. + useEffect(() => { + const query = window.matchMedia?.(SYSTEM_DARK_QUERY); + if (!query) return; + const follow = () => setSystemDark(query.matches); + follow(); + query.addEventListener("change", follow); + return () => query.removeEventListener("change", follow); + }, []); + const resolved = resolveTheme(preference, systemDark); useEffect(() => { - applyDarkTheme(dark, { - setStoredValue: (key, value) => window.localStorage.setItem(key, value), + applyTheme(resolved, { toggleRootClass: (name, force) => document.documentElement.classList.toggle(name, force), setRootColorScheme: (scheme) => { document.documentElement.style.colorScheme = scheme; }, }); - }, [dark]); + }, [resolved]); + + // Written only when the person chooses, so following the system stays the default until they do. + const setPreference = useCallback((next: ThemePreference) => { + setPreferenceState(next); + try { + window.localStorage.setItem(THEME_STORAGE_KEY, next); + } catch { + // The choice holds for this visit; it just isn't remembered. + } + }, []); return ( - + {children} ); diff --git a/app/src/lib/theme.ts b/app/src/lib/theme.ts index 59f663febd..1c613de78b 100644 --- a/app/src/lib/theme.ts +++ b/app/src/lib/theme.ts @@ -1,18 +1,66 @@ -export const THEME_STORAGE_KEY = "openbot-theme"; +/** + * Light, dark, or whatever the system is set to. The default is the system's setting, and it follows + * the system live, so a computer that turns dark at sunset turns OpenBot dark with it. + * + * `index.html` repeats `parseThemePreference` and `resolveTheme` inline, before the first paint, so the app never flashes + * the wrong theme while the bundle loads. The two read the same keys; a test holds them together. + */ -export function parseStoredDarkTheme(value: string | null) { - return value === "dark"; +/** Where the person's choice is kept: "system", "light" or "dark". */ +export const THEME_STORAGE_KEY = "openbot-appearance"; + +/** + * Where OpenBot kept its old switch, as "dark" or "light". It wrote "light" on every load whether or + * not anybody chose it, so only "dark" there means somebody asked for it; "light" there is the old + * default and now means "follow the system". + */ +export const LEGACY_THEME_STORAGE_KEY = "openbot-theme"; + +/** The media query the system's setting arrives on. */ +export const SYSTEM_DARK_QUERY = "(prefers-color-scheme: dark)"; + +export type ThemePreference = "system" | "light" | "dark"; +export type ResolvedTheme = "light" | "dark"; + +export const THEME_PREFERENCES: readonly ThemePreference[] = [ + "system", + "light", + "dark", +]; + +/** + * The person's choice, from what is stored under the two keys. Anything not a choice, including + * nothing at all, is "system". + */ +export function parseThemePreference( + stored: string | null, + legacy: string | null = null, +): ThemePreference { + if (stored === "system" || stored === "light" || stored === "dark") + return stored; + return legacy === "dark" ? "dark" : "system"; +} + +/** The theme to draw: the choice, or the system's when the choice is to follow it. */ +export function resolveTheme( + preference: ThemePreference, + systemDark: boolean, +): ResolvedTheme { + if (preference === "system") return systemDark ? "dark" : "light"; + return preference; } type ThemeEffects = { - setStoredValue: (key: string, value: string) => void; toggleRootClass: (name: string, force: boolean) => void; - setRootColorScheme: (scheme: "dark" | "light") => void; + setRootColorScheme: (scheme: ResolvedTheme) => void; }; -export function applyDarkTheme(dark: boolean, effects: ThemeEffects) { - effects.setStoredValue(THEME_STORAGE_KEY, dark ? "dark" : "light"); - effects.toggleRootClass("dark", dark); - // `index.html` sets this inline before paint, and an inline style outranks the palette. - effects.setRootColorScheme(dark ? "dark" : "light"); +/** + * Draw a theme: the `dark` class the tokens hang off, and the colour scheme the browser draws its + * own controls and scrollbars in. `index.html` sets the scheme inline before paint, and an inline + * style outranks the stylesheet, so this has to set it too. + */ +export function applyTheme(theme: ResolvedTheme, effects: ThemeEffects) { + effects.toggleRootClass("dark", theme === "dark"); + effects.setRootColorScheme(theme); } diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx index 9d52eb0016..c0c6febc3d 100644 --- a/app/src/routes/_authed/settings/index.tsx +++ b/app/src/routes/_authed/settings/index.tsx @@ -16,17 +16,30 @@ import { ItemDescription, ItemTitle, } from "@/components/ui/item"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; -import { Switch } from "@/components/ui/switch"; import { formatHotkey, HOTKEYS } from "@/lib/hotkeys/hotkeys"; +import { THEME_PREFERENCES, type ThemePreference } from "@/lib/theme"; import { SkinPicker } from "@/skins/skin-picker"; export const Route = createFileRoute("/_authed/settings/")({ component: RouteComponent, }); +const APPEARANCE: Record = { + system: "System", + light: "Light", + dark: "Dark", +}; + function RouteComponent() { - const { dark, setDark } = useTheme(); + const { preference, setPreference } = useTheme(); /* * The measurements that used to be written out here now live in `PageShell`, which Skills, Admin @@ -44,19 +57,32 @@ function RouteComponent() { + {/* Three choices, so a select like the message list's below, not a switch. */} - Dark theme + Appearance - Use the dark appearance across OpenBot. + System follows your device's light or dark setting. - + diff --git a/app/tests/settings-appearance.test.tsx b/app/tests/settings-appearance.test.tsx new file mode 100644 index 0000000000..bd3ae47ab7 --- /dev/null +++ b/app/tests/settings-appearance.test.tsx @@ -0,0 +1,101 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import type { UserPreferences } from "@openbot/shared/user-preferences"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, render } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ThemeProvider } from "@/components/theme-provider"; +import { authKeys } from "@/lib/auth/queries"; +import { userPreferencesQueryOptions } from "@/lib/settings/message-list"; +import { settingsKeys } from "@/lib/settings/queries"; +import { THEME_STORAGE_KEY } from "@/lib/theme"; +import { Route as SettingsRoute } from "@/routes/_authed/settings/index"; + +beforeAll(() => GlobalRegistrator.register()); +beforeEach(() => { + window.localStorage.clear(); + document.documentElement.classList.remove("dark"); +}); +afterEach(() => cleanup()); +afterAll(() => GlobalRegistrator.unregister()); + +async function renderSettings() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }); + queryClient.setQueryData(authKeys.currentUser(), { + id: "alice", + email: "alice@example.com", + name: "Alice", + role: "user", + onboarding: null, + }); + const preferences: UserPreferences = { + messageListEmphasis: "thread", + selfHostBannerDismissed: true, + narration: null, + }; + queryClient.setQueryData( + userPreferencesQueryOptions("alice").queryKey, + preferences, + ); + queryClient.setQueryData(settingsKeys.instructions(), ""); + const rootRoute = createRootRoute({ + component: SettingsRoute.options.component, + }); + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + const view = render( + + + + + , + ); + await view.findByText("Appearance"); + return view; +} + +test("Appearance offers System, Light and Dark where the dark switch was", async () => { + const view = await renderSettings(); + expect(view.queryByRole("switch", { name: "Dark theme" })).toBeNull(); + const appearance = view.getByRole("combobox", { name: "Appearance" }); + expect(appearance.textContent).toContain("System"); + + const user = userEvent.setup({ document }); + await user.click(appearance); + const options = (await view.findAllByRole("option")).map( + (option) => option.textContent, + ); + expect(options).toEqual(["System", "Light", "Dark"]); +}); + +test("choosing Dark draws it and remembers it", async () => { + const view = await renderSettings(); + const user = userEvent.setup({ document }); + await user.click(view.getByRole("combobox", { name: "Appearance" })); + await user.click(await view.findByRole("option", { name: "Dark" })); + + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark"); + expect( + view.getByRole("combobox", { name: "Appearance" }).textContent, + ).toContain("Dark"); +}); diff --git a/app/tests/theme-preference.test.ts b/app/tests/theme-preference.test.ts index bd1abf6763..9812b8c629 100644 --- a/app/tests/theme-preference.test.ts +++ b/app/tests/theme-preference.test.ts @@ -1,32 +1,291 @@ -import { describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { act, cleanup, render } from "@testing-library/react"; +import { createElement } from "react"; +import { ThemeProvider, useTheme } from "../src/components/theme-provider"; import { - applyDarkTheme, - parseStoredDarkTheme, + applyTheme, + LEGACY_THEME_STORAGE_KEY, + parseThemePreference, + resolveTheme, + SYSTEM_DARK_QUERY, THEME_STORAGE_KEY, } from "../src/lib/theme"; +/** + * OpenBot follows the system unless the person chose otherwise: System, Light or Dark, with System + * the default, decided before the first paint. + */ describe("theme preference", () => { - test("only the stored dark value enables dark theme", () => { - expect(parseStoredDarkTheme("dark")).toBe(true); - expect(parseStoredDarkTheme("light")).toBe(false); - expect(parseStoredDarkTheme(null)).toBe(false); + test("nothing stored follows the system", () => { + expect(parseThemePreference(null)).toBe("system"); + expect(parseThemePreference("nonsense")).toBe("system"); }); - test("persists and applies the selected theme", () => { - const writes: Array<[string, string]> = []; - const toggles: Array<[string, boolean]> = []; - const schemes: Array = []; + test("a stored choice is kept", () => { + expect(parseThemePreference("light")).toBe("light"); + expect(parseThemePreference("dark")).toBe("dark"); + expect(parseThemePreference("system")).toBe("system"); + }); + + test("OpenBot's old switch counts only when it was turned on", () => { + // It wrote "light" on every load whether or not anybody chose it. + expect(parseThemePreference(null, "light")).toBe("system"); + expect(parseThemePreference(null, "dark")).toBe("dark"); + // A choice made since wins over it. + expect(parseThemePreference("light", "dark")).toBe("light"); + }); + + test("following the system draws what the system is set to", () => { + expect(resolveTheme("system", true)).toBe("dark"); + expect(resolveTheme("system", false)).toBe("light"); + expect(resolveTheme("light", true)).toBe("light"); + expect(resolveTheme("dark", false)).toBe("dark"); + }); - applyDarkTheme(true, { - setStoredValue: (key, value) => writes.push([key, value]), + test("drawing a theme sets the class and the colour scheme", () => { + const toggles: Array<[string, boolean]> = []; + const schemes: string[] = []; + applyTheme("dark", { + toggleRootClass: (name, force) => toggles.push([name, force]), + setRootColorScheme: (scheme) => schemes.push(scheme), + }); + applyTheme("light", { toggleRootClass: (name, force) => toggles.push([name, force]), setRootColorScheme: (scheme) => schemes.push(scheme), }); + expect(toggles).toEqual([ + ["dark", true], + ["dark", false], + ]); + expect(schemes).toEqual(["dark", "light"]); + }); +}); + +describe("pre-paint theme boot", () => { + const html = readFileSync(new URL("../index.html", import.meta.url), "utf8"); + const boot = html.match(/]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/); + + /** Runs the inline script against a stand-in document, storage and system setting. */ + function bootWith( + stored: Record, + systemDark: boolean, + storageThrows = false, + ) { + const classes = new Set(); + const style: { colorScheme?: string } = {}; + const queries: string[] = []; + runInNewContext(boot?.[1] ?? "", { + window: { + localStorage: { + getItem: (key: string) => { + if (storageThrows) throw new Error("denied"); + return stored[key] ?? null; + }, + }, + matchMedia: (query: string) => { + queries.push(query); + return { matches: systemDark }; + }, + }, + document: { + documentElement: { + classList: { + toggle: (name: string, force: boolean) => + force ? classes.add(name) : classes.delete(name), + }, + style, + }, + }, + }); + return { dark: classes.has("dark"), scheme: style.colorScheme, queries }; + } + + test("with nothing chosen it follows the system", () => { + expect(bootWith({}, true)).toMatchObject({ dark: true, scheme: "dark" }); + expect(bootWith({}, false)).toMatchObject({ dark: false, scheme: "light" }); + expect(bootWith({}, true).queries).toEqual([SYSTEM_DARK_QUERY]); + }); + + test("it reads the keys the app writes, and a choice beats the system", () => { + expect(bootWith({ [THEME_STORAGE_KEY]: "light" }, true).dark).toBe(false); + expect(bootWith({ [THEME_STORAGE_KEY]: "dark" }, false).dark).toBe(true); + expect(bootWith({ [THEME_STORAGE_KEY]: "system" }, true).dark).toBe(true); + expect(bootWith({ [LEGACY_THEME_STORAGE_KEY]: "dark" }, false).dark).toBe( + true, + ); + expect(bootWith({ [LEGACY_THEME_STORAGE_KEY]: "light" }, true).dark).toBe( + true, + ); + // The same rule as `lib/theme.ts` for everything either key can hold, so the app never redraws. + const held = [undefined, "system", "light", "dark", "nonsense"]; + for (const stored of held) + for (const legacy of held) + for (const systemDark of [true, false]) { + const keys: Record = {}; + if (stored) keys[THEME_STORAGE_KEY] = stored; + if (legacy) keys[LEGACY_THEME_STORAGE_KEY] = legacy; + const theme = resolveTheme( + parseThemePreference(stored ?? null, legacy ?? null), + systemDark, + ); + expect({ + keys, + systemDark, + dark: bootWith(keys, systemDark).dark, + }).toEqual({ keys, systemDark, dark: theme === "dark" }); + } + }); + + test("storage that throws still follows the system", () => { + expect(bootWith({}, true, true)).toMatchObject({ + dark: true, + scheme: "dark", + }); + expect(bootWith({}, false, true)).toMatchObject({ + dark: false, + scheme: "light", + }); + }); +}); + +/** + * The provider, once the app has loaded: it reads the keys the boot script read, follows the system + * live while the choice is System, and writes only the new key, only when somebody chooses. + */ +describe("the theme provider", () => { + beforeAll(() => GlobalRegistrator.register()); + afterAll(() => GlobalRegistrator.unregister()); + + /** A stand-in for the system's setting that a test can flip, as the system does at sunset. */ + let systemDark = false; + const listeners = new Set<() => void>(); + function setSystemDark(next: boolean) { + systemDark = next; + act(() => { + for (const listener of listeners) listener(); + }); + } + + let originalMatchMedia: typeof window.matchMedia; + beforeEach(() => { + systemDark = false; + listeners.clear(); + window.localStorage.clear(); + document.documentElement.classList.remove("dark"); + document.documentElement.style.colorScheme = ""; + originalMatchMedia = window.matchMedia; + window.matchMedia = ((query: string) => ({ + media: query, + get matches() { + return query === SYSTEM_DARK_QUERY && systemDark; + }, + addEventListener: (_type: string, listener: () => void) => + listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => + listeners.delete(listener), + })) as unknown as typeof window.matchMedia; + }); + afterEach(() => { + cleanup(); + window.matchMedia = originalMatchMedia; + }); + + let theme: ReturnType | undefined; + function Probe() { + theme = useTheme(); + return null; + } + function renderProvider() { + theme = undefined; + render(createElement(ThemeProvider, null, createElement(Probe))); + } + const drawn = () => ({ + dark: document.documentElement.classList.contains("dark"), + scheme: document.documentElement.style.colorScheme, + }); + + test("with nothing chosen it follows the system, live", () => { + renderProvider(); + expect(theme?.preference).toBe("system"); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + + setSystemDark(true); + expect(theme?.resolved).toBe("dark"); + expect(drawn()).toEqual({ dark: true, scheme: "dark" }); + + setSystemDark(false); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + }); + + test("a choice beats the system, and stops following it", () => { + renderProvider(); + act(() => theme?.setPreference("light")); + setSystemDark(true); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + + act(() => theme?.setPreference("dark")); + setSystemDark(false); + expect(drawn()).toEqual({ dark: true, scheme: "dark" }); + }); + + test("only a choice is written, and only under the new key", () => { + renderProvider(); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBeNull(); + expect(window.localStorage.getItem(LEGACY_THEME_STORAGE_KEY)).toBeNull(); + + act(() => theme?.setPreference("dark")); + expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe("dark"); + expect(window.localStorage.getItem(LEGACY_THEME_STORAGE_KEY)).toBeNull(); + }); + + test("the old switch's Light lands on System, and its Dark stays Dark", () => { + window.localStorage.setItem(LEGACY_THEME_STORAGE_KEY, "light"); + systemDark = true; + renderProvider(); + expect(theme?.preference).toBe("system"); + expect(drawn().dark).toBe(true); + cleanup(); + + window.localStorage.setItem(LEGACY_THEME_STORAGE_KEY, "dark"); + systemDark = false; + renderProvider(); + expect(theme?.preference).toBe("dark"); + expect(drawn().dark).toBe(true); + }); + + test("storage that throws still follows the system after the app loads", () => { + const storage = Object.getOwnPropertyDescriptor(window, "localStorage"); + Object.defineProperty(window, "localStorage", { + configurable: true, + get() { + throw new Error("denied"); + }, + }); + try { + expect(() => window.localStorage).toThrow("denied"); + systemDark = true; + renderProvider(); + expect(theme?.preference).toBe("system"); + expect(drawn()).toEqual({ dark: true, scheme: "dark" }); - expect(writes).toEqual([[THEME_STORAGE_KEY, "dark"]]); - expect(toggles).toEqual([["dark", true]]); - expect(schemes).toEqual(["dark"]); + // The choice still holds for this visit; it just isn't remembered. + act(() => theme?.setPreference("light")); + expect(drawn()).toEqual({ dark: false, scheme: "light" }); + } finally { + if (storage) Object.defineProperty(window, "localStorage", storage); + else Reflect.deleteProperty(window, "localStorage"); + } }); }); From 3306a54a3cdfbaf99d3fea3832c77e78fa14af29 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:59:37 -0700 Subject: [PATCH 02/47] feat(ui): a visual system of cool greys, one accent and five type sizes --- .claude/skills/openbot-screen-layout/SKILL.md | 10 +- .../channels/composer/attachment-strip.tsx | 4 +- app/src/lib/utils.ts | 28 +- app/src/theme.css | 279 +++++++++++++----- app/tests/visual-system.test.ts | 260 ++++++++++++++++ extension/src/content/notice.ts | 46 +-- 6 files changed, 525 insertions(+), 102 deletions(-) create mode 100644 app/tests/visual-system.test.ts diff --git a/.claude/skills/openbot-screen-layout/SKILL.md b/.claude/skills/openbot-screen-layout/SKILL.md index 20cd104d6f..39442b1160 100644 --- a/.claude/skills/openbot-screen-layout/SKILL.md +++ b/.claude/skills/openbot-screen-layout/SKILL.md @@ -188,11 +188,11 @@ itself when the dialog changes something. ## Units -The root is the browser's 16px, so Tailwind's spacing, sizes and widths are their documented values -(`p-4` is 16px, `max-w-3xl` is 768px) and land on its 4px grid. Text is the exception: `styles.css` -sets every `--text-*` to Tailwind's default times 15/16, so `text-sm` is 13.125px and body text is -15px. That is the scale this app has always read at; do not size text in px or rem by hand to match -the docs. +The root is the browser's 16px, so Tailwind's spacing, sizes, widths and text sizes are their +documented values (`p-4` is 16px, `max-w-3xl` is 768px, `text-sm` is 14px) and land on its 4px grid. +Text used to be the exception, on a 15px scale of its own; it is not any more. Where a screen needs one +of the named sizes, use the five `type-*` utilities in `theme.css`; do not size text in px or rem by +hand. Even so, never hardcode a pixel constant derived from a Tailwind token, and never compute a ratio from one. Measure the element — `offsetWidth`, `clientWidth`, a `ResizeObserver` — and scale from what diff --git a/app/src/components/channels/composer/attachment-strip.tsx b/app/src/components/channels/composer/attachment-strip.tsx index 90ba773333..b752533518 100644 --- a/app/src/components/channels/composer/attachment-strip.tsx +++ b/app/src/components/channels/composer/attachment-strip.tsx @@ -144,8 +144,8 @@ export function AttachmentStrip({ * ON THE SIZE LINE, NOT UNDER IT, AND THAT IS A LAYOUT CONSTRAINT RATHER THAN A * PREFERENCE. The tile's height is fixed so that a file and a thumbnail read as * one row. A third line does not fit: with three lines the two in-flow children - * need 65px (a 20px icon and three 15px lines) inside a 62px box. Something - * has to give, and the only choices are the icon shrinking to 17px or the text + * need 68px (a 20px icon and three 16px lines) inside a 62px box. Something + * has to give, and the only choices are the icon shrinking to 14px or the text * spilling past the border. Folding the warning onto this line keeps two. * * `truncate` is the guarantee, not the wording: `formatBytes` is unbounded, so a diff --git a/app/src/lib/utils.ts b/app/src/lib/utils.ts index 365058cebd..ee2cf32355 100644 --- a/app/src/lib/utils.ts +++ b/app/src/lib/utils.ts @@ -1,5 +1,31 @@ import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; +import { extendTailwindMerge } from "tailwind-merge"; + +/** + * The five sizes of the type scale (`theme.css`). + * + * Each sets a font size, a line height and tracking. `tailwind-merge` only knows Tailwind's own + * utilities, so without this it keeps `text-sm` beside `type-body` and the stylesheet's order picks + * `text-sm`. Registered as font sizes, a `type-*` given later replaces an earlier `text-*` size, and + * drops the `leading-*` that came with it. + */ +export const TYPE_SCALE_CLASSES = [ + "type-display", + "type-nav", + "type-body", + "type-sub", + "type-caption", +] as const; + +const twMerge = extendTailwindMerge({ + extend: { + classGroups: { + "font-size": [ + { type: TYPE_SCALE_CLASSES.map((name) => name.slice("type-".length)) }, + ], + }, + }, +}); export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); diff --git a/app/src/theme.css b/app/src/theme.css index 8e30caa8b0..acbc4e706b 100644 --- a/app/src/theme.css +++ b/app/src/theme.css @@ -1,76 +1,150 @@ @custom-variant dark (&:is(.dark *)); +/* + * OpenBot's visual system, in the variables every screen already reads. Neutral greys with a cool + * bias; a near-black primary that turns light in the dark; one accent, `--brand`, for focus, + * selection and links and never for headings (shadcn's `--accent` stays the neutral hover fill); + * four status colours for how work stands. Every colour here has a dark value in `.dark` below, and + * `tests/visual-system.test.ts` fails if one is missing or a colour that carries words falls under + * 4.5:1 on a ground it sits on. + */ :root { color-scheme: light; - --background: oklch(0.985 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.96 0 0); - --muted-foreground: oklch(0.5 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --success: oklch(0.6 0.118 184.704); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); + --background: #f6f6f8; + --foreground: #18181b; + --card: #ffffff; + --card-foreground: #18181b; + --popover: #ffffff; + --popover-foreground: #18181b; + --primary: #111114; + --primary-foreground: #ffffff; + --secondary: #f1f1f4; + --secondary-foreground: #18181b; + --muted: #f1f1f4; + --muted-foreground: #6b6b75; + --accent: #f1f1f4; + --accent-foreground: #18181b; + --destructive: #b91c1c; + --success: #147a3a; + --border: rgb(24 24 27 / 9%); + --input: rgb(24 24 27 / 12%); + --ring: #6430ab; --chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); - --radius: 0.5rem; - --sidebar: oklch(0.97 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.5 0 0); - --sidebar-accent: oklch(0.95 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --radius: 0.55rem; + --sidebar: #f1f1f4; + --sidebar-foreground: #18181b; + --sidebar-primary: #6430ab; + --sidebar-primary-foreground: #ffffff; + /* A row lifts to the card's white on the sidebar's grey, the way a selected segment does. */ + --sidebar-accent: #ffffff; + --sidebar-accent-foreground: #18181b; + --sidebar-border: rgb(24 24 27 / 9%); + --sidebar-ring: #6430ab; + + /* The one accent, and what sits on it. */ + --brand: #6430ab; + --brand-foreground: #ffffff; + /* How work stands: ready for review, needs you, working, couldn't finish. Only on status. */ + --status-done: #147a3a; + --status-done-soft: #e6f4ea; + --status-needs: #a34a06; + --status-needs-soft: #fdf3e1; + --status-working: #1d4ed8; + --status-working-soft: #eaf0fd; + --status-problem: #b91c1c; + --status-problem-soft: #fbeaea; + /* + * Not registered with Tailwind: `--shadow-*` is its namespace, and a theme key of the same name + * would point at itself. Use them as `shadow-(--shadow-float)`. + */ + --shadow-float: + 0 0 0 0.5px rgb(1 5 7 / 10%), 0 1px 1.5px rgb(1 5 7 / 6%), + 0 4px 12px -4px rgb(1 5 7 / 12%); + --shadow-card: 0 0 0 1px var(--border), 0 6px 18px -12px rgb(1 5 7 / 18%); + --shadow-raised: 0 0 0 1px var(--border), 0 10px 26px -14px rgb(1 5 7 / 20%); + /* + * Glass: the chrome that floats over a screen lets what scrolls under it show through, frosted. + * Translucent enough to read as a layer, opaque enough that text under it never competes with the + * controls on it. `--veil` is the same material laid over a whole screen, for a sheet that the + * screen stays visible behind. The `glass` and `veil` utilities below draw them. + */ + --glass: rgb(255 255 255 / 80%); + --veil: rgb(246 246 248 / 74%); + --glass-edge: inset 0 0.5px 0 rgb(255 255 255 / 80%); + --shadow-glass: var(--glass-edge), var(--shadow-float); + /* The smallest thing a finger can hit. */ + --target: 44px; + /* + * Motion. `--ease-out` and `--ease-in` are Tailwind's own names, so `ease-out` and `ease-in` draw + * these curves; `--ease-out` is the one `lib/motion.ts` already uses. + */ + --ease-out: cubic-bezier(0.23, 1, 0.32, 1); + --ease-in: cubic-bezier(0.7, 0, 0.84, 0); + --dur-tap: 120ms; + --dur-swap: 200ms; + --dur-enter: 250ms; + --dur-sheet: 350ms; } +/* Near-black and neutral, the same roles, and the primary action turns light. */ .dark { color-scheme: dark; - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); - --primary-foreground: oklch(0.205 0 0); - --secondary: oklch(0.269 0 0); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.269 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); - --success: oklch(0.704 0.14 182.503); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); + --background: #111113; + --foreground: #f4f4f5; + --card: #1c1c1f; + --card-foreground: #f4f4f5; + --popover: #1c1c1f; + --popover-foreground: #f4f4f5; + --primary: #f4f4f5; + --primary-foreground: #111114; + --secondary: #26262b; + --secondary-foreground: #f4f4f5; + --muted: #26262b; + --muted-foreground: #8e8e98; + --accent: #26262b; + --accent-foreground: #f4f4f5; + --destructive: #f87171; + --success: #4ade80; + --border: rgb(255 255 255 / 10%); + --input: rgb(255 255 255 / 15%); + --ring: #b49ae8; --chart-1: oklch(0.87 0 0); --chart-2: oklch(0.556 0 0); --chart-3: oklch(0.439 0 0); --chart-4: oklch(0.371 0 0); --chart-5: oklch(0.269 0 0); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --sidebar: #18181b; + --sidebar-foreground: #f4f4f5; + --sidebar-primary: #b49ae8; + --sidebar-primary-foreground: #1a1033; + --sidebar-accent: #26262b; + --sidebar-accent-foreground: #f4f4f5; + --sidebar-border: rgb(255 255 255 / 10%); + --sidebar-ring: #b49ae8; + + --brand: #b49ae8; + /* Dark text on the light lilac: white on it was 2.4:1. */ + --brand-foreground: #1a1033; + --status-done: #4ade80; + --status-done-soft: #14301f; + --status-needs: #fbbf24; + --status-needs-soft: #3a2a0c; + --status-working: #7aa2ff; + --status-working-soft: #18233d; + --status-problem: #f87171; + --status-problem-soft: #3a1717; + --shadow-float: + 0 0 0 0.5px rgb(255 255 255 / 10%), 0 4px 12px -4px rgb(0 0 0 / 50%); + --shadow-card: 0 0 0 1px var(--border); + --shadow-raised: 0 0 0 1px var(--border), 0 10px 26px -14px rgb(0 0 0 / 60%); + --glass: rgb(28 28 31 / 82%); + --veil: rgb(17 17 19 / 72%); + --glass-edge: inset 0 0.5px 0 rgb(255 255 255 / 8%); + --shadow-glass: var(--glass-edge), var(--shadow-float); } * { @@ -102,23 +176,11 @@ body { */ /* - * Text is sized on a 15px base while everything else is on Tailwind's 16px rem. - * - * The root stays at the browser's 16px so spacing, sizes and radii land on Tailwind's 4px grid - * (`p-4` is 16px, `h-8` is 32px). Only type keeps the scale it had under the old 15px root: each - * size below is Tailwind's default multiplied by 15/16, so `text-sm` is still 13.125px. Line - * heights are Tailwind's own unitless ratios and scale with them. `body` reads `--text-base`, so - * unclassed text is 15px too. + * No `--text-*` here. Text follows the browser's own size, 16px by default, on Tailwind's own scale, + * like the spacing and the radii; OpenBot used to set every size to 15/16 of Tailwind's. The type + * scale below is in rem too, so it follows the person's text size. */ @theme inline { - --text-xs: 0.703125rem; /* 11.25px */ - --text-sm: 0.8203125rem; /* 13.125px */ - --text-base: 0.9375rem; /* 15px */ - --text-lg: 1.0546875rem; /* 16.875px */ - --text-xl: 1.171875rem; /* 18.75px */ - --text-2xl: 1.40625rem; /* 22.5px */ - --text-3xl: 1.7578125rem; /* 28.125px */ - --text-4xl: 2.109375rem; /* 33.75px */ --font-heading: var(--font-sans); --font-sans: "Inter Variable", sans-serif; --color-sidebar-ring: var(--sidebar-ring); @@ -160,6 +222,81 @@ body { --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --color-brand: var(--brand); + --color-brand-foreground: var(--brand-foreground); + --color-status-done: var(--status-done); + --color-status-done-soft: var(--status-done-soft); + --color-status-needs: var(--status-needs); + --color-status-needs-soft: var(--status-needs-soft); + --color-status-working: var(--status-working); + --color-status-working-soft: var(--status-working-soft); + --color-status-problem: var(--status-problem); + --color-status-problem-soft: var(--status-problem-soft); +} + +/* + * The type scale: five sizes, in rem so they follow the person's text size. Inter's tracking tightens + * as it grows. Weights are not part of a size: 600 for titles, 500 for row titles, chips and + * buttons, 400 otherwise. `cn` knows these as font sizes (`lib/utils.ts`), so one given later + * replaces a primitive's `text-*` and the `leading-*` that came with it. + * + * type-display 24/28 screen titles + * type-nav 18/22 navigation rows, Settings rows + * type-body 17/23 messages, task and card titles + * type-sub 15/20 status lines, chips, the composer, section headers, buttons + * type-caption 13/16 captions, labels, the footer, timestamps, tags + */ +@utility type-display { + font-size: 1.5rem; + line-height: 1.75rem; + letter-spacing: -0.018em; +} +@utility type-nav { + font-size: 1.125rem; + line-height: 1.375rem; + letter-spacing: -0.011em; +} +@utility type-body { + font-size: 1.0625rem; + line-height: 1.4375rem; + letter-spacing: -0.011em; +} +@utility type-sub { + font-size: 0.9375rem; + line-height: 1.25rem; + letter-spacing: -0.006em; +} +@utility type-caption { + font-size: 0.8125rem; + line-height: 1rem; + letter-spacing: 0; +} +/* + * Frosted chrome over a scrolling screen. The blur is what makes the translucency readable: without + * it, a message passing under the composer would sit legibly behind its placeholder. A browser that + * cannot blur gets the opaque card instead, never a see-through bar. + */ +@utility glass { + background-color: var(--card); + @supports (backdrop-filter: blur(1px)) or + (-webkit-backdrop-filter: blur(1px)) { + background-color: var(--glass); + -webkit-backdrop-filter: blur(22px) saturate(180%); + backdrop-filter: blur(22px) saturate(180%); + } +} +@utility veil { + background-color: var(--background); + @supports (backdrop-filter: blur(1px)) or + (-webkit-backdrop-filter: blur(1px)) { + background-color: var(--veil); + -webkit-backdrop-filter: blur(28px) saturate(160%); + backdrop-filter: blur(28px) saturate(160%); + } +} +/* Inter's single-storey a (cv11) and flat-topped 3 (ss03). On `body`, so every screen has them. */ +@utility face { + font-feature-settings: "cv11", "ss03"; } @layer base { @@ -167,7 +304,7 @@ body { @apply border-border outline-ring/50; } body { - @apply bg-background text-foreground text-base; + @apply bg-background text-foreground face; } html { @apply font-sans; diff --git a/app/tests/visual-system.test.ts b/app/tests/visual-system.test.ts new file mode 100644 index 0000000000..8effb73153 --- /dev/null +++ b/app/tests/visual-system.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { cn, TYPE_SCALE_CLASSES } from "../src/lib/utils"; + +/** + * OpenBot's visual system, carried by the variables every screen already reads, so these + * tests read the stylesheet itself: every variable has a value in both themes, the colours that + * carry words clear their contrast floor on the grounds they sit on, and the type scale is five + * sizes in rem. That is `theme.css`, which the app and the Recorder's pages both import. + */ + +const styles = readFileSync( + new URL("../src/theme.css", import.meta.url), + "utf8", +); + +/** The custom properties declared directly inside the first block that opens with `selector {`. */ +function block(selector: string): Map { + const start = styles.indexOf(`${selector} {`); + if (start === -1) throw new Error(`no ${selector} block`); + let depth = 0; + let end = start; + for (let at = styles.indexOf("{", start); at < styles.length; at++) { + if (styles[at] === "{") depth++; + if (styles[at] === "}") depth--; + if (depth === 0) { + end = at; + break; + } + } + const body = styles.slice(start, end).replace(/\/\*[\s\S]*?\*\//g, ""); + const variables = new Map(); + for (const match of body.matchAll(/(--[\w-]+):\s*([^;]+);/g)) { + variables.set( + match[1] as string, + (match[2] as string).replace(/\s+/g, " "), + ); + } + return variables; +} + +const light = block(":root"); +const dark = block(".dark"); + +/** A variable's value, following `var(--x)` within the same theme. */ +function resolve(theme: Map, name: string): string { + const fallback = theme === dark ? light : undefined; + let value = theme.get(name) ?? fallback?.get(name); + for (let hops = 0; value?.startsWith("var(") && hops < 5; hops++) { + const inner = value.slice(4, -1); + value = theme.get(inner) ?? fallback?.get(inner); + } + if (!value) throw new Error(`${name} has no value`); + return value; +} + +function luminance(hex: string): number { + if (!/^#[0-9a-f]{6}$/i.test(hex)) throw new Error(`${hex} is not #rrggbb`); + const channels = [1, 3, 5].map((at) => { + const c = Number.parseInt(hex.slice(at, at + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return ( + 0.2126 * (channels[0] ?? 0) + + 0.7152 * (channels[1] ?? 0) + + 0.0722 * (channels[2] ?? 0) + ); +} + +function contrast(a: string, b: string): number { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return ((hi ?? 0) + 0.05) / ((lo ?? 0) + 0.05); +} + +const themes = [ + ["light", light], + ["dark", dark], +] as const; + +describe("light and dark", () => { + test("every variable the light theme defines has a dark value", () => { + const aliases = [...light.entries()] + .filter(([, value]) => value.startsWith("var(--")) + .map(([name]) => name); + const missing = [...light.keys()].filter( + (name) => + !dark.has(name) && + !aliases.includes(name) && + // Shape and motion are the same in both themes. + !/^--(radius|target|ease|dur)/.test(name), + ); + expect(missing).toEqual([]); + for (const name of ["--glass", "--veil", "--shadow-glass"]) + expect(dark.has(name)).toBe(true); + }); + + test("the visual system's roles arrive under their own names", () => { + const roles = [ + "--brand", + "--brand-foreground", + ...["done", "needs", "working", "problem"].flatMap((status) => [ + `--status-${status}`, + `--status-${status}-soft`, + ]), + "--glass", + "--veil", + "--shadow-float", + "--shadow-card", + "--shadow-raised", + "--shadow-glass", + "--ease-out", + "--ease-in", + "--dur-tap", + "--dur-swap", + "--dur-enter", + "--dur-sheet", + ]; + expect(roles.filter((name) => !light.has(name))).toEqual([]); + expect(light.get("--target")).toBe("44px"); + expect(light.get("--radius")).toBe("0.55rem"); + expect(styles).not.toContain("--duet-"); + }); + + test("the brand is for focus, selection and links; shadcn's accent stays a neutral fill", () => { + for (const [, theme] of themes) { + expect(resolve(theme, "--ring")).toBe(resolve(theme, "--brand")); + expect(resolve(theme, "--sidebar-primary")).toBe( + resolve(theme, "--brand"), + ); + expect(resolve(theme, "--accent")).toBe(resolve(theme, "--secondary")); + } + }); +}); + +describe("contrast, checked in both themes", () => { + const grounds = [ + "--background", + "--card", + "--popover", + "--secondary", + "--muted", + "--accent", + "--sidebar", + "--sidebar-accent", + ]; + for (const [themeName, theme] of themes) { + test(`text roles clear 4.5:1 on every ground, ${themeName}`, () => { + for (const ink of [ + "--foreground", + "--muted-foreground", + "--card-foreground", + "--popover-foreground", + "--secondary-foreground", + "--accent-foreground", + "--sidebar-foreground", + "--sidebar-accent-foreground", + "--brand", + "--destructive", + "--success", + "--status-done", + "--status-needs", + "--status-working", + "--status-problem", + ]) + for (const ground of grounds) + expect({ + ink, + ground, + clears: + contrast(resolve(theme, ink), resolve(theme, ground)) >= 4.5, + }).toEqual({ ink, ground, clears: true }); + }); + + test(`each status word clears 4.5:1 on its own soft fill, ${themeName}`, () => { + for (const status of ["done", "needs", "working", "problem"]) + expect({ + status, + ratio: + contrast( + resolve(theme, `--status-${status}`), + resolve(theme, `--status-${status}-soft`), + ) >= 4.5, + }).toEqual({ status, ratio: true }); + }); + } + + test("what sits on the primary clears 4.5:1", () => { + for (const [themeName, theme] of themes) + for (const [on, fill] of [ + ["--primary-foreground", "--primary"], + ["--brand-foreground", "--brand"], + ["--sidebar-primary-foreground", "--sidebar-primary"], + ]) + expect({ + themeName, + on, + clears: + contrast( + resolve(theme, on as string), + resolve(theme, fill as string), + ) >= 4.5, + }).toEqual({ themeName, on, clears: true }); + }); +}); + +describe("the type scale", () => { + const rules = styles.replace(/\/\*[\s\S]*?\*\//g, ""); + + test("the root font size is the browser's", () => { + expect(rules).not.toMatch(/html\s*\{\s*font-size/); + // OpenBot's 15px scale is gone: no `text-*` size is redefined, and body text is not pinned. + expect(rules).not.toMatch(/--text-[\w-]+\s*:/); + expect(rules).not.toMatch(/body\s*\{[^}]*@apply[^;]*\btext-base\b/); + }); + + test("five sizes, in rem", () => { + const sizes = new Map(); + for (const match of styles.matchAll( + /@utility (type-[\w-]+)\s*\{\s*font-size:\s*([^;]+);/g, + )) + sizes.set(match[1] as string, match[2] as string); + expect(Object.fromEntries(sizes)).toEqual({ + "type-display": "1.5rem", + "type-nav": "1.125rem", + "type-body": "1.0625rem", + "type-sub": "0.9375rem", + "type-caption": "0.8125rem", + }); + }); + + test("Inter's single-storey a and flat-topped 3 are on for every screen", () => { + expect(rules).toMatch( + /@utility face\s*\{\s*font-feature-settings:\s*"cv11",\s*"ss03";/, + ); + expect(rules).toMatch(/body\s*\{[^}]*@apply[^;]*\bface\b/); + }); +}); + +describe("merging classes", () => { + test("a type size given later replaces a primitive's text size and leading", () => { + for (const size of TYPE_SCALE_CLASSES) + expect(cn("px-3 text-sm leading-relaxed", size)).toBe(`px-3 ${size}`); + expect(cn("text-base font-medium", "type-display font-semibold")).toBe( + "type-display font-semibold", + ); + }); + + test("a colour does not knock out a type size", () => { + expect(cn("type-sub text-muted-foreground", "text-foreground")).toBe( + "type-sub text-foreground", + ); + }); + + test("every type utility in the stylesheet is known to the merge", () => { + const declared = [...styles.matchAll(/@utility (type-[\w-]+)/g)].map( + (match) => match[1], + ); + expect(new Set(declared)).toEqual(new Set(TYPE_SCALE_CLASSES)); + }); +}); diff --git a/extension/src/content/notice.ts b/extension/src/content/notice.ts index 95e274d4cd..80c89077b5 100644 --- a/extension/src/content/notice.ts +++ b/extension/src/content/notice.ts @@ -10,34 +10,34 @@ export const NOTICE_TAG = "openbot-recorder-notice"; export const NOTICE_TOKENS = { light: { - "--background": "oklch(0.985 0 0)", - "--foreground": "oklch(0.145 0 0)", - "--card": "oklch(1 0 0)", - "--card-foreground": "oklch(0.145 0 0)", - "--border": "oklch(0.922 0 0)", - "--primary": "oklch(0.205 0 0)", - "--primary-foreground": "oklch(0.985 0 0)", - "--secondary": "oklch(0.97 0 0)", - "--muted-foreground": "oklch(0.5 0 0)", - "--destructive": "oklch(0.577 0.245 27.325)", - "--ring": "oklch(0.708 0 0)", + "--background": "#f6f6f8", + "--foreground": "#18181b", + "--card": "#ffffff", + "--card-foreground": "#18181b", + "--border": "rgb(24 24 27 / 9%)", + "--primary": "#111114", + "--primary-foreground": "#ffffff", + "--secondary": "#f1f1f4", + "--muted-foreground": "#6b6b75", + "--destructive": "#b91c1c", + "--ring": "#6430ab", }, dark: { - "--background": "oklch(0.145 0 0)", - "--foreground": "oklch(0.985 0 0)", - "--card": "oklch(0.205 0 0)", - "--card-foreground": "oklch(0.985 0 0)", - "--border": "oklch(1 0 0 / 10%)", - "--primary": "oklch(0.922 0 0)", - "--primary-foreground": "oklch(0.205 0 0)", - "--secondary": "oklch(0.269 0 0)", - "--muted-foreground": "oklch(0.708 0 0)", - "--destructive": "oklch(0.704 0.191 22.216)", - "--ring": "oklch(0.556 0 0)", + "--background": "#111113", + "--foreground": "#f4f4f5", + "--card": "#1c1c1f", + "--card-foreground": "#f4f4f5", + "--border": "rgb(255 255 255 / 10%)", + "--primary": "#f4f4f5", + "--primary-foreground": "#111114", + "--secondary": "#26262b", + "--muted-foreground": "#8e8e98", + "--destructive": "#f87171", + "--ring": "#b49ae8", }, } as const; -export const RADIUS = "0.5rem"; +export const RADIUS = "0.55rem"; type Choice = "stop" | "restart" | "continue"; From c4d5ff31c0bded23613f32538c63beaf325f3d44 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:48:28 -0700 Subject: [PATCH 03/47] feat(orb): an orb drawn on a canvas, in a colour for each Bot --- app/src/components/agents/orb/bot-orb-draw.ts | 701 ++++++++++++++++++ app/tests/bot-orb.test.tsx | 193 +++++ 2 files changed, 894 insertions(+) create mode 100644 app/src/components/agents/orb/bot-orb-draw.ts create mode 100644 app/tests/bot-orb.test.tsx diff --git a/app/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts new file mode 100644 index 0000000000..d1605ae02c --- /dev/null +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -0,0 +1,701 @@ +/** + * The orb, every Bot's face and OpenBot's own mark, drawn on a 2D canvas: a glass sphere of + * drifting colour with two eye-lights that look, blink and smile. It comes in nine colours; a Bot's + * is picked from its seed, and OpenBot's own is violet. It also plays a finish: the check and the + * burst of rays the orb shows when something the person allowed is done. + * + * Every frame is a pure function of an `OrbPose`, so the component that animates it owns time and + * this file owns only the picture. + */ + +const TAU = Math.PI * 2; +const PI = Math.PI; + +export const clamp = (x: number, a = 0, b = 1) => (x < a ? a : x > b ? b : x); +const lerp = (a: number, b: number, u: number) => a + (b - a) * u; +const eio = (u: number) => { + const v = clamp(u); + return v < 0.5 ? 4 * v * v * v : 1 - (-2 * v + 2) ** 3 / 2; +}; +const eout = (u: number) => 1 - (1 - clamp(u)) ** 3; +const smooth = (u: number) => { + const v = clamp(u); + return v * v * (3 - 2 * v); +}; +const bump = (u: number) => (u <= 0 || u >= 1 ? 0 : Math.sin(PI * u) ** 2); + +// ---------- motion: keyed tracks with eases and closed-form springs ---------- + +/** One move of a track: to `to` from time `at`, over an ease of `ease` seconds or on a spring of [stiffness, damping]. */ +export type TrackKey = + | { at: number; to: number; ease: number } + | { at: number; to: number; spring: readonly [number, number] }; + +/** A value that starts at `from` and moves through its keys in order. */ +export type Track = { from: number; keys: TrackKey[] }; + +/** Step response of a damped spring: closed form, so it is a pure function of elapsed time. */ +function springStep(tau: number, w: number, z: number) { + if (tau <= 0) return 0; + if (z >= 1) return 1 - Math.exp(-w * tau) * (1 + w * tau); + const wd = w * Math.sqrt(1 - z * z); + return ( + 1 - + Math.exp(-z * w * tau) * + (Math.cos(wd * tau) + ((z * w) / wd) * Math.sin(wd * tau)) + ); +} + +/** + * The track's value at time t. Each key moves the value from the previous key's value to its own, + * and the responses are summed, which for springs is exactly a spring chasing a stepped target: + * anticipation, overshoot and settle all fall out of the same maths. + */ +export function track(tr: Track, t: number) { + let v = tr.from; + let prev = tr.from; + for (const k of tr.keys) { + if (t < k.at) break; + const tau = t - k.at; + const r = + "ease" in k + ? eio(tau / k.ease) + : springStep(tau, k.spring[0], k.spring[1]); + v += (k.to - prev) * r; + prev = k.to; + } + return v; +} + +/** A blink at `tau` seconds after it starts: shut fast, open slower, 0.24 s in all. */ +export function blinkCurve(tau: number) { + if (tau < 0 || tau > 0.24) return 0; + return tau < 0.07 ? eout(tau / 0.07) : 1 - eio((tau - 0.07) / 0.17); +} + +/** A nod that rings down, `tau` seconds after it starts. */ +export function nodCurve(tau: number) { + return tau >= 0 && tau < 2.2 ? Math.exp(-3 * tau) * Math.sin(8 * tau) : 0; +} + +export function mulberry32(seed: number) { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** + * Where it looks while nothing needs it: mostly at you, now and then a glance aside and back. + * Each look snaps over 0.14 s and then holds, so the pauses are part of the motion. + */ +export function restLook(ta: number, period: number, seed: number) { + const k = Math.floor(ta / period); + const f = ta - k * period; + const at = (n: number): [number, number, number] => { + const r = mulberry32((seed * 7919 + n * 104729) | 0); + return r() < 0.55 + ? [0, 0, 0] + : [(r() * 2 - 1) * 0.7, (r() * 2 - 1) * 0.4, (r() * 2 - 1) * 0.24]; + }; + const a = at(k - 1); + const b = at(k); + const u = eout(f / 0.14); + return [lerp(a[0], b[0], u), lerp(a[1], b[1], u), lerp(a[2], b[2], u)]; +} + +// ---------- colour ---------- + +export type Rgb = [number, number, number]; + +function ok2rgb(L: number, C: number, h: number): Rgb | null { + const hr = (h * PI) / 180; + const a = C * Math.cos(hr); + const b = C * Math.sin(hr); + const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; + const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; + const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; + const linear = [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; + const out: number[] = []; + for (const x of linear) { + if (x < -0.002 || x > 1.002) return null; + const c = clamp(x); + out.push( + Math.round( + 255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055), + ), + ); + } + return out as Rgb; +} + +/** An oklch colour as sRGB, losing chroma until it fits rather than clipping its hue. */ +export function oklch(L: number, C: number, h: number): Rgb { + let chroma = C; + for (let i = 0; i < 40; i++) { + const c = ok2rgb(L, chroma, h); + if (c) return c; + chroma *= 0.95; + } + return ok2rgb(L, 0, h) ?? [128, 128, 128]; +} + +const rgba = (c: Rgb, a: number) => + `rgba(${c[0]},${c[1]},${c[2]},${a <= 0 ? 0 : a >= 1 ? 1 : a.toFixed(3)})`; +const mix = (a: Rgb, b: Rgb, u: number): Rgb => [ + Math.round(lerp(a[0], b[0], u)), + Math.round(lerp(a[1], b[1], u)), + Math.round(lerp(a[2], b[2], u)), +]; +const WHITE: Rgb = [255, 255, 255]; + +type Oklch = readonly [L: number, C: number, h: number]; + +/** + * One of the orb's colours, as oklch [L, C, h]: four tones for the sphere's drifting colour and its + * pale base. `main` also tints its glow and, in the dark, the landing's ping and a finish's rays; + * `deep` draws those in the light. `shade` is the tint of the sphere's shaded rim, as sRGB. + */ +type PaletteSpec = { + main: Oklch; + side: Oklch; + deep: Oklch; + pale: Oklch; + base: Oklch; + shade: readonly [r: number, g: number, b: number]; +}; + +/** + * The nine colours. A Bot's is `orbPaletteFor` its seed, which picks by position in `ORB_PALETTES`: + * add a colour at the end, never reorder them, or every Bot changes colour. + */ +const PALETTES = { + violet: { + main: [0.7, 0.14, 288], + side: [0.72, 0.15, 318], + deep: [0.47, 0.2, 296], + pale: [0.86, 0.08, 308], + base: [0.93, 0.03, 300], + shade: [40, 16, 78], + }, + ember: { + main: [0.79, 0.13, 62], + side: [0.75, 0.15, 35], + deep: [0.53, 0.17, 30], + pale: [0.89, 0.08, 52], + base: [0.94, 0.03, 50], + shade: [84, 30, 14], + }, + moss: { + main: [0.77, 0.11, 182], + side: [0.78, 0.13, 152], + deep: [0.5, 0.12, 165], + pale: [0.89, 0.07, 160], + base: [0.94, 0.03, 160], + shade: [14, 60, 40], + }, + sun: { + main: [0.81, 0.15, 72], + side: [0.85, 0.14, 92], + deep: [0.57, 0.14, 58], + pale: [0.93, 0.08, 92], + base: [0.95, 0.03, 90], + shade: [92, 58, 10], + }, + berry: { + main: [0.7, 0.15, 328], + side: [0.72, 0.16, 352], + deep: [0.48, 0.18, 345], + pale: [0.86, 0.08, 350], + base: [0.93, 0.03, 350], + shade: [80, 14, 48], + }, + ocean: { + main: [0.74, 0.13, 205], + side: [0.75, 0.13, 232], + deep: [0.53, 0.2, 262], + pale: [0.86, 0.08, 220], + base: [0.93, 0.025, 235], + shade: [14, 30, 80], + }, + // Beside ocean on the wheel, so lighter and softer: a clear sky against ocean's deep water. + sky: { + main: [0.79, 0.11, 220], + side: [0.8, 0.1, 242], + deep: [0.55, 0.15, 245], + pale: [0.9, 0.06, 228], + base: [0.95, 0.02, 230], + shade: [10, 40, 76], + }, + coral: { + main: [0.78, 0.13, 32], + side: [0.74, 0.15, 8], + deep: [0.52, 0.17, 18], + pale: [0.88, 0.08, 22], + base: [0.94, 0.03, 20], + shade: [84, 20, 24], + }, + // Nearly grey: for a team big enough to want a quiet one. + slate: { + main: [0.72, 0.045, 232], + side: [0.76, 0.04, 250], + deep: [0.48, 0.05, 265], + pale: [0.88, 0.02, 255], + base: [0.93, 0.012, 258], + shade: [30, 38, 54], + }, +} as const satisfies Record; + +export type OrbPaletteName = keyof typeof PALETTES; + +/** Every colour, in the order `orbPaletteFor` picks from. */ +export const ORB_PALETTES = Object.keys(PALETTES) as readonly OrbPaletteName[]; + +/** + * The colour for a Bot's seed: a 32-bit FNV-1a hash of it, modulo the nine. Stable across reloads + * and releases, so a Bot keeps its colour. + */ +export function orbPaletteFor(seed: string): OrbPaletteName { + let h = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return ORB_PALETTES[(h >>> 0) % ORB_PALETTES.length] as OrbPaletteName; +} + +/** A colour's tones as sRGB, ready to draw. */ +export type Palette = Record; +const paletteMemo = new Map(); + +/** A colour with its saturation and hue nudged, as the orb livens when it listens and cools when it works. */ +export function palette( + name: OrbPaletteName, + sat: number, + hue: number, +): Palette { + const ks = Math.round(clamp(sat, 0.5, 1.3) * 50) / 50; + const kh = Math.round(hue * 2) / 2; + const key = `${name}|${ks}|${kh}`; + let p = paletteMemo.get(key); + if (!p) { + const spec: PaletteSpec = PALETTES[name]; + const c = ([L, C, h]: Oklch, sk = ks, hk = kh) => oklch(L, C * sk, h + hk); + p = { + main: c(spec.main), + side: c(spec.side), + deep: c(spec.deep), + pale: c(spec.pale), + base: c(spec.base, 1, kh * 0.5), + shade: [...spec.shade], + }; + paletteMemo.set(key, p); + } + return p; +} + +// ---------- the pose ---------- + +/** Everything one frame depends on. `ta` is seconds on the orb's own clock; ages are -1 when nothing happened yet. */ +export type OrbPose = { + ta: number; + /** 0 at rest, 1 listening (can overshoot a little). */ + listen: number; + think: number; + /** The landing's glow, 0 to 1. */ + done: number; + smile: number; + tilt: number; + /** Where it looks: right and down, about -1 to 1. */ + ax: number; + ay: number; + blink: number; + nod: number; + doneAge: number; + noticeAge: number; + /** Seconds into a finish (a check and a burst of rays, `finishPhase`), or -1 when none is playing. */ + finishAge: number; + /** -1 to 1, a slow breath. */ + breath: number; +}; + +// ---------- the finish ---------- + +/** How long a finish plays, in seconds, from the eyes giving way to the check until they are back. */ +export const FINISH_S = 1.2; +/** A moment in a finish when the check is whole and the rays have gone: the finish as a still. */ +export const FINISH_HELD = 0.8; +/** The short strokes that burst out round the orb when it finishes. */ +export const RAYS = 10; + +/** Where a finish is at one moment. Every part of it is a pure function of its age. */ +export type FinishPhase = { + /** How much of the check is drawn along its stroke, 0 to 1. */ + check: number; + /** The check's opacity: whole while it holds, gone as the eyes come back. */ + checkAlpha: number; + /** How far the rays have flown, 0 to 1, or -1 before they leave and once they have faded. */ + rays: number; + /** The eye-lights' opacity: they give way to the check and come back after it. */ + eyes: number; + /** The sphere's pop as the check lands, 0 to 1. */ + pop: number; +}; + +const NO_FINISH: FinishPhase = { + check: 0, + checkAlpha: 0, + rays: -1, + eyes: 1, + pop: 0, +}; + +/** + * A finish `age` seconds in: the eyes fade as the sphere pops and the check strokes on, the rays + * burst outward and fade, the check holds, then fades as the eyes come back. -1 is no finish. + */ +export function finishPhase(age: number): FinishPhase { + if (!(age >= 0 && age < FINISH_S)) return NO_FINISH; + const back = smooth((age - 0.85) / 0.3); + return { + check: eout((age - 0.06) / 0.3), + checkAlpha: 1 - back, + rays: age >= 0.1 && age < 0.75 ? (age - 0.1) / 0.65 : -1, + eyes: age < 0.85 ? 1 - smooth(age / 0.12) : back, + pop: bump(age / 0.5), + }; +} + +// ---------- the orb's pieces ---------- + +type Ctx = CanvasRenderingContext2D; + +function blob(ctx: Ctx, x: number, y: number, r: number, c: Rgb, a: number) { + if (a <= 0.003 || r <= 0) return; + const g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, rgba(c, a)); + g.addColorStop(0.42, rgba(c, a * 0.78)); + g.addColorStop(0.76, rgba(c, a * 0.24)); + g.addColorStop(1, rgba(c, 0)); + ctx.fillStyle = g; + ctx.fillRect(x - r, y - r, 2 * r, 2 * r); +} + +function light(ctx: Ctx, x: number, y: number, r: number, a: number) { + if (a <= 0.003 || r <= 0) return; + const g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, `rgba(255,255,255,${clamp(a).toFixed(3)})`); + g.addColorStop(0.45, `rgba(255,255,255,${clamp(a * 0.42).toFixed(3)})`); + g.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = g; + ctx.fillRect(x - r, y - r, 2 * r, 2 * r); +} + +function circle(ctx: Ctx, r: number) { + ctx.beginPath(); + ctx.arc(0, 0, r, 0, TAU); +} + +// angle, orbit, radius of the mesh's four colour masses +const MESH = [ + [3.95, 0.43, 0.88], + [5.5, 0.45, 0.74], + [0.78, 0.45, 0.86], + [2.36, 0.45, 0.88], +] as const; + +function meshPos(ta: number, fast: boolean) { + const rot = fast ? 1.45 * ta + 1.1 : 0.22 * ta; + return MESH.map((b, i) => { + const a = + b[0] + + rot + + (fast + ? 0.5 * Math.sin(1.1 * ta + i * 1.93) + : 0.42 * Math.sin(0.29 * ta + i * 1.93)); + return [Math.cos(a) * b[1], Math.sin(a) * b[1], b[2]] as const; + }); +} + +/** Four soft colour masses drifting round a pale base; a faster swirl joins them while it works. */ +function mesh( + ctx: Ctx, + R: number, + S: OrbPose, + pal: Palette, + lx: number, + ly: number, +) { + ctx.fillStyle = rgba(pal.base, 1); + ctx.fillRect(-R * 1.7, -R * 1.7, R * 3.4, R * 3.4); + const cols = [pal.main, pal.pale, pal.side, pal.deep]; + meshPos(S.ta, false).forEach((p, i) => { + blob(ctx, p[0] * R + lx * R, p[1] * R + ly * R, p[2] * R, cols[i], 0.94); + }); + const swirl = S.think * 0.55; + if (swirl > 0.01) { + meshPos(S.ta, true).forEach((p, i) => { + blob( + ctx, + p[0] * R + lx * R * 0.6, + p[1] * R + ly * R * 0.6, + p[2] * R * 0.92, + cols[i], + 0.9 * swirl, + ); + }); + } +} + +/** Lit top edge, shaded bottom: what makes the disc read as a sphere. Call inside the clip. */ +function rim(ctx: Ctx, R: number, shade: Rgb, dark: boolean) { + const g = ctx.createRadialGradient( + 0, + -R * 0.3, + R * 0.55, + 0, + -R * 0.12, + R * 1.16, + ); + g.addColorStop(0, rgba(shade, 0)); + g.addColorStop(0.72, rgba(shade, 0)); + g.addColorStop(1, rgba(shade, dark ? 0.3 : 0.24)); + ctx.fillStyle = g; + ctx.fillRect(-R * 1.3, -R * 1.3, R * 2.6, R * 2.6); + const lw = Math.max(1, R * 0.05); + const lg = ctx.createLinearGradient(0, -R, 0, R * 0.25); + lg.addColorStop(0, "rgba(255,255,255,.7)"); + lg.addColorStop(1, "rgba(255,255,255,0)"); + ctx.strokeStyle = lg; + ctx.lineWidth = lw; + circle(ctx, R - lw * 0.5); + ctx.stroke(); +} + +function glow(ctx: Ctx, R: number, amt: number, c: Rgb, dark: boolean) { + if (amt < 0.005) return; + const r = R * 1.85; + const g = ctx.createRadialGradient(0, 0, R * 0.55, 0, 0, r); + g.addColorStop(0, rgba(c, amt * (dark ? 0.8 : 0.5))); + g.addColorStop(0.38, rgba(c, amt * (dark ? 0.34 : 0.18))); + g.addColorStop(1, rgba(c, 0)); + ctx.fillStyle = g; + ctx.fillRect(-r, -r, 2 * r, 2 * r); +} + +/** One ring that leaves the orb and fades: the landing's ping. */ +function ping(ctx: Ctx, r0: number, age: number, pal: Palette, dark: boolean) { + const dur = 1; + if (!(age >= 0 && age < dur)) return; + const u = age / dur; + const r = r0 * (1.02 + (1.5 - 1.02) * eout(u)); + const a = (1 - u) ** 1.5; + ctx.strokeStyle = rgba( + dark ? mix(pal.main, WHITE, 0.2) : pal.deep, + a * (dark ? 0.75 : 0.62), + ); + ctx.lineWidth = Math.max(1, r0 * 0.07 * (1 - u * 0.6)); + circle(ctx, r); + ctx.stroke(); +} + +/** + * The finish's burst: a ring of short strokes, one straight up, that fly out round the sphere, + * shortening and fading as they go. Every other one is longer, so the ring sparkles rather than ticks. + */ +function rays(ctx: Ctx, r0: number, u: number, pal: Palette, dark: boolean) { + if (!(u >= 0 && u < 1)) return; + const travel = eout(u); + const a = (1 - u) ** 1.4; + ctx.save(); + ctx.lineCap = "round"; + ctx.strokeStyle = rgba( + dark ? mix(pal.main, WHITE, 0.25) : pal.deep, + a * (dark ? 0.9 : 0.8), + ); + ctx.lineWidth = Math.max(1, r0 * 0.09 * (1 - 0.5 * u)); + for (let i = 0; i < RAYS; i++) { + const angle = -PI / 2 + (TAU * i) / RAYS; + const length = r0 * (i % 2 === 0 ? 0.3 : 0.2) * (1 - 0.55 * u); + const inner = r0 * (1.3 + 0.32 * travel); + const dx = Math.cos(angle); + const dy = Math.sin(angle); + ctx.beginPath(); + ctx.moveTo(dx * inner, dy * inner); + ctx.lineTo(dx * (inner + length), dy * (inner + length)); + ctx.stroke(); + } + ctx.restore(); +} + +// The check's three points, as shares of the sphere's radius: in at the left, down to the elbow, up +// to the right. Its two legs are about 0.37 and 0.75 of the radius long. +const CHECK = [ + [-0.38, 0.02], + [-0.12, 0.28], + [0.42, -0.26], +] as const; + +/** The check, stroked on along its length to `drawn` (0 to 1), in the eye-lights' white. */ +function check( + ctx: Ctx, + R: number, + drawn: number, + alpha: number, + dark: boolean, +) { + if (drawn <= 0 || alpha <= 0.003) return; + const [a, b, c] = CHECK.map(([x, y]) => [x * R, y * R] as const); + const first = Math.hypot(b[0] - a[0], b[1] - a[1]); + const second = Math.hypot(c[0] - b[0], c[1] - b[1]); + const along = drawn * (first + second); + ctx.save(); + ctx.globalAlpha = clamp(alpha); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.lineWidth = Math.max(1.6, R * 0.17); + ctx.strokeStyle = "rgba(255,255,255,.97)"; + ctx.shadowColor = dark ? "rgba(8,6,20,.4)" : "rgba(20,16,33,.28)"; + ctx.shadowBlur = Math.max(1, R * 0.07); + ctx.beginPath(); + ctx.moveTo(a[0], a[1]); + if (along <= first) { + const u = along / first; + ctx.lineTo(lerp(a[0], b[0], u), lerp(a[1], b[1], u)); + } else { + const u = (along - first) / second; + ctx.lineTo(b[0], b[1]); + ctx.lineTo(lerp(b[0], c[0], u), lerp(b[1], c[1], u)); + } + ctx.stroke(); + ctx.restore(); +} + +/** The sphere's scale: it breathes, swells a little to listen and to land, and pops as a check lands. */ +function scaleOf(S: OrbPose, finish: FinishPhase) { + const L = clamp(S.listen, 0, 1.25); + return ( + 1 + + 0.015 * S.breath * (1 - S.think) + + 0.025 * L + + 0.04 * S.done + + 0.06 * finish.pop + ); +} + +function eyePath(ctx: Ctx, w: number, h: number, smile: number) { + const rx = (w / 2) * (1 + 0.12 * smile); + const ry = h / 2; + const e = 0.32 * ry * smile; + const top = -ry; + const bot = lerp(ry, -0.36 * ry, smile); + const ct = (top - 0.25 * e) / 0.75; + const cb = (bot - 0.25 * e) / 0.75; + ctx.beginPath(); + ctx.moveTo(-rx, e); + ctx.bezierCurveTo(-rx, ct, rx, ct, rx, e); + ctx.bezierCurveTo(rx, cb, -rx, cb, -rx, e); + ctx.closePath(); +} + +/** The sphere: the mesh turned toward where it looks, its rim, and two eye-lights that blink and smile. */ +function sphere( + ctx: Ctx, + R: number, + S: OrbPose, + pal: Palette, + dark: boolean, + size: number, +) { + const na = S.noticeAge; + const perk = na >= 0 && na < 1.6 ? Math.exp(-2.6 * na) * smooth(na / 0.1) : 0; + const finish = finishPhase(S.finishAge); + const sc = scaleOf(S, finish); + ctx.save(); + ctx.translate(0, S.nod * R * 0.11); + ctx.scale(sc, sc); + circle(ctx, R); + ctx.clip(); + const yaw = clamp(S.ax, -1.1, 1.1) * 0.62; + const pitch = -clamp(S.ay, -1.1, 1.1) * 0.5; + const roll = S.tilt; + mesh(ctx, R, S, pal, Math.sin(yaw) * 0.2, -Math.sin(pitch) * 0.16); + light(ctx, -0.42 * R, -0.5 * R, 0.5 * R, 0.26); + light(ctx, 0, R * 0.05, R * 0.9, 0.28 * S.done); + rim(ctx, R, pal.shade, dark); + const small = size < 40; + const widen = 1 + 0.16 * perk; + const open = clamp(1 - 0.94 * S.blink) * (1 - 0.3 * S.think) * widen; + const cr = Math.cos(roll); + const sr = Math.sin(roll); + const ew = Math.max(small ? 2.4 : 1.8, R * 0.16 * widen); + const eh = Math.max(small ? 3.4 : 2.4, R * 0.3); + for (const side of [-1, 1]) { + if (finish.eyes <= 0.003) break; + const lon = side * 0.35; + const lat = 0.05 + 0.04 * S.smile; + const x = Math.sin(lon) * Math.cos(lat); + const y = Math.sin(lat); + const z = Math.cos(lon) * Math.cos(lat); + const y1 = y * Math.cos(pitch) + z * Math.sin(pitch); + const z1 = -y * Math.sin(pitch) + z * Math.cos(pitch); + const x2 = x * Math.cos(yaw) + z1 * Math.sin(yaw); + const sx = x2 * cr - y1 * sr; + const sy = x2 * sr + y1 * cr; + const fx = Math.sqrt(clamp(1 - sx * sx, 0.12, 1)); + const fy = Math.sqrt(clamp(1 - sy * sy, 0.2, 1)); + const h = Math.max(small ? 1.1 : 0.9, eh * open * fy); + ctx.save(); + ctx.globalAlpha = finish.eyes; + ctx.translate(sx * R * 0.84, -sy * R * 0.84 + (eh - h) * 0.28); + ctx.rotate(-roll); + ctx.shadowColor = dark ? "rgba(8,6,20,.4)" : "rgba(20,16,33,.28)"; + ctx.shadowBlur = Math.max(1, R * 0.07); + ctx.fillStyle = "rgba(255,255,255,.97)"; + eyePath(ctx, ew * fx, h, clamp(S.smile) * (1 - S.blink)); + ctx.fill(); + ctx.restore(); + } + check(ctx, R, finish.check, finish.checkAlpha, dark); + ctx.restore(); +} + +/** + * The whole orb in `palette`, centred on the context's origin, for a footprint of `size` CSS px. + * Its glow, the landing's ping and a finish's rays spill past the footprint, so draw it on a canvas + * twice its size. + */ +export function drawBotOrb( + ctx: Ctx, + size: number, + S: OrbPose, + { dark, palette: name }: { dark: boolean; palette: OrbPaletteName }, +) { + const finish = finishPhase(S.finishAge); + const pal = palette( + name, + 0.9 + 0.14 * clamp(S.listen) + 0.08 * S.think + 0.12 * clamp(S.done), + -6 * S.think, + ); + const r = (size / 2) * 0.72; + const sc = scaleOf(S, finish); + const glowAmt = clamp( + 0.18 + + 0.42 * clamp(S.listen) + + 0.22 * S.think + + 0.55 * S.done + + 0.45 * finish.pop, + ); + glow(ctx, r * sc, glowAmt * 0.85, mix(pal.main, pal.deep, 0.4), dark); + ping(ctx, r, S.doneAge, pal, dark); + rays(ctx, r, finish.rays, pal, dark); + sphere(ctx, r, S, pal, dark, size * 0.72); +} diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx new file mode 100644 index 0000000000..eda56ee4ac --- /dev/null +++ b/app/tests/bot-orb.test.tsx @@ -0,0 +1,193 @@ +import { describe, expect, test } from "bun:test"; +import { + drawBotOrb, + FINISH_HELD, + FINISH_S, + finishPhase, + ORB_PALETTES, + type OrbPose, + oklch, + orbPaletteFor, + palette, + RAYS, + track, +} from "@/components/agents/orb/bot-orb-draw"; + +const REST: OrbPose = { + ta: 1.6, + listen: 0, + think: 0, + done: 0, + smile: 0, + tilt: 0, + ax: 0, + ay: 0.04, + blink: 0, + nod: 0, + doneAge: -1, + noticeAge: -1, + finishAge: -1, + breath: 0, +}; + +/** + * Just enough of a 2D context to run the drawing: it counts every stroke, and counts the frames + * drawn, each of which starts by clearing the canvas. + */ +function fakeContext() { + const gradient = { addColorStop: () => {} }; + const ctx = { + strokes: 0, + draws: 0, + stroke() { + this.strokes++; + }, + clearRect() { + this.draws++; + }, + createLinearGradient: () => gradient, + createRadialGradient: () => gradient, + }; + return new Proxy(ctx, { + get: (target, key) => + key in target ? target[key as keyof typeof target] : () => {}, + set: (target, key, value) => { + (target as Record)[key] = value; + return true; + }, + }); +} + +/** Every stroke one frame draws: the rim, the landing's ping, and a finish's rays and check. */ +const strokesOf = (pose: OrbPose) => { + const ctx = fakeContext(); + drawBotOrb(ctx as unknown as CanvasRenderingContext2D, 60, pose, { + dark: false, + palette: "violet", + }); + return ctx.strokes; +}; + +/** The Bots a fresh OpenBot ships with, whose ids are their seeds. */ +const TEAM = [ + "expense-review", + "feedback-digest", + "interview-notes", + "meeting-follow-ups", + "onboarding-buddy", + "oncall-handover", + "release-notes", + "research-desk", + "ticket-triage", +]; + +describe("the orb's motion", () => { + test("an eased key arrives and a spring overshoots, then both settle on the target", () => { + const eased = { from: 0, keys: [{ at: 1, to: 1, ease: 0.4 }] }; + expect(track(eased, 0.5)).toBe(0); + expect(track(eased, 1.2)).toBeGreaterThan(0); + expect(track(eased, 2)).toBe(1); + + const sprung = { + from: 0, + keys: [{ at: 0, to: 1, spring: [14, 0.5] as const }], + }; + const peak = Math.max( + ...Array.from({ length: 60 }, (_, i) => track(sprung, i / 60)), + ); + expect(peak).toBeGreaterThan(1); + expect(track(sprung, 5)).toBeCloseTo(1, 4); + }); + + test("a later key moves on from where the earlier one left it", () => { + const tr = { + from: 0, + keys: [ + { at: 0, to: 1, ease: 0.4 }, + { at: 1, to: 0.25, ease: 0.4 }, + ], + }; + expect(track(tr, 0.9)).toBe(1); + expect(track(tr, 3)).toBe(0.25); + }); +}); + +describe("the colours", () => { + test("every palette's base is pale and its shade deep", () => { + for (const name of ORB_PALETTES) { + const pal = palette(name, 1, 0); + expect(Math.min(...pal.base)).toBeGreaterThan(200); + expect(Math.max(...pal.deep)).toBeLessThan(230); + expect(Math.max(...pal.shade)).toBeLessThan(100); + } + }); + + test("a colour outside sRGB loses chroma rather than turning into another hue", () => { + const [r, g, b] = oklch(0.6, 0.5, 262); + expect(b).toBeGreaterThan(r); + expect(b).toBeGreaterThan(g); + }); + + test("a seed always gets the same palette", () => { + for (const seed of TEAM) { + expect(orbPaletteFor(seed)).toBe(orbPaletteFor(seed)); + expect(ORB_PALETTES).toContain(orbPaletteFor(seed)); + } + // Pinned, so a change to the hash, which would recolour every Bot, can't land unnoticed. + expect(orbPaletteFor("research-desk")).toBe("sky"); + expect(orbPaletteFor("release-notes")).toBe("violet"); + expect(ORB_PALETTES).toContain(orbPaletteFor("")); + }); + + test("nine palettes, and a team of nine seeds uses more than four of them", () => { + expect(ORB_PALETTES).toHaveLength(9); + expect(new Set(ORB_PALETTES).size).toBe(9); + expect(new Set(TEAM.map(orbPaletteFor)).size).toBeGreaterThan(4); + }); + + test("each palette is a colour of its own", () => { + const bases = ORB_PALETTES.map((name) => palette(name, 1, 0).main.join()); + expect(new Set(bases).size).toBe(ORB_PALETTES.length); + }); +}); + +describe("the drawing", () => { + test("has no grilles: at rest its only stroke is the sphere's rim", () => { + expect(strokesOf(REST)).toBe(1); + }); +}); + +describe("a finish", () => { + test("the eyes give way to a check while the rays fly, then the eyes come back", () => { + expect(finishPhase(-1)).toMatchObject({ check: 0, rays: -1, eyes: 1 }); + + const burst = finishPhase(0.4); + expect(burst.eyes).toBe(0); + expect(burst.check).toBe(1); + expect(burst.rays).toBeGreaterThan(0); + expect(burst.rays).toBeLessThan(1); + + // Held: the check whole, the burst over. + expect(finishPhase(FINISH_HELD)).toMatchObject({ + check: 1, + checkAlpha: 1, + rays: -1, + eyes: 0, + }); + + expect(finishPhase(FINISH_S)).toEqual(finishPhase(-1)); + }); + + test("the check strokes on along its length", () => { + const early = finishPhase(0.12).check; + expect(early).toBeGreaterThan(0); + expect(early).toBeLessThan(finishPhase(0.24).check); + }); + + test("draws its rays and its check over the orb, and nothing once it is over", () => { + const rest = strokesOf(REST); + expect(strokesOf({ ...REST, finishAge: 0.4 })).toBe(rest + RAYS + 1); + expect(strokesOf({ ...REST, finishAge: FINISH_HELD })).toBe(rest + 1); + expect(strokesOf({ ...REST, finishAge: FINISH_S })).toBe(rest); + }); +}); From 867dd0ea178b051f1f11c9f09677f9ced4cceaf3 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:53:34 -0700 Subject: [PATCH 04/47] feat(orb): one animation clock for every orb, and eyes that follow the pointer --- app/src/components/agents/orb/bot-orb.tsx | 468 +++++++++++++++++++++ app/src/components/agents/orb/orb-clock.ts | 140 ++++++ app/tests/bot-orb.test.tsx | 387 ++++++++++++++++- 3 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 app/src/components/agents/orb/bot-orb.tsx create mode 100644 app/src/components/agents/orb/orb-clock.ts diff --git a/app/src/components/agents/orb/bot-orb.tsx b/app/src/components/agents/orb/bot-orb.tsx new file mode 100644 index 0000000000..44c37f1e08 --- /dev/null +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -0,0 +1,468 @@ +import { useReducedMotion } from "motion/react"; +import { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; +import { + blinkCurve, + clamp, + drawBotOrb, + FINISH_HELD, + FINISH_S, + mulberry32, + nodCurve, + type OrbPaletteName, + type OrbPose, + restLook, + type Track, + type TrackKey, + track, +} from "./bot-orb-draw"; +import { pointer, subscribeFrame, subscribePointer } from "./orb-clock"; + +/** + * What the orb is doing: resting, listening to you type, working, or landing a finished thing. + * `finished` is a moment rather than a state: something the person allowed is done, and the orb + * plays a check with a burst of rays once, over whatever it was doing, which carries on under it. + * Hold it for `FINISH_MS`, then pass the state it was in before. + */ +export type BotOrbState = + | "idle" + | "listening" + | "thinking" + | "done" + | "finished"; + +/** How long a finish plays: the check, its rays, and the eyes coming back. */ +export const FINISH_MS = FINISH_S * 1000; + +type Look = readonly [number, number]; +const LOOK_AT_YOU: Look = [0, 0.04]; +/** Up and aside, as if thinking it over. */ +const LOOK_UP: Look = [-0.36, -0.5]; +/** Down at what you are typing. */ +const LOOK_DOWN: Look = [0.1, 0.6]; +const LOOKS: Record = { + idle: LOOK_AT_YOU, + listening: LOOK_DOWN, + thinking: LOOK_UP, + done: LOOK_AT_YOU, + finished: LOOK_AT_YOU, +}; +/** Seconds between blinks, before each one's jitter. */ +const BLINK_EVERY = 4.2; +/** How far the canvas draws past the footprint on every side, as a share of the size. */ +const SPILL = 0.5; +/** Backing pixels per CSS pixel, at most: sharp on a 3× screen without a huge canvas. */ +const MAX_DPR = 3; +/** Below this footprint, in CSS px, an orb only blinks and watches: no glances, no breath. */ +const SMALL = 40; +/** How much of its look an orb gives the pointer: all of it at rest, some while it listens or works. */ +const WATCH: Record = { + idle: 1, + listening: 0.5, + thinking: 0.35, + done: 1, + finished: 1, +}; +/** The pointer perks it up within this share of its size from its centre… */ +const NOTICE_NEAR = 0.9; +/** …and has to go past this one before it can perk it up again. */ +const NOTICE_FAR = 1.3; + +/** Every track the state drives, and when the things that play once last happened. */ +type Motion = { + listen: Track; + think: Track; + done: Track; + smile: Track; + ax: Track; + ay: Track; + doneAt: number; + noticedAt: number; + finishedAt: number; + nods: number[]; +}; + +const still = (v: number): Track => ({ from: v, keys: [] }); + +/** The motion at rest in `state`, as it is on the first frame. */ +function settled(state: BotOrbState): Motion { + const look = LOOKS[state]; + return { + listen: still(state === "listening" ? 1 : 0), + think: still(state === "thinking" ? 1 : 0), + done: still(0), + smile: still(state === "done" ? 1 : 0), + ax: still(look[0]), + ay: still(look[1]), + doneAt: -1, + noticedAt: -1, + finishedAt: state === "finished" ? 0 : -1, + nods: [], + }; +} + +/** The track restarted from its value at `now`, heading for the new key. */ +const retarget = (tr: Track, now: number, key: TrackKey): Track => ({ + from: track(tr, now), + keys: [key], +}); + +/** Moves every track toward `state` from time `now`, each on its own spring or ease. */ +function enter(m: Motion, state: BotOrbState, now: number): Motion { + const look = LOOKS[state]; + const gaze = + state === "thinking" + ? ([6.5, 0.85] as const) + : state === "listening" + ? ([15, 0.6] as const) + : ([12, 0.8] as const); + const next: Motion = { + ...m, + listen: retarget( + m.listen, + now, + state === "listening" + ? { at: now, to: 1, spring: [14, 0.5] } + : { at: now, to: 0, ease: 0.45 }, + ), + think: retarget(m.think, now, { + at: now, + to: state === "thinking" ? 1 : 0, + ease: state === "thinking" ? 0.4 : 0.45, + }), + smile: retarget(m.smile, now, { + at: now + (state === "done" ? 0.08 : 0), + to: state === "done" ? 1 : 0, + ease: state === "done" ? 0.4 : 0.8, + }), + ax: retarget(m.ax, now, { at: now, to: look[0], spring: gaze }), + ay: retarget(m.ay, now, { at: now, to: look[1], spring: gaze }), + done: retarget(m.done, now, { at: now, to: 0, ease: 0.45 }), + }; + if (state === "listening") next.noticedAt = now; + if (state === "done") { + // It lands: a glow that springs up and settles away, a ping and a nod. + next.done = { + from: track(m.done, now), + keys: [ + { at: now, to: 1, spring: [11, 0.42] }, + { at: now + 1.4, to: 0, ease: 1.2 }, + ], + }; + next.doneAt = now; + next.nods = [now + 0.1]; + } + return next; +} + +/** + * The pose at `t` seconds on the orb's clock. `seed` keeps two orbs from blinking and glancing in + * step. A small orb keeps still apart from its blinks, so a list of them doesn't fidget. + */ +function poseAt( + m: Motion, + t: number, + seed: number, + { reduced, small }: { reduced: boolean; small: boolean }, +): OrbPose { + const listen = track(m.listen, t); + const think = clamp(track(m.think, t)); + // Left alone it glances about now and then; while it listens or works it keeps its eyes on that. + const calm = reduced || small ? 0 : (1 - clamp(listen)) * (1 - think); + const glance = restLook(t, 3.6, seed); + let blink = 0; + if (!reduced) { + const k = Math.floor(t / BLINK_EVERY); + for (const n of [k - 1, k]) { + const at = n * BLINK_EVERY + 1.3 + mulberry32(seed + n)() * 1.6; + blink = Math.max(blink, blinkCurve(t - at)); + } + } + return { + ta: t, + listen, + think, + done: Math.max(0, track(m.done, t)), + smile: clamp(track(m.smile, t)), + tilt: 0.5 * glance[2] * calm, + ax: track(m.ax, t) + 0.6 * glance[0] * calm, + ay: track(m.ay, t) + 0.6 * glance[1] * calm, + blink, + nod: m.nods.reduce((n, at) => n + nodCurve(t - at), 0), + doneAge: m.doneAt < 0 ? -1 : t - m.doneAt, + noticeAge: m.noticedAt < 0 ? -1 : t - m.noticedAt, + // Still, a finish is its check without the burst. + finishAge: m.finishedAt < 0 ? -1 : reduced ? FINISH_HELD : t - m.finishedAt, + breath: small ? 0 : Math.sin((Math.PI * 2 * t) / 5.5), + }; +} + +/** + * An orb watching the pointer: the look it is heading for (`tx`, `ty`), where its eyes are on a + * spring (`x`, `y`, with their speeds), how much of its look it gives the pointer (`w`), whether it + * can see the pointer at all, and whether the pointer is close enough to have perked it up. `epoch` + * and `last` are the pointer's epoch and the time when it last looked. + */ +export type Gaze = { + tx: number; + ty: number; + x: number; + y: number; + vx: number; + vy: number; + w: number; + seen: boolean; + near: boolean; + epoch: number; + last: number; +}; + +export const restingGaze = (): Gaze => ({ + tx: 0, + ty: 0, + x: 0, + y: 0, + vx: 0, + vy: 0, + w: 0, + seen: false, + near: false, + epoch: -1, + last: -1, +}); + +type Box = Pick; + +/** + * Moves `gaze` on to time `t` for an orb in `state`, measuring where the orb is on screen only when + * the pointer, the scroll or the window has changed. True on the frame the pointer comes close, + * which perks the orb up; a listening orb has already noticed you, so it never perks. + */ +export function followPointer( + gaze: Gaze, + t: number, + state: BotOrbState, + measure: () => Box, +): boolean { + const dt = gaze.last < 0 ? 0 : Math.min(1 / 30, Math.max(0, t - gaze.last)); + gaze.last = t; + let perked = false; + if (gaze.epoch !== pointer.epoch) { + gaze.epoch = pointer.epoch; + const box = measure(); + const dx = pointer.x - (box.left + box.width / 2); + const dy = pointer.y - (box.top + box.height / 2); + const d = Math.hypot(dx, dy) || 1; + // Right over it, it looks straight out at you; the further away, the further round it turns. + const reach = d / (d + Math.max(48, box.width * 1.6)); + gaze.tx = (dx / d) * reach * 0.95; + gaze.ty = (dy / d) * reach; + gaze.seen = pointer.on && box.width > 0; + const near = + gaze.seen && d < box.width * (gaze.near ? NOTICE_FAR : NOTICE_NEAR); + perked = near && !gaze.near && state !== "listening"; + gaze.near = near; + } + gaze.vx += ((gaze.tx - gaze.x) * 150 - gaze.vx * 19) * dt; + gaze.vy += ((gaze.ty - gaze.y) * 150 - gaze.vy * 19) * dt; + gaze.x += gaze.vx * dt; + gaze.y += gaze.vy * dt; + gaze.w += ((gaze.seen ? WATCH[state] : 0) - gaze.w) * (1 - Math.exp(-dt * 5)); + return perked; +} + +/** Its own look, glances and all, gives way to the pointer by as much as it is watching. */ +function lookAt(pose: OrbPose, gaze: Gaze): OrbPose { + return { + ...pose, + ax: pose.ax + (gaze.x - pose.ax) * gaze.w, + ay: pose.ay + (gaze.y - pose.ay) * gaze.w, + tilt: pose.tilt + (-0.12 * gaze.x - pose.tilt) * gaze.w, + }; +} + +/** + * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it, or null where + * there is no 2D context to draw with. + */ +function surface(canvas: HTMLCanvasElement, size: number) { + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const box = size * (1 + 2 * SPILL); + const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1); + canvas.width = Math.round(box * dpr); + canvas.height = Math.round(box * dpr); + return (pose: OrbPose, palette: OrbPaletteName) => { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, box, box); + ctx.translate(box / 2, box / 2); + drawBotOrb(ctx, size, pose, { + dark: document.documentElement.classList.contains("dark"), + palette, + }); + }; +} + +let seeds = 0; +const secondsSince = (start: number) => (performance.now() - start) / 1000; + +/** + * A Bot's orb, in `palette`. `size` is its footprint in CSS px; its glow spills past it. It breathes + * and glances about when idle, watches what you type when listening, swirls while thinking, and + * lands with a ping and a smile when done. Passed `finished`, its eyes give way to a check while + * short rays burst out round it, and then it is back as it was. Wherever the pointer is, its eyes + * follow it: fully at rest, partly while it listens or works, with a small perk when the pointer + * comes close. An orb under 40 px only blinks and watches. + * + * Every orb on the page draws on one shared animation frame, and only while it is on screen and the + * tab is shown. With reduced motion, or `still`, it is a still in the state's pose, drawn again + * only when the state or the theme changes, and its eyes stay put. + */ +export function BotOrb({ + className, + palette, + size, + state = "idle", + still = false, +}: { + className?: string; + palette: OrbPaletteName; + size: number; + state?: BotOrbState; + still?: boolean; +}) { + const boxRef = useRef(null); + const canvasRef = useRef(null); + const reduced = (useReducedMotion() ?? false) || still; + const clock = useRef<{ start: number; seed: number } | null>(null); + clock.current ??= { start: performance.now(), seed: ++seeds * 7 }; + const motion = useRef(null); + const shown = useRef(null); + const current = useRef(state); + const gaze = useRef(null); + gaze.current ??= restingGaze(); + + // It starts at rest in its first state, except that a first "done" still lands. After that a + // change moves every track from where it is, so a change mid-motion never jumps. A finish moves + // no track: it plays over the state it was in, and coming back to that state changes nothing. + useEffect(() => { + current.current = state; + if (!clock.current) return; + const t = secondsSince(clock.current.start); + if (state === "finished") { + motion.current = { + ...(motion.current ?? settled("idle")), + finishedAt: t, + }; + return; + } + if (shown.current === state) return; + const before = motion.current; + motion.current = + shown.current === null && state !== "done" + ? { ...settled(state), finishedAt: before?.finishedAt ?? -1 } + : enter(before ?? settled("idle"), state, t); + shown.current = state; + }, [state]); + + // A still: a composed picture in the state's pose, eyes open, its look held, no ping; finished, + // the check in place of the eyes, with no burst, for as long as it is held. Nothing draws it + // again on its own, so it is redrawn when the theme flips the root's `dark` class. + useEffect(() => { + const canvas = canvasRef.current; + const draw = reduced && canvas ? surface(canvas, size) : null; + if (!draw || !clock.current) return; + const { seed } = clock.current; + const held = + state === "finished" + ? { ...settled(shown.current ?? "idle"), finishedAt: 0 } + : settled(state); + const paint = () => + draw( + poseAt(held, 1.6, seed, { reduced: true, small: size < SMALL }), + palette, + ); + paint(); + const theme = new MutationObserver(paint); + theme.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => theme.disconnect(); + }, [reduced, size, palette, state]); + + // Alive: drawn on the shared clock while it is on screen. It counts as on screen until an + // IntersectionObserver says otherwise, and always where there is none to ask. It reads the theme + // on every frame, so a flip shows on the next one. + useEffect(() => { + const canvas = canvasRef.current; + const box = boxRef.current; + const draw = !reduced && canvas ? surface(canvas, size) : null; + if (!draw || !box || !clock.current || !gaze.current) return; + const { start, seed } = clock.current; + const eyes = gaze.current; + const small = size < SMALL; + const measure = () => box.getBoundingClientRect(); + const frame = (now: number) => { + const t = Math.max(0, (now - start) / 1000); + const state = current.current; + if (followPointer(eyes, t, state, measure) && motion.current) { + motion.current = { ...motion.current, noticedAt: t }; + } + const pose = poseAt(motion.current ?? settled(state), t, seed, { + reduced: false, + small, + }); + draw(lookAt(pose, eyes), palette); + }; + let unsubscribe: (() => void) | null = null; + const onScreen = (on: boolean) => { + if (on && !unsubscribe) unsubscribe = subscribeFrame(frame); + if (!on && unsubscribe) { + unsubscribe(); + unsubscribe = null; + } + }; + const sight = + "IntersectionObserver" in window + ? new IntersectionObserver((entries) => + // The newest entry is where it is now. + onScreen(entries.at(-1)?.isIntersecting ?? false), + ) + : null; + sight?.observe(box); + onScreen(true); + const unwatch = subscribePointer(); + return () => { + sight?.disconnect(); + onScreen(false); + unwatch(); + }; + }, [reduced, size, palette]); + + const spill = size * SPILL; + return ( + + + + ); +} diff --git a/app/src/components/agents/orb/orb-clock.ts b/app/src/components/agents/orb/orb-clock.ts new file mode 100644 index 0000000000..176196bbfb --- /dev/null +++ b/app/src/components/agents/orb/orb-clock.ts @@ -0,0 +1,140 @@ +/** + * What every orb on the page shares, so a list of fifty Bots costs one animation loop and one set of + * listeners rather than fifty of each: a clock that asks the browser for one frame and draws every + * orb in it, and where the pointer is, for the orbs whose eyes follow it. + * + * Both start with their first subscriber and stop with their last, so nothing is left running or + * listening on a page with no orb on it. + */ + +type FrameCallback = (now: number) => void; + +const subscribers = new Set(); +let frame: number | null = null; +/** The document whose visibility the clock follows, while it has subscribers. */ +let watched: Document | null = null; + +function run(now: number) { + frame = null; + // A Set visits what is added during the loop and skips what is removed, so an orb that unmounts + // mid-frame is not drawn after it has gone. + for (const callback of subscribers) callback(now); + schedule(); +} + +function schedule() { + if (frame !== null || subscribers.size === 0 || document.hidden) return; + frame = window.requestAnimationFrame(run); +} + +function halt() { + if (frame !== null) window.cancelAnimationFrame(frame); + frame = null; +} + +/** A hidden tab draws nothing, and picks up again where its orbs are now once it is shown. */ +function onVisibility() { + if (document.hidden) halt(); + else schedule(); +} + +/** + * Calls `callback` once on every animation frame, with the frame's timestamp, until the returned + * function is called. Every subscriber is drawn in the same frame; no frame is asked for while the + * tab is hidden or nobody is subscribed. + */ +export function subscribeFrame(callback: FrameCallback): () => void { + subscribers.add(callback); + if (!watched) { + watched = document; + watched.addEventListener("visibilitychange", onVisibility); + } + schedule(); + return () => { + if (!subscribers.delete(callback) || subscribers.size > 0) return; + halt(); + watched?.removeEventListener("visibilitychange", onVisibility); + watched = null; + }; +} + +// ---------- the pointer ---------- + +type PointerState = { x: number; y: number; on: boolean; epoch: number }; + +const state: PointerState = { x: 0, y: 0, on: false, epoch: 0 }; + +/** + * Where the pointer is, in client px, and whether it is on the page at all. `epoch` counts every + * move, scroll and resize, so an orb measures itself again only when something has changed. Kept + * up to date only while something is subscribed with `subscribePointer`. + */ +export const pointer: Readonly = state; + +let watchers = 0; +let lift: ReturnType | undefined; +let listening: Window | null = null; + +const aim = (event: PointerEvent) => { + state.x = event.clientX; + state.y = event.clientY; + state.on = true; + state.epoch++; + clearTimeout(lift); +}; +const away = () => { + state.on = false; + state.epoch++; +}; +// A finger lifts off the glass; a mouse stays where it was. Give a tap a moment of being watched. +const lifted = (event: PointerEvent) => { + if (event.pointerType !== "mouse") lift = setTimeout(away, 1600); +}; +const left = (event: PointerEvent) => { + if (!event.relatedTarget) away(); +}; +const moved = () => { + state.epoch++; +}; + +function listen(target: Window) { + target.addEventListener("pointermove", aim, { passive: true }); + target.addEventListener("pointerdown", aim, { passive: true }); + target.addEventListener("pointerup", lifted, { passive: true }); + target.document.addEventListener("pointerout", left, { passive: true }); + target.addEventListener("blur", away); + target.addEventListener("scroll", moved, { capture: true, passive: true }); + target.addEventListener("resize", moved); +} + +function unlisten(target: Window) { + target.removeEventListener("pointermove", aim); + target.removeEventListener("pointerdown", aim); + target.removeEventListener("pointerup", lifted); + target.document.removeEventListener("pointerout", left); + target.removeEventListener("blur", away); + target.removeEventListener("scroll", moved, { capture: true }); + target.removeEventListener("resize", moved); + clearTimeout(lift); + // Nobody is following it now, so what it last knew is stale for whoever subscribes next. + away(); +} + +/** + * Keeps `pointer` up to date until the returned function is called: one set of window listeners for + * every orb that watches. + */ +export function subscribePointer(): () => void { + if (watchers++ === 0) { + listening = window; + listen(listening); + } + let subscribed = true; + return () => { + if (!subscribed) return; + subscribed = false; + if (--watchers > 0 || !listening) return; + unlisten(listening); + listening = null; + }; +} diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index eda56ee4ac..8297202c87 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -1,4 +1,19 @@ -import { describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { cleanup, render } from "@testing-library/react"; +import { + BotOrb, + type BotOrbState, + followPointer, + restingGaze, +} from "@/components/agents/orb/bot-orb"; import { drawBotOrb, FINISH_HELD, @@ -12,6 +27,15 @@ import { RAYS, track, } from "@/components/agents/orb/bot-orb-draw"; +import { subscribePointer } from "@/components/agents/orb/orb-clock"; +import { settleReactWork } from "./settle-react-work"; + +beforeAll(() => GlobalRegistrator.register({ url: "http://localhost/" })); +afterEach(() => cleanup()); +afterAll(async () => { + await settleReactWork(); + await GlobalRegistrator.unregister(); +}); const REST: OrbPose = { ta: 1.6, @@ -191,3 +215,364 @@ describe("a finish", () => { expect(strokesOf({ ...REST, finishAge: FINISH_S })).toBe(rest); }); }); + +/** Every canvas draws on `ctx` until `restore`, as if each had its own 2D context. */ +function withContext(ctx: ReturnType) { + const getContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = (() => + ctx) as unknown as typeof getContext; + return () => { + HTMLCanvasElement.prototype.getContext = getContext; + }; +} + +/** + * Animation frames that run only when the test says, as the browser would at its next paint: + * `requested` counts every frame asked for, `pending` the ones not yet run or cancelled. + */ +function manualFrames() { + const queue = new Map(); + let next = 1; + let requested = 0; + const request = window.requestAnimationFrame; + const cancel = window.cancelAnimationFrame; + window.requestAnimationFrame = (callback) => { + requested++; + queue.set(next, callback); + return next++; + }; + window.cancelAnimationFrame = (id) => { + queue.delete(id); + }; + return { + get requested() { + return requested; + }, + get pending() { + return queue.size; + }, + run() { + const due = [...queue.values()]; + queue.clear(); + const now = performance.now(); + for (const callback of due) callback(now); + }, + restore() { + window.requestAnimationFrame = request; + window.cancelAnimationFrame = cancel; + }, + }; +} + +/** An `IntersectionObserver` that reports only what the test tells it to. */ +class Sight { + static all: Sight[] = []; + readonly root = null; + readonly rootMargin = ""; + readonly thresholds = []; + targets: Element[] = []; + + constructor(private readonly callback: IntersectionObserverCallback) { + Sight.all.push(this); + } + + observe(target: Element) { + this.targets.push(target); + } + + unobserve(target: Element) { + this.targets = this.targets.filter((t) => t !== target); + } + + disconnect() { + this.targets = []; + } + + takeRecords() { + return []; + } + + static show(target: Element, isIntersecting: boolean) { + for (const sight of Sight.all) { + if (!sight.targets.includes(target)) continue; + sight.callback( + [{ target, isIntersecting } as IntersectionObserverEntry], + sight as unknown as IntersectionObserver, + ); + } + } +} + +/** Runs `body` with `IntersectionObserver` set to `observer`, or missing when it is undefined. */ +function withObserver(observer: typeof Sight | undefined, body: () => void) { + const original = globalThis.IntersectionObserver; + if (observer) { + globalThis.IntersectionObserver = + observer as unknown as typeof IntersectionObserver; + } else { + Reflect.deleteProperty(globalThis, "IntersectionObserver"); + } + try { + body(); + } finally { + globalThis.IntersectionObserver = original; + Sight.all = []; + } +} + +const orbOf = (container: HTMLElement) => + container.querySelector("[data-state]") as HTMLElement; + +describe("", () => { + test("holds its footprint, hides from assistive tech, and draws on a canvas twice its size", () => { + const view = render( + , + ); + const orb = view.container.firstElementChild as HTMLElement; + expect(orb.getAttribute("aria-hidden")).toBe("true"); + expect(orb.dataset.state).toBe("listening"); + expect(orb.dataset.palette).toBe("violet"); + expect(orb.style.width).toBe("40px"); + const canvas = orb.querySelector("canvas") as HTMLCanvasElement; + expect(canvas.style.width).toBe("80px"); + expect(canvas.style.left).toBe("-20px"); + }); + + test("still, it draws once and again only when the theme changes, never on a frame loop", async () => { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + try { + render(); + expect(ctx.draws).toBe(1); + document.documentElement.classList.add("dark"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(ctx.draws).toBe(2); + expect(frames.requested).toBe(0); + + cleanup(); + render(); + expect(frames.requested).toBe(1); + } finally { + cleanup(); + document.documentElement.classList.remove("dark"); + frames.restore(); + restore(); + } + }); + + test("still, finished is the check without the burst", () => { + const strokes = (state?: "finished") => { + const ctx = fakeContext(); + const restore = withContext(ctx); + try { + render(); + cleanup(); + return ctx.strokes; + } finally { + restore(); + } + }; + expect(strokes("finished")).toBe(strokes() + 1); + }); + + test("follows a change of state", () => { + const view = render(); + expect(orbOf(view.container).dataset.state).toBe("idle"); + view.rerender(); + expect(orbOf(view.container).dataset.state).toBe("thinking"); + view.rerender(); + expect(orbOf(view.container).dataset.state).toBe("finished"); + }); + + test("fifty orbs share one animation frame", () => { + withObserver(undefined, () => { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + try { + render( +
+ {Array.from({ length: 50 }, (_, i) => `bot-${i}`).map((id) => ( + + ))} +
, + ); + expect(frames.requested).toBe(1); + frames.run(); + expect(ctx.draws).toBe(50); + expect(frames.requested).toBe(2); + + // The last one gone, the clock stops asking for frames. + cleanup(); + expect(frames.pending).toBe(0); + } finally { + cleanup(); + frames.restore(); + restore(); + } + }); + }); + + test("an orb off screen is not drawn", () => { + withObserver(Sight, () => { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + try { + const view = render(); + const orb = orbOf(view.container); + Sight.show(orb, false); + expect(frames.pending).toBe(0); + frames.run(); + expect(ctx.draws).toBe(0); + + Sight.show(orb, true); + expect(frames.pending).toBe(1); + frames.run(); + expect(ctx.draws).toBe(1); + } finally { + cleanup(); + frames.restore(); + restore(); + } + }); + }); + + test("nothing draws while the tab is hidden", () => { + withObserver(undefined, () => { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + const hide = (hidden: boolean) => { + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => hidden, + }); + document.dispatchEvent(new Event("visibilitychange")); + }; + try { + render(); + expect(frames.pending).toBe(1); + hide(true); + expect(frames.pending).toBe(0); + frames.run(); + expect(ctx.draws).toBe(0); + + hide(false); + expect(frames.pending).toBe(1); + frames.run(); + expect(ctx.draws).toBe(1); + } finally { + Reflect.deleteProperty(document, "hidden"); + cleanup(); + frames.restore(); + restore(); + } + }); + }); + + test("renders with no canvas context", () => { + const frames = manualFrames(); + try { + const view = render( + , + ); + expect(view.container.querySelector("canvas")).not.toBeNull(); + // Nothing to draw on, so it asks for no frames. + expect(frames.requested).toBe(0); + } finally { + cleanup(); + frames.restore(); + } + }); +}); + +describe("its eyes", () => { + /** An orb 40 px across, centred at (120, 120). */ + const rect = { left: 100, top: 100, width: 40, height: 40 }; + const measure = () => rect; + const moveTo = (x: number, y: number) => + window.dispatchEvent( + new PointerEvent("pointermove", { clientX: x, clientY: y }), + ); + /** A second of frames at 30 a second, from `from` seconds on, and whether any of them perked up. */ + const watchFor = ( + gaze: ReturnType, + state: BotOrbState, + from = 0, + ) => { + let perked = false; + for (let i = 1; i <= 30; i++) { + perked = followPointer(gaze, from + i / 30, state, measure) || perked; + } + return perked; + }; + + test("turn to the pointer, fully at rest and partly while it works", () => { + const stop = subscribePointer(); + try { + moveTo(600, 120); + const idle = restingGaze(); + const thinking = restingGaze(); + watchFor(idle, "idle"); + watchFor(idle, "idle", 1); + watchFor(thinking, "thinking"); + watchFor(thinking, "thinking", 1); + // Far to its right: it looks right, level. + expect(idle.x).toBeGreaterThan(0.6); + expect(Math.abs(idle.y)).toBeLessThan(0.05); + expect(idle.w).toBeCloseTo(1, 2); + expect(thinking.w).toBeCloseTo(0.35, 2); + } finally { + stop(); + } + }); + + test("perk up once when the pointer comes close, and let go only well past it", () => { + const stop = subscribePointer(); + try { + moveTo(600, 120); + const gaze = restingGaze(); + expect(watchFor(gaze, "idle")).toBe(false); + + // Within 0.9 of its size of its centre. + moveTo(150, 120); + expect(watchFor(gaze, "idle", 1)).toBe(true); + expect(watchFor(gaze, "idle", 2)).toBe(false); + + // Out to 1.2 of its size: still close, no second perk. + moveTo(168, 120); + expect(watchFor(gaze, "idle", 3)).toBe(false); + moveTo(150, 120); + expect(watchFor(gaze, "idle", 4)).toBe(false); + + // Past 1.3 it lets go, and the next approach perks again. + moveTo(180, 120); + watchFor(gaze, "idle", 5); + moveTo(150, 120); + expect(watchFor(gaze, "idle", 6)).toBe(true); + } finally { + stop(); + } + }); + + test("a listening orb has already noticed you, and stops watching once the pointer leaves", () => { + const stop = subscribePointer(); + try { + moveTo(150, 120); + const gaze = restingGaze(); + expect(watchFor(gaze, "listening")).toBe(false); + expect(gaze.w).toBeGreaterThan(0.4); + + document.dispatchEvent( + new PointerEvent("pointerout", { bubbles: true, relatedTarget: null }), + ); + watchFor(gaze, "listening", 1); + watchFor(gaze, "listening", 2); + expect(gaze.w).toBeLessThan(0.01); + } finally { + stop(); + } + }); +}); From 7a8f072e7eefdd6a12e29a815cda0e90be3dd403 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 13:56:05 -0700 Subject: [PATCH 05/47] feat(orb): every Bot and OpenBot's own mark draw the orb --- app/src/components/agents/abstract-avatar.tsx | 15 +- app/src/components/agents/orb/agent-orb.tsx | 458 ++---------------- app/src/components/agents/orb/ai-core.tsx | 395 --------------- app/tests/bot-orb.test.tsx | 51 ++ app/tests/channel-work-row.test.tsx | 2 +- 5 files changed, 96 insertions(+), 825 deletions(-) delete mode 100644 app/src/components/agents/orb/ai-core.tsx diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index 9ca9845070..57df2578c9 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,5 +1,13 @@ -import Avatar from "boring-avatars"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; +/** + * A Bot's face: its orb, in the colour its seed picks, so the same Bot looks the same everywhere. + * The orb hides itself from assistive tech, so the Bot is announced once, by name. + * + * Clipped to its circle, so its glow never spills into the row beside it or makes a list scroll. + * At rest the glow is too faint that far out to be missed. + */ export function AbstractAvatar({ name, seed, @@ -16,10 +24,7 @@ export function AbstractAvatar({ className="inline-flex shrink-0 overflow-hidden rounded-full" style={{ height: size, width: size }} > - {/* The drawing carries its own role; hidden so the coworker is announced once, by name. */} - + ); } diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx index 9fc2c7f393..a5365af6b1 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -1,437 +1,47 @@ -import { - type MotionStyle, - motion, - type TargetAndTransition, - type Transition, - useReducedMotion, - useTransform, -} from "motion/react"; import { cn } from "@/lib/utils"; -import { - type AIAmplitude, - type AIState, - getAIStateMotion, - useAmplitudeValue, -} from "./ai-core"; - -const SIZE_THRESHOLD_SMALL = 50; -const SIZE_THRESHOLD_TINY = 30; -const SIZE_THRESHOLD_MEDIUM = 100; -const BLUR_MULTIPLIER_SMALL = 0.008; -const BLUR_MIN_SMALL = 1; -const BLUR_MULTIPLIER_LARGE = 0.015; -const BLUR_MIN_LARGE = 4; -const CONTRAST_MULTIPLIER_SMALL = 0.004; -const CONTRAST_MIN_SMALL = 1.2; -const CONTRAST_MULTIPLIER_LARGE = 0.008; -const CONTRAST_MIN_LARGE = 1.5; -const DOT_SIZE_MULTIPLIER_SMALL = 0.004; -const DOT_SIZE_MIN_SMALL = 0.05; -const DOT_SIZE_MULTIPLIER_LARGE = 0.008; -const DOT_SIZE_MIN_LARGE = 0.1; -const SHADOW_MULTIPLIER_SMALL = 0.004; -const SHADOW_MIN_SMALL = 0.5; -const SHADOW_MULTIPLIER_LARGE = 0.008; -const SHADOW_MIN_LARGE = 2; -const MASK_RADIUS_TINY = "0%"; -const MASK_RADIUS_SMALL = "5%"; -const MASK_RADIUS_MEDIUM = "15%"; -const MASK_RADIUS_LARGE = "25%"; -const CONTRAST_TINY = 1.1; -const CONTRAST_MULTIPLIER_FINAL = 1.2; -const CONTRAST_MIN_FINAL = 1.3; - -/** Loud audio tightens the gradient, which reads as the orb "focusing". */ -const AMPLITUDE_BLUR_FALLOFF = 0.45; -/** Amplitude adds at most 12% of extra size on top of the state scale. */ -const AMPLITUDE_SCALE_GAIN = 0.12; -/** Single lateral nudge used to signal `error`, well under 200ms. */ -const ERROR_SHAKE_KEYFRAMES = [0, -3, 3, 0]; -const ERROR_SHAKE_DURATION = 0.18; -const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const; -const GLOW_BLUR_RATIO = 0.28; -const GLOW_MAX_OPACITY = 0.7; -/** Depth rim, as a fraction of the orb size. */ -const RIM_RATIO = 0.06; -const RIM_MIN = 1.5; -/** Sheen drift period at rest, before the state speed divides it. */ -const DRIFT_BASE_SECONDS = 12; -/** `idle` breathes: a slow, shallow scale cycle that never draws attention. */ -const BREATHE_SCALE = [1, 1.035, 1]; -const BREATHE_SECONDS = 5.5; -const SPRING_DEFAULT: Transition = { - bounce: 0.1, - duration: 0.25, - type: "spring", +import { BotOrb, type BotOrbState } from "./bot-orb"; + +/** What OpenBot is doing, wherever its own mark stands for it. */ +export type AIState = + | "idle" + | "listening" + | "thinking" + | "streaming" + | "done" + | "error"; + +/** + * Each state as the orb shows it. Streaming is still working. An error rests, and the words beside + * the mark say what went wrong. + */ +const ORB_STATE: Record = { + idle: "idle", + listening: "listening", + thinking: "thinking", + streaming: "thinking", + done: "done", + error: "idle", }; export interface AgentOrbProps { - /** - * Live audio level, 0–1. Pass the `MotionValue` from `useAudioAmplitude` so - * the signal never re-renders React. - */ - amplitude?: AIAmplitude; - /** Ambient rotation period in seconds, before the state speed multiplier. */ - animationDuration?: number; className?: string; - colors?: { - bg?: string; - c1?: string; - c2?: string; - c3?: string; - /** Fourth mesh stop. More stops means fewer visible repeats per rotation. */ - c4?: string; - }; + /** Its footprint, in px, as "64px". */ size?: string; - /** Shared AI state driving speed, scale, saturation and reactivity. */ state?: AIState; } -const AgentOrb: React.FC = ({ - size = "192px", +/** OpenBot's own mark: the orb, in OpenBot's violet. */ +export default function AgentOrb({ className, - colors, - animationDuration = 20, - amplitude, + size = "192px", state = "idle", -}) => { - const shouldReduceMotion = useReducedMotion(); - const amplitudeValue = useAmplitudeValue(amplitude); - const stateMotion = getAIStateMotion(state); - - /** - * Higher chroma than the original pastels. - * - * The first palette sat at chroma 0.12–0.15, which washes out once the mesh is - * blurred and the dot pattern is overlaid on top. Pushing to ~0.2 and adding a - * saturate() pass to the mesh is what makes the colour survive all that. - */ - const defaultColors = { - bg: "oklch(92% 0.03 300)", - c1: "oklch(68% 0.21 350)", // Pink - c2: "oklch(70% 0.18 210)", // Blue - c3: "oklch(66% 0.2 285)", // Violet - c4: "oklch(72% 0.19 325)", // Magenta, the fourth stop for variety - }; - - const finalColors = { ...defaultColors, ...colors }; - // The bloom stays in the orb's own palette — see the note in ai-orb-aura. - const glowColor = finalColors.c2; - - // Extract numeric value from size for calculations - const sizeValue = Number.parseInt(size.replace("px", ""), 10); - - // Responsive calculations based on size - const blurAmount = - sizeValue < SIZE_THRESHOLD_SMALL - ? Math.max(sizeValue * BLUR_MULTIPLIER_SMALL, BLUR_MIN_SMALL) // Reduced blur for small sizes - : Math.max(sizeValue * BLUR_MULTIPLIER_LARGE, BLUR_MIN_LARGE); - - const contrastAmount = - sizeValue < SIZE_THRESHOLD_SMALL - ? Math.max(sizeValue * CONTRAST_MULTIPLIER_SMALL, CONTRAST_MIN_SMALL) // Reduced contrast for small sizes - : Math.max(sizeValue * CONTRAST_MULTIPLIER_LARGE, CONTRAST_MIN_LARGE); - - const dotSize = - sizeValue < SIZE_THRESHOLD_SMALL - ? Math.max(sizeValue * DOT_SIZE_MULTIPLIER_SMALL, DOT_SIZE_MIN_SMALL) // Smaller dots for small sizes - : Math.max(sizeValue * DOT_SIZE_MULTIPLIER_LARGE, DOT_SIZE_MIN_LARGE); - - const shadowSpread = - sizeValue < SIZE_THRESHOLD_SMALL - ? Math.max(sizeValue * SHADOW_MULTIPLIER_SMALL, SHADOW_MIN_SMALL) // Reduced shadow for small sizes - : Math.max(sizeValue * SHADOW_MULTIPLIER_LARGE, SHADOW_MIN_LARGE); - - // Adjust mask radius based on size to reduce black center in small sizes - const getMaskRadius = (value: number) => { - if (value < SIZE_THRESHOLD_TINY) { - return MASK_RADIUS_TINY; - } - if (value < SIZE_THRESHOLD_SMALL) { - return MASK_RADIUS_SMALL; - } - if (value < SIZE_THRESHOLD_MEDIUM) { - return MASK_RADIUS_MEDIUM; - } - return MASK_RADIUS_LARGE; - }; - - const maskRadius = getMaskRadius(sizeValue); - - // Use more subtle contrast for very small sizes - const getFinalContrast = (value: number) => { - if (value < SIZE_THRESHOLD_TINY) { - return CONTRAST_TINY; // Very subtle contrast for tiny sizes - } - if (value < SIZE_THRESHOLD_SMALL) { - return Math.max( - contrastAmount * CONTRAST_MULTIPLIER_FINAL, - CONTRAST_MIN_FINAL, - ); // Reduced contrast for small sizes - } - return contrastAmount; - }; - - const finalContrast = getFinalContrast(sizeValue); - - // Reactivity is gated by the state preset: `thinking` barely listens so the - // orb keeps churning internally instead of throbbing with room noise. - const reactivity = shouldReduceMotion ? 0 : stateMotion.reactivity; - - const reactiveBlur = useTransform(amplitudeValue, (level) => { - const focus = 1 - level * reactivity * AMPLITUDE_BLUR_FALLOFF; - return `${blurAmount * focus}px`; - }); - - const reactiveScale = useTransform( - amplitudeValue, - (level) => stateMotion.scale + level * reactivity * AMPLITUDE_SCALE_GAIN, - ); - - const loopDuration = shouldReduceMotion - ? animationDuration - : animationDuration / stateMotion.speed; - - // Rim thickness scales with the orb, so the lit edge reads the same at 24px - // and at 240px instead of swallowing the small one. - const rim = Math.max(sizeValue * RIM_RATIO, RIM_MIN); - // The sheen drifts slower than the mesh rotates; a busier state speeds it up - // a touch without matching the rotation, which would look mechanical. - const driftDuration = (DRIFT_BASE_SECONDS / (1 + stateMotion.speed)) * 2; - - const getRootAnimate = (): TargetAndTransition => { - if (shouldReduceMotion) { - return { scale: 1, x: 0 }; - } - if (state === "error") { - return { scale: 1, x: ERROR_SHAKE_KEYFRAMES }; - } - if (stateMotion.motif === "breathe") { - return { scale: BREATHE_SCALE, x: 0 }; - } - return { scale: 1, x: 0 }; - }; - - const getRootTransition = (): Transition => { - if (shouldReduceMotion) { - return { duration: 0 }; - } - if (state === "error") { - return { duration: ERROR_SHAKE_DURATION, ease: EASE_IN_OUT }; - } - if (stateMotion.motif === "breathe") { - return { - duration: BREATHE_SECONDS, - ease: EASE_IN_OUT, - repeat: Number.POSITIVE_INFINITY, - }; - } - return SPRING_DEFAULT; - }; - +}: AgentOrbProps) { return ( - // The gradient disc clips its own overflow, so the bloom and the status - // motif have to live in a wrapper rather than inside it. - - {/* Bloom tinted by the state's semantic accent. */} - - - - {/* Specular sheen and depth rim. The mesh alone reads as a flat blurred - disc; a drifting highlight plus a lit top edge and shaded bottom are - what make it read as a sphere. */} - - + ); -}; - -export default AgentOrb; +} diff --git a/app/src/components/agents/orb/ai-core.tsx b/app/src/components/agents/orb/ai-core.tsx deleted file mode 100644 index a1f13a6361..0000000000 --- a/app/src/components/agents/orb/ai-core.tsx +++ /dev/null @@ -1,395 +0,0 @@ -"use client"; - -import { - type MotionValue, - useAnimationFrame, - useMotionValue, -} from "motion/react"; -import { useCallback, useEffect, useRef, useState } from "react"; - -/** - * The single state contract shared by every SmoothUI AI component. - * - * Passing the same value to an orb, a prompt input and a tool card makes the - * whole surface move as one organism instead of a set of independent widgets. - */ -export type AIState = - | "idle" - | "listening" - | "thinking" - | "streaming" - | "done" - | "error"; - -/** - * A behavioural hint each component expresses **in its own material**. - * - * Deliberately not an overlay. Bolting one shared graphic onto every orb makes - * four different materials look like the same widget wearing a costume, and it - * pushes literal iconography (a checkmark, a warning ring) onto pieces that are - * decorative by nature. Instead: a shader warps, a canvas ring loses harmonics, - * a character changes expression. Same vocabulary, different flesh. - */ -export type AIStateMotif = - | "breathe" - | "receive" - | "scan" - | "pulse" - | "ping" - | "fault"; - -/** Semantic accent applied on top of the component's own palette. */ -export type AIStateAccent = "success" | "danger" | null; - -/** Motion parameters a component reads to render a given {@link AIState}. */ -export type AIStateMotion = { - /** Semantic colour override, so status is carried by hue and not only motion. */ - accent: AIStateAccent; - /** Outer bloom strength, 0–1. */ - glow: number; - /** Palette hue rotation in degrees. Small shifts read as a mood change. */ - hueRotate: number; - /** Overall motion energy, 0–1. Scales ambient loops and displacement. */ - intensity: number; - /** Behavioural hint; each component expresses it in its own material. */ - motif: AIStateMotif; - /** Seconds between discrete pulses, for rhythmic behaviour. */ - pulseSeconds: number; - /** - * How far a shader's domain warp pushes the field, 0–1. Low values read as a - * calm surface; high values churn without the silhouette growing. - */ - turbulence: number; - /** Revolutions per second of the noise field — the slow tumble. */ - tumble: number; - /** How much external amplitude reaches the surface, 0 = ignore it. */ - reactivity: number; - /** Chroma multiplier. Below 1 desaturates. */ - saturation: number; - /** Resting scale of the surface, 1 = no change. */ - scale: number; - /** Ambient loop speed multiplier, 1 = the component's base tempo. */ - speed: number; -}; - -/** - * Per-state presets. - * - * Deliberate choices worth keeping: - * - `thinking` has `scale: 1` — internal churn only, so layout stays calm while - * the model works. Growing the surface here makes pages feel unstable. - * - `error` desaturates instead of growing, so it reads as a state change - * rather than an attention grab. - * - `done` overshoots once; components are expected to settle back to `idle`. - * - Each state owns a distinct `motif`, so the six states are told apart by - * what moves, not by how fast it moves. - */ -export const AI_STATE_MOTION: Record = { - done: { - accent: "success", - glow: 0.7, - hueRotate: 0, - intensity: 0.4, - motif: "ping", - pulseSeconds: 0.65, - reactivity: 0, - saturation: 1, - scale: 1.1, - speed: 0.8, - // The field settles almost flat — stillness is what reads as "finished". - tumble: 0.02, - turbulence: 0.08, - }, - error: { - accent: "danger", - glow: 0.25, - hueRotate: 0, - intensity: 0.5, - motif: "fault", - pulseSeconds: 0.9, - reactivity: 0, - saturation: 0.3, - scale: 0.96, - speed: 1, - // Tumble stalls while turbulence stays mid: the surface twitches in place - // instead of flowing, which is what a fault feels like. - tumble: 0, - turbulence: 0.55, - }, - idle: { - accent: null, - glow: 0.15, - hueRotate: 0, - intensity: 0.3, - motif: "breathe", - pulseSeconds: 4.5, - reactivity: 0, - saturation: 0.75, - scale: 0.94, - speed: 0.6, - tumble: 0.012, - turbulence: 0.14, - }, - listening: { - accent: null, - glow: 0.6, - hueRotate: 0, - intensity: 0.75, - motif: "receive", - pulseSeconds: 1.6, - reactivity: 1, - saturation: 1.05, - scale: 1.06, - speed: 1, - tumble: 0.03, - turbulence: 0.42, - }, - streaming: { - accent: null, - glow: 0.45, - hueRotate: -10, - intensity: 0.6, - motif: "pulse", - pulseSeconds: 1.25, - reactivity: 0.6, - saturation: 1, - scale: 1.02, - speed: 1.4, - tumble: 0.06, - turbulence: 0.5, - }, - thinking: { - accent: null, - glow: 0.35, - hueRotate: 18, - intensity: 1, - motif: "scan", - pulseSeconds: 1.1, - reactivity: 0.15, - saturation: 1, - scale: 1, - speed: 2.4, - // High churn, unchanged silhouette: the work is visible without the orb - // growing and unsettling the layout around it. - tumble: 0.14, - turbulence: 0.95, - }, -}; - -/** Semantic accents. Deliberately not tokens — orbs render outside a theme. */ -export const AI_ACCENT_COLORS: Record<"success" | "danger", string> = { - danger: "oklch(63% 0.21 25)", - success: "oklch(72% 0.17 150)", -}; - -/** - * Colour used by a state's overlay motif: the semantic accent when the state - * has one, otherwise the component's own secondary colour. - */ -export const getAIStateAccentColor = ( - state: AIState | undefined, - fallback: string, -): string => { - const accent = AI_STATE_MOTION[state ?? "idle"]?.accent; - return accent ? AI_ACCENT_COLORS[accent] : fallback; -}; - -/** Motion preset for a state, falling back to `idle` for unknown values. */ -export const getAIStateMotion = (state: AIState | undefined): AIStateMotion => - AI_STATE_MOTION[state ?? "idle"] ?? AI_STATE_MOTION.idle; - -/** - * Amplitude accepted by every reactive AI component. - * - * A `MotionValue` is the preferred form: it updates outside React, so a 60fps - * audio signal never triggers a re-render. - */ -export type AIAmplitude = number | MotionValue | undefined; - -const isMotionValue = (value: AIAmplitude): value is MotionValue => - typeof value === "object" && value !== null && "get" in value; - -/** - * Normalises the `amplitude` prop into a stable `MotionValue` so - * component internals only deal with one shape. - */ -export const useAmplitudeValue = ( - amplitude: AIAmplitude, -): MotionValue => { - const fallback = useMotionValue(0); - const numeric = typeof amplitude === "number" ? amplitude : null; - - useEffect(() => { - if (numeric !== null) { - fallback.set(numeric); - } - }, [numeric, fallback]); - - return isMotionValue(amplitude) ? amplitude : fallback; -}; - -export type AudioAmplitudeStatus = - | "idle" - | "requesting" - | "active" - | "denied" - | "unsupported"; - -export type UseAudioAmplitudeOptions = { - /** Request microphone access as soon as the hook mounts. */ - autoStart?: boolean; - /** - * Envelope smoothing, 0–1. Higher is smoother and lazier; the default keeps - * attack snappy so an orb reacts on the first syllable. - */ - smoothing?: number; - /** FFT size handed to the analyser node. Must be a power of two. */ - fftSize?: number; -}; - -export type UseAudioAmplitudeResult = { - /** Smoothed RMS level, 0–1, as a `MotionValue`. */ - amplitude: MotionValue; - status: AudioAmplitudeStatus; - start: () => Promise; - stop: () => void; -}; - -const DEFAULT_SMOOTHING = 0.55; -const DEFAULT_FFT_SIZE = 512; -/** Raw RMS rarely exceeds ~0.3 for speech, so normalise into a usable 0–1. */ -const RMS_TO_UNIT = 3.2; -/** Attack is faster than release so peaks land immediately and decay gently. */ -const ATTACK_FACTOR = 0.35; - -/** - * Reads microphone loudness as a 0–1 `MotionValue`. - * - * SSR-safe, permission-aware, and silent on failure — a denied prompt leaves - * the amplitude at 0 so the consuming component simply falls back to its - * ambient animation. - */ -export const useAudioAmplitude = ( - options: UseAudioAmplitudeOptions = {}, -): UseAudioAmplitudeResult => { - const { - autoStart = false, - smoothing = DEFAULT_SMOOTHING, - fftSize = DEFAULT_FFT_SIZE, - } = options; - - const amplitude = useMotionValue(0); - const [status, setStatus] = useState("idle"); - - const contextRef = useRef(null); - const streamRef = useRef(null); - const analyserRef = useRef(null); - const bufferRef = useRef | null>(null); - - const stop = useCallback(() => { - for (const track of streamRef.current?.getTracks() ?? []) { - track.stop(); - } - streamRef.current = null; - analyserRef.current = null; - bufferRef.current = null; - contextRef.current?.close(); - contextRef.current = null; - amplitude.set(0); - setStatus("idle"); - }, [amplitude]); - - const start = useCallback(async () => { - if (analyserRef.current) { - return; - } - - const AudioContextCtor = - typeof window === "undefined" - ? undefined - : (window.AudioContext ?? - (window as unknown as { webkitAudioContext?: typeof AudioContext }) - .webkitAudioContext); - - if (!(AudioContextCtor && navigator.mediaDevices?.getUserMedia)) { - setStatus("unsupported"); - return; - } - - setStatus("requesting"); - - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const context = new AudioContextCtor(); - const analyser = context.createAnalyser(); - analyser.fftSize = fftSize; - context.createMediaStreamSource(stream).connect(analyser); - - streamRef.current = stream; - contextRef.current = context; - analyserRef.current = analyser; - bufferRef.current = new Float32Array(analyser.fftSize); - setStatus("active"); - } catch { - setStatus("denied"); - } - }, [fftSize]); - - useEffect(() => { - if (autoStart) { - start(); - } - return stop; - }, [autoStart, start, stop]); - - useAnimationFrame(() => { - const analyser = analyserRef.current; - const buffer = bufferRef.current; - if (!(analyser && buffer)) { - return; - } - - analyser.getFloatTimeDomainData(buffer); - - let sumOfSquares = 0; - for (const sample of buffer) { - sumOfSquares += sample * sample; - } - const rms = Math.sqrt(sumOfSquares / buffer.length); - const target = Math.min(1, rms * RMS_TO_UNIT); - - const previous = amplitude.get(); - const factor = target > previous ? smoothing * ATTACK_FACTOR : smoothing; - amplitude.set(previous + (target - previous) * (1 - factor)); - }); - - return { amplitude, start, status, stop }; -}; - -/** - * Amplitude generator for demos, docs and previews — no microphone involved. - * - * Produces a plausible speech-like envelope whose energy follows the current - * {@link AIState}, so every example can show the reactive behaviour without - * asking the visitor for permissions. - */ -export const useSimulatedAmplitude = ( - state: AIState = "idle", -): MotionValue => { - const amplitude = useMotionValue(0); - const motion = getAIStateMotion(state); - - useAnimationFrame((time) => { - const t = time / 1000; - // Three detuned sines read as organic; a single sine reads as a metronome. - const envelope = - 0.5 + - 0.3 * Math.sin(t * 2.1 * motion.speed) + - 0.14 * Math.sin(t * 5.3 * motion.speed + 1.7) + - 0.06 * Math.sin(t * 11.7 * motion.speed + 0.4); - - amplitude.set(Math.min(1, Math.max(0, envelope * motion.intensity))); - }); - - return amplitude; -}; diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 8297202c87..333c2d7573 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -8,6 +8,8 @@ import { } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { cleanup, render } from "@testing-library/react"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import AgentOrb, { type AIState } from "@/components/agents/orb/agent-orb"; import { BotOrb, type BotOrbState, @@ -576,3 +578,52 @@ describe("its eyes", () => { } }); }); + +describe("a Bot's face and OpenBot's mark", () => { + test("a Bot's avatar is named once and drawn as its orb", () => { + const view = render( + , + ); + const avatar = view.getByRole("img", { name: "Research desk" }); + expect(view.queryAllByRole("img")).toHaveLength(1); + expect(avatar.style.width).toBe("36px"); + const orb = orbOf(avatar); + expect(orb.getAttribute("aria-hidden")).toBe("true"); + expect(orb.dataset.palette).toBe(orbPaletteFor("research-desk")); + expect(orb.style.width).toBe("36px"); + expect(avatar.querySelector("canvas")).not.toBeNull(); + expect(avatar.querySelector("svg")).toBeNull(); + }); + + test("two Bots with different seeds can differ in colour, the same seed never does", () => { + const paletteOf = (seed: string) => { + const view = render(); + const palette = orbOf(view.container).dataset.palette; + cleanup(); + return palette; + }; + expect(paletteOf("research-desk")).not.toBe(paletteOf("release-notes")); + expect(paletteOf("research-desk")).toBe(paletteOf("research-desk")); + }); + + test("OpenBot's mark is violet and maps every state", () => { + const states: Record = { + idle: "idle", + listening: "listening", + thinking: "thinking", + streaming: "thinking", + done: "done", + error: "idle", + }; + for (const [state, shown] of Object.entries(states)) { + const view = render(); + const orb = orbOf(view.container); + expect(orb.dataset.palette).toBe("violet"); + expect(orb.dataset.state).toBe(shown); + expect(orb.style.width).toBe("64px"); + cleanup(); + } + const view = render(); + expect(orbOf(view.container).style.width).toBe("192px"); + }); +}); diff --git a/app/tests/channel-work-row.test.tsx b/app/tests/channel-work-row.test.tsx index a37164c9f9..d4a7ab4b62 100644 --- a/app/tests/channel-work-row.test.tsx +++ b/app/tests/channel-work-row.test.tsx @@ -56,7 +56,7 @@ test("says who was asked, with their avatar and no clock", () => { // The avatar is decoration beside a label that already names the Bot, so it is announced once. const avatar = row.querySelector("[aria-hidden='true'] [role='img']"); expect(avatar?.getAttribute("aria-label")).toBe("Ana"); - expect(row.querySelector("svg")).not.toBeNull(); + expect(row.querySelector("canvas")).not.toBeNull(); }); test("prefers the name and avatar the person knows the Bot by", () => { From a901367689f02c0fe67963662faf96364a7eaee3 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:02:37 -0700 Subject: [PATCH 06/47] feat(orb): conversation faces and onboarding's example Bots draw the orb The last two places still drawing boring-avatars' gradient circles now draw the orb in the colour the Bot's id picks, so a Bot looks the same in the sidebar, the conversation header and its profile. Nothing imports boring-avatars any more, so it leaves the app's dependencies. --- app/package.json | 1 - app/src/components/channels/avatar.tsx | 14 +++++++++----- app/src/routes/_authed/onboarding.tsx | 4 ++-- bun.lock | 3 --- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/package.json b/app/package.json index b5be1f7ac8..406d4f0a40 100644 --- a/app/package.json +++ b/app/package.json @@ -28,7 +28,6 @@ "@tanstack/react-router": "^1.170.27", "@tanstack/react-table": "^9.3.1", "better-auth": "^1.6.27", - "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", diff --git a/app/src/components/channels/avatar.tsx b/app/src/components/channels/avatar.tsx index 44487201b5..2192e39759 100644 --- a/app/src/components/channels/avatar.tsx +++ b/app/src/components/channels/avatar.tsx @@ -1,13 +1,15 @@ -import Avatar from "boring-avatars"; import { memo } from "react"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; import { cn } from "@/lib/utils"; /** * Memoized roster avatar. Row updates usually change preview/timestamp only, and * `use-channel-events` preserves participant id arrays for unchanged rows. * - * `size-full` opts the generated SVG out of ancestor icon selectors such as - * `[&_svg:not([class*='size-'])]:size-4`. + * Each face is the Bot's orb, in the colour its id picks: the same colour `AbstractAvatar` draws + * from the seed, which is the id unless a tenant package sets one. Clipped to its circle, so its glow + * never spills into the row. * * `typing` overlays a working indicator at the bottom-right — three bouncing dots, so a channel * whose agent is mid-turn reads as busy from the roster without moving the row's layout. @@ -23,7 +25,9 @@ export const ChannelAvatar = memo(function ChannelAvatar({ }) { const avatar = participantIds.length === 1 ? ( - + + + ) : ( ); @@ -97,7 +101,7 @@ function GroupFaces({ WebkitMaskComposite: "source-in", }} > - + ); })} diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 68d318767f..93a0f87072 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -1,10 +1,10 @@ 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 { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { HarnessMark } from "@/components/agents/harness-mark"; import AgentOrb from "@/components/agents/orb/agent-orb"; import { Composer } from "@/components/channels/composer"; @@ -146,7 +146,7 @@ function RosterStep() { // Dimmed and labelled, so an invented name never reads as a Bot this deployment has. className={`bg-card p-4 rounded-lg flex flex-row gap-4 items-center ${a.example ? "opacity-70" : ""}`} > - +

{a.name} diff --git a/bun.lock b/bun.lock index 64588375bc..c1004715e4 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,6 @@ "@tanstack/react-router": "^1.170.27", "@tanstack/react-table": "^9.3.1", "better-auth": "^1.6.27", - "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", @@ -1114,8 +1113,6 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "boring-avatars": ["boring-avatars@2.0.4", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw=="], - "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], From 291277b306443eb430b54d366f853160beb4ef26 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:05:14 -0700 Subject: [PATCH 07/47] docs: changelog for OpenBot's new look --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27745c6f84..8a4e7f8da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,16 @@ again when they come back to the end or press the arrow. A rail beside a convers per turn: hovering one previews what was asked and answered, and choosing it goes there. Nothing changes for a deployment. +### OpenBot has a new look + +- Every screen uses a new visual system: cool neutral greys, one purple accent for focus, + selection and links, four status colours, frosted chrome and new motion. Text now follows the + browser's own size, so it is a little larger than before. +- Dark mode follows the system by default. Settings → General → Appearance offers System, Light and + Dark, kept in this browser. Someone who had turned the old dark switch on stays dark. +- Every Bot, and OpenBot's own mark, is an orb with eyes, in a colour picked from the Bot, that blinks + and follows the pointer. It holds still with reduced motion. + ### Generated interfaces use A2UI 1.0 With generative UI on, a Bot builds an interface from the A2UI 1.0 basic components instead of the From 2f423ec9c9c04e135eaed4ff700ef35808e784b3 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:15:45 -0700 Subject: [PATCH 08/47] fix(orb): keep small faces in their circles, sharp on any screen, still when asked A Bot's orb is a block, so a group conversation's 12px faces no longer sit on the header's text baseline and drop half out of their circles. It resizes its canvas when the screen's pixel density changes, so dragging a window to another screen or zooming no longer leaves it soft, and it follows the reduced-motion setting live rather than only when it first draws. The changelog and the stylesheet now describe what this step draws, not what later steps will. --- CHANGELOG.md | 6 +- app/src/components/agents/abstract-avatar.tsx | 4 +- app/src/components/agents/orb/bot-orb.tsx | 71 ++++++++++++++++--- app/src/components/channels/avatar.tsx | 3 +- app/src/theme.css | 7 +- app/tests/bot-orb.test.tsx | 7 ++ 6 files changed, 79 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a4e7f8da9..d48712a801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,9 +135,9 @@ changes for a deployment. ### OpenBot has a new look -- Every screen uses a new visual system: cool neutral greys, one purple accent for focus, - selection and links, four status colours, frosted chrome and new motion. Text now follows the - browser's own size, so it is a little larger than before. +- Every screen has new colours: cool neutral greys, a near-black primary button that turns light + in dark mode, a purple focus ring, slightly rounder corners and quicker easing. Text now follows + the browser's own size, so it is a little larger than before. - Dark mode follows the system by default. Settings → General → Appearance offers System, Light and Dark, kept in this browser. Someone who had turned the old dark switch on stays dark. - Every Bot, and OpenBot's own mark, is an orb with eyes, in a colour picked from the Bot, that blinks diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index 57df2578c9..a718780cca 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -2,7 +2,9 @@ import { BotOrb } from "@/components/agents/orb/bot-orb"; import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; /** - * A Bot's face: its orb, in the colour its seed picks, so the same Bot looks the same everywhere. + * A Bot's face: its orb, in the colour its seed picks. The seed is the Bot's id unless a tenant + * package set one or the Bot was copied from another, so `ChannelAvatar`, which goes by id, matches it + * for every other Bot. * The orb hides itself from assistive tech, so the Bot is announced once, by name. * * Clipped to its circle, so its glow never spills into the row beside it or makes a list scroll. diff --git a/app/src/components/agents/orb/bot-orb.tsx b/app/src/components/agents/orb/bot-orb.tsx index 44c37f1e08..c8d4ebcec3 100644 --- a/app/src/components/agents/orb/bot-orb.tsx +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -1,5 +1,4 @@ -import { useReducedMotion } from "motion/react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; import { blinkCurve, @@ -289,10 +288,16 @@ function surface(canvas: HTMLCanvasElement, size: number) { const ctx = canvas.getContext("2d"); if (!ctx) return null; const box = size * (1 + 2 * SPILL); - const dpr = Math.min(MAX_DPR, window.devicePixelRatio || 1); - canvas.width = Math.round(box * dpr); - canvas.height = Math.round(box * dpr); + let dpr = 0; return (pose: OrbPose, palette: OrbPaletteName) => { + // Read on every draw: a window dragged to another screen, or a zoom, changes it under an orb + // that stays mounted, and an orb drawn for the old one goes soft. + const next = Math.min(MAX_DPR, window.devicePixelRatio || 1); + if (next !== dpr) { + dpr = next; + canvas.width = Math.round(box * dpr); + canvas.height = Math.round(box * dpr); + } ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, box, box); ctx.translate(box / 2, box / 2); @@ -303,6 +308,49 @@ function surface(canvas: HTMLCanvasElement, size: number) { }; } +/** + * Calls `onChange` whenever the screen's pixel density changes, for a still orb that nothing else + * redraws. A resolution query matches only the density it names, so it is asked again after each change. + */ +function watchResolution(onChange: () => void): () => void { + if (typeof window.matchMedia !== "function") return () => {}; + let query: MediaQueryList | null = null; + const arm = () => { + query?.removeEventListener("change", fire); + query = window.matchMedia( + `(resolution: ${window.devicePixelRatio || 1}dppx)`, + ); + query.addEventListener("change", fire); + }; + const fire = () => { + arm(); + onChange(); + }; + arm(); + return () => query?.removeEventListener("change", fire); +} + +const REDUCE_MOTION = "(prefers-reduced-motion: reduce)"; + +/** + * Whether the person asks for less motion, kept current: turning it on while the app is open stills + * every orb already on screen, not only the ones drawn afterwards. + */ +function usePrefersReducedMotion(): boolean { + return useSyncExternalStore( + (onChange) => { + if (typeof window.matchMedia !== "function") return () => {}; + const query = window.matchMedia(REDUCE_MOTION); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, + () => + typeof window.matchMedia === "function" && + window.matchMedia(REDUCE_MOTION).matches, + () => false, + ); +} + let seeds = 0; const secondsSince = (start: number) => (performance.now() - start) / 1000; @@ -333,7 +381,7 @@ export function BotOrb({ }) { const boxRef = useRef(null); const canvasRef = useRef(null); - const reduced = (useReducedMotion() ?? false) || still; + const reduced = usePrefersReducedMotion() || still; const clock = useRef<{ start: number; seed: number } | null>(null); clock.current ??= { start: performance.now(), seed: ++seeds * 7 }; const motion = useRef(null); @@ -388,7 +436,11 @@ export function BotOrb({ attributes: true, attributeFilter: ["class"], }); - return () => theme.disconnect(); + const unwatch = watchResolution(paint); + return () => { + theme.disconnect(); + unwatch(); + }; }, [reduced, size, palette, state]); // Alive: drawn on the shared clock while it is on screen. It counts as on screen until an @@ -444,10 +496,7 @@ export function BotOrb({ return ( ", () => { expect(canvas.style.left).toBe("-20px"); }); + test("is a block, so a small face never sits on a line's baseline and drops out of its circle", () => { + const view = render(); + const orb = view.container.firstElementChild as HTMLElement; + expect(orb.classList.contains("block")).toBe(true); + expect(orb.classList.contains("inline-block")).toBe(false); + }); + test("still, it draws once and again only when the theme changes, never on a frame loop", async () => { const ctx = fakeContext(); const frames = manualFrames(); From cb8f677604caab3f8d6708c204aa8ce6ff3b2244 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Wed, 28 Oct 2026 14:21:20 -0700 Subject: [PATCH 09/47] fix(orb): the skins draw the orb too The skins import the orb's state type from agent-orb now that ai-core is gone, OpenBot's mark keeps its agent-orb class for them, and their tests check the orb's colour rather than boring-avatars' SVG. The Appearance test's preferences carry the new skin field. --- app/src/components/agents/orb/agent-orb.tsx | 2 +- app/src/skins/openduet/header.tsx | 2 +- app/tests/settings-appearance.test.tsx | 1 + app/tests/skins-opendots-frame.test.tsx | 4 ++-- app/tests/skins-openduet-frame.test.tsx | 6 ++++-- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx index a5365af6b1..2594bbc527 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -38,7 +38,7 @@ export default function AgentOrb({ }: AgentOrbProps) { return ( { * deployment's own name from `brand.yaml`, and every Bot wears OpenBot's own avatar. */ describe("OpenDots skin wears the deployment's brand", () => { - /** What the drawing of one avatar looks like, without the ids React gives each instance. */ + /** Which colour one avatar's orb is drawn in, which is all its seed decides. */ const drawing = (root: Element | null) => - (root?.querySelector("svg")?.outerHTML ?? "").replace(/_r_[\w]+_/g, ""); + root?.querySelector("[data-palette]")?.getAttribute("data-palette") ?? ""; test("the frame names the product from brand.yaml and never says OpenDots or Dots", async () => { const { view } = draw("/channel/new?agent=ada"); diff --git a/app/tests/skins-openduet-frame.test.tsx b/app/tests/skins-openduet-frame.test.tsx index dca77f6989..b1aad4bab5 100644 --- a/app/tests/skins-openduet-frame.test.tsx +++ b/app/tests/skins-openduet-frame.test.tsx @@ -562,9 +562,11 @@ describe("the frame carries the deployment's brand, not OpenDuet's", () => { const menu = view.getByRole("dialog"); expect(within(menu).getByText(appConfig.brand.productName)).toBeTruthy(); expect(readable()).not.toContain("OpenDuet"); - // OpenBot's own orb, as sign-in and onboarding draw it, and no drawing of OpenDuet's. + // OpenBot's own orb, as sign-in and onboarding draw it, in OpenBot's violet. expect(orb.querySelector(".agent-orb")).not.toBeNull(); - expect(orb.querySelector("canvas")).toBeNull(); + expect( + orb.querySelector('.agent-orb[data-palette="violet"]'), + ).not.toBeNull(); }); test("Tasks names nothing as OpenDuet either", async () => { From f5df1e12c9ce369799866b40199dd41b9d66845f Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:42:02 -0700 Subject: [PATCH 10/47] fix(orb): OpenMuse's header draws its face at the size of its slot AgentOrb read "100%" as 100px, so with no Bot yet the header drew a 100px orb in a 44 or 52px slot. With a Bot, the classes that used to rescale boring-avatars' SVG squeezed the avatar's circle to 44px below 900px wide while the orb inside stayed 52px, clipped and off centre. The frame now picks 44 or 52px from the window's width and draws the face at that size, the selectors that matched only the old SVG are gone here and from OpenDots' Bot rows, and AgentOrb takes its size only in px and refuses anything else. --- app/src/components/agents/orb/agent-orb.tsx | 10 +++- app/src/skins/opendots/sidebar.tsx | 2 - app/src/skins/openmuse/frame.tsx | 34 ++++++++++-- app/tests/bot-orb.test.tsx | 10 ++++ app/tests/skins-openmuse.test.tsx | 60 +++++++++++++++++++++ 5 files changed, 108 insertions(+), 8 deletions(-) diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx index 2594bbc527..30a42be29a 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -25,8 +25,11 @@ const ORB_STATE: Record = { export interface AgentOrbProps { className?: string; - /** Its footprint, in px, as "64px". */ - size?: string; + /** + * Its footprint, in px, as "64px". Only px: the orb is drawn on a canvas at that size, so a share + * of its container, as "100%", has nothing to be a share of. + */ + size?: `${number}px`; state?: AIState; } @@ -36,6 +39,9 @@ export default function AgentOrb({ size = "192px", state = "idle", }: AgentOrbProps) { + if (!/^\d+(\.\d+)?px$/.test(size)) { + throw new Error(`AgentOrb's size is in px, as "64px", not "${size}".`); + } return ( { + const query = window.matchMedia(WIDE); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, + () => window.matchMedia(WIDE).matches, + () => false, + ); +} + /** * OpenMuse's shell around every OpenBot page: one centred column, the agent at the top, a bell for * what waits on you, and a floating pill of five tabs. The page itself is OpenBot's, untouched — @@ -89,6 +113,8 @@ export function OpenMuseFrame({ children }: { children: ReactNode }) { const userId = user.data?.id; const chosenId = useChosenBotId(userId); const [sheet, setSheet] = useState(null); + // The centre face, in px: 44 on a phone, 52 from 900px wide, where the header grows. + const face = useWide() ? 52 : 44; /* * The Bot this screen is about, when it is about one: the Bot of a one-to-one conversation, the Bot @@ -164,15 +190,15 @@ export function OpenMuseFrame({ children }: { children: ReactNode }) { * The Bot's own OpenBot avatar, the one its page and the Bots roster draw. With no Bot * yet, the deployment's own mark stands in. */} - + {bot ? ( ) : ( - + )} diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 34e38c9fed..66e054b483 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -633,4 +633,14 @@ describe("a Bot's face and OpenBot's mark", () => { const view = render(); expect(orbOf(view.container).style.width).toBe("192px"); }); + + test("OpenBot's mark is drawn at a size in px, and refuses a share of its container", () => { + const view = render(); + expect(orbOf(view.container).style.width).toBe("27.5px"); + // Read as a number, "100%" would draw a 100px orb whatever its container is. + // @ts-expect-error Only a size in px type-checks. + expect(() => AgentOrb({ size: "100%" })).toThrow( + `AgentOrb's size is in px, as "64px", not "100%".`, + ); + }); }); diff --git a/app/tests/skins-openmuse.test.tsx b/app/tests/skins-openmuse.test.tsx index b88cfaea57..ef11cc3e59 100644 --- a/app/tests/skins-openmuse.test.tsx +++ b/app/tests/skins-openmuse.test.tsx @@ -743,6 +743,66 @@ describe("the frame wears the deployment's brand, not OpenMuse's", () => { expect(readable()).not.toMatch(/openmuse/i); expect(capybaras()).toEqual([]); }); + + /* + * The face is a canvas drawn at a size in px, so no class can rescale it afterwards: one drawn + * bigger than its slot is clipped and sits off centre. The slot is 44px on a phone and 52px from + * 900px wide. + */ + test.each([ + [390, "44px"], + [1024, "52px"], + ])( + "%ipx wide, the centre face and the orb in it are drawn at %s", + async (width, size) => { + const viewport = ( + window as unknown as { + happyDOM: { + setViewport: (size: { width: number; height: number }) => void; + }; + } + ).happyDOM; + viewport.setViewport({ width, height: 844 }); + const drawnAt = (orb: HTMLElement | null) => [ + orb?.style.width, + orb?.style.height, + ]; + try { + serve(); + renderAt("/connections"); + const centre = await page().findByRole("button", { + name: /Switch Bot$/, + }); + const face = within(centre).getByRole("img", { name: "Muse" }); + expect(drawnAt(face)).toEqual([size, size]); + expect(drawnAt(face.querySelector("[data-palette]"))).toEqual([ + size, + size, + ]); + cleanup(); + + // With no Bot yet, the deployment's own mark fills the same slot. + const real = globalThis.fetch; + globalThis.fetch = Object.assign( + (input: RequestInfo | URL, init?: RequestInit) => + String(input) === "/api/agents" + ? Promise.resolve(Response.json({ agents: [] })) + : real(input, init), + { preconnect: originalFetch.preconnect }, + ); + renderAt("/s/activity"); + const empty = await page().findByRole("button", { + name: "Choose a Bot", + }); + expect(drawnAt(empty.querySelector(".agent-orb"))).toEqual([ + size, + size, + ]); + } finally { + viewport.setViewport({ width: 1024, height: 768 }); + } + }, + ); }); describe("every OpenBot surface stays reachable", () => { From 851afa13f6a3cba250e7d620e9c20178b72c7f50 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:47:33 -0700 Subject: [PATCH 11/47] fix(orb): a face at rest draws nothing until it changes Every orb on screen redrew on every animation frame, uncapped, so a sidebar of conversations kept a few dozen canvases repainting at the screen's full rate while the app sat idle: a small orb's colour still drifted, so no two frames were ever the same. A small orb's colour now holds still, an orb skips any frame that would draw what its canvas already shows in the same theme and at the same density, and a small orb draws at most about thirty frames a second. A face at rest with the pointer still now draws only to blink. --- app/src/components/agents/orb/bot-orb-draw.ts | 36 ++++++- app/src/components/agents/orb/bot-orb.tsx | 64 +++++++---- app/tests/bot-orb.test.tsx | 102 +++++++++++++++++- 3 files changed, 171 insertions(+), 31 deletions(-) diff --git a/app/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts index d1605ae02c..741e718d76 100644 --- a/app/src/components/agents/orb/bot-orb-draw.ts +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -491,11 +491,13 @@ function glow(ctx: Ctx, R: number, amt: number, c: Rgb, dark: boolean) { ctx.fillRect(-r, -r, 2 * r, 2 * r); } +/** How long the landing's ping rings, in seconds. */ +const PING_S = 1; + /** One ring that leaves the orb and fades: the landing's ping. */ function ping(ctx: Ctx, r0: number, age: number, pal: Palette, dark: boolean) { - const dur = 1; - if (!(age >= 0 && age < dur)) return; - const u = age / dur; + if (!(age >= 0 && age < PING_S)) return; + const u = age / PING_S; const r = r0 * (1.02 + (1.5 - 1.02) * eout(u)); const a = (1 - u) ** 1.5; ctx.strokeStyle = rgba( @@ -606,6 +608,9 @@ function eyePath(ctx: Ctx, w: number, h: number, smile: number) { ctx.closePath(); } +/** How long an orb stays perked up after the pointer comes close, in seconds. */ +const PERK_S = 1.6; + /** The sphere: the mesh turned toward where it looks, its rim, and two eye-lights that blink and smile. */ function sphere( ctx: Ctx, @@ -616,7 +621,8 @@ function sphere( size: number, ) { const na = S.noticeAge; - const perk = na >= 0 && na < 1.6 ? Math.exp(-2.6 * na) * smooth(na / 0.1) : 0; + const perk = + na >= 0 && na < PERK_S ? Math.exp(-2.6 * na) * smooth(na / 0.1) : 0; const finish = finishPhase(S.finishAge); const sc = scaleOf(S, finish); ctx.save(); @@ -699,3 +705,25 @@ export function drawBotOrb( rays(ctx, r, finish.rays, pal, dark); sphere(ctx, r, S, pal, dark, size * 0.72); } + +/** How long each age in a pose shows, in seconds: after that the orb draws as if it never happened. */ +const SHOWS_FOR: Partial> = { + doneAge: PING_S, + noticeAge: PERK_S, + finishAge: FINISH_S, +}; + +/** + * Whether two poses draw the same picture, every value within a thousandth of the other's. An age + * counts only while what it times still shows, so an orb that has settled poses the same picture + * frame after frame and need not draw it again. + */ +export function samePicture(a: OrbPose, b: OrbPose): boolean { + for (const key of Object.keys(a) as (keyof OrbPose)[]) { + const shows = SHOWS_FOR[key]; + const seen = (v: number) => + shows === undefined || (v >= 0 && v < shows) ? v : -1; + if (Math.abs(seen(a[key]) - seen(b[key])) >= 1e-3) return false; + } + return true; +} diff --git a/app/src/components/agents/orb/bot-orb.tsx b/app/src/components/agents/orb/bot-orb.tsx index c8d4ebcec3..3e2b956379 100644 --- a/app/src/components/agents/orb/bot-orb.tsx +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -11,6 +11,7 @@ import { type OrbPaletteName, type OrbPose, restLook, + samePicture, type Track, type TrackKey, track, @@ -52,8 +53,15 @@ const BLINK_EVERY = 4.2; const SPILL = 0.5; /** Backing pixels per CSS pixel, at most: sharp on a 3× screen without a huge canvas. */ const MAX_DPR = 3; -/** Below this footprint, in CSS px, an orb only blinks and watches: no glances, no breath. */ +/** Below this footprint, in CSS px, an orb only blinks and watches: no glances, no breath, no drift. */ const SMALL = 40; +/** + * The least time between two frames of a small orb, in ms: about thirty a second, plenty for eyes a + * few px across, where a fast screen would otherwise draw every face in a list 120 times a second. + */ +const SMALL_FRAME_MS = 30; +/** The moment on its clock a still is composed at, and where a small orb's colour holds. */ +const STILL_T = 1.6; /** How much of its look an orb gives the pointer: all of it at rest, some while it listens or works. */ const WATCH: Record = { idle: 1, @@ -156,7 +164,8 @@ function enter(m: Motion, state: BotOrbState, now: number): Motion { /** * The pose at `t` seconds on the orb's clock. `seed` keeps two orbs from blinking and glancing in - * step. A small orb keeps still apart from its blinks, so a list of them doesn't fidget. + * step. A small orb keeps still apart from its blinks, its colour included, so a list of them + * doesn't fidget and, once its eyes settle, has nothing new to draw. */ function poseAt( m: Motion, @@ -178,7 +187,7 @@ function poseAt( } } return { - ta: t, + ta: small ? STILL_T : t, listen, think, done: Math.max(0, track(m.done, t)), @@ -281,18 +290,29 @@ function lookAt(pose: OrbPose, gaze: Gaze): OrbPose { } /** - * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it, or null where - * there is no 2D context to draw with. + * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it in `palette`, or + * null where there is no 2D context to draw with. A pose that would draw what the canvas already + * shows, in the same theme and at the same density, is skipped. */ -function surface(canvas: HTMLCanvasElement, size: number) { +function surface( + canvas: HTMLCanvasElement, + size: number, + palette: OrbPaletteName, +) { const ctx = canvas.getContext("2d"); if (!ctx) return null; const box = size * (1 + 2 * SPILL); let dpr = 0; - return (pose: OrbPose, palette: OrbPaletteName) => { + let shown: { pose: OrbPose; dark: boolean } | null = null; + return (pose: OrbPose) => { // Read on every draw: a window dragged to another screen, or a zoom, changes it under an orb // that stays mounted, and an orb drawn for the old one goes soft. const next = Math.min(MAX_DPR, window.devicePixelRatio || 1); + const dark = document.documentElement.classList.contains("dark"); + if (next === dpr && shown?.dark === dark && samePicture(shown.pose, pose)) { + return; + } + shown = { pose, dark }; if (next !== dpr) { dpr = next; canvas.width = Math.round(box * dpr); @@ -301,10 +321,7 @@ function surface(canvas: HTMLCanvasElement, size: number) { ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, box, box); ctx.translate(box / 2, box / 2); - drawBotOrb(ctx, size, pose, { - dark: document.documentElement.classList.contains("dark"), - palette, - }); + drawBotOrb(ctx, size, pose, { dark, palette }); }; } @@ -360,11 +377,11 @@ const secondsSince = (start: number) => (performance.now() - start) / 1000; * lands with a ping and a smile when done. Passed `finished`, its eyes give way to a check while * short rays burst out round it, and then it is back as it was. Wherever the pointer is, its eyes * follow it: fully at rest, partly while it listens or works, with a small perk when the pointer - * comes close. An orb under 40 px only blinks and watches. + * comes close. An orb under 40 px only blinks and watches, at about thirty frames a second. * - * Every orb on the page draws on one shared animation frame, and only while it is on screen and the - * tab is shown. With reduced motion, or `still`, it is a still in the state's pose, drawn again - * only when the state or the theme changes, and its eyes stay put. + * Every orb on the page draws on one shared animation frame, and only while it is on screen, the + * tab is shown and its picture has changed. With reduced motion, or `still`, it is a still in the + * state's pose, drawn again only when the state or the theme changes, and its eyes stay put. */ export function BotOrb({ className, @@ -418,7 +435,7 @@ export function BotOrb({ // again on its own, so it is redrawn when the theme flips the root's `dark` class. useEffect(() => { const canvas = canvasRef.current; - const draw = reduced && canvas ? surface(canvas, size) : null; + const draw = reduced && canvas ? surface(canvas, size, palette) : null; if (!draw || !clock.current) return; const { seed } = clock.current; const held = @@ -426,10 +443,7 @@ export function BotOrb({ ? { ...settled(shown.current ?? "idle"), finishedAt: 0 } : settled(state); const paint = () => - draw( - poseAt(held, 1.6, seed, { reduced: true, small: size < SMALL }), - palette, - ); + draw(poseAt(held, STILL_T, seed, { reduced: true, small: size < SMALL })); paint(); const theme = new MutationObserver(paint); theme.observe(document.documentElement, { @@ -445,17 +459,21 @@ export function BotOrb({ // Alive: drawn on the shared clock while it is on screen. It counts as on screen until an // IntersectionObserver says otherwise, and always where there is none to ask. It reads the theme - // on every frame, so a flip shows on the next one. + // on every frame, so a flip shows on the next one; a frame that would draw what is already there + // is skipped, so a small orb at rest with the pointer still draws nothing between blinks. useEffect(() => { const canvas = canvasRef.current; const box = boxRef.current; - const draw = !reduced && canvas ? surface(canvas, size) : null; + const draw = !reduced && canvas ? surface(canvas, size, palette) : null; if (!draw || !box || !clock.current || !gaze.current) return; const { start, seed } = clock.current; const eyes = gaze.current; const small = size < SMALL; const measure = () => box.getBoundingClientRect(); + let ticked = Number.NEGATIVE_INFINITY; const frame = (now: number) => { + if (small && now - ticked < SMALL_FRAME_MS) return; + ticked = now; const t = Math.max(0, (now - start) / 1000); const state = current.current; if (followPointer(eyes, t, state, measure) && motion.current) { @@ -465,7 +483,7 @@ export function BotOrb({ reduced: false, small, }); - draw(lookAt(pose, eyes), palette); + draw(lookAt(pose, eyes)); }; let unsubscribe: (() => void) | null = null; const onScreen = (on: boolean) => { diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 66e054b483..0f6bc3dbea 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -27,6 +27,7 @@ import { orbPaletteFor, palette, RAYS, + samePicture, track, } from "@/components/agents/orb/bot-orb-draw"; import { subscribePointer } from "@/components/agents/orb/orb-clock"; @@ -181,6 +182,18 @@ describe("the drawing", () => { test("has no grilles: at rest its only stroke is the sphere's rim", () => { expect(strokesOf(REST)).toBe(1); }); + + test("two poses draw the same picture once what their ages time is over, not while it plays", () => { + expect(samePicture(REST, { ...REST })).toBe(true); + expect(samePicture(REST, { ...REST, ax: REST.ax + 0.01 })).toBe(false); + // A ping long rung, a perk long faded and a finish long over: the orb at rest. + expect( + samePicture(REST, { ...REST, doneAge: 4, noticeAge: 9, finishAge: 30 }), + ).toBe(true); + expect( + samePicture({ ...REST, doneAge: 0.2 }, { ...REST, doneAge: 0.3 }), + ).toBe(false); + }); }); describe("a finish", () => { @@ -229,8 +242,9 @@ function withContext(ctx: ReturnType) { } /** - * Animation frames that run only when the test says, as the browser would at its next paint: - * `requested` counts every frame asked for, `pending` the ones not yet run or cancelled. + * Animation frames that run only when the test says, as the browser would at its next paint, at + * the time the test gives or now: `requested` counts every frame asked for, `pending` the ones not + * yet run or cancelled. */ function manualFrames() { const queue = new Map(); @@ -253,10 +267,9 @@ function manualFrames() { get pending() { return queue.size; }, - run() { + run(now = performance.now()) { const due = [...queue.values()]; queue.clear(); - const now = performance.now(); for (const callback of due) callback(now); }, restore() { @@ -423,6 +436,87 @@ describe("", () => { }); }); + const pointAt = (x: number, y: number) => + window.dispatchEvent( + new PointerEvent("pointermove", { clientX: x, clientY: y }), + ); + + /** + * One orb `size` px across, its corner at (100, 100) on screen. `play` runs its frames from `from` + * to `to` seconds after it mounts, `fps` a second, calling `each` before every frame, and says how + * many of them it drew. + */ + function onScreen(size: number) { + const ctx = fakeContext(); + const frames = manualFrames(); + const restore = withContext(ctx); + const view = render(); + orbOf(view.container).getBoundingClientRect = () => + ({ left: 100, top: 100, width: size, height: size }) as DOMRect; + const start = performance.now(); + return { + play(from: number, to: number, fps = 60, each?: (t: number) => void) { + const before = ctx.draws; + for (let i = Math.ceil(from * fps); i < to * fps; i++) { + each?.(i / fps); + frames.run(start + (i / fps) * 1000); + } + return ctx.draws - before; + }, + done() { + cleanup(); + frames.restore(); + restore(); + }, + }; + } + + test("a small orb at rest with the pointer still draws nothing once it settles, and draws again when the pointer moves", () => { + withObserver(undefined, () => { + const orb = onScreen(28); + try { + pointAt(400, 114); + // It turns to the pointer, settles and blinks once, all before 3.2s. + expect(orb.play(0, 3.2)).toBeGreaterThan(0); + // Its next blink is 5.5s in at the soonest. + expect(orb.play(3.2, 4.4)).toBe(0); + // A theme flip is drawn once, though nothing else has moved. + document.documentElement.classList.add("dark"); + expect(orb.play(4.4, 4.8)).toBe(1); + pointAt(114, 400); + expect(orb.play(4.8, 5.4)).toBeGreaterThan(0); + } finally { + document.documentElement.classList.remove("dark"); + orb.done(); + } + }); + }); + + test("a small orb draws about thirty frames a second on a 120Hz screen, a large one every frame", () => { + withObserver(undefined, () => { + // A pointer circling the orb, so its eyes always have somewhere new to look. + const circle = (t: number) => + pointAt( + 114 + 200 * Math.cos(2 * Math.PI * t), + 114 + 200 * Math.sin(2 * Math.PI * t), + ); + const small = onScreen(28); + try { + const drawn = small.play(0, 1, 120, circle); + expect(drawn).toBeGreaterThanOrEqual(28); + expect(drawn).toBeLessThanOrEqual(31); + } finally { + small.done(); + } + const large = onScreen(56); + try { + expect(large.play(0, 1, 120, circle)).toBe(120); + } finally { + large.done(); + } + }); + }); + test("an orb off screen is not drawn", () => { withObserver(Sight, () => { const ctx = fakeContext(); From cfd681dfc017f9e794da385fc7e64630437b6e6f Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:55:14 -0700 Subject: [PATCH 12/47] fix(orb): two Bots in one colour no longer share a face, and none is violet A Bot's face was one of nine colours and nothing else, so a team of four had about an even chance of two identical faces. In the Bots OpenBot ships with, three were the same sky blue, and release notes wore violet, OpenBot's own mark. A Bot is now drawn from its seed: one of the eight colours that aren't violet, with its colour masses turned, its hue nudged and its eyes spaced by the same hash, so two Bots in one colour still look apart. BotOrb takes the seed rather than a colour and draws OpenBot's violet when it has none. The OpenDots avatar test checked one colour in nine, so it passed or failed on the hash; it now checks that the seed reaches the orb. --- CHANGELOG.md | 6 +- app/src/components/agents/abstract-avatar.tsx | 5 +- app/src/components/agents/orb/agent-orb.tsx | 1 - app/src/components/agents/orb/bot-orb-draw.ts | 95 +++++++++++--- app/src/components/agents/orb/bot-orb.tsx | 65 +++++----- app/src/components/channels/avatar.tsx | 9 +- app/tests/bot-orb.test.tsx | 122 +++++++++++++----- app/tests/skins-opendots-frame.test.tsx | 26 ++-- 8 files changed, 218 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d48712a801..ef2661bd6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -140,8 +140,10 @@ changes for a deployment. the browser's own size, so it is a little larger than before. - Dark mode follows the system by default. Settings → General → Appearance offers System, Light and Dark, kept in this browser. Someone who had turned the old dark switch on stays dark. -- Every Bot, and OpenBot's own mark, is an orb with eyes, in a colour picked from the Bot, that blinks - and follows the pointer. It holds still with reduced motion. +- Every Bot, and OpenBot's own mark, is an orb with eyes that blinks and follows the pointer. It + holds still with reduced motion. +- Each Bot's orb takes its colour and pattern from the Bot, so two Bots in one colour still look + different. Violet is kept for OpenBot's own mark. ### Generated interfaces use A2UI 1.0 diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index a718780cca..d746511206 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,8 +1,7 @@ import { BotOrb } from "@/components/agents/orb/bot-orb"; -import { orbPaletteFor } from "@/components/agents/orb/bot-orb-draw"; /** - * A Bot's face: its orb, in the colour its seed picks. The seed is the Bot's id unless a tenant + * A Bot's face: its orb, with the look its seed picks. The seed is the Bot's id unless a tenant * package set one or the Bot was copied from another, so `ChannelAvatar`, which goes by id, matches it * for every other Bot. * The orb hides itself from assistive tech, so the Bot is announced once, by name. @@ -26,7 +25,7 @@ export function AbstractAvatar({ className="inline-flex shrink-0 overflow-hidden rounded-full" style={{ height: size, width: size }} > - + ); } diff --git a/app/src/components/agents/orb/agent-orb.tsx b/app/src/components/agents/orb/agent-orb.tsx index 30a42be29a..4f953f922b 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -45,7 +45,6 @@ export default function AgentOrb({ return ( diff --git a/app/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts index 741e718d76..f3e3f2b370 100644 --- a/app/src/components/agents/orb/bot-orb-draw.ts +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -1,7 +1,8 @@ /** * The orb, every Bot's face and OpenBot's own mark, drawn on a 2D canvas: a glass sphere of - * drifting colour with two eye-lights that look, blink and smile. It comes in nine colours; a Bot's - * is picked from its seed, and OpenBot's own is violet. It also plays a finish: the check and the + * drifting colour with two eye-lights that look, blink and smile. It comes in nine colours. A Bot's + * look, its colour and the few small ways two Bots in one colour still differ, is picked from its + * seed; OpenBot's own is violet, which no Bot is given. It also plays a finish: the check and the * burst of rays the orb shows when something the person allowed is done. * * Every frame is a pure function of an `OrbPose`, so the component that animates it owns time and @@ -174,8 +175,8 @@ type PaletteSpec = { }; /** - * The nine colours. A Bot's is `orbPaletteFor` its seed, which picks by position in `ORB_PALETTES`: - * add a colour at the end, never reorder them, or every Bot changes colour. + * The nine colours: violet, OpenBot's own, and the eight a Bot's look is picked from by position. + * Adding, removing or reordering one changes the colour of most Bots. */ const PALETTES = { violet: { @@ -256,20 +257,67 @@ const PALETTES = { export type OrbPaletteName = keyof typeof PALETTES; -/** Every colour, in the order `orbPaletteFor` picks from. */ +/** Every colour, OpenBot's violet first. */ export const ORB_PALETTES = Object.keys(PALETTES) as readonly OrbPaletteName[]; +/** The eight colours a Bot can be: all but violet, so a Bot is never mistaken for OpenBot. */ +const BOT_PALETTES = ORB_PALETTES.filter((name) => name !== "violet"); + /** - * The colour for a Bot's seed: a 32-bit FNV-1a hash of it, modulo the nine. Stable across reloads - * and releases, so a Bot keeps its colour. + * How one orb is drawn apart from its motion: its colour, how far round its colour masses are + * turned, in radians, a nudge to its hue in degrees, and how far apart its eyes are, as a share of + * the usual spacing. */ -export function orbPaletteFor(seed: string): OrbPaletteName { +export type OrbLook = { + palette: OrbPaletteName; + turn: number; + hue: number; + eyes: number; +}; + +/** OpenBot's own look: violet, untouched. */ +export const OPENBOT_LOOK: OrbLook = { + palette: "violet", + turn: 0, + hue: 0, + eyes: 1, +}; + +/** The most a Bot's hue is nudged either way, in degrees: a touch, never enough to pass for another colour. */ +const HUE_NUDGE = 4; +/** The most a Bot's eyes are moved apart or together, as a share of the usual spacing. */ +const EYE_NUDGE = 0.08; + +/** A 32-bit FNV-1a hash of the seed, mixed with murmur3's finaliser so every character moves every bit. */ +function hashSeed(seed: string): number { let h = 0x811c9dc5; for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); } - return ORB_PALETTES[(h >>> 0) % ORB_PALETTES.length] as OrbPaletteName; + h ^= h >>> 16; + h = Math.imul(h, 0x85ebca6b); + h ^= h >>> 13; + h = Math.imul(h, 0xc2b2ae35); + h ^= h >>> 16; + return h >>> 0; +} + +/** + * The look for a Bot's seed. Eight colours repeat soon in a team, so the same hash also turns the + * colour masses, nudges the hue and spaces the eyes, and two Bots in one colour still look apart. + * Stable across reloads and releases, so a Bot keeps its face. + */ +export function orbLookFor(seed: string): OrbLook { + const h = hashSeed(seed); + // The eight colours take the low three bits, and each of the rest the next eight. + const share = (from: number) => ((h >>> from) & 0xff) / 256; + return { + palette: BOT_PALETTES[h % BOT_PALETTES.length] as OrbPaletteName, + turn: share(3) * TAU, + hue: Math.round((share(11) * 2 - 1) * HUE_NUDGE), + eyes: 1 + (share(19) * 2 - 1) * EYE_NUDGE, + }; } /** A colour's tones as sRGB, ready to draw. */ @@ -412,8 +460,8 @@ const MESH = [ [2.36, 0.45, 0.88], ] as const; -function meshPos(ta: number, fast: boolean) { - const rot = fast ? 1.45 * ta + 1.1 : 0.22 * ta; +function meshPos(ta: number, fast: boolean, turn: number) { + const rot = turn + (fast ? 1.45 * ta + 1.1 : 0.22 * ta); return MESH.map((b, i) => { const a = b[0] + @@ -425,24 +473,28 @@ function meshPos(ta: number, fast: boolean) { }); } -/** Four soft colour masses drifting round a pale base; a faster swirl joins them while it works. */ +/** + * Four soft colour masses drifting round a pale base, starting `turn` radians round; a faster swirl + * joins them while it works. + */ function mesh( ctx: Ctx, R: number, S: OrbPose, pal: Palette, + turn: number, lx: number, ly: number, ) { ctx.fillStyle = rgba(pal.base, 1); ctx.fillRect(-R * 1.7, -R * 1.7, R * 3.4, R * 3.4); const cols = [pal.main, pal.pale, pal.side, pal.deep]; - meshPos(S.ta, false).forEach((p, i) => { + meshPos(S.ta, false, turn).forEach((p, i) => { blob(ctx, p[0] * R + lx * R, p[1] * R + ly * R, p[2] * R, cols[i], 0.94); }); const swirl = S.think * 0.55; if (swirl > 0.01) { - meshPos(S.ta, true).forEach((p, i) => { + meshPos(S.ta, true, turn).forEach((p, i) => { blob( ctx, p[0] * R + lx * R * 0.6, @@ -617,6 +669,7 @@ function sphere( R: number, S: OrbPose, pal: Palette, + look: OrbLook, dark: boolean, size: number, ) { @@ -633,7 +686,7 @@ function sphere( const yaw = clamp(S.ax, -1.1, 1.1) * 0.62; const pitch = -clamp(S.ay, -1.1, 1.1) * 0.5; const roll = S.tilt; - mesh(ctx, R, S, pal, Math.sin(yaw) * 0.2, -Math.sin(pitch) * 0.16); + mesh(ctx, R, S, pal, look.turn, Math.sin(yaw) * 0.2, -Math.sin(pitch) * 0.16); light(ctx, -0.42 * R, -0.5 * R, 0.5 * R, 0.26); light(ctx, 0, R * 0.05, R * 0.9, 0.28 * S.done); rim(ctx, R, pal.shade, dark); @@ -646,7 +699,7 @@ function sphere( const eh = Math.max(small ? 3.4 : 2.4, R * 0.3); for (const side of [-1, 1]) { if (finish.eyes <= 0.003) break; - const lon = side * 0.35; + const lon = side * 0.35 * look.eyes; const lat = 0.05 + 0.04 * S.smile; const x = Math.sin(lon) * Math.cos(lat); const y = Math.sin(lat); @@ -675,7 +728,7 @@ function sphere( } /** - * The whole orb in `palette`, centred on the context's origin, for a footprint of `size` CSS px. + * The whole orb with `look`, centred on the context's origin, for a footprint of `size` CSS px. * Its glow, the landing's ping and a finish's rays spill past the footprint, so draw it on a canvas * twice its size. */ @@ -683,13 +736,13 @@ export function drawBotOrb( ctx: Ctx, size: number, S: OrbPose, - { dark, palette: name }: { dark: boolean; palette: OrbPaletteName }, + { dark, look }: { dark: boolean; look: OrbLook }, ) { const finish = finishPhase(S.finishAge); const pal = palette( - name, + look.palette, 0.9 + 0.14 * clamp(S.listen) + 0.08 * S.think + 0.12 * clamp(S.done), - -6 * S.think, + look.hue - 6 * S.think, ); const r = (size / 2) * 0.72; const sc = scaleOf(S, finish); @@ -703,7 +756,7 @@ export function drawBotOrb( glow(ctx, r * sc, glowAmt * 0.85, mix(pal.main, pal.deep, 0.4), dark); ping(ctx, r, S.doneAge, pal, dark); rays(ctx, r, finish.rays, pal, dark); - sphere(ctx, r, S, pal, dark, size * 0.72); + sphere(ctx, r, S, pal, look, dark, size * 0.72); } /** How long each age in a pose shows, in seconds: after that the orb draws as if it never happened. */ diff --git a/app/src/components/agents/orb/bot-orb.tsx b/app/src/components/agents/orb/bot-orb.tsx index 3e2b956379..d628b9cd45 100644 --- a/app/src/components/agents/orb/bot-orb.tsx +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import { cn } from "@/lib/utils"; import { blinkCurve, @@ -8,8 +8,10 @@ import { FINISH_S, mulberry32, nodCurve, - type OrbPaletteName, + OPENBOT_LOOK, + type OrbLook, type OrbPose, + orbLookFor, restLook, samePicture, type Track, @@ -290,15 +292,11 @@ function lookAt(pose: OrbPose, gaze: Gaze): OrbPose { } /** - * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it in `palette`, or + * Sizes the canvas for a footprint of `size` and returns how to draw a pose on it with `look`, or * null where there is no 2D context to draw with. A pose that would draw what the canvas already * shows, in the same theme and at the same density, is skipped. */ -function surface( - canvas: HTMLCanvasElement, - size: number, - palette: OrbPaletteName, -) { +function surface(canvas: HTMLCanvasElement, size: number, look: OrbLook) { const ctx = canvas.getContext("2d"); if (!ctx) return null; const box = size * (1 + 2 * SPILL); @@ -321,7 +319,7 @@ function surface( ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, box, box); ctx.translate(box / 2, box / 2); - drawBotOrb(ctx, size, pose, { dark, palette }); + drawBotOrb(ctx, size, pose, { dark, look }); }; } @@ -368,16 +366,17 @@ function usePrefersReducedMotion(): boolean { ); } -let seeds = 0; +let staggers = 0; const secondsSince = (start: number) => (performance.now() - start) / 1000; /** - * A Bot's orb, in `palette`. `size` is its footprint in CSS px; its glow spills past it. It breathes - * and glances about when idle, watches what you type when listening, swirls while thinking, and - * lands with a ping and a smile when done. Passed `finished`, its eyes give way to a check while - * short rays burst out round it, and then it is back as it was. Wherever the pointer is, its eyes - * follow it: fully at rest, partly while it listens or works, with a small perk when the pointer - * comes close. An orb under 40 px only blinks and watches, at about thirty frames a second. + * A Bot's orb, with the look its `seed` picks; with no seed, OpenBot's own, in violet. `size` is its + * footprint in CSS px; its glow spills past it. It breathes and glances about when idle, watches + * what you type when listening, swirls while thinking, and lands with a ping and a smile when done. + * Passed `finished`, its eyes give way to a check while short rays burst out round it, and then it + * is back as it was. Wherever the pointer is, its eyes follow it: fully at rest, partly while it + * listens or works, with a small perk when the pointer comes close. An orb under 40 px only blinks + * and watches, at about thirty frames a second. * * Every orb on the page draws on one shared animation frame, and only while it is on screen, the * tab is shown and its picture has changed. With reduced motion, or `still`, it is a still in the @@ -385,13 +384,13 @@ const secondsSince = (start: number) => (performance.now() - start) / 1000; */ export function BotOrb({ className, - palette, + seed, size, state = "idle", still = false, }: { className?: string; - palette: OrbPaletteName; + seed?: string; size: number; state?: BotOrbState; still?: boolean; @@ -399,8 +398,13 @@ export function BotOrb({ const boxRef = useRef(null); const canvasRef = useRef(null); const reduced = usePrefersReducedMotion() || still; - const clock = useRef<{ start: number; seed: number } | null>(null); - clock.current ??= { start: performance.now(), seed: ++seeds * 7 }; + const look = useMemo( + () => (seed === undefined ? OPENBOT_LOOK : orbLookFor(seed)), + [seed], + ); + // Each orb's own, so two orbs on a page, even two of one Bot, don't blink or glance in step. + const clock = useRef<{ start: number; stagger: number } | null>(null); + clock.current ??= { start: performance.now(), stagger: ++staggers * 7 }; const motion = useRef(null); const shown = useRef(null); const current = useRef(state); @@ -435,15 +439,17 @@ export function BotOrb({ // again on its own, so it is redrawn when the theme flips the root's `dark` class. useEffect(() => { const canvas = canvasRef.current; - const draw = reduced && canvas ? surface(canvas, size, palette) : null; + const draw = reduced && canvas ? surface(canvas, size, look) : null; if (!draw || !clock.current) return; - const { seed } = clock.current; + const { stagger } = clock.current; const held = state === "finished" ? { ...settled(shown.current ?? "idle"), finishedAt: 0 } : settled(state); const paint = () => - draw(poseAt(held, STILL_T, seed, { reduced: true, small: size < SMALL })); + draw( + poseAt(held, STILL_T, stagger, { reduced: true, small: size < SMALL }), + ); paint(); const theme = new MutationObserver(paint); theme.observe(document.documentElement, { @@ -455,7 +461,7 @@ export function BotOrb({ theme.disconnect(); unwatch(); }; - }, [reduced, size, palette, state]); + }, [reduced, size, look, state]); // Alive: drawn on the shared clock while it is on screen. It counts as on screen until an // IntersectionObserver says otherwise, and always where there is none to ask. It reads the theme @@ -464,9 +470,9 @@ export function BotOrb({ useEffect(() => { const canvas = canvasRef.current; const box = boxRef.current; - const draw = !reduced && canvas ? surface(canvas, size, palette) : null; + const draw = !reduced && canvas ? surface(canvas, size, look) : null; if (!draw || !box || !clock.current || !gaze.current) return; - const { start, seed } = clock.current; + const { start, stagger } = clock.current; const eyes = gaze.current; const small = size < SMALL; const measure = () => box.getBoundingClientRect(); @@ -479,7 +485,7 @@ export function BotOrb({ if (followPointer(eyes, t, state, measure) && motion.current) { motion.current = { ...motion.current, noticedAt: t }; } - const pose = poseAt(motion.current ?? settled(state), t, seed, { + const pose = poseAt(motion.current ?? settled(state), t, stagger, { reduced: false, small, }); @@ -508,14 +514,15 @@ export function BotOrb({ onScreen(false); unwatch(); }; - }, [reduced, size, palette]); + }, [reduced, size, look]); const spill = size * SPILL; return ( - + ) : ( @@ -102,7 +101,7 @@ function GroupFaces({ WebkitMaskComposite: "source-in", }} > - +

); })} diff --git a/app/tests/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx index 0f6bc3dbea..2ca0690592 100644 --- a/app/tests/bot-orb.test.tsx +++ b/app/tests/bot-orb.test.tsx @@ -21,10 +21,12 @@ import { FINISH_HELD, FINISH_S, finishPhase, + OPENBOT_LOOK, ORB_PALETTES, + type OrbLook, type OrbPose, oklch, - orbPaletteFor, + orbLookFor, palette, RAYS, samePicture, @@ -58,22 +60,33 @@ const REST: OrbPose = { }; /** - * Just enough of a 2D context to run the drawing: it counts every stroke, and counts the frames - * drawn, each of which starts by clearing the canvas. + * Just enough of a 2D context to run the drawing: it counts every stroke, counts the frames drawn, + * each of which starts by clearing the canvas, and keeps where each soft blob and each eye is drawn + * and in what colour. */ function fakeContext() { - const gradient = { addColorStop: () => {} }; + const marks: string[] = []; + const gradient = { + addColorStop: (_: number, colour: string) => marks.push(colour), + }; const ctx = { strokes: 0, draws: 0, + marks, stroke() { this.strokes++; }, clearRect() { this.draws++; }, + translate(x: number, y: number) { + marks.push(`at ${x.toFixed(2)},${y.toFixed(2)}`); + }, createLinearGradient: () => gradient, - createRadialGradient: () => gradient, + createRadialGradient(x: number, y: number) { + marks.push(`blob ${x.toFixed(2)},${y.toFixed(2)}`); + return gradient; + }, }; return new Proxy(ctx, { get: (target, key) => @@ -85,16 +98,19 @@ function fakeContext() { }); } -/** Every stroke one frame draws: the rim, the landing's ping, and a finish's rays and check. */ -const strokesOf = (pose: OrbPose) => { +/** One frame of an orb 60px across in `pose`, drawn on a fake context. */ +const drawn = (pose: OrbPose, look: OrbLook = OPENBOT_LOOK) => { const ctx = fakeContext(); drawBotOrb(ctx as unknown as CanvasRenderingContext2D, 60, pose, { dark: false, - palette: "violet", + look, }); - return ctx.strokes; + return ctx; }; +/** Every stroke one frame draws: the rim, the landing's ping, and a finish's rays and check. */ +const strokesOf = (pose: OrbPose) => drawn(pose).strokes; + /** The Bots a fresh OpenBot ships with, whose ids are their seeds. */ const TEAM = [ "expense-review", @@ -155,21 +171,57 @@ describe("the colours", () => { expect(b).toBeGreaterThan(g); }); - test("a seed always gets the same palette", () => { + test("a seed always gets the same look", () => { for (const seed of TEAM) { - expect(orbPaletteFor(seed)).toBe(orbPaletteFor(seed)); - expect(ORB_PALETTES).toContain(orbPaletteFor(seed)); + expect(orbLookFor(seed)).toEqual(orbLookFor(seed)); + expect(ORB_PALETTES).toContain(orbLookFor(seed).palette); } - // Pinned, so a change to the hash, which would recolour every Bot, can't land unnoticed. - expect(orbPaletteFor("research-desk")).toBe("sky"); - expect(orbPaletteFor("release-notes")).toBe("violet"); - expect(ORB_PALETTES).toContain(orbPaletteFor("")); + // Pinned, so a change to the hash, which would change every Bot's face, can't land unnoticed. + expect(orbLookFor("research-desk").palette).toBe("ocean"); + expect(orbLookFor("ticket-triage").palette).toBe("sun"); + expect(ORB_PALETTES).toContain(orbLookFor("").palette); }); test("nine palettes, and a team of nine seeds uses more than four of them", () => { expect(ORB_PALETTES).toHaveLength(9); expect(new Set(ORB_PALETTES).size).toBe(9); - expect(new Set(TEAM.map(orbPaletteFor)).size).toBeGreaterThan(4); + const used = TEAM.map((seed) => orbLookFor(seed).palette); + expect(new Set(used).size).toBeGreaterThan(4); + }); + + test("no Bot is violet, which is OpenBot's own, and every other colour is given", () => { + const seeds = [ + ...TEAM, + "", + ...Array.from({ length: 400 }, (_, i) => `bot-${i}`), + ]; + const given = new Set(seeds.map((seed) => orbLookFor(seed).palette)); + expect(given.has("violet")).toBe(false); + expect([...given].sort()).toEqual( + ORB_PALETTES.filter((name) => name !== "violet").sort(), + ); + expect(OPENBOT_LOOK.palette).toBe("violet"); + }); + + test("every Bot in the default team is drawn unlike every other, even two in one colour", () => { + const apart = (a: number, b: number) => { + const d = Math.abs(a - b) % (2 * Math.PI); + return Math.min(d, 2 * Math.PI - d); + }; + let sharing = 0; + for (const [i, a] of TEAM.entries()) { + for (const b of TEAM.slice(i + 1)) { + const one = orbLookFor(a); + const other = orbLookFor(b); + expect(drawn(REST, one).marks).not.toEqual(drawn(REST, other).marks); + if (one.palette !== other.palette) continue; + sharing++; + // Their colours sit at least a sixth of a turn apart, which shows at any size. + expect(apart(one.turn, other.turn)).toBeGreaterThan(Math.PI / 3); + } + } + // Eight colours can't go round nine Bots, so the team has Bots that share one. + expect(sharing).toBeGreaterThan(0); }); test("each palette is a colour of its own", () => { @@ -340,9 +392,7 @@ const orbOf = (container: HTMLElement) => describe("", () => { test("holds its footprint, hides from assistive tech, and draws on a canvas twice its size", () => { - const view = render( - , - ); + const view = render(); const orb = view.container.firstElementChild as HTMLElement; expect(orb.getAttribute("aria-hidden")).toBe("true"); expect(orb.dataset.state).toBe("listening"); @@ -354,7 +404,7 @@ describe("", () => { }); test("is a block, so a small face never sits on a line's baseline and drops out of its circle", () => { - const view = render(); + const view = render(); const orb = view.container.firstElementChild as HTMLElement; expect(orb.classList.contains("block")).toBe(true); expect(orb.classList.contains("inline-block")).toBe(false); @@ -365,7 +415,7 @@ describe("", () => { const frames = manualFrames(); const restore = withContext(ctx); try { - render(); + render(); expect(ctx.draws).toBe(1); document.documentElement.classList.add("dark"); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -373,7 +423,7 @@ describe("", () => { expect(frames.requested).toBe(0); cleanup(); - render(); + render(); expect(frames.requested).toBe(1); } finally { cleanup(); @@ -388,7 +438,9 @@ describe("", () => { const ctx = fakeContext(); const restore = withContext(ctx); try { - render(); + render( + , + ); cleanup(); return ctx.strokes; } finally { @@ -399,11 +451,11 @@ describe("", () => { }); test("follows a change of state", () => { - const view = render(); + const view = render(); expect(orbOf(view.container).dataset.state).toBe("idle"); - view.rerender(); + view.rerender(); expect(orbOf(view.container).dataset.state).toBe("thinking"); - view.rerender(); + view.rerender(); expect(orbOf(view.container).dataset.state).toBe("finished"); }); @@ -416,7 +468,7 @@ describe("", () => { render(
{Array.from({ length: 50 }, (_, i) => `bot-${i}`).map((id) => ( - + ))}
, ); @@ -450,7 +502,7 @@ describe("", () => { const ctx = fakeContext(); const frames = manualFrames(); const restore = withContext(ctx); - const view = render(); + const view = render(); orbOf(view.container).getBoundingClientRect = () => ({ left: 100, top: 100, width: size, height: size }) as DOMRect; const start = performance.now(); @@ -523,7 +575,7 @@ describe("", () => { const frames = manualFrames(); const restore = withContext(ctx); try { - const view = render(); + const view = render(); const orb = orbOf(view.container); Sight.show(orb, false); expect(frames.pending).toBe(0); @@ -555,7 +607,7 @@ describe("", () => { document.dispatchEvent(new Event("visibilitychange")); }; try { - render(); + render(); expect(frames.pending).toBe(1); hide(true); expect(frames.pending).toBe(0); @@ -579,7 +631,7 @@ describe("", () => { const frames = manualFrames(); try { const view = render( - , + , ); expect(view.container.querySelector("canvas")).not.toBeNull(); // Nothing to draw on, so it asks for no frames. @@ -690,7 +742,8 @@ describe("a Bot's face and OpenBot's mark", () => { expect(avatar.style.width).toBe("36px"); const orb = orbOf(avatar); expect(orb.getAttribute("aria-hidden")).toBe("true"); - expect(orb.dataset.palette).toBe(orbPaletteFor("research-desk")); + expect(orb.dataset.palette).toBe(orbLookFor("research-desk").palette); + expect(orb.dataset.seed).toBe("research-desk"); expect(orb.style.width).toBe("36px"); expect(avatar.querySelector("canvas")).not.toBeNull(); expect(avatar.querySelector("svg")).toBeNull(); @@ -703,7 +756,8 @@ describe("a Bot's face and OpenBot's mark", () => { cleanup(); return palette; }; - expect(paletteOf("research-desk")).not.toBe(paletteOf("release-notes")); + // Chosen to land on different colours: one pair in eight shares one. + expect(paletteOf("research-desk")).not.toBe(paletteOf("expense-review")); expect(paletteOf("research-desk")).toBe(paletteOf("research-desk")); }); diff --git a/app/tests/skins-opendots-frame.test.tsx b/app/tests/skins-opendots-frame.test.tsx index 9d6a2de277..3a9bf4b000 100644 --- a/app/tests/skins-opendots-frame.test.tsx +++ b/app/tests/skins-opendots-frame.test.tsx @@ -19,7 +19,6 @@ import { } from "@tanstack/react-router"; import { cleanup, render, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { appConfig } from "@/lib/generated/application-config"; import { NAVIGATION, navigationFor } from "@/lib/navigation"; import { queryClient } from "@/query-client"; @@ -595,9 +594,13 @@ describe("OpenDots screens", () => { * deployment's own name from `brand.yaml`, and every Bot wears OpenBot's own avatar. */ describe("OpenDots skin wears the deployment's brand", () => { - /** Which colour one avatar's orb is drawn in, which is all its seed decides. */ - const drawing = (root: Element | null) => - root?.querySelector("[data-palette]")?.getAttribute("data-palette") ?? ""; + /* + * The seed one avatar's orb is drawn from, which decides everything about its look. Its colour + * alone would not do: eight colours go round, so a Bot's seed and its id share one an eighth of + * the time. + */ + const drawnFrom = (root: Element | null) => + root?.querySelector("[data-seed]")?.getAttribute("data-seed"); test("the frame names the product from brand.yaml and never says OpenDots or Dots", async () => { const { view } = draw("/channel/new?agent=ada"); @@ -636,23 +639,14 @@ describe("OpenDots skin wears the deployment's brand", () => { await view.findByRole("heading", { name: "Ada" }); const persona = view.container.querySelector("[data-opendots-persona]"); for (const bot of bots) { - const reference = render( - , - ); const row = within(list).getByText(bot.name).closest("a"); expect( row?.querySelector(`[role="img"][aria-label="${bot.name}"]`), ).toBeTruthy(); - expect(drawing(row)).toBe(drawing(reference.container)); // Drawn from the seed, not the id: the id would be a different Bot's face. - const fromId = render( - , - ); - expect(drawing(row)).not.toBe(drawing(fromId.container)); - reference.unmount(); - fromId.unmount(); + expect(drawnFrom(row)).toBe(bot.avatarSeed); + expect(drawnFrom(row)).not.toBe(bot.id); } - const ada = render(); - expect(drawing(persona)).toBe(drawing(ada.container)); + expect(drawnFrom(persona)).toBe("seed-ada"); }); }); From 8c09eb79ab6628803911f6a5ec0e894ae5184835 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 00:49:49 -0700 Subject: [PATCH 13/47] feat(setup): a setup checklist the server answers for each reader GET /api/setup says, row by row, whether Intelligence, the model, the Bot computer, more apps and Slack and Teams work here, from their own answers rather than from a variable being set. POST /api/setup/check asks them again. An admin, or somebody running OpenBot alone, is told the page or the variable that closes a gap; everybody else is told only that it works or that their admin sets it up, and their response carries no variable name. --- server/src/app.ts | 13 + server/src/computer/policy-network.ts | 2 +- server/src/index.ts | 68 +- server/src/setup/checklist.ts | 597 ++++++++++++++ server/src/setup/computer-check.ts | 81 ++ server/tests/setup-checklist.test.ts | 1033 +++++++++++++++++++++++++ 6 files changed, 1781 insertions(+), 13 deletions(-) create mode 100644 server/src/setup/checklist.ts create mode 100644 server/src/setup/computer-check.ts create mode 100644 server/tests/setup-checklist.test.ts diff --git a/server/src/app.ts b/server/src/app.ts index 2cdf12f37a..8ad8f54af0 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -143,6 +143,7 @@ import type { IntentRouter } from "./routing/classify"; import { createRoutingRoutes } from "./routing/routes"; import { createCoworkerRoutingService } from "./routing/service"; import type { SelfHostBanner } from "./self-host-banner"; +import { createSetupRoutes, type SetupSources } from "./setup/checklist"; import { createTeamBotRoutes } from "./team-bots/routes"; import type { TeamBots } from "./team-bots/team-bots"; import type { PackageStatusReader } from "./tenant-package"; @@ -461,6 +462,11 @@ export function createApp( teamBots?: TeamBots; /** Pause, reset, Activity and attention for a person's own Bots. See agents/lifecycle.ts. */ lifecycle?: BotLifecycleServices; + /** + * The setup checklist onboarding's "Check your setup" step draws, read from this server's own + * configuration and state. See setup/checklist.ts. Absent, `/api/setup` is not mounted. + */ + setup?: SetupSources; }, /** * Whether to offer help self-hosting, which also hides it from a deployment that pays for @@ -1673,6 +1679,13 @@ export function createApp( "/api/bots", createBotLifecycleRoutes(coworker.lifecycle, requireUser), ); + /* + * Signed in, unlike `/api/capabilities`, which anybody can read. The checklist names what this + * deployment has and lacks, and for an admin which variables would close a gap, which is not a + * visitor's to have before they sign in. + */ + if (coworker?.setup) + app.route("/api/setup", createSetupRoutes(coworker.setup, requireUser)); if (coworker?.groups) app.route("/api/groups", createGroupRoutes(coworker.groups, requireUser)); if (coworker?.teamBots) diff --git a/server/src/computer/policy-network.ts b/server/src/computer/policy-network.ts index 1967cef7ca..b8664547d6 100644 --- a/server/src/computer/policy-network.ts +++ b/server/src/computer/policy-network.ts @@ -73,7 +73,7 @@ export type EgressPushReport = { * A seat on the shared computer and an isolated Bot's computer each name their Bot. A person's * computer carries every Bot that person has used, and only the computer knows which, so it is asked. */ -async function botsOn( +export async function botsOn( computer: ComputerLocation, options: { token?: string; fetchImpl: typeof fetch }, ): Promise { diff --git a/server/src/index.ts b/server/src/index.ts index e33d2951f2..55ff2c277b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -282,6 +282,8 @@ import { createRoutineStore } from "./routines/store"; import { createIntentRouter } from "./routing/classify"; import { createModelCompleter } from "./routing/model"; import { createSelfHostBanner } from "./self-host-banner"; +import { createSetupSources, modelKeyOf } from "./setup/checklist"; +import { createComputerCheck } from "./setup/computer-check"; import { createTeamBots } from "./team-bots/team-bots"; import { createPackageStatusReader, @@ -1293,18 +1295,32 @@ const intentRouter = createIntentRouter({ // Said once at boot: whether the key can call the model the Bots run on. A key without access to it // otherwise passes every start-up check and fails at the first message, in front of whoever sent it. -void reportModelAvailability({ - provider: runtimeModel.provider, - model: runtimeModel.defaultModel, - apiKey: () => - resolveModelApiKey({ - encryptionKey: config.keyEncryptionKey, - reader: credentialStore, - provider: runtimeModel.provider, - keyId: tenantPackage.model.credentialSecretRef, - environment: process.env, - }), -}); +// Kept, with a stamp naming the key it asked about, so the setup checklist can repeat the answer +// for that key and say "not checked yet" for any other. The checklist's "Check again" asks again +// through the same function. Never rejects: a failed read reads as not checked. +const checkModelAvailability = () => + reportModelAvailability({ + provider: runtimeModel.provider, + model: runtimeModel.defaultModel, + apiKey: () => + resolveModelApiKey({ + encryptionKey: config.keyEncryptionKey, + reader: credentialStore, + provider: runtimeModel.provider, + keyId: tenantPackage.model.credentialSecretRef, + environment: process.env, + }), + }); +const modelCheckAtBoot = (async () => { + const { stamp } = await modelKeyOf({ + plan: Boolean(runtimeModel.plan), + provider: runtimeModel.provider, + keyId: tenantPackage.model.credentialSecretRef, + credentials: credentialStore, + environment: process.env, + }); + return { outcome: await checkModelAvailability(), stamp }; +})().catch(() => undefined); // Named collaborators are bound once in actorAgentResolver for chat, hops, and headless turns. @@ -2769,6 +2785,34 @@ const app = createApp( profiles: agentProfileStore, auditStore: bootAuditStore, }, + // The checklist onboarding ends on. Facts and verdicts about this configuration, never values. + setup: createSetupSources({ + productName: tenantPackage.productName, + config, + environment: process.env, + model: { + provider: runtimeModel.provider, + keyId: tenantPackage.model.credentialSecretRef, + defaultModel: runtimeModel.defaultModel, + plan: Boolean(runtimeModel.plan), + check: checkModelAvailability, + }, + credentials: credentialStore, + modelCheckAtBoot, + entitlements: () => selfHostBannerIntelligence.getRuntimeEntitlements(), + computer: Boolean(config.computer), + computerCheck: createComputerCheck({ + provider: computerProvider, + controls: enterpriseControls, + ...(config.computer?.token ? { token: config.computer.token } : {}), + ...(config.webBotAuth ? { webBotAuth: config.webBotAuth } : {}), + }), + apps: { + configured: Boolean(composio), + provider: composio?.provider ?? config.brokerProvider ?? "composio", + }, + chat: Boolean(deliveryProviders.slack), + }), }, createSelfHostBanner({ enabled: config.selfHostBanner, diff --git a/server/src/setup/checklist.ts b/server/src/setup/checklist.ts new file mode 100644 index 0000000000..17a7549063 --- /dev/null +++ b/server/src/setup/checklist.ts @@ -0,0 +1,597 @@ +import { Hono, type MiddlewareHandler } from "hono"; +import type { AppVariables } from "../auth/guards"; +import type { DeploymentConfig } from "../config"; +import type { CredentialStatusReader } from "../credentials"; +import { entitlementWarning } from "../intelligence-entitlement-check"; +import type { ModelCheckOutcome } from "../model-availability-check"; +import type { BrokerProvider } from "../plugins/access"; +import type { RuntimeEntitlementResponse } from "../self-host-banner"; + +/** + * The setup checklist: one row for each thing this deployment depends on, and what it says about it. + * + * Onboarding's "Check your setup" step draws it. It answers what an admin is about to ask after a + * first run, "what is set up, and what still needs doing", from the server's own configuration and + * state rather than from a list the app keeps. + * + * READY MEANS IT WORKS, NOT THAT SOMETHING IS SET. Intelligence is read from its own entitlement + * answer (the server cannot boot without its address, so "set" would always be true), and the model + * from the provider's answer for the key in use now (a key that is present can still be refused). A + * key nobody has asked the provider about says so ("not checked yet") rather than Ready. + * + * PRESENCE AND VERDICTS, NEVER A VALUE. Every fact below is a yes, a no or a state word. A key is + * "set" because the environment or the vault has one, and nothing here reads, masks, measures or + * echoes what it is. The two outbound reads, Intelligence's entitlement and one `GET /v1/models/` + * at the provider, are the ones the server already makes at boot; "Check again" repeats them, at + * most once every {@link RECHECK_INTERVAL_MS}. + * + * WHO READS IT DECIDES WHAT IT SAYS. An admin, and somebody trying OpenBot alone on their own + * machine, is told how to fix each gap: the page to open, or the variable to set. Everybody else is + * told whether it works and that their admin sets it up, and the response they receive carries no + * variable name at all. That is the copy rule's line ("Admin is allowed exact names") drawn by the + * reader rather than by the page: onboarding is a page anybody opens, so the server decides per + * reader and a member's browser never holds the deployment's configuration vocabulary. + * + * Ported from OpenDuet's checklist without its Google rows: OpenBot does not ship a Google app, and + * a person's own apps are the Connect your apps step's to draw. + */ + +/** The guides a fix links to. The repository is where the docs ship from. */ +const DOCS = "https://github.com/CopilotKit/openbot/blob/main/docs"; +const QUICK_START = "https://github.com/CopilotKit/openbot#quick-start"; + +/** How long a "Check again" is answered from the last one, so a held button is not a flood. */ +export const RECHECK_INTERVAL_MS = 10_000; + +/** How long Intelligence has to answer before the row says it could not be reached. */ +const INTELLIGENCE_TIMEOUT_MS = 5_000; + +export type SetupRowId = + | "intelligence" + | "model" + | "computer" + | "apps" + | "chat"; + +/** + * How a gap is closed, or null when there is nothing for the reader to do. + * + * - `env`: variables to set on the server, by name, and a guide. Only ever sent to an admin. + * - `page`: a screen in the app that closes it. `path` is in-app, never another origin. + */ +export type SetupFix = + | { + kind: "env"; + names: string[]; + docsUrl: string; + /** A screen that does the same job, where there is one. */ + page?: { label: string; path: string }; + } + | { kind: "page"; label: string; path: string; docsUrl?: string }; + +/** + * What a row says about its dependency. + * + * `ready` works, as far as anything here can tell. `needs_setup` does not, and the row says why. + * `not_checked` is set up, but nobody has asked whether it works: a model key added since the server + * started, or one behind a custom endpoint the provider's own check cannot reach. + */ +export type SetupStatus = "ready" | "needs_setup" | "not_checked"; + +export type SetupRow = { + id: SetupRowId; + /** Every row today is the admin's to set up. Kept so a person's own row can join without a shape change. */ + scope: "deployment"; + title: string; + /** True only where the product cannot answer without it. */ + required: boolean; + status: SetupStatus; + /** `status === "ready"`, for a reader that only asks yes or no. */ + ready: boolean; + /** One sentence: what it gives you when ready, what happens without it when not. */ + detail: string; + fix: SetupFix | null; +}; + +export type SetupChecklist = { + /** `admin` for an admin or a local single-person install; `member` for everybody else. */ + audience: "admin" | "member"; + rows: SetupRow[]; +}; + +/** What Intelligence said about this deployment. See {@link intelligenceFact}. */ +export type IntelligenceFact = + | { state: "ready" } + /** It answered, and did not confirm the entitlement. `advice` is the admin's next step. */ + | { state: "refused"; advice: string } + /** It did not answer in time, or the read failed. */ + | { state: "unreachable" }; + +/** + * What the model provider said about the key in use now. + * + * `not_checked` when nobody has asked about this key (one added since the last check) or the + * provider did not answer; `cannot_check` when the key sits behind a custom endpoint, which the + * provider's own model lookup cannot vouch for. + */ +export type ModelVerdict = + | "available" + | "refused_key" + | "refused_model" + | "not_checked" + | "cannot_check"; + +/** + * Everything the rows are decided from. Booleans, names and state words, never a value: see the + * module comment. + */ +export type SetupFacts = { + intelligence: IntelligenceFact; + model: { + provider: "openai" | "anthropic"; + /** Where the key the Bots answer with comes from, or null when there is none. */ + source: "plan" | "credential" | "environment" | null; + verdict: ModelVerdict; + /** The model the Bots run on, named when the key cannot use it. */ + model: string; + }; + /** A Bot computer is configured. Absent, its routes are not mounted at all. */ + computer: boolean; + computerStatus?: SetupStatus; + /** A broker for apps beyond the ones an admin adds, and which one this deployment would use. */ + apps: { configured: boolean; provider: BrokerProvider }; + /** The OpenTag pairing that carries Slack and Microsoft Teams. */ + chat: boolean; +}; + +const KEY_NAME = { + openai: "OPENAI_API_KEY", + anthropic: "ANTHROPIC_API_KEY", +} as const; +const PROVIDER_NAME = { openai: "OpenAI", anthropic: "Anthropic" } as const; +const BROKER = { + composio: { key: "COMPOSIO_API_KEY", docs: `${DOCS}/plugins/composio.md` }, + arcade: { key: "ARCADE_API_KEY", docs: `${DOCS}/plugins/arcade.md` }, +} as const; + +/** What a member is told about a dependency that is set up and was never checked. */ +const MEMBER_NOT_CHECKED = "Set up, not checked yet."; + +/** The rows for one reader. Pure, so every branch can be pinned without a server. */ +export function setupRows( + facts: SetupFacts, + options: { productName: string; audience: "admin" | "member" }, +): SetupRow[] { + const { productName: product, audience } = options; + const admin = audience === "admin"; + + /** + * A row as its reader should see it. A member is told it works, that it is not checked yet, or + * that their admin sets it up, and is handed no fix: the variable names stay on the server. + */ + const deployment = (row: { + id: SetupRowId; + title: string; + required: boolean; + status: SetupStatus; + readyDetail: string; + gapDetail: string; + fix: SetupFix | null; + }): SetupRow => ({ + id: row.id, + scope: "deployment", + title: row.title, + required: row.required, + status: row.status, + ready: row.status === "ready", + detail: + row.status === "ready" + ? row.readyDetail + : admin + ? row.gapDetail + : row.status === "not_checked" + ? MEMBER_NOT_CHECKED + : "Your admin sets this up.", + fix: row.status === "ready" || !admin ? null : row.fix, + }); + + const broker = BROKER[facts.apps.provider]; + + return [ + deployment({ + id: "intelligence", + title: "CopilotKit Intelligence", + required: true, + ...intelligenceRow(facts.intelligence, product), + }), + deployment({ + id: "model", + title: "AI model", + required: true, + ...modelRow(facts.model, product), + }), + deployment({ + id: "computer", + title: "Bot computer", + required: false, + status: facts.computer + ? (facts.computerStatus ?? "not_checked") + : "needs_setup", + readyDetail: "Bots can browse the web and work with files.", + gapDetail: "Without it, Bots can't browse the web or work with files.", + fix: { + kind: "env", + names: ["AGENT_COMPUTER_URL", "COMPUTER_TOKEN"], + docsUrl: `${DOCS}/configuration.md#computer-and-supervisor`, + }, + }), + deployment({ + id: "apps", + title: "More apps", + required: false, + status: facts.apps.configured ? "ready" : "needs_setup", + readyDetail: "People can connect more apps than the ones an admin adds.", + gapDetail: "Without it, people can connect only the apps an admin adds.", + fix: { kind: "env", names: [broker.key], docsUrl: broker.docs }, + }), + deployment({ + id: "chat", + title: "Slack and Teams", + required: false, + status: facts.chat ? "ready" : "needs_setup", + readyDetail: `People can talk to ${product} in Slack and Microsoft Teams.`, + gapDetail: `Without it, people can talk to ${product} only here.`, + fix: { + kind: "env", + names: ["OPENTAG_SHARED_SECRET", "OPENTAG_URL"], + docsUrl: `${DOCS}/configuration.md#slack-and-microsoft-teams-opentag-pairing`, + }, + }), + ]; +} + +const INTELLIGENCE_VARIABLES = [ + "INTELLIGENCE_API_URL", + "INTELLIGENCE_GATEWAY_WS_URL", + "INTELLIGENCE_API_KEY", +]; + +/** + * Intelligence as its own entitlement answer says. Its advice is the admin's sentence the boot log + * already prints (`entitlementWarning`), which names the licence and where it goes. + */ +function intelligenceRow(fact: IntelligenceFact, product: string) { + const readyDetail = `Keeps your conversations and what ${product} remembers.`; + if (fact.state === "ready") + return { + status: "ready" as const, + readyDetail, + gapDetail: "", + fix: null, + }; + if (fact.state === "refused") + return { + status: "needs_setup" as const, + readyDetail, + gapDetail: fact.advice, + fix: null, + }; + return { + status: "needs_setup" as const, + readyDetail, + gapDetail: `${product} couldn't reach Intelligence, so conversations and memory may fail. Check again in a moment, or check its address and key.`, + fix: { + kind: "env" as const, + names: INTELLIGENCE_VARIABLES, + docsUrl: QUICK_START, + }, + }; +} + +/** The model row, from where its key comes from and what the provider last said about that key. */ +function modelRow(model: SetupFacts["model"], product: string) { + const provider = PROVIDER_NAME[model.provider]; + const fix: SetupFix = { + kind: "env", + names: [KEY_NAME[model.provider]], + docsUrl: QUICK_START, + page: { label: "Credentials", path: "/admin/credentials" }, + }; + const readyDetail = `Lets ${product} answer you.`; + if (model.source === null) + return { + status: "needs_setup" as const, + readyDetail, + gapDetail: `Without a model key, ${product} can't answer.`, + fix, + }; + // A subscription plan answers without a key of ours, so there is no key to ask about. + if (model.source === "plan") + return { status: "ready" as const, readyDetail, gapDetail: "", fix }; + switch (model.verdict) { + case "available": + return { status: "ready" as const, readyDetail, gapDetail: "", fix }; + case "refused_key": + return { + status: "needs_setup" as const, + readyDetail, + gapDetail: `${provider} refused the model key, so ${product} can't answer.`, + fix, + }; + case "refused_model": + return { + status: "needs_setup" as const, + readyDetail, + gapDetail: `The model key can't use ${model.model}, so ${product} can't answer.`, + fix, + }; + case "cannot_check": + return { + status: "not_checked" as const, + readyDetail, + gapDetail: `Key set. It uses a custom endpoint, which ${product} can't check.`, + fix: null, + }; + case "not_checked": + return { + status: "not_checked" as const, + readyDetail, + gapDetail: `Key set, not checked yet. Check again to ask ${provider}.`, + fix: null, + }; + } +} + +/** + * What an entitlement answer, or a failed read, means for the Intelligence row. Ready only when + * Intelligence answered ready and active, which is what `entitlementWarning` returns null for. + */ +export function intelligenceFact( + answer: RuntimeEntitlementResponse | Error, +): IntelligenceFact { + if (answer instanceof Error) return { state: "unreachable" }; + const warning = entitlementWarning(answer); + return warning + ? { state: "refused", advice: warning.fix } + : { state: "ready" }; +} + +/** + * The provider's answer about the key in use now. + * + * A check about another key says nothing about this one: the stamp names the key the check was + * made with (see {@link modelKeyOf}), and a key added or rotated since is "not checked yet". Only a + * definite answer counts: a 401 or 403 on the key, a 404 on the model, a 2xx. "Could not ask" is + * not evidence either way. + */ +export function modelVerdict( + check: { outcome: ModelCheckOutcome; stamp: string | null } | undefined, + stamp: string | null, +): ModelVerdict { + if (!check || stamp === null || check.stamp !== stamp) return "not_checked"; + const { outcome } = check; + if (outcome.kind === "available") return "available"; + // Skipped with a key in hand means a custom endpoint: the provider's own lookup cannot vouch. + if (outcome.kind === "skipped") return "cannot_check"; + if (outcome.line.type === "model-credential-rejected") return "refused_key"; + if (outcome.line.type === "model-unavailable") return "refused_model"; + return "not_checked"; +} + +/** + * Where the key the built-in Bots answer with comes from, in the order the runtime resolves it, and + * a stamp naming that one key. + * + * The stamp is what tells a check about this key from a check about the one before it: the vault + * row's id (rotating a key writes a new row), or the source's name for the environment and a plan, + * which cannot change without a restart. An id, never anything read from the secret. + */ +export async function modelKeyOf(input: { + plan: boolean; + provider: "openai" | "anthropic"; + keyId: string; + credentials: CredentialStatusReader; + environment: Record; +}): Promise<{ + source: SetupFacts["model"]["source"]; + stamp: string | null; +}> { + // A subscription plan answers without any key (see `planModelForEnvironment`). + if (input.plan) return { source: "plan", stamp: "plan" }; + // The vault before the environment, as `resolveModelApiKey` reads them. Statuses only: the list + // carries no secret, so nothing here could hold one even by mistake. + const stored = (await input.credentials.list()).find( + (credential) => + credential.kind === "model" && + credential.provider === input.provider && + credential.keyId === input.keyId && + credential.revokedAt === null, + ); + if (stored) return { source: "credential", stamp: `credential:${stored.id}` }; + return input.environment[KEY_NAME[input.provider]]?.trim() + ? { source: "environment", stamp: "environment" } + : { source: null, stamp: null }; +} + +/** What the route needs: the product's name, the facts, and a way to ask again. */ +export type SetupSources = { + productName: string; + /** A local install for one person, whose only reader is the person running it. */ + localEvaluation: boolean; + facts: () => Promise; + /** Ask Intelligence, the model provider and the computers again. Resolves when all have answered. */ + recheck: () => Promise; +}; + +/** A promise that gives up after `ms`, as an Error, so a slow service reads as unreachable. */ +function withinTime(promise: Promise, ms: number): Promise { + return Promise.race([ + promise.catch((error: unknown) => + error instanceof Error ? error : new Error(String(error)), + ), + new Promise((resolve) => { + const timer = setTimeout( + () => resolve(new Error("Intelligence did not answer.")), + ms, + ); + timer.unref?.(); + }), + ]); +} + +/** + * The facts read from this server's configuration and stores. + * + * Built once at boot and asked on every request, so a credential added a moment ago shows up on the + * next read. The verdicts that need another service, Intelligence's entitlement, the model + * provider's answer and the computers', are kept from the last time they were asked (at boot) and + * asked again on `recheck`. + */ +export function createSetupSources(input: { + productName: string; + config: Pick; + environment: Record; + model: { + provider: "openai" | "anthropic"; + keyId: string; + defaultModel: string; + plan: boolean; + /** One read of the provider's model endpoint with the key in use now. Never rejects. */ + check?: () => Promise; + }; + credentials: CredentialStatusReader; + /** The answer the boot check got, with the stamp of the key it asked about. */ + modelCheckAtBoot?: Promise< + { outcome: ModelCheckOutcome; stamp: string | null } | undefined + >; + /** Intelligence's entitlement answer for this deployment. Asked at creation and on recheck. */ + entitlements: () => Promise; + computer: boolean; + /** Authenticated reachability and policy delivery, with per-operation deadlines and no wake. */ + computerCheck?: () => Promise; + apps: { configured: boolean; provider: BrokerProvider }; + chat: boolean; +}): SetupSources { + const keyFacts = { + plan: input.model.plan, + provider: input.model.provider, + keyId: input.model.keyId, + credentials: input.credentials, + environment: input.environment, + }; + const askIntelligence = () => + withinTime(input.entitlements(), INTELLIGENCE_TIMEOUT_MS).then( + intelligenceFact, + ); + + const askComputer = async (): Promise => { + if (!input.computer) return "needs_setup"; + if (!input.computerCheck) return "not_checked"; + try { + // The fleet check owns its operation deadlines; one push's budget cannot cover the fleet. + return await input.computerCheck(); + } catch { + return "needs_setup"; + } + }; + let computerStatus = askComputer(); + let intelligence = askIntelligence(); + let modelCheck: Promise< + { outcome: ModelCheckOutcome; stamp: string | null } | undefined + > = (input.modelCheckAtBoot ?? Promise.resolve(undefined)).catch( + () => undefined, + ); + let lastRecheck: { at: number; done: Promise } | null = null; + + return { + productName: input.productName, + localEvaluation: input.config.singleUser, + facts: async () => { + const [key, check, intelligenceNow, computerNow] = await Promise.all([ + modelKeyOf(keyFacts), + modelCheck, + intelligence, + computerStatus, + ]); + return { + intelligence: intelligenceNow, + model: { + provider: input.model.provider, + source: key.source, + verdict: modelVerdict(check, key.stamp), + model: input.model.defaultModel, + }, + computer: input.computer, + computerStatus: computerNow, + apps: input.apps, + chat: input.chat, + }; + }, + recheck: () => { + const now = Date.now(); + if (lastRecheck && now - lastRecheck.at < RECHECK_INTERVAL_MS) + return lastRecheck.done; + intelligence = askIntelligence(); + computerStatus = askComputer(); + const check = input.model.check; + if (check) { + modelCheck = (async () => { + const { stamp } = await modelKeyOf(keyFacts); + return stamp === null ? undefined : { outcome: await check(), stamp }; + })().catch(() => undefined); + } + const done = Promise.all([intelligence, modelCheck, computerStatus]).then( + () => {}, + ); + lastRecheck = { at: now, done }; + return done; + }, + }; +} + +/** + * `GET /` answers the checklist for whoever is signed in. `POST /check` asks Intelligence, the + * model provider and the computers again first, then answers the same way. + */ +export function createSetupRoutes( + sources: SetupSources, + requireUser: MiddlewareHandler<{ Variables: AppVariables }>, +) { + const routes = new Hono<{ Variables: AppVariables }>(); + routes.use("*", requireUser); + routes.onError((error, context) => { + console.error( + JSON.stringify({ + type: "setup-checklist-route-error", + error: error.name, + timestamp: new Date().toISOString(), + }), + ); + return context.json( + { error: "Couldn't check your setup. Try again." }, + 503, + ); + }); + const answer = async (context: { + var: AppVariables; + json: (body: { checklist: SetupChecklist }) => Response; + }) => { + const audience = + context.var.actor.role === "admin" || sources.localEvaluation + ? "admin" + : "member"; + const facts = await sources.facts(); + return context.json({ + checklist: { + audience, + rows: setupRows(facts, { productName: sources.productName, audience }), + }, + }); + }; + routes.get("/", (context) => answer(context)); + routes.post("/check", async (context) => { + await sources.recheck(); + return answer(context); + }); + return routes; +} diff --git a/server/src/setup/computer-check.ts b/server/src/setup/computer-check.ts new file mode 100644 index 0000000000..e0ddad6260 --- /dev/null +++ b/server/src/setup/computer-check.ts @@ -0,0 +1,81 @@ +import { + botsOn, + type EgressPolicy, + pushEgressPolicy, +} from "../computer/policy-network"; +import type { ComputerProvider } from "../computer/provider"; +import type { WebBotAuth } from "../web-bot-auth"; +import type { SetupStatus } from "./checklist"; + +/** + * Whether the Bot computers work, for the setup checklist's computer row. + * + * Ready means every running computer took the network policy of each Bot it carries, which is the + * authenticated round trip every action starts with (a computer refuses connections until it has + * one). A person's computer is asked which Bots those are, as the policy push asks it. None running + * is "not checked": a computer that has not started yet is not a broken one. + * + * Only running computers, without waking any. Each list, ask, policy read and push has five seconds; + * a healthy fleet's total time scales with its size. Serial delivery bounds the active work to one. + */ +export function createComputerCheck(input: { + provider?: Pick; + controls: () => + | { policyForBot: (botId: string) => Promise } + | undefined; + token?: string; + webBotAuth?: WebBotAuth; + fetchImpl?: typeof fetch; +}): () => Promise { + let inFlight: Promise | undefined; + let operation: Promise = Promise.resolve(); + const bounded = (work: Promise): Promise => { + operation = work; + let timer: ReturnType; + return Promise.race([ + work, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error("The computer check did not answer.")), + 5_000, + ); + timer.unref?.(); + }), + ]).finally(() => clearTimeout(timer)); + }; + const check = async (): Promise => { + if (!input.provider) return "needs_setup"; + const computers = await bounded(input.provider.list()); + const running = computers.filter( + (computer) => computer.status === "running" && computer.url, + ); + if (running.length === 0) return "not_checked"; + const controls = input.controls(); + if (!controls) return "not_checked"; + for (const computer of running) { + if (!computer.url) continue; + const bots = await bounded( + botsOn(computer, { + ...(input.token ? { token: input.token } : {}), + fetchImpl: input.fetchImpl ?? fetch, + }), + ); + for (const botId of bots) { + const policy = await bounded(controls.policyForBot(botId)); + await bounded(pushEgressPolicy(computer.url, botId, policy, input)); + } + } + return "ready"; + }; + return () => { + inFlight ??= check().finally(() => { + // A timed-out store/provider may not support cancellation. Keep its verdict until that + // operation settles, so rechecks cannot stack more work behind a hung read or push. + const release = () => { + inFlight = undefined; + }; + void operation.then(release, release); + }); + return inFlight; + }; +} diff --git a/server/tests/setup-checklist.test.ts b/server/tests/setup-checklist.test.ts new file mode 100644 index 0000000000..acab0be977 --- /dev/null +++ b/server/tests/setup-checklist.test.ts @@ -0,0 +1,1033 @@ +import { describe, expect, jest, test } from "bun:test"; +import { createApp } from "../src/app"; +import { loadConfig } from "../src/config"; +import type { CredentialStatus } from "../src/credentials"; +import type { ModelCheckOutcome } from "../src/model-availability-check"; +import type { RuntimeEntitlementResponse } from "../src/self-host-banner"; +import { + createSetupSources, + intelligenceFact, + modelKeyOf, + modelVerdict, + RECHECK_INTERVAL_MS, + type SetupChecklist, + type SetupFacts, + type SetupRow, + setupRows, +} from "../src/setup/checklist"; +import { createComputerCheck } from "../src/setup/computer-check"; +import { testEnvironment } from "./support/environment"; + +/** + * The setup checklist onboarding's "Check your setup" step draws: one row for each thing the + * deployment depends on. + * + * What is pinned here is what the screen promises. The rows follow the server's own configuration, + * an admin is told how to close each gap, a member is told only that it works or that their admin + * sets it up, and no response ever carries a secret, whoever asks. + */ + +const PRODUCT = "OpenBot"; + +const READY: SetupFacts = { + intelligence: { state: "ready" }, + model: { + provider: "openai", + source: "environment", + verdict: "available", + model: "gpt-5.6-terra", + }, + computer: true, + computerStatus: "ready", + apps: { configured: true, provider: "composio" }, + chat: true, +}; + +const UNSET: SetupFacts = { + intelligence: { state: "ready" }, + model: { + provider: "openai", + source: null, + verdict: "not_checked", + model: "gpt-5.6-terra", + }, + computer: false, + apps: { configured: false, provider: "composio" }, + chat: false, +}; + +/** An entitlement answer Intelligence gives a self-hosted deployment with no licence. */ +const UNLICENSED: RuntimeEntitlementResponse = { + status: "misconfigured", + error: { + code: "RUNTIME_ENTITLEMENTS_SELF_HOSTED_MISCONFIGURED", + message: "No licence.", + retryable: false, + }, +}; +const ENTITLED: RuntimeEntitlementResponse = { + status: "ready", + entitlement: { + active: true, + source: "selfHostedDeploymentLicense", + features: {}, + limits: {}, + }, +}; + +const row = (rows: SetupRow[], id: SetupRow["id"]) => { + const found = rows.find((candidate) => candidate.id === id); + if (!found) throw new Error(`no ${id} row`); + return found; +}; + +describe("setup rows", () => { + test("everything set reads Ready, with nothing to fix", () => { + const rows = setupRows(READY, { productName: PRODUCT, audience: "admin" }); + + expect(rows.map((candidate) => candidate.id)).toEqual([ + "intelligence", + "model", + "computer", + "apps", + "chat", + ]); + for (const candidate of rows) { + expect(candidate.status).toBe("ready"); + expect(candidate.ready).toBe(true); + expect(candidate.fix).toBeNull(); + } + }); + + test("only Intelligence and the model are required", () => { + const rows = setupRows(READY, { productName: PRODUCT, audience: "admin" }); + expect( + rows.filter((candidate) => candidate.required).map((c) => c.id), + ).toEqual(["intelligence", "model"]); + }); + + test("an admin is told what each gap costs and how to close it", () => { + const rows = setupRows(UNSET, { productName: PRODUCT, audience: "admin" }); + + const model = row(rows, "model"); + expect(model.ready).toBe(false); + expect(model.detail).toBe("Without a model key, OpenBot can't answer."); + expect(model.fix).toEqual({ + kind: "env", + names: ["OPENAI_API_KEY"], + docsUrl: "https://github.com/CopilotKit/openbot#quick-start", + page: { label: "Credentials", path: "/admin/credentials" }, + }); + + expect(row(rows, "computer").fix).toMatchObject({ + kind: "env", + names: ["AGENT_COMPUTER_URL", "COMPUTER_TOKEN"], + }); + expect(row(rows, "apps").fix).toMatchObject({ + kind: "env", + names: ["COMPOSIO_API_KEY"], + }); + expect(row(rows, "apps").detail).toBe( + "Without it, people can connect only the apps an admin adds.", + ); + expect(row(rows, "chat").fix).toMatchObject({ + kind: "env", + names: ["OPENTAG_SHARED_SECRET", "OPENTAG_URL"], + }); + }); + + test("an Anthropic deployment names Anthropic's key", () => { + const rows = setupRows( + { ...UNSET, model: { ...UNSET.model, provider: "anthropic" } }, + { productName: PRODUCT, audience: "admin" }, + ); + expect(row(rows, "model").fix).toMatchObject({ + names: ["ANTHROPIC_API_KEY"], + }); + }); + + test("a member sees their admin sets it up, and no variable name", () => { + const rows = setupRows(UNSET, { + productName: PRODUCT, + audience: "member", + }); + + for (const candidate of rows.filter((c) => c.scope === "deployment")) { + if (candidate.ready) continue; + expect(candidate.detail).toBe("Your admin sets this up."); + expect(candidate.fix).toBeNull(); + } + const body = JSON.stringify(rows); + for (const name of [ + "OPENAI_API_KEY", + "COMPOSIO_API_KEY", + "OPENTAG_SHARED_SECRET", + "AGENT_COMPUTER_URL", + "/admin/", + ]) { + expect(body).not.toContain(name); + } + }); + + test("a key the provider refused is a gap, named for what was refused", () => { + const key = setupRows( + { ...READY, model: { ...READY.model, verdict: "refused_key" } }, + { productName: PRODUCT, audience: "admin" }, + ); + expect(row(key, "model").status).toBe("needs_setup"); + expect(row(key, "model").detail).toBe( + "OpenAI refused the model key, so OpenBot can't answer.", + ); + + const model = setupRows( + { ...READY, model: { ...READY.model, verdict: "refused_model" } }, + { productName: PRODUCT, audience: "admin" }, + ); + expect(row(model, "model").detail).toBe( + "The model key can't use gpt-5.6-terra, so OpenBot can't answer.", + ); + }); + + test("a key nobody asked the provider about is not checked, never Ready", () => { + const unchecked = setupRows( + { ...READY, model: { ...READY.model, verdict: "not_checked" } }, + { productName: PRODUCT, audience: "admin" }, + ); + expect(row(unchecked, "model")).toMatchObject({ + status: "not_checked", + ready: false, + detail: "Key set, not checked yet. Check again to ask OpenAI.", + fix: null, + }); + + const custom = setupRows( + { ...READY, model: { ...READY.model, verdict: "cannot_check" } }, + { productName: PRODUCT, audience: "admin" }, + ); + expect(row(custom, "model")).toMatchObject({ + status: "not_checked", + detail: "Key set. It uses a custom endpoint, which OpenBot can't check.", + }); + + const member = setupRows( + { ...READY, model: { ...READY.model, verdict: "not_checked" } }, + { productName: PRODUCT, audience: "member" }, + ); + expect(row(member, "model")).toMatchObject({ + status: "not_checked", + detail: "Set up, not checked yet.", + fix: null, + }); + }); + + test("Intelligence that did not confirm the entitlement needs setup, with its own advice", () => { + const refused = intelligenceFact(UNLICENSED); + expect(refused.state).toBe("refused"); + const admin = setupRows( + { ...READY, intelligence: refused }, + { productName: PRODUCT, audience: "admin" }, + ); + expect(row(admin, "intelligence")).toMatchObject({ + status: "needs_setup", + required: true, + fix: null, + }); + expect(row(admin, "intelligence").detail).toContain( + "COPILOTKIT_LICENSE_TOKEN", + ); + + const member = setupRows( + { ...READY, intelligence: refused }, + { productName: PRODUCT, audience: "member" }, + ); + expect(row(member, "intelligence").detail).toBe("Your admin sets this up."); + expect(JSON.stringify(member)).not.toContain("COPILOTKIT_LICENSE_TOKEN"); + }); + + test("Intelligence that could not be reached needs setup, and names its variables to an admin", () => { + const rows = setupRows( + { ...READY, intelligence: intelligenceFact(new Error("timeout")) }, + { productName: PRODUCT, audience: "admin" }, + ); + expect(row(rows, "intelligence")).toMatchObject({ + status: "needs_setup", + detail: + "OpenBot couldn't reach Intelligence, so conversations and memory may fail. Check again in a moment, or check its address and key.", + fix: { + kind: "env", + names: [ + "INTELLIGENCE_API_URL", + "INTELLIGENCE_GATEWAY_WS_URL", + "INTELLIGENCE_API_KEY", + ], + }, + }); + expect(intelligenceFact(ENTITLED)).toEqual({ state: "ready" }); + }); +}); + +describe("what the provider's answer means for the key in use now", () => { + const rejected = { + kind: "warning" as const, + line: { + type: "model-credential-rejected" as const, + provider: "openai" as const, + model: "gpt-5.6-terra", + status: 401, + fix: "replace it", + }, + }; + + test("an answer about this key is its verdict", () => { + expect( + modelVerdict( + { outcome: { kind: "available" }, stamp: "environment" }, + "environment", + ), + ).toBe("available"); + expect( + modelVerdict({ outcome: rejected, stamp: "environment" }, "environment"), + ).toBe("refused_key"); + expect( + modelVerdict( + { outcome: { kind: "skipped" }, stamp: "environment" }, + "environment", + ), + ).toBe("cannot_check"); + }); + + test("an answer about another key, no answer, or an unreadable one is not checked", () => { + expect( + modelVerdict( + { outcome: rejected, stamp: "environment" }, + "credential:credential-2", + ), + ).toBe("not_checked"); + expect(modelVerdict(undefined, "environment")).toBe("not_checked"); + expect( + modelVerdict( + { + outcome: { + kind: "warning", + line: { + type: "model-check-unreadable", + provider: "openai", + model: "gpt-5.6-terra", + fix: "often temporary", + }, + }, + stamp: "environment", + }, + "environment", + ), + ).toBe("not_checked"); + }); +}); + +describe("where the model key comes from", () => { + const credential = (overrides: Partial = {}) => + ({ + id: "credential-1", + kind: "model", + provider: "openai", + keyId: "openai-api-key", + metadata: {}, + revokedAt: null, + ...overrides, + }) satisfies CredentialStatus; + const reader = (list: CredentialStatus[]) => ({ list: async () => list }); + const base = { + plan: false, + provider: "openai" as const, + keyId: "openai-api-key", + }; + + test("a plan, then the vault, then the environment, as the runtime reads them", async () => { + expect( + ( + await modelKeyOf({ + ...base, + plan: true, + credentials: reader([]), + environment: {}, + }) + ).source, + ).toBe("plan"); + expect( + ( + await modelKeyOf({ + ...base, + credentials: reader([credential()]), + environment: { OPENAI_API_KEY: "sk-set" }, + }) + ).source, + ).toBe("credential"); + expect( + ( + await modelKeyOf({ + ...base, + credentials: reader([]), + environment: { OPENAI_API_KEY: "sk-set" }, + }) + ).source, + ).toBe("environment"); + expect( + ( + await modelKeyOf({ + ...base, + credentials: reader([]), + environment: { OPENAI_API_KEY: " " }, + }) + ).source, + ).toBeNull(); + }); + + test("the stamp names the one key, so a rotated vault key is a new key", async () => { + expect( + await modelKeyOf({ + ...base, + credentials: reader([credential({ id: "credential-7" })]), + environment: { OPENAI_API_KEY: "sk-set" }, + }), + ).toEqual({ source: "credential", stamp: "credential:credential-7" }); + expect( + await modelKeyOf({ + ...base, + credentials: reader([]), + environment: { OPENAI_API_KEY: "sk-set" }, + }), + ).toEqual({ source: "environment", stamp: "environment" }); + }); + + test("a revoked key, another provider's key and another key id do not count", async () => { + expect( + ( + await modelKeyOf({ + ...base, + credentials: reader([ + credential({ revokedAt: new Date() }), + credential({ provider: "anthropic" }), + credential({ keyId: "something-else" }), + credential({ kind: "connector" }), + ]), + environment: {}, + }) + ).source, + ).toBeNull(); + }); +}); + +/** Every secret this server reads from its environment, each set to a value no row may contain. */ +const SENTINELS = { + INTELLIGENCE_API_KEY: "sentinel-intelligence-key-0f3a", + COPILOTKIT_LICENSE_TOKEN: "sentinel-licence-token-91bc", + OPENAI_API_KEY: "sentinel-openai-key-7d21", + ANTHROPIC_API_KEY: "sentinel-anthropic-key-4e88", + COMPOSIO_API_KEY: "sentinel-composio-key-2b6f", + OPENTAG_SHARED_SECRET: "sentinel-opentag-secret-0123456789abcdef", + COMPUTER_TOKEN: "sentinel-computer-token-5c90", + SUPERVISOR_TOKEN: "sentinel-supervisor-token-a1d4", + MANAGED_AGENT_TOKEN: "sentinel-managed-agent-token-66e2", + GOOGLE_OAUTH_CLIENT_SECRET: "sentinel-google-secret-3f17", + BETTER_AUTH_SECRET: "sentinel-better-auth-secret-long-enough-9a0b", + AGENT_TOOL_TOKEN: "sentinel-agent-tool-token-8812", + WORKER_SHARED_SECRET: "sentinel-worker-secret-c4e0", +}; + +/** One member and one admin, told apart by the roles the repository answers with. */ +const PEOPLE = { + member: { + user: { + id: "member-1", + email: "member@openbot.test", + name: "A Member", + image: null, + }, + roles: ["user"], + }, + admin: { + user: { + id: "admin-1", + email: "admin@openbot.test", + name: "An Admin", + image: null, + }, + roles: ["admin"], + }, +} as const; + +/** + * The real route, mounted by `createApp`, with sources built from a real configuration. Only the + * stores, Intelligence and the model provider are stand-ins. + */ +function appWith(input: { + environment: Record; + who: keyof typeof PEOPLE; + credentials?: CredentialStatus[]; + modelCheckAtBoot?: ModelCheckOutcome; + /** What a "Check again" hears from the provider; counted in `checks`. */ + modelCheck?: () => ModelCheckOutcome; + entitlements?: () => Promise; + computer?: boolean; + computerCheck?: () => Promise<"ready" | "needs_setup" | "not_checked">; + apps?: boolean; + chat?: boolean; + signedIn?: boolean; +}) { + const config = loadConfig(testEnvironment(input.environment)); + const person = PEOPLE[input.who]; + const counts = { checks: 0 }; + const setup = createSetupSources({ + productName: PRODUCT, + config, + environment: testEnvironment(input.environment), + model: { + provider: "openai", + keyId: "openai-api-key", + defaultModel: "gpt-5.6-terra", + plan: false, + check: async () => { + counts.checks += 1; + return input.modelCheck?.() ?? { kind: "available" }; + }, + }, + credentials: { list: async () => input.credentials ?? [] }, + ...(input.modelCheckAtBoot + ? { + modelCheckAtBoot: Promise.resolve({ + outcome: input.modelCheckAtBoot, + stamp: "environment", + }), + } + : {}), + entitlements: input.entitlements ?? (async () => ENTITLED), + computer: input.computer ?? Boolean(config.computer), + computerCheck: input.computerCheck ?? (async () => "ready"), + apps: { configured: input.apps ?? false, provider: "composio" }, + chat: input.chat ?? false, + }); + const app = createApp( + config, + { + handler: () => new Response(null, { status: 204 }), + api: { + getSession: async () => + input.signedIn === false ? null : { user: person.user }, + }, + } as never, + { rolesForUser: async () => [...person.roles] }, + /* + * Positions 4-34 are the other stores; `coworker` is 35. Every parameter from 4 on is optional, + * so a wrong count is a silent type-check pass: see onboarding-routes.test.ts. + */ + ...(Array.from({ length: 31 }) as never[]), + { setup } as never, + ); + return Object.assign(app, { counts }); +} + +async function checklistFrom( + app: ReturnType, + method: "GET" | "POST" = "GET", +) { + const response = await app.request( + method === "GET" + ? "http://openbot.local/api/setup" + : "http://openbot.local/api/setup/check", + { method }, + ); + expect(response.status).toBe(200); + const text = await response.text(); + return { + text, + checklist: (JSON.parse(text) as { checklist: SetupChecklist }).checklist, + }; +} + +describe("GET /api/setup", () => { + test("asks who is signed in", async () => { + const app = appWith({ environment: {}, who: "member", signedIn: false }); + const response = await app.request("http://openbot.local/api/setup"); + expect(response.status).toBe(401); + }); + + test("set configuration reads Ready, row by row", async () => { + const { checklist } = await checklistFrom( + appWith({ + who: "admin", + environment: { OPENAI_API_KEY: "sk-set" }, + modelCheckAtBoot: { kind: "available" }, + computer: true, + apps: true, + chat: true, + }), + ); + expect(checklist.audience).toBe("admin"); + expect( + checklist.rows.filter((candidate) => !candidate.ready).map((c) => c.id), + ).toEqual([]); + }); + + test("unset configuration reads Needs setup, with each fix", async () => { + const { checklist } = await checklistFrom( + appWith({ + who: "admin", + environment: { OPENAI_API_KEY: undefined }, + computer: false, + }), + ); + const gaps = Object.fromEntries( + checklist.rows + .filter((candidate) => !candidate.ready) + .map((candidate) => [candidate.id, candidate.fix?.kind ?? null]), + ); + expect(gaps).toEqual({ + model: "env", + computer: "env", + apps: "env", + chat: "env", + }); + // Intelligence answered ready and active, so that row is the one that holds. + expect(row(checklist.rows, "intelligence").ready).toBe(true); + }); + + test("Intelligence is read from its entitlement answer, not from its address being set", async () => { + const unlicensed = await checklistFrom( + appWith({ + who: "admin", + environment: {}, + entitlements: async () => UNLICENSED, + }), + ); + expect(row(unlicensed.checklist.rows, "intelligence").status).toBe( + "needs_setup", + ); + + const down = await checklistFrom( + appWith({ + who: "admin", + environment: {}, + entitlements: () => Promise.reject(new Error("ECONNREFUSED")), + }), + ); + expect(row(down.checklist.rows, "intelligence")).toMatchObject({ + status: "needs_setup", + fix: { kind: "env" }, + }); + }); + + test("a key added since the boot check is not checked until Check again asks", async () => { + const app = appWith({ + who: "admin", + environment: { OPENAI_API_KEY: "sk-set" }, + modelCheckAtBoot: { kind: "available" }, + credentials: [ + { + id: "credential-9", + kind: "model", + provider: "openai", + keyId: "openai-api-key", + metadata: {}, + revokedAt: null, + }, + ], + modelCheck: () => ({ + kind: "warning", + line: { + type: "model-credential-rejected", + provider: "openai", + model: "gpt-5.6-terra", + status: 401, + fix: "replace it", + }, + }), + }); + + const before = await checklistFrom(app); + expect(row(before.checklist.rows, "model").status).toBe("not_checked"); + expect(app.counts.checks).toBe(0); + + const after = await checklistFrom(app, "POST"); + expect(app.counts.checks).toBe(1); + expect(row(after.checklist.rows, "model")).toMatchObject({ + status: "needs_setup", + detail: "OpenAI refused the model key, so OpenBot can't answer.", + }); + + // A second press inside the interval is answered from the first, with no second call out. + expect(RECHECK_INTERVAL_MS).toBeGreaterThan(1000); + await checklistFrom(app, "POST"); + expect(app.counts.checks).toBe(1); + + // And plain reads after it keep the answer. + const read = await checklistFrom(app); + expect(row(read.checklist.rows, "model").status).toBe("needs_setup"); + }); + + test("Check again asks Intelligence again too", async () => { + let answers = 0; + const app = appWith({ + who: "admin", + environment: {}, + entitlements: async () => { + answers += 1; + return answers === 1 ? UNLICENSED : ENTITLED; + }, + }); + const before = await checklistFrom(app); + expect(row(before.checklist.rows, "intelligence").status).toBe( + "needs_setup", + ); + const after = await checklistFrom(app, "POST"); + expect(row(after.checklist.rows, "intelligence").status).toBe("ready"); + }); + + test("Check again asks who is signed in", async () => { + const app = appWith({ environment: {}, who: "member", signedIn: false }); + const response = await app.request("http://openbot.local/api/setup/check", { + method: "POST", + }); + expect(response.status).toBe(401); + }); + + test("the boot verdict on an environment key is repeated", async () => { + const { checklist } = await checklistFrom( + appWith({ + who: "admin", + environment: { OPENAI_API_KEY: "sk-set" }, + modelCheckAtBoot: { + kind: "warning", + line: { + type: "model-credential-rejected", + provider: "openai", + model: "gpt-5.6-terra", + status: 401, + fix: "replace it", + }, + }, + }), + ); + expect(row(checklist.rows, "model")).toMatchObject({ + ready: false, + detail: "OpenAI refused the model key, so OpenBot can't answer.", + }); + }); + + test("a member is answered as a member, with no variable names", async () => { + const { checklist, text } = await checklistFrom( + appWith({ + who: "member", + environment: { OPENAI_API_KEY: undefined }, + entitlements: async () => UNLICENSED, + }), + ); + expect(checklist.audience).toBe("member"); + expect(row(checklist.rows, "model")).toMatchObject({ + required: true, + ready: false, + detail: "Your admin sets this up.", + fix: null, + }); + expect(text).not.toMatch(/[A-Z]{3,}_[A-Z_]{3,}/); + }); + + test.each([ + ["an admin", "admin"], + ["a member", "member"], + ] as const)("no secret reaches %s, set or not", async (_label, who) => { + for (const environment of [ + SENTINELS, + { ...SENTINELS, OPENAI_API_KEY: undefined, COMPOSIO_API_KEY: undefined }, + ]) { + const app = appWith({ + who, + environment: { + ...environment, + AGENT_COMPUTER_URL: "http://localhost:4100", + }, + entitlements: async () => UNLICENSED, + credentials: [ + { + id: "credential-1", + kind: "model", + provider: "openai", + keyId: "openai-api-key", + metadata: { note: "statuses carry no secret" }, + revokedAt: null, + }, + ], + apps: true, + chat: true, + }); + // The plain read, and the answer after Check again asked both services. + const text = [ + (await checklistFrom(app)).text, + (await checklistFrom(app, "POST")).text, + ].join("\n"); + for (const sentinel of Object.values(SENTINELS)) { + expect(text).not.toContain(sentinel); + } + // Nor the halves a mask would leave: the first and last few characters of each. + for (const sentinel of Object.values(SENTINELS)) { + expect(text).not.toContain(sentinel.slice(-6)); + } + } + }); +}); + +test("healthy sequential computer pushes keep their own deadlines in the checklist", async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const recheckStarted = Promise.withResolvers(); + const replies = [ + Promise.withResolvers(), + Promise.withResolvers(), + ]; + const requested: string[] = []; + let listings = 0; + let entitlements = 0; + jest.useFakeTimers(); + try { + const app = appWith({ + environment: {}, + who: "admin", + computer: true, + entitlements: async () => { + if (++entitlements === 2) recheckStarted.resolve(); + return ENTITLED; + }, + computerCheck: createComputerCheck({ + provider: { + list: async () => { + listings += 1; + return [ + { + key: { kind: "bot", botId: "one" }, + status: "running", + url: "http://one.local", + }, + { + key: { kind: "bot", botId: "two" }, + status: "running", + url: "http://two.local", + }, + { + key: { kind: "bot", botId: "sleeping" }, + status: "stopped", + url: "http://sleep.local", + }, + ]; + }, + }, + controls: () => ({ + policyForBot: async () => ({ mode: "allow_all", rules: [] }), + }), + fetchImpl: Object.assign( + async (url: string | URL | Request) => { + requested.push(String(url)); + const index = requested.length - 1; + (index === 0 ? first : second).resolve(); + return replies[index]!.promise; + }, + { preconnect() {} }, + ), + }), + }); + const answer = checklistFrom(app); + await first.promise; + const recheck = checklistFrom(app, "POST"); + await recheckStarted.promise; + jest.advanceTimersByTime(3_000); + replies[0]!.resolve(new Response(null, { status: 204 })); + await second.promise; + jest.advanceTimersByTime(3_000); + replies[1]!.resolve(new Response(null, { status: 204 })); + expect(row((await answer).checklist.rows, "computer").status).toBe("ready"); + expect(row((await recheck).checklist.rows, "computer").status).toBe( + "ready", + ); + expect(listings).toBe(1); + expect(requested).toEqual([ + "http://one.local/egress-policy", + "http://two.local/egress-policy", + ]); + } finally { + for (const reply of replies) reply.resolve(new Response()); + jest.useRealTimers(); + } +}); + +test("a configured computer that refuses its policy is Needs setup, then recovers on recheck", async () => { + let ready = false; + const tokens: Array = []; + const app = appWith({ + environment: {}, + who: "admin", + computer: true, + computerCheck: createComputerCheck({ + provider: { + list: async () => [ + { + key: { kind: "bot", botId: "one" }, + status: "running", + url: "http://one.local", + }, + ], + }, + controls: () => ({ + policyForBot: async () => ({ mode: "allow_all", rules: [] }), + }), + token: "configured-computer-token", + fetchImpl: Object.assign( + async (_url: string | URL | Request, init?: RequestInit) => { + tokens.push( + new Headers(init?.headers).get("x-openbot-computer-token"), + ); + return new Response(null, { status: ready ? 204 : 401 }); + }, + { preconnect() {} }, + ), + }), + }); + const response = await app.request("/api/setup"); + const first = await response.json(); + expect(row(first.checklist.rows, "computer").status).toBe("needs_setup"); + ready = true; + const after = await app.request("/api/setup/check", { + method: "POST", + }); + expect(row((await after.json()).checklist.rows, "computer").status).toBe( + "ready", + ); + expect(tokens).toEqual([ + "configured-computer-token", + "configured-computer-token", + ]); +}); + +test("a person's computer is asked which Bots it carries, and each takes its own policy", async () => { + const requested: string[] = []; + const policies: string[] = []; + const app = appWith({ + environment: {}, + who: "admin", + computer: true, + computerCheck: createComputerCheck({ + provider: { + list: async () => [ + { + key: { kind: "person", personId: "alice" }, + status: "running", + url: "http://alice.local", + }, + ], + }, + controls: () => ({ + policyForBot: async (botId) => { + policies.push(botId); + return { mode: "allow_all", rules: [] }; + }, + }), + fetchImpl: Object.assign( + async (url: string | URL | Request) => { + requested.push(String(url)); + return String(url).endsWith("/computers") + ? Response.json({ + computers: [{ botId: "writer" }, { botId: "researcher" }], + }) + : new Response(null, { status: 204 }); + }, + { preconnect() {} }, + ), + }), + }); + const answer = await app.request("/api/setup"); + expect(row((await answer.json()).checklist.rows, "computer").status).toBe( + "ready", + ); + expect(policies).toEqual(["writer", "researcher"]); + expect(requested).toEqual([ + "http://alice.local/computers", + "http://alice.local/egress-policy", + "http://alice.local/egress-policy", + ]); +}); + +test.each(["list", "policy", "push"] as const)( + "a hung computer %s fails the checklist without stacking rechecks", + async (stage) => { + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const calls: string[] = []; + const enter = async (step: typeof stage) => { + calls.push(step); + if (step === stage) { + started.resolve(); + await release.promise; + } + }; + jest.useFakeTimers(); + try { + const app = appWith({ + environment: {}, + who: "admin", + computer: true, + computerCheck: createComputerCheck({ + provider: { + list: async () => { + await enter("list"); + return [ + { + key: { kind: "bot", botId: "one" }, + status: "running", + url: "http://one.local", + }, + ]; + }, + }, + controls: () => ({ + policyForBot: async () => { + await enter("policy"); + return { mode: "allow_all", rules: [] }; + }, + }), + fetchImpl: Object.assign( + async () => { + await enter("push"); + return new Response(null, { status: 204 }); + }, + { preconnect() {} }, + ), + }), + }); + await started.promise; + jest.advanceTimersByTime(5_000); + expect( + row((await checklistFrom(app)).checklist.rows, "computer").status, + ).toBe("needs_setup"); + expect( + row((await checklistFrom(app, "POST")).checklist.rows, "computer") + .status, + ).toBe("needs_setup"); + expect(calls).toEqual( + ["list", "policy", "push"].slice( + 0, + ["list", "policy", "push"].indexOf(stage) + 1, + ), + ); + } finally { + release.resolve(); + jest.useRealTimers(); + } + }, +); + +test("a configured computer without a reachability verdict is not yet checked", () => { + const { computerStatus: _, ...facts } = READY; + expect( + row( + setupRows(facts, { productName: PRODUCT, audience: "admin" }), + "computer", + ).status, + ).toBe("not_checked"); +}); From 1da3ef7b0fe368965dbf481d6d11639d7c91a9ab Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 00:49:49 -0700 Subject: [PATCH 14/47] feat(onboarding): a connection started in onboarding comes back to it Onboarding is the only page somebody who has not finished it may open, so a consent started on its Connect your apps step returns to that step, worked or not, for OAuth and brokered apps alike. A consent cancelled at the vendor now returns to the screen it started on too, instead of the Connections list. The returnTo name is still narrowed to a closed set before it is sealed or used. --- server/src/people/onboarding.ts | 8 +- server/src/plugins/oauth.ts | 39 +++++++++- .../src/plugins/route-groups/connections.ts | 24 +++--- .../plugins/route-groups/oauth-callback.ts | 22 +++++- server/tests/plugin-oauth-callback.test.ts | 76 +++++++++++++++++++ server/tests/plugin-oauth.test.ts | 51 +++++++++++++ server/tests/plugin-routes.test.ts | 19 +++++ 7 files changed, 217 insertions(+), 22 deletions(-) diff --git a/server/src/people/onboarding.ts b/server/src/people/onboarding.ts index fafe7b9933..80ebd1d09e 100644 --- a/server/src/people/onboarding.ts +++ b/server/src/people/onboarding.ts @@ -5,10 +5,10 @@ import { users } from "../db/schema"; /** * Where one person is in first-run onboarding. * - * A null `completedAt` is what gates the app into /onboarding. `step` is stored and served but - * NOTHING RESUMES FROM IT YET: the wizard keeps its step in browser state while its content is - * still being designed, so this stays 0 through a whole run. It exists so resuming is a frontend - * change when the wizard settles, not a migration. + * A null `completedAt` is what gates the app into /onboarding. `step` is the step somebody was last + * on, written on every move, so arriving at /onboarding without `?step=` opens it again. The number + * is the app's: each step has one of its own (`STEP_NUMBER` in `routes/_authed/onboarding.tsx`), + * never its position in the list, which changes with who is reading. 0 is "not started". */ export type OnboardingStatus = { step: number; diff --git a/server/src/plugins/oauth.ts b/server/src/plugins/oauth.ts index 570a487f61..927027cfe4 100644 --- a/server/src/plugins/oauth.ts +++ b/server/src/plugins/oauth.ts @@ -48,7 +48,7 @@ const CALLBACK_PATH = "/api/plugins/oauth/callback"; /** * Which screen started this, so somebody is returned to the one they left. * - * A closed set of two names, never a URL, and that is the security point rather than a style choice. + * A closed set of names, never a URL, and that is the security point rather than a style choice. * Carrying a destination through an OAuth flow is how open redirects get built: a * `returnTo=https://evil.test` the callback honours turns this deployment into a redirector that * arrives with a fresh consent behind it. A name cannot express another origin, and an unrecognised @@ -58,7 +58,26 @@ const CALLBACK_PATH = "/api/plugins/oauth/callback"; * It lives in the SEALED state rather than on the callback URL because the callback is a request * somebody else's server sent the browser on. Nothing on it is believable by itself. */ -export type ConnectOrigin = "settings" | "admin"; +export type ConnectOrigin = + | "settings" + | "admin" + /** Onboarding's Connect your apps step. */ + | "onboarding"; + +const ORIGINS: ReadonlySet = new Set([ + "admin", + "onboarding", +]); + +/** + * A query string's or a sealed state's `returnTo`, narrowed to a name this deployment knows. + * Anything else is the default, never carried: see {@link ConnectOrigin}. + */ +export function connectOriginFrom(value: unknown): ConnectOrigin { + return typeof value === "string" && ORIGINS.has(value) + ? (value as ConnectOrigin) + : "settings"; +} export type ConnectState = { /** Who is connecting. Taken from their session when the flow starts, never from the callback. */ @@ -121,6 +140,18 @@ export function connectedAccountsUrlFor( ): string { const origin = appUrl?.replace(/\/+$/, "") ?? ""; + /* + * Onboarding is the only page somebody who has not finished it may open: every other one sends + * them back to it (`routes/_authed.tsx`). So both outcomes land on the step that started the flow, + * which reads the connection again on arrival and says how it went. A failure carries + * `connected=failed`, which the step reads once and drops from the address. + */ + if (returnTo === "onboarding") { + return "serverId" in where + ? `${origin}/onboarding?step=apps` + : `${origin}/onboarding?step=apps&connected=failed`; + } + if (returnTo === "admin") { /* * The admin route takes the server key as its path parameter, so a failure has nowhere generic @@ -219,8 +250,8 @@ export async function readConnectState( userId: payload.userId, serverId: payload.serverId, verifier: payload.verifier, - // Only the one name is recognised; anything else becomes the default rather than being carried. - returnTo: payload.returnTo === "admin" ? "admin" : "settings", + // Only the known names are recognised; anything else becomes the default rather than being carried. + returnTo: connectOriginFrom(payload.returnTo), }; } catch { return null; diff --git a/server/src/plugins/route-groups/connections.ts b/server/src/plugins/route-groups/connections.ts index 72fd2f323e..bc0917218f 100644 --- a/server/src/plugins/route-groups/connections.ts +++ b/server/src/plugins/route-groups/connections.ts @@ -19,6 +19,7 @@ import { type ConnectOrigin, challengeFor, connectedAccountsUrlFor, + connectOriginFrom, createVerifier, redirectUriFor, sealConnectState, @@ -706,12 +707,13 @@ export function mountConnectionRoutes({ * chooses where somebody lands holding a just-completed consent. A url taken from the body, * the query or a header would therefore be an open redirect with a consent screen in front * of it — the exact thing {@link ConnectOrigin} exists to stop on the OAuth flow below, and - * it is narrowed here in the same way: the caller may name one of two PAGES, and the origin - * underneath them is this deployment's configured app URL in both cases. + * it is narrowed here in the same way: the caller may name one of a few PAGES, and the origin + * underneath them is this deployment's configured app URL in every case. * - * Both pages confirm on load, which is what makes either of them a correct destination: the + * Every one of them confirms on load, which is what makes it a correct destination: the * return trip carries nothing signed, so arriving proves nothing, and the page asks Composio - * whether the account is really attached before anything here says it is. + * whether the account is really attached before anything here says it is. Onboarding's + * Connect your apps step confirms the app it left for, which it noted before leaving. * * FORCED TO "admin" FOR A DEPLOYMENT ACCOUNT, AND NOT LEFT TO THE QUERY. A Shared app's * settings page shows nobody's own connection — the account the consent is about belongs to @@ -722,9 +724,7 @@ export function mountConnectionRoutes({ const returnTo: ConnectOrigin = account.holder === "deployment" ? "admin" - : context.req.query("returnTo") === "admin" - ? "admin" - : "settings"; + : connectOriginFrom(context.req.query("returnTo")); /* * THE URL IS A BEARER CAPABILITY. Whoever opens it attaches an account to the connection @@ -900,14 +900,13 @@ export function mountConnectionRoutes({ } /* - * Where to come back to, as one of two names rather than a URL the caller chose. + * Where to come back to, as one of a few names rather than a URL the caller chose. * * Read from the query and narrowed immediately, so an unrecognised value is the default rather * than something carried into a sealed state. See {@link ConnectOrigin}: a destination that could * name another origin is an open redirect with a consent screen in front of it. */ - const returnTo = - context.req.query("returnTo") === "admin" ? "admin" : "settings"; + const returnTo = connectOriginFrom(context.req.query("returnTo")); const verifier = createVerifier(); return context.json({ @@ -1453,8 +1452,9 @@ export function mountConnectionRoutes({ 503, ); } - const returnTo: ConnectOrigin = - context.req.query("returnTo") === "admin" ? "admin" : "settings"; + const returnTo: ConnectOrigin = connectOriginFrom( + context.req.query("returnTo"), + ); let returnUrl: string; try { returnUrl = brokerReturnUrl( diff --git a/server/src/plugins/route-groups/oauth-callback.ts b/server/src/plugins/route-groups/oauth-callback.ts index 71e1c2a35d..ed4e972de8 100644 --- a/server/src/plugins/route-groups/oauth-callback.ts +++ b/server/src/plugins/route-groups/oauth-callback.ts @@ -30,7 +30,12 @@ export function mountOAuthCallbackRoute({ * one, and spelling out which is which tells anybody probing this endpoint how far they got. */ routes.get("/oauth/callback", async (context) => { - const failed = connectedAccountsUrlFor(connect?.appUrl, { + /* + * Where a failure lands. The list until the state is read, because an unreadable state names no + * screen; after that, the screen the flow started on, so a failed connect from onboarding + * returns to its step rather than to a page onboarding would bounce them out of. + */ + let failed = connectedAccountsUrlFor(connect?.appUrl, { failed: true, }); if (!connect?.publicUrl) return context.redirect(failed); @@ -68,7 +73,20 @@ export function mountOAuthCallbackRoute({ context.req.query("state") ?? "", connect.encryptionKey, ); - if (!code || !state) return context.redirect(failed); + if (!state) return context.redirect(failed); + /* + * The screen the flow started on, as soon as the state names it, and before the code is + * asked for. A person who presses Cancel on the vendor's consent comes back with the state and + * an `error=access_denied` in place of a code. Sent to the list, somebody mid-onboarding was + * bounced by the onboarding gate to its first step with no word about what happened. Sent + * here, they land on the step they pressed Connect on, which says it didn't connect. + */ + failed = connectedAccountsUrlFor( + connect.appUrl, + { failed: true }, + state.returnTo, + ); + if (!code) return context.redirect(failed); /* * Is the person in the state still somebody here? diff --git a/server/tests/plugin-oauth-callback.test.ts b/server/tests/plugin-oauth-callback.test.ts index ca6c96acc4..51ecb9be1b 100644 --- a/server/tests/plugin-oauth-callback.test.ts +++ b/server/tests/plugin-oauth-callback.test.ts @@ -555,3 +555,79 @@ describe("a callback whose own checks failed", () => { expect(recorded).toEqual([]); }); }); + +/* + * Onboarding's Connect your apps step offers Connect. Somebody who has not finished onboarding is + * sent back to it from any other page, so the flow returns to that step itself, whether it worked + * or not. + */ +describe("a consent started in onboarding", () => { + async function startedFromOnboarding(hono: ReturnType) { + const started = await hono.request( + "http://t/api/plugins/servers/notion/connect?returnTo=onboarding", + { method: "POST" }, + ); + const { authorizationUrl } = (await started.json()) as { + authorizationUrl: string; + }; + return new URL(authorizationUrl).searchParams.get("state") ?? ""; + } + + test("comes back to the Connect your apps step", async () => { + const recorded: Recorded[] = []; + const hono = app({ recorded }); + const state = await startedFromOnboarding(hono); + + await withWillingVendor(async () => { + const response = await hono.request(callbackUrl(state)); + expect(response.headers.get("location")).toBe( + "https://app.example/onboarding?step=apps", + ); + }); + expect(recorded).toHaveLength(1); + }); + + test("a refusal after the state is read comes back to the step too, saying so", async () => { + const recorded: Recorded[] = []; + const hono = app({ recorded, personHasAccess: async () => false }); + const state = await startedFromOnboarding(hono); + + const response = await hono.request(callbackUrl(state)); + expect(response.headers.get("location")).toBe( + "https://app.example/onboarding?step=apps&connected=failed", + ); + expect(recorded).toEqual([]); + }); + + /* + * Cancel on the vendor's consent: the state comes back with `error=access_denied` and no code. + * The person goes back to the step they pressed Connect on, not to a page the onboarding gate + * would bounce them out of. + */ + test("a consent declined at the vendor comes back to the step, saying so", async () => { + const recorded: Recorded[] = []; + const hono = app({ recorded }); + const state = await startedFromOnboarding(hono); + + const response = await hono.request( + `${CALLBACK}?error=access_denied&state=${encodeURIComponent(state)}`, + ); + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe( + "https://app.example/onboarding?step=apps&connected=failed", + ); + expect(recorded).toEqual([]); + }); + + test("a declined consent with no state still goes to the list", async () => { + const hono = app({ recorded: [] }); + const response = await hono.request(`${CALLBACK}?error=access_denied`); + expect(response.headers.get("location")).toBe(FAILED); + }); + + test("a state nobody can read still goes to the list", async () => { + const hono = app({ recorded: [] }); + const response = await hono.request(callbackUrl("not-a-state")); + expect(response.headers.get("location")).toBe(FAILED); + }); +}); diff --git a/server/tests/plugin-oauth.test.ts b/server/tests/plugin-oauth.test.ts index 7b052737b4..128c1a47c0 100644 --- a/server/tests/plugin-oauth.test.ts +++ b/server/tests/plugin-oauth.test.ts @@ -5,6 +5,7 @@ import { authorizationUrlFor, challengeFor, connectedAccountsUrlFor, + connectOriginFrom, createVerifier, readConnectState, redeemAuthorizationCode, @@ -62,6 +63,37 @@ describe("the state that travels through the vendor", () => { expect((await readConnectState(sealed, KEY, NOW))?.returnTo).toBe("admin"); }); + test("onboarding is a screen to return to as well", async () => { + const sealed = await sealConnectState( + { + userId: "user-1", + serverId: "notion", + verifier: "v-1", + returnTo: "onboarding", + }, + KEY, + NOW, + ); + expect((await readConnectState(sealed, KEY, NOW))?.returnTo).toBe( + "onboarding", + ); + }); + + test("a returnTo from the query is narrowed to a known name", () => { + expect(connectOriginFrom("onboarding")).toBe("onboarding"); + expect(connectOriginFrom("admin")).toBe("admin"); + for (const hostile of [ + undefined, + "", + "https://evil.test", + "/onboarding", + "ONBOARDING", + "onboarding-apps", + ]) { + expect(connectOriginFrom(hostile)).toBe("settings"); + } + }); + /* * THE OPEN REDIRECT THIS CANNOT BECOME. A destination carried through an OAuth flow is the classic * shape of one: the callback arrives with a fresh consent behind it, and anything it is willing to @@ -421,6 +453,25 @@ describe("where the callback sends somebody afterwards", () => { ).toBe("/connections/google-drive"); }); + test("a connect started in onboarding returns to its Connect your apps step, either way", () => { + // Onboarding is the one page somebody who has not finished it may open, so both outcomes land + // there. Any other page would send them back to it without a word about the connection. + expect( + connectedAccountsUrlFor( + "http://localhost:3010", + { serverId: "notion" }, + "onboarding", + ), + ).toBe("http://localhost:3010/onboarding?step=apps"); + expect( + connectedAccountsUrlFor( + "http://localhost:3010", + { failed: true }, + "onboarding", + ), + ).toBe("http://localhost:3010/onboarding?step=apps&connected=failed"); + }); + test("a connect started on the admin page returns to the admin page", () => { // The round trip this removes: an administrator who connected from the connector's setup screen // used to be put down on their personal settings page, mid-task, on another part of the app. diff --git a/server/tests/plugin-routes.test.ts b/server/tests/plugin-routes.test.ts index c468d29b8f..6483d15508 100644 --- a/server/tests/plugin-routes.test.ts +++ b/server/tests/plugin-routes.test.ts @@ -2233,6 +2233,25 @@ describe("connecting a brokered app", () => { ]); }); + test("a person who started on onboarding's Connect your apps step is sent back to it", async () => { + /* + * Onboarding is the one page somebody who has not finished it may open, so a consent started + * there has to come back there: any other page would send them on to onboarding's start with no + * word about the account. The step confirms the account on arrival, as the account page does. + */ + const { authorized, connect } = brokeredApp(); + + await connect({}, "?returnTo=onboarding"); + + expect(authorized).toEqual([ + { + account: ADMIN_ACCOUNT, + toolkit: "linear", + returnUrl: "http://localhost:3001/onboarding?step=apps", + }, + ]); + }); + test("a returnTo naming somewhere else is the default, not a destination", async () => { // Narrowed to one of two names on the way in, so an unrecognised value never reaches the url // that gets built. A full address in that parameter is the attack this shape refuses. From 1091f1a936a5a5bcf8a6a7a34d1c62c74ab4ea8f Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:14:31 -0700 Subject: [PATCH 15/47] feat(onboarding): a step frame that is two panes on a desktop A step-by-step onboarding frame that works on a desktop screen too. One DOM, laid out by CSS: from lg up, the steps as a list (the ones reached can be picked) with the step's heading, sentence and Continue/Back on the left, and its picture filling the right; below lg, a dash for each step, a round Back and the buttons pinned to the bottom. Focus moves to each new heading. A demo's clock runs only while it is on screen in a shown tab, and holds still with reduced motion. --- app/src/components/onboarding/demo-stage.tsx | 107 ++++ .../onboarding/onboarding-frame.tsx | 363 +++++++++++ .../onboarding/onboarding-wizard.tsx | 277 +++++++++ app/tests/onboarding-frame.test.tsx | 581 ++++++++++++++++++ 4 files changed, 1328 insertions(+) create mode 100644 app/src/components/onboarding/demo-stage.tsx create mode 100644 app/src/components/onboarding/onboarding-frame.tsx create mode 100644 app/src/components/onboarding/onboarding-wizard.tsx create mode 100644 app/tests/onboarding-frame.test.tsx diff --git a/app/src/components/onboarding/demo-stage.tsx b/app/src/components/onboarding/demo-stage.tsx new file mode 100644 index 0000000000..20635caef1 --- /dev/null +++ b/app/src/components/onboarding/demo-stage.tsx @@ -0,0 +1,107 @@ +import { useReducedMotion } from "motion/react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { IllustrationFrame } from "./illustration-frame"; + +/** How long one beat of a demo holds before the next. */ +export const DEMO_BEAT_MS = 2400; + +/** What a demo draws each beat with. */ +export type DemoBeat = { + /** Which beat, from 0 to `beats - 1`. Reduced motion holds `rest`. */ + beat: number; + /** Moving now: on screen, in a shown tab, and motion allowed. Anything animated stops without it. */ + playing: boolean; + /** The reader asked for less motion. */ + reduced: boolean; +}; + +/** + * Whether an element is on screen in a shown tab, kept current. + * + * On screen until an IntersectionObserver says otherwise, and always where there is none to ask, + * so a browser without one still plays rather than freezing on its first beat. + */ +function useOnScreen() { + const ref = useRef(null); + const [visible, setVisible] = useState(true); + useEffect(() => { + const element = ref.current; + let intersecting = true; + const update = () => setVisible(intersecting && !document.hidden); + const observer = + typeof IntersectionObserver === "undefined" + ? null + : new IntersectionObserver(([entry]) => { + intersecting = entry?.isIntersecting ?? false; + update(); + }); + if (element) observer?.observe(element); + document.addEventListener("visibilitychange", update); + update(); + return () => { + observer?.disconnect(); + document.removeEventListener("visibilitychange", update); + }; + }, []); + return [ref, visible] as const; +} + +/** + * The picture a step shows, and the clock it plays on. + * + * A PICTURE, AND SAID TO BE ONE. Everything inside sits in {@link IllustrationFrame}: nothing in it + * can be clicked, typed in or tabbed to, and a screen reader hears `label` once in its place. The + * buttons drawn on the approval card are a drawing of the decision, not the decision. + * + * ONE CLOCK PER DEMO, AND ONLY WHILE SOMEBODY CAN SEE IT. The beat advances every `beatMs` while + * the demo is on screen in a shown tab. Scrolled away, in a background tab, or with the reader's + * reduced-motion setting on, it stops, and `playing` tells the orbs and loops inside to stop too. + * With reduced motion it holds `rest`, the beat that says the most as a still. + */ +export function DemoStage({ + kind, + label, + beats = 1, + rest = beats - 1, + beatMs = DEMO_BEAT_MS, + className = "", + children, +}: { + /** Which demo, for tests and styles. */ + kind: string; + /** What the picture shows, read in place of everything drawn inside it. */ + label: string; + beats?: number; + rest?: number; + beatMs?: number; + className?: string; + children: (now: DemoBeat) => ReactNode; +}) { + const reduced = useReducedMotion() === true; + const [ref, visible] = useOnScreen(); + const [beat, setBeat] = useState(0); + const playing = !reduced && visible; + useEffect(() => { + if (!playing || beats < 2) return; + const timer = setInterval( + () => setBeat((value) => (value + 1) % beats), + beatMs, + ); + return () => clearInterval(timer); + }, [playing, beats, beatMs]); + const shown = reduced ? rest : beat; + + return ( +
+ + {children({ beat: shown, playing, reduced })} + +
+ ); +} diff --git a/app/src/components/onboarding/onboarding-frame.tsx b/app/src/components/onboarding/onboarding-frame.tsx new file mode 100644 index 0000000000..462fa121ec --- /dev/null +++ b/app/src/components/onboarding/onboarding-frame.tsx @@ -0,0 +1,363 @@ +import { IconCheck, IconChevronLeft } from "@tabler/icons-react"; +import type { ReactNode } from "react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import { buttonVariants } from "@/components/ui/button"; +import { appConfig } from "@/lib/generated/application-config"; +import { cn } from "@/lib/utils"; + +/** + * The frame every onboarding step sits in: one DOM, laid out two ways by CSS. + * + * FROM `lg` UP, TWO PANES. On the left, the steps as a list with the one on screen marked, then + * the step's heading and sentence, then Continue and Back. On the right, filling the rest of the + * screen and held in view while the left scrolls, the step's picture. Steps already reached can be + * picked from the list; the ones ahead cannot. + * + * BELOW `lg`, ONE COLUMN, the way OpenDuet lays a step out on a phone: a round back chevron at the + * top left, the steps as dashes across the top, the heading and its picture, and the buttons pinned + * to the bottom, full width, with a fade above them where the step scrolls under. + * + * The frame owns where things are, never what a step says. Back, the list and the buttons sit in + * the same place on every step, so moving through onboarding never moves the control somebody is + * about to press. Back is drawn twice, once for each layout, and only one is ever displayed: each + * sits where its layout's reading order puts it, so the keyboard meets it where the eye does. + * + * AN OPTIONAL STEP HAS A SECOND BUTTON. "Not now" goes beside the primary (under it on a phone, the + * same size and shape, outlined), and its place is kept when it goes (`reserveSecondary`), so the + * primary does not move mid-step. A footnote, where a step has one, sits just above the buttons. + */ +export function OnboardingFrame({ + titles, + index, + reached, + onBack, + onPick, + primary, + secondary, + reserveSecondary = false, + footnote, + notice, + intro, + demo, + demoFirst = false, +}: { + /** Every step's name, in order. */ + titles: string[]; + /** Zero-based. */ + index: number; + /** The furthest step reached: every step up to it can be picked from the list. */ + reached: number; + onBack: () => void; + onPick: (index: number) => void; + /** Null when the step's own form carries its button. */ + primary: { + label: string; + onClick: () => void; + disabled?: boolean; + /** Drawn green with a check: what the button asked for has happened. */ + done?: boolean; + /** Waiting on an answer, such as the browser's prompt: a spinner beside the label. */ + pending?: boolean; + } | null; + /** A way past the step, such as Not now or Skip intro. */ + secondary?: { label: string; onClick: () => void }; + /** Keep the second button's place while it has none, so the primary doesn't move. */ + reserveSecondary?: boolean; + /** A line about the step, just above the buttons. */ + footnote?: ReactNode; + /** Said just above the buttons, such as a save that failed. */ + notice?: ReactNode; + /** The heading, its sentence, and anything that belongs with them. */ + intro: ReactNode; + /** The picture, or the step's own rows. */ + demo?: ReactNode; + /** Below `lg`, the picture above the heading. */ + demoFirst?: boolean; +}) { + const total = titles.length; + const shape = + "h-[52px] w-full rounded-full px-5 type-sub font-semibold lg:h-10 lg:w-auto lg:min-w-32 lg:rounded-lg"; + return ( +
+
+ {/* + * The phone's chrome. Back is not drawn on the first step, rather than drawn disabled: there + * is nowhere behind it, and a control that does nothing is one more thing a screen reader + * stops on. + */} +
+ {index > 0 ? ( + + ) : null} + +
+ +
+ +
+ {intro} +
+ + {demo ? ( +
+
{demo}
+
+ ) : null} + + {/* + * On a phone, a step taller than the screen scrolls under the buttons rather than being cut + * at them: the fade above says there is more. On a desktop the buttons follow the sentence. + */} +
+
+ {notice} + {footnote ? ( +

+ {footnote} +

+ ) : null} +
+ {index > 0 ? ( + + ) : null} + {primary ? ( + + ) : null} + {secondary ? ( + + ) : reserveSecondary ? ( +
+ ) : null} +
+
+
+
+ ); +} + +/** + * The steps as a list, on a desktop: each with a mark, done ones ticked, the one on screen filled. + * A step already reached is a button back to it; one ahead is only its name. + */ +function StepList({ + titles, + index, + reached, + onPick, +}: { + titles: string[]; + index: number; + reached: number; + onPick: (index: number) => void; +}) { + return ( + + ); +} + +/** + * Where somebody is, as short dashes with the current one filled, on a phone. + * + * A progress bar to assistive technology, read as "Step 3 of 7": the dashes are a picture of that + * sentence, so they are hidden and the sentence is what is announced. + */ +export function StepDashes({ index, total }: { index: number; total: number }) { + return ( +
+ {Array.from({ length: total }, (_, at) => ( + + ))} +
+ ); +} + +/** + * A step's heading and sentence, the way every step draws them: centred on a phone, flush left in + * the desktop's left pane. `children` is the sentence; `after` is a control that belongs with it. + */ +export function StepIntro({ + title, + children, + after, +}: { + title: ReactNode; + children?: ReactNode; + after?: ReactNode; +}) { + return ( +
+

+ {title} +

+ {children ? ( +
+ {children} +
+ ) : null} + {after ?
{after}
: null} +
+ ); +} diff --git a/app/src/components/onboarding/onboarding-wizard.tsx b/app/src/components/onboarding/onboarding-wizard.tsx new file mode 100644 index 0000000000..0867836c4b --- /dev/null +++ b/app/src/components/onboarding/onboarding-wizard.tsx @@ -0,0 +1,277 @@ +import { AnimatePresence, MotionConfig, motion } from "motion/react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { OnboardingFrame } from "./onboarding-frame"; + +/** + * One step: an id that outlives the list changing around it, its name in the step list, its words + * and its picture, and what its buttons say and do. Ids are this app's own fixed words, which is + * what lets them sit in a selector unescaped. + * + * TWO HALVES, BECAUSE A DESKTOP SHOWS THEM SIDE BY SIDE. `intro` is the step's heading (an `h1`), + * its sentence and any control that belongs with them; `demo` is what it shows. From `lg` up the + * intro sits under the step list on the left and the demo fills the right; below, the demo follows + * the intro, or comes first where `demoFirst` says so (the name step, under its orb). + * + * The buttons are the frame's, so a step describes them rather than drawing them: the primary's + * words where the step has its own action ("Turn on notifications"), what that action does given + * the move to the next step, and, on an optional step, a second button such as "Not now" that + * moves on and writes nothing. A step whose own form carries its button (`ownAction`) gets no + * primary at all. + */ +export type OnboardingStep = { + id: string; + /** The step's name in the step list: short, a few words. */ + title: string; + intro: () => ReactNode; + demo?: () => ReactNode; + /** Below `lg`, the demo above the intro rather than after it. */ + demoFirst?: boolean; + /** The primary's words here. "Continue" when absent. */ + primaryLabel?: string; + /** What the primary does, given the move to the next step. Moving on when absent. */ + onPrimary?: (next: () => void) => void; + /** The primary reads as done, green with a check. */ + primaryDone?: boolean; + /** The primary is waiting on something, such as the browser's permission prompt. */ + primaryPending?: boolean; + primaryDisabled?: boolean; + /** The step's own form has the button that moves on, so the frame draws none. */ + ownAction?: boolean; + /** A way past it, beside the primary. Moves on when it has no action of its own. */ + secondary?: { label: string; onClick?: (next: () => void) => void }; + /** + * An optional step keeps the second button's place even while it has none, so the primary stays + * put for the whole step: Get notified drops Not now once the browser has answered. + */ + optional?: boolean; + /** A line about the step, above the buttons. */ + footnote?: ReactNode; + /** Something the step has to say right above its buttons, such as why the button became Next. */ + notice?: ReactNode; +}; + +/** + * A step crossfades with a short travel toward the way the journey is going: the chrome around it + * stays still, so only the content moves, and never by more than 24 px. + */ +const variants = { + initial: (direction: number) => ({ x: 24 * direction, opacity: 0 }), + active: { x: 0, opacity: 1 }, + exit: (direction: number) => ({ x: -24 * direction, opacity: 0 }), +}; + +/** A pane that crossfades as the step changes, keyed on the step so the old one can leave. */ +function Pane({ + stepId, + direction, + children, + attribute, +}: { + stepId: string | undefined; + direction: number; + children: ReactNode; + attribute: "data-step" | "data-step-demo"; +}) { + return ( + + + {children} + + + ); +} + +/** + * The steps, moved through one at a time inside {@link OnboardingFrame}. + * + * WHERE SOMEBODY IS, BY ID. The list grows after it first draws: the self-hosting step joins once + * the server says this is somebody trying OpenBot alone, and that answer can arrive while they are + * on step two. Held as an index, their place would slide; held as an id, the step they are on stays + * the step they are on, and only the list around it changes. + * + * WHO HOLDS THE ID. The route does, through `stepId` and `onMove`, because the step lives in the + * address (`?step=`) so that a reload, browser Back and a consent's return all land on the right + * one. Without `onMove` the wizard keeps the id itself, from `initialStepId`. + * + * HOW FAR SOMEBODY HAS COME. Every step up to the furthest one they have been on can be picked + * from the desktop's step list; the ones after it cannot, because nobody has seen them yet. + * + * FOCUS FOLLOWS THE STEP. A change of step replaces the heading, the picture and the buttons, so + * the element that had focus either vanished or now means something else. Focus moves to the new + * step's heading, which a screen reader then reads, and the first draw leaves focus alone. Motion + * respects the reader's reduced-motion setting: the movement becomes a fade. + */ +export function OnboardingWizard({ + steps, + stepId: heldStepId, + onMove, + initialStepId, + notice, +}: { + steps: OnboardingStep[]; + /** The step to show, when the caller holds it. */ + stepId?: string; + /** Asked to move to another step, when the caller holds it. */ + onMove?: (to: string, way: "forward" | "back") => void; + initialStepId?: string; + /** Said above the buttons on every step, such as a save that failed. */ + notice?: ReactNode; +}) { + const [own, setOwn] = useState(() => initialStepId ?? steps[0]?.id ?? ""); + const current = heldStepId ?? own; + const container = useRef(null); + + // A step that left the list (the server's answer changed) falls back to the first, never to nothing. + const found = steps.findIndex((step) => step.id === current); + const index = found === -1 ? 0 : found; + const step = steps[index]; + + /* + * The furthest step reached, by id, so a step joining the list (the self-hosting one, once the + * server answers) does not shift it. Every step up to it can be picked from the list. + */ + const visited = useRef(new Set()); + if (step) visited.current.add(step.id); + const reachedAt = steps.reduce( + (furthest, candidate, at) => + visited.current.has(candidate.id) ? Math.max(furthest, at) : furthest, + index, + ); + + // The way the journey is going, for the motion: toward a later step, or back to an earlier one. + const shown = useRef(index); + const way = useRef(1); + if (index !== shown.current) { + way.current = index > shown.current ? 1 : -1; + shown.current = index; + } + const direction = way.current; + + const go = (to: number) => { + const target = steps[to]; + if (!target || to === index) return; + if (onMove) onMove(target.id, to > index ? "forward" : "back"); + else setOwn(target.id); + }; + /* + * On from the step this was handed to, not from whichever step is current when it runs: a step + * that moves on after a beat (Get notified, once the browser said yes) must not skip a step if + * the person pressed Back or Continue during that beat. + */ + const showing = useRef(step?.id); + showing.current = step?.id; + const from = step?.id; + const next = () => { + if (showing.current !== from) return; + go(index + 1); + }; + + /* + * Keyed on the id, not the step object: the list is rebuilt on every render of the route, and a + * fresh object would pull focus back to the heading each time anything on the page changed, such + * as the checklist's answer arriving while somebody tabs through it. The first draw is skipped, + * so arriving on a page leaves focus where the browser put it. + */ + const stepId = step?.id; + const arrivedOn = useRef(stepId); + const moved = useRef(false); + useEffect(() => { + if (!stepId) return; + if (!moved.current && stepId === arrivedOn.current) return; + moved.current = true; + // The new heading starts at the top, on the page and in the desktop's picture pane. + window.scrollTo?.({ top: 0 }); + const frame = container.current; + const demo = frame?.querySelector("[data-onboarding-demo]"); + if (demo) demo.scrollTop = 0; + const heading = frame?.querySelector( + `[data-step="${stepId}"] h1`, + ); + if (!heading) return; + // Focusable by script only, so it is not a stop when tabbing through the page. + heading.tabIndex = -1; + heading.focus({ preventScroll: true }); + }, [stepId]); + + return ( + +
+ + {step.demo()} + + ) : null + } + demoFirst={step?.demoFirst === true} + footnote={step?.footnote} + index={index} + intro={ + + {step?.intro()} + + } + notice={ + step?.notice || notice ? ( + <> + {step?.notice} + {notice} + + ) : undefined + } + onBack={() => go(index - 1)} + onPick={(at) => { + if (at <= reachedAt) go(at); + }} + primary={ + step?.ownAction + ? null + : { + label: step?.primaryLabel ?? "Continue", + disabled: + step?.primaryDisabled === true || + step?.primaryPending === true, + done: step?.primaryDone === true, + pending: step?.primaryPending === true, + onClick: () => { + if (step?.onPrimary) return step.onPrimary(next); + next(); + }, + } + } + reached={reachedAt} + reserveSecondary={step?.optional === true} + secondary={ + step?.secondary + ? { + label: step.secondary.label, + onClick: () => + step.secondary?.onClick + ? step.secondary.onClick(next) + : next(), + } + : undefined + } + titles={steps.map((candidate) => candidate.title)} + /> +
+
+ ); +} diff --git a/app/tests/onboarding-frame.test.tsx b/app/tests/onboarding-frame.test.tsx new file mode 100644 index 0000000000..2c2d4647b8 --- /dev/null +++ b/app/tests/onboarding-frame.test.tsx @@ -0,0 +1,581 @@ +import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import { DemoStage } from "@/components/onboarding/demo-stage"; +import { StepIntro } from "@/components/onboarding/onboarding-frame"; +import { + type OnboardingStep, + OnboardingWizard, +} from "@/components/onboarding/onboarding-wizard"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The frame every onboarding step sits in. One DOM, laid out two ways: on a phone, Back at the top + * left, the steps as dashes and the buttons pinned to the bottom; from `lg` up, the step list and + * the step's words on the left with Continue and Back under them, and its picture on the right. + * happy-dom applies no stylesheet, so both layouts' parts are in the document here, and each test + * reads the one it is about. + */ + +beforeAll(() => GlobalRegistrator.register({ url: "http://localhost:3010" })); +afterEach(cleanup); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const plain = (id: string, title: string): OnboardingStep => ({ + id, + title, + intro: () => About {title}., + demo: () =>

Picture of {title}

, +}); + +const STEPS = [ + plain("one", "One"), + plain("two", "Two"), + plain("three", "Three"), +]; + +function draw(props: Partial[0]> = {}) { + return render(); +} + +const progress = (view: ReturnType) => + view.getByRole("progressbar", { name: "Onboarding progress" }); + +const dashes = (view: ReturnType) => + Array.from(progress(view).querySelectorAll("span")); + +const footer = (view: ReturnType) => { + const found = view.container.querySelector("footer"); + if (!found) throw new Error("no footer"); + return found; +}; + +/** The primary and the second button, in the footer, without the desktop's Back. */ +const footerButtons = (view: ReturnType) => + Array.from(footer(view).querySelectorAll("button")).filter( + (button) => !button.hasAttribute("data-onboarding-back"), + ); + +const primary = (view: ReturnType) => { + const found = footer(view).querySelector( + "[data-onboarding-primary]", + ); + if (!found) throw new Error("no primary"); + return found; +}; + +/** The phone's Back, at the top left. */ +const phoneBack = (view: ReturnType) => + view.container.querySelector( + '[data-onboarding-back="phone"]', + ); + +/** The desktop's Back, beside Continue. */ +const desktopBack = (view: ReturnType) => + view.container.querySelector( + '[data-onboarding-back="desktop"]', + ); + +const stepList = (view: ReturnType) => + view.getByRole("navigation", { name: "Onboarding steps" }); + +test("the first step has no Back, and the dashes say step 1", () => { + const view = draw(); + + expect(phoneBack(view)).toBeNull(); + expect(desktopBack(view)).toBeNull(); + expect(progress(view).getAttribute("aria-valuetext")).toBe("Step 1 of 3"); + expect(dashes(view)).toHaveLength(3); + expect(dashes(view).map((dash) => dash.hasAttribute("data-current"))).toEqual( + [true, false, false], + ); +}); + +test("on a phone, Back sits in the top bar and the button is pinned below the step", () => { + const view = draw({ initialStepId: "two" }); + + expect(phoneBack(view)?.closest("header")).not.toBeNull(); + expect(progress(view).closest("header")).not.toBeNull(); + expect(primary(view).textContent).toBe("Continue"); + // Below the step's words and its picture in document order, which is where it sits on screen. + const heading = view.getByRole("heading", { name: "Two" }); + const picture = view.getByText("Picture of Two"); + for (const before of [heading, picture]) { + expect( + before.compareDocumentPosition(primary(view)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } + // No step here is optional, so nothing offers to skip it. + expect(view.queryByRole("button", { name: "Not now" })).toBeNull(); +}); + +test("the step's words and its picture sit in their own panes, words first", () => { + const view = draw({ initialStepId: "two" }); + + const intro = view.container.querySelector("[data-onboarding-intro]"); + const demo = view.container.querySelector("[data-onboarding-demo]"); + expect(intro?.textContent).toContain("About Two."); + expect(demo?.textContent).toBe("Picture of Two"); + expect( + intro && + demo && + intro.compareDocumentPosition(demo) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + // The desktop's two columns: the picture fills the right, held in view. + expect(demo?.className).toContain("lg:col-start-2"); + expect(demo?.className).toContain("lg:sticky"); +}); + +test("a step with its picture first puts it above the heading on a phone", () => { + const view = draw({ + initialStepId: "two", + steps: [STEPS[0], { ...plain("two", "Two"), demoFirst: true }, STEPS[2]], + }); + + const intro = view.container.querySelector("[data-onboarding-intro]"); + const demo = view.container.querySelector("[data-onboarding-demo]"); + expect(demo?.className).toContain("row-start-2"); + expect(intro?.className).toContain("row-start-3"); + // On a desktop it is still the right-hand pane, and the words still head the left. + expect(intro?.className).toContain("lg:row-start-2"); + expect(demo?.className).toContain("lg:col-start-2"); +}); + +test("Continue moves on, the dashes follow, and focus lands on the new heading", async () => { + const view = draw(); + + fireEvent.click(primary(view)); + + const heading = await view.findByRole("heading", { name: "Two" }); + expect(progress(view).getAttribute("aria-valuetext")).toBe("Step 2 of 3"); + expect(dashes(view).map((dash) => dash.hasAttribute("data-current"))).toEqual( + [false, true, false], + ); + await waitFor(() => expect(document.activeElement).toBe(heading)); + expect(phoneBack(view)).not.toBeNull(); +}); + +test("Back returns, and hides again on the first step", async () => { + const view = draw({ initialStepId: "two" }); + + fireEvent.click(phoneBack(view) as HTMLButtonElement); + + await view.findByRole("heading", { name: "One" }); + expect(progress(view).getAttribute("aria-valuetext")).toBe("Step 1 of 3"); + expect(phoneBack(view)).toBeNull(); +}); + +test("a step that joins the list later does not move somebody off theirs", () => { + const view = draw({ initialStepId: "two" }); + + view.rerender( + , + ); + + expect(view.getByRole("heading", { name: "Two" })).not.toBeNull(); + expect(progress(view).getAttribute("aria-valuetext")).toBe("Step 2 of 4"); +}); + +/** Three steps where the second is optional, the way Connect your apps is. */ +const OPTIONAL: OnboardingStep[] = [ + plain("one", "One"), + { + ...plain("apps", "Connect your apps"), + secondary: { label: "Not now" }, + footnote: "You can connect more apps later.", + }, + plain("three", "Three"), +]; + +test("an optional step says Continue, with Not now beside it, the same size", () => { + const view = draw({ initialStepId: "apps", steps: OPTIONAL }); + + const [continueButton, notNow] = footerButtons(view); + expect(footerButtons(view)).toHaveLength(2); + expect(continueButton?.textContent).toBe("Continue"); + expect(notNow?.textContent).toBe("Not now"); + // Under the primary on a phone, in the same column, and drawn the same height and shape. + expect(continueButton?.parentElement).toBe(notNow?.parentElement ?? null); + expect( + continueButton && + notNow && + continueButton.compareDocumentPosition(notNow) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + for (const shape of ["h-[52px]", "w-full", "rounded-full"]) { + expect(continueButton?.className).toContain(shape); + expect(notNow?.className).toContain(shape); + } +}); + +test("the footnote sits just above the buttons", () => { + const view = draw({ initialStepId: "apps", steps: OPTIONAL }); + + const footnote = view.getByText("You can connect more apps later."); + expect(footnote.closest("footer")).not.toBeNull(); + expect( + footnote.compareDocumentPosition(primary(view)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); +}); + +test("Continue and Not now both move on", async () => { + const continued = draw({ initialStepId: "apps", steps: OPTIONAL }); + fireEvent.click(continued.getByRole("button", { name: "Continue" })); + expect(await continued.findByRole("heading", { name: "Three" })).toBeTruthy(); + cleanup(); + + const skipped = draw({ initialStepId: "apps", steps: OPTIONAL }); + fireEvent.click(skipped.getByRole("button", { name: "Not now" })); + expect(await skipped.findByRole("heading", { name: "Three" })).toBeTruthy(); +}); + +test("a step's own action decides when to move on", async () => { + let handed: (() => void) | null = null; + const view = draw({ + initialStepId: "apps", + steps: [ + OPTIONAL[0] as OnboardingStep, + { + ...(OPTIONAL[1] as OnboardingStep), + primaryLabel: "Turn on notifications", + onPrimary: (next) => { + handed = next; + }, + }, + OPTIONAL[2] as OnboardingStep, + ], + }); + + fireEvent.click(view.getByRole("button", { name: "Turn on notifications" })); + // Nothing moved: the step holds the move until its own action is done. + expect(view.getByRole("heading", { name: "Connect your apps" })).toBeTruthy(); + act(() => (handed as unknown as () => void)()); + expect(await view.findByRole("heading", { name: "Three" })).toBeTruthy(); +}); + +test("a move handed out on one step does nothing once somebody has left it", async () => { + let handed: (() => void) | null = null; + const view = draw({ + initialStepId: "apps", + steps: [ + OPTIONAL[0] as OnboardingStep, + { + ...(OPTIONAL[1] as OnboardingStep), + onPrimary: (next) => { + handed = next; + }, + }, + OPTIONAL[2] as OnboardingStep, + ], + }); + + fireEvent.click(view.getByRole("button", { name: "Continue" })); + fireEvent.click(phoneBack(view) as HTMLButtonElement); + await view.findByRole("heading", { name: "One" }); + act(() => (handed as unknown as () => void)()); + await settleReactWork(); + expect(view.getByRole("heading", { name: "One" })).toBeTruthy(); +}); + +test("a done primary reads green with a check", () => { + const view = draw({ + initialStepId: "apps", + steps: [ + OPTIONAL[0] as OnboardingStep, + { + ...plain("apps", "Get notified"), + primaryLabel: "Notifications on", + primaryDone: true, + }, + OPTIONAL[2] as OnboardingStep, + ], + }); + + const [done] = footerButtons(view); + expect(footerButtons(view)).toHaveLength(1); + expect(done?.textContent).toBe("Notifications on"); + expect(done?.hasAttribute("data-done")).toBe(true); + expect(done?.className).toContain("bg-status-done-soft"); +}); + +test("an optional step keeps Not now's place when it goes, so the primary doesn't move", () => { + const view = draw({ + initialStepId: "apps", + steps: [ + OPTIONAL[0] as OnboardingStep, + { + ...plain("apps", "Get notified"), + primaryLabel: "Notifications on", + primaryDone: true, + optional: true, + }, + OPTIONAL[2] as OnboardingStep, + ], + }); + + expect(footerButtons(view)).toHaveLength(1); + const slot = footer(view).querySelector("[data-onboarding-secondary-slot]"); + expect(slot?.className).toContain("h-[52px]"); + expect(slot?.getAttribute("aria-hidden")).toBe("true"); +}); + +test("a waiting primary shows a spinner, says it is busy, and can't be pressed again", () => { + const view = draw({ + initialStepId: "apps", + steps: [ + OPTIONAL[0] as OnboardingStep, + { + ...plain("apps", "Get notified"), + primaryLabel: "Turn on notifications", + primaryPending: true, + secondary: { label: "Not now" }, + }, + OPTIONAL[2] as OnboardingStep, + ], + }); + + const waiting = primary(view); + expect(waiting.getAttribute("aria-busy")).toBe("true"); + expect(waiting.disabled).toBe(true); + expect(waiting.querySelector(".animate-spin")).not.toBeNull(); +}); + +test("a step whose own form moves on gets no primary, and keeps Back", () => { + const view = draw({ + initialStepId: "three", + steps: [ + ...STEPS.slice(0, 2), + { ...plain("three", "Three"), ownAction: true }, + ], + }); + + expect(footer(view).querySelector("[data-onboarding-primary]")).toBeNull(); + expect(phoneBack(view)).not.toBeNull(); + expect(desktopBack(view)).not.toBeNull(); +}); + +test("held by its caller, it asks to move and draws whatever step it is handed", async () => { + const asked: Array<[string, string]> = []; + const view = render( + asked.push([to, way])} + stepId="apps" + steps={OPTIONAL} + />, + ); + + fireEvent.click(view.getByRole("button", { name: "Continue" })); + fireEvent.click(phoneBack(view) as HTMLButtonElement); + expect(asked).toEqual([ + ["three", "forward"], + ["one", "back"], + ]); + // Nothing moved until the caller said so. + expect(view.getByRole("heading", { name: "Connect your apps" })).toBeTruthy(); + + view.rerender( + asked.push([to, way])} + stepId="one" + steps={OPTIONAL} + />, + ); + const heading = await view.findByRole("heading", { name: "One" }); + await waitFor(() => expect(document.activeElement).toBe(heading)); +}); + +/* + * THE DESKTOP'S STEP LIST. Every step by name, the one on screen marked as the current step, the + * ones before it ticked. A step already reached is a button back to it; one ahead is only its name. + */ + +test("on a desktop, the step list names every step and marks the one on screen", () => { + const view = draw({ initialStepId: "two" }); + + const items = within(stepList(view)).getAllByRole("listitem"); + expect(items.map((item) => item.textContent?.replace(/^\d/, ""))).toEqual([ + "One", + "Two", + "Three", + ]); + const current = stepList(view).querySelector('[aria-current="step"]'); + expect(current?.textContent).toContain("Two"); + // The list is the desktop's, the dashes the phone's. + expect(stepList(view).className).toContain("hidden lg:block"); + expect(progress(view).parentElement?.className).toContain("lg:hidden"); +}); + +test("a step already reached can be picked from the list; one ahead cannot", async () => { + const view = draw({ initialStepId: "two" }); + + const list = within(stepList(view)); + expect(list.queryByRole("button", { name: "Three" })).toBeNull(); + expect(list.queryByRole("button", { name: "Two" })).toBeNull(); + + fireEvent.click(list.getByRole("button", { name: "One" })); + const heading = await view.findByRole("heading", { name: "One" }); + await waitFor(() => expect(document.activeElement).toBe(heading)); + + // Two has been seen now, so it is a way back; Three still is not. + expect( + within(stepList(view)).getByRole("button", { name: "Two" }), + ).toBeTruthy(); + expect( + within(stepList(view)).queryByRole("button", { name: "Three" }), + ).toBeNull(); + fireEvent.click(within(stepList(view)).getByRole("button", { name: "Two" })); + expect(await view.findByRole("heading", { name: "Two" })).toBeTruthy(); +}); + +test("held by its caller, a pick from the list is asked for like Back", () => { + const asked: Array<[string, string]> = []; + const view = render( + asked.push([to, way])} + stepId="three" + steps={STEPS} + />, + ); + + fireEvent.click(within(stepList(view)).getByRole("button", { name: "One" })); + expect(asked).toEqual([["one", "back"]]); +}); + +test("on a desktop, Back sits beside Continue, before it, and Back on a phone is hidden there", () => { + const view = draw({ initialStepId: "two" }); + + const back = desktopBack(view); + expect(back?.closest("footer")).not.toBeNull(); + expect(back?.textContent).toBe("Back"); + expect(back?.className).toContain("hidden"); + expect(back?.className).toContain("lg:inline-flex"); + expect(back?.className).toContain("lg:order-first"); + expect(phoneBack(view)?.parentElement?.className).toContain("lg:hidden"); + + fireEvent.click(back as HTMLButtonElement); + expect(view.getByRole("heading", { name: "One" })).toBeTruthy(); +}); + +/* + * THE DEMOS' CLOCK. A picture moves only while somebody can see it: on screen, in a shown tab. + */ + +/** An `IntersectionObserver` that reports only what the test tells it to. */ +class Sight { + static all: Sight[] = []; + readonly root = null; + readonly rootMargin = ""; + readonly thresholds = []; + targets: Element[] = []; + constructor(private readonly callback: IntersectionObserverCallback) { + Sight.all.push(this); + } + observe(target: Element) { + this.targets.push(target); + } + unobserve() {} + disconnect() { + this.targets = []; + } + takeRecords() { + return []; + } + static show(target: Element, isIntersecting: boolean) { + for (const sight of Sight.all) { + if (!sight.targets.includes(target)) continue; + sight.callback( + [{ target, isIntersecting } as IntersectionObserverEntry], + sight as unknown as IntersectionObserver, + ); + } + } +} + +function drawStage() { + return render( + + {({ beat, playing }) => ( +

+ Beat {beat} {playing ? "playing" : "still"} +

+ )} +
, + ); +} + +const stageOf = (view: ReturnType) => + view.container.querySelector("[data-demo]") as HTMLElement; + +test("a demo is one picture, read by its label, with nothing inside to reach", () => { + const view = drawStage(); + + const picture = view.getByRole("img", { name: "A test picture" }); + expect(picture.firstElementChild?.hasAttribute("inert")).toBe(true); +}); + +test("a demo plays its beats while on screen, and holds still once scrolled away", async () => { + const original = globalThis.IntersectionObserver; + globalThis.IntersectionObserver = + Sight as unknown as typeof IntersectionObserver; + try { + const view = drawStage(); + const stage = stageOf(view); + expect(stage.hasAttribute("data-playing")).toBe(true); + await waitFor(() => + expect(stage.getAttribute("data-demo-beat")).not.toBe("0"), + ); + + act(() => Sight.show(stage, false)); + expect(stage.hasAttribute("data-playing")).toBe(false); + expect(view.getByText(/still/)).toBeTruthy(); + const held = stage.getAttribute("data-demo-beat"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(stage.getAttribute("data-demo-beat")).toBe(held); + + act(() => Sight.show(stage, true)); + expect(stage.hasAttribute("data-playing")).toBe(true); + } finally { + globalThis.IntersectionObserver = original; + Sight.all = []; + } +}); + +test("a demo in a hidden tab holds still until the tab is shown", async () => { + const view = drawStage(); + const stage = stageOf(view); + const hidden = Object.getOwnPropertyDescriptor(Document.prototype, "hidden"); + try { + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => true, + }); + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(stage.hasAttribute("data-playing")).toBe(false); + const held = stage.getAttribute("data-demo-beat"); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(stage.getAttribute("data-demo-beat")).toBe(held); + } finally { + Reflect.deleteProperty(document, "hidden"); + if (hidden) Object.defineProperty(Document.prototype, "hidden", hidden); + } + act(() => { + document.dispatchEvent(new Event("visibilitychange")); + }); + expect(stage.hasAttribute("data-playing")).toBe(true); +}); From d18760631177b54015c89c80aa9e3afb4614c2bd Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:14:31 -0700 Subject: [PATCH 16/47] feat(onboarding): a picture for each step of the intro Welcome types a request under OpenBot's listening orb, a computer of its own shows the Bot's desktop with its pointer on an errand, the team shows three Bots' orbs taking turns at work, and an approval card waits for Allow once before it sends. Somebody running OpenBot alone also sees it on a laptop, with the agents they have, and for their organization. Each is a picture, read by its label, with nothing inside to click. --- .../computer/desktop-illustration.tsx | 17 +- .../onboarding/demos/approval-demo.tsx | 113 ++++++++++++++ .../onboarding/demos/computer-demo.tsx | 37 +++++ .../onboarding/demos/self-host-demo.tsx | 94 +++++++++++ .../components/onboarding/demos/team-demo.tsx | 130 ++++++++++++++++ .../onboarding/demos/welcome-demo.tsx | 147 ++++++++++++++++++ 6 files changed, 534 insertions(+), 4 deletions(-) create mode 100644 app/src/components/onboarding/demos/approval-demo.tsx create mode 100644 app/src/components/onboarding/demos/computer-demo.tsx create mode 100644 app/src/components/onboarding/demos/self-host-demo.tsx create mode 100644 app/src/components/onboarding/demos/team-demo.tsx create mode 100644 app/src/components/onboarding/demos/welcome-demo.tsx diff --git a/app/src/components/computer/desktop-illustration.tsx b/app/src/components/computer/desktop-illustration.tsx index a0321c9a73..d93188d814 100644 --- a/app/src/components/computer/desktop-illustration.tsx +++ b/app/src/components/computer/desktop-illustration.tsx @@ -10,8 +10,17 @@ import { cn } from "@/lib/utils"; * Built the way Tailark builds its workspace illustrations — theme tokens, thin borders, skeleton * bars where text would be — so it needs no asset, follows the theme, and never shows a stale * product screenshot. Decorative only: hidden from the tree and inert to the pointer. + * + * `still` holds the cursor where it rests, as reduced motion does: onboarding passes it while the + * picture is off screen or its tab is hidden, so a loop nobody can see does not keep running. */ -export function DesktopIllustration({ className }: { className?: string }) { +export function DesktopIllustration({ + className, + still = false, +}: { + className?: string; + still?: boolean; +}) { return ( ); } @@ -70,8 +79,8 @@ const CLICK_EASE = [ * Percentages rather than pixels, so the same journey fits whatever size the illustration is * drawn at. */ -function PointerCursor() { - const reducedMotion = useReducedMotion(); +function PointerCursor({ still }: { still: boolean }) { + const reducedMotion = useReducedMotion() || still; return ( + {({ beat, playing }) => { + const pressed = beat >= 1; + const sent = beat === 2; + return ( +
+
+ + {/* The team step's Research Analyst, with the same face. */} + + +
+

+ Research Analyst wants to send an email +

+

+ To ana@example.com +

+
+ + {sent ? "Sent" : "Needs you"} + +
+
+ +

+ Hi Ana, Friday at 7 works for all eight of us. I'll book the + table. +

+
+
+ {sent ? ( +

+ + Allowed once +

+ ) : ( + <> + + {APPROVAL_CHOICES[0]} + + + {APPROVAL_CHOICES[1]} + + + {APPROVAL_CHOICES[2]} + + + )} +
+
+ ); + }} + + ); +} diff --git a/app/src/components/onboarding/demos/computer-demo.tsx b/app/src/components/onboarding/demos/computer-demo.tsx new file mode 100644 index 0000000000..1ed0808dfa --- /dev/null +++ b/app/src/components/onboarding/demos/computer-demo.tsx @@ -0,0 +1,37 @@ +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { DesktopIllustration } from "@/components/computer/desktop-illustration"; +import { ComputerPlaceholder } from "@/components/computer/placeholder"; +import { DemoStage } from "../demo-stage"; + +/** + * A browser and files of its own: the desktop a Bot works on, its browser and its folder of files, + * with its pointer on an errand, and the orb working in the corner. + * + * The computer panel's own drawing of a Bot's desktop, so the picture matches what it shows + * when a Bot is using it. Two beats only to swap the caption; the pointer keeps its own loop, held + * still while the demo is off screen. + */ +export function ComputerDemo() { + return ( + + {({ beat, playing }) => ( +
+ + +
+ + + {beat === 0 ? "Reading the page" : "Saving it to a file"} + +
+
+ )} +
+ ); +} diff --git a/app/src/components/onboarding/demos/self-host-demo.tsx b/app/src/components/onboarding/demos/self-host-demo.tsx new file mode 100644 index 0000000000..0f33d0e154 --- /dev/null +++ b/app/src/components/onboarding/demos/self-host-demo.tsx @@ -0,0 +1,94 @@ +import { WEB_HARNESSES } from "@openbot/shared/harnesses"; +import { AnimatePresence, motion } from "motion/react"; +import type { ReactNode } from "react"; +import { HarnessMark } from "@/components/agents/harness-mark"; +import { appConfig } from "@/lib/generated/application-config"; +import { LaptopIllustration } from "../laptop-illustration"; +import { OrganizationFlow } from "../organization-flow"; +import { DemoStage } from "../demo-stage"; + +/** Every framework the New Bot wizard offers by name; "an agent you already run" is the sentence. */ +export const FRAMEWORKS = WEB_HARNESSES.filter( + (harness) => harness.id !== "byo-url", +); + +const PRODUCT = appConfig.brand.productName; + +/** The three things somebody running OpenBot themselves is deciding, one picture each. */ +const PANELS: Array<{ id: string; caption: string; picture: () => ReactNode }> = + [ + { + id: "local", + caption: "On this computer", + picture: () => , + }, + { + id: "agents", + caption: "With the agents you already have", + picture: () => ( +
+ {FRAMEWORKS.map((harness) => ( + + + {harness.name} + + ))} +
+ ), + }, + { + id: "organization", + caption: "For your organization", + picture: () => , + }, + ]; + +function Panel({ panel }: { panel: (typeof PANELS)[number] }) { + return ( +
+

+ {panel.caption} +

+ {panel.picture()} +
+ ); +} + +/** + * Running it yourself: on this computer, with the agents you already have, and for your + * organization. The three local-evaluation slides as one picture that turns through them. + * With reduced motion the three sit one above the other. + */ +export function SelfHostDemo() { + return ( + harness.name).join(", ")}; and your organization signing in with Google, Microsoft or Okta`} + rest={-1} + > + {({ beat, reduced }) => + reduced ? ( + PANELS.map((panel) => ) + ) : ( + + + {PANELS[beat] ? : null} + + + ) + } + + ); +} diff --git a/app/src/components/onboarding/demos/team-demo.tsx b/app/src/components/onboarding/demos/team-demo.tsx new file mode 100644 index 0000000000..b4d1a7c211 --- /dev/null +++ b/app/src/components/onboarding/demos/team-demo.tsx @@ -0,0 +1,130 @@ +import { useQuery } from "@tanstack/react-query"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { type AgentProfile, agentListQueryOptions } from "@/lib/agents/queries"; +import { cn } from "@/lib/utils"; +import { DemoStage } from "../demo-stage"; + +/** What a card needs. Examples carry these and nothing more. */ +type TeamCard = Pick & { + /** What it does, in a few words. */ + role: string; + /** Invented, to show what a team looks like: dimmed and labelled, never read as a Bot here. */ + example?: boolean; +}; + +/** + * Stand-ins for a deployment with fewer than three public Bots to show. Invented names on purpose: + * they illustrate a team without claiming any of these exist here. Their seeds are picked to draw + * three different colours: berry, ocean and coral. + */ +const EXAMPLES: TeamCard[] = [ + { + id: "example-research", + name: "Research Analyst", + avatarSeed: "example-research", + role: "Finds and compares", + }, + { + id: "example-data", + name: "Data Analyst", + avatarSeed: "data-analyst", + role: "Reads the numbers", + }, + { + id: "example-support", + name: "Support Desk", + avatarSeed: "example-support-agent", + role: "Answers customers", + }, +]; + +/** The Bots a team card shows: real public Bots first, examples topping up a sparse deployment. */ +export function teamCards(agents: AgentProfile[] | undefined): TeamCard[] { + const shared = + agents?.filter((agent) => !agent.mine && agent.visibility === "public") ?? + []; + return [ + ...shared.slice(0, 3).map((agent) => ({ + id: agent.id, + name: agent.name, + avatarSeed: agent.avatarSeed, + role: agent.title, + })), + // Slicing past the end is [], so three or more real Bots take no examples at all. + ...EXAMPLES.slice(shared.length).map((example) => ({ + ...example, + example: true, + })), + ]; +} + +/** + * Your team of Bots: three Bots, each its own orb in its own colour, and a place for one of yours. + * One at a time, a Bot picks something up and works on it, so the cards read as a team. + */ +export function TeamDemo() { + const { data: agents } = useQuery(agentListQueryOptions()); + const cards = teamCards(agents); + return ( + `${card.name} (${card.example ? "example" : "available"})`, + ) + .join(", ")}, and one of your own`} + rest={-1} + > + {({ beat, playing }) => ( + <> + {cards.map((card, at) => { + const working = at === beat; + return ( +
+ + + +
+

{card.name}

+

+ {card.example ? "Example" : card.role || "Ready"} +

+
+ + + Working + +
+ ); + })} +
+ +

+ Your own Bot +

+
+ + )} +
+ ); +} diff --git a/app/src/components/onboarding/demos/welcome-demo.tsx b/app/src/components/onboarding/demos/welcome-demo.tsx new file mode 100644 index 0000000000..eeca941845 --- /dev/null +++ b/app/src/components/onboarding/demos/welcome-demo.tsx @@ -0,0 +1,147 @@ +import { IconArrowUp, IconMicrophone, IconPlus } from "@tabler/icons-react"; +import { useEffect, useState } from "react"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { cn } from "@/lib/utils"; +import { DemoStage } from "../demo-stage"; + +/** The request the composer types, a thing somebody would really hand off. */ +export const WELCOME_REQUEST = + "Find somewhere near the office for a team dinner on Friday, for eight."; + +/** How long each character takes to land. Linear: a typewriter that eases reads as hesitating. */ +const CHARACTER_MS = 38; + +/** + * Types `text` in while `active`, one character at a time, starting again each time it turns on. + * Off, or with nothing moving, it is the whole text at once. + */ +function useTyped(text: string, active: boolean): string { + const [shown, setShown] = useState(active ? 0 : text.length); + useEffect(() => { + if (!active) { + setShown(text.length); + return; + } + setShown(0); + const timer = setInterval( + () => + setShown((count) => { + if (count >= text.length) { + clearInterval(timer); + return count; + } + return count + 1; + }), + CHARACTER_MS, + ); + return () => clearInterval(timer); + }, [text, active]); + return text.slice(0, shown); +} + +/** + * Welcome's picture: OpenBot's orb listening while a request is typed, then working on it. + * + * A DRAWING OF THE COMPOSER, NOT THE COMPOSER. The real one reads its text once, when it mounts, + * so it cannot be typed into from outside; and a live editor in a picture would be one more thing + * to tab to. This is its outline: the same card, the same buttons, and the words arriving. + * + * Three beats: typing, handed off and working, done. With reduced motion it is the typed request + * with the orb listening. + */ +export function WelcomeDemo() { + return ( + + {({ beat, playing, reduced }) => ( + + )} + + ); +} + +function WelcomeScene({ + beat, + playing, + reduced, +}: { + beat: number; + playing: boolean; + reduced: boolean; +}) { + const typing = beat === 0; + const typed = useTyped(WELCOME_REQUEST, typing && playing); + const sent = !typing; + const state = typing ? "listening" : beat === 1 ? "thinking" : "done"; + return ( + <> + +
+
+

+ {sent && !reduced ? WELCOME_REQUEST : typed} + {typing && playing ? ( + + ) : null} +

+
+ + + +
+ + + + 0 || sent + ? "bg-primary text-primary-foreground" + : "bg-muted text-muted-foreground", + )} + > + + +
+
+
+

+ + + {beat === 2 ? "Found three places" : "Working on it"} + +

+
+ + ); +} From 996151da58b3972331c14ffef35040c32f8e359b Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:14:31 -0700 Subject: [PATCH 17/47] feat(onboarding): connect apps, get notified and check your setup Connect your apps lists the apps the person's default Bot uses as them and connects the ones with a sign-in page right there, coming back to the step; a brokered consent is asked of the vendor on return, so the row turns Connected only when it is. Get notified previews the notification OpenBot really sends and asks the browser, sharing its answer with a Bot's page. Check your setup draws the server's checklist with a status pill per row, and Check again. --- app/src/components/bot-profile/profile.tsx | 24 +- .../onboarding/connect-apps-step.tsx | 243 ++++++++ app/src/components/onboarding/notify-step.tsx | 172 ++++++ .../components/onboarding/setup-checklist.tsx | 267 +++++++++ app/src/lib/notifications/permission.ts | 77 +++ app/src/lib/plugins/mutations.ts | 11 +- app/src/lib/setup/mutations.ts | 21 + app/src/lib/setup/queries.ts | 70 +++ app/tests/onboarding-steps.test.tsx | 541 ++++++++++++++++++ app/tests/setup-checklist.test.tsx | 342 +++++++++++ 10 files changed, 1746 insertions(+), 22 deletions(-) create mode 100644 app/src/components/onboarding/connect-apps-step.tsx create mode 100644 app/src/components/onboarding/notify-step.tsx create mode 100644 app/src/components/onboarding/setup-checklist.tsx create mode 100644 app/src/lib/notifications/permission.ts create mode 100644 app/src/lib/setup/mutations.ts create mode 100644 app/src/lib/setup/queries.ts create mode 100644 app/tests/onboarding-steps.test.tsx create mode 100644 app/tests/setup-checklist.test.tsx diff --git a/app/src/components/bot-profile/profile.tsx b/app/src/components/bot-profile/profile.tsx index fef082c983..43e5282327 100644 --- a/app/src/components/bot-profile/profile.tsx +++ b/app/src/components/bot-profile/profile.tsx @@ -7,7 +7,6 @@ import { import { useMutation, useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; import type * as React from "react"; -import { useState } from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import { BotNeedsYou } from "@/components/approvals/waiting"; import { PageRows, PageSection } from "@/components/layout/page-shell"; @@ -40,6 +39,10 @@ import { type BotNotify, botLifecycleQueryOptions, } from "@/lib/bot-lifecycle/queries"; +import { + requestNotificationPermission, + useNotificationPermission, +} from "@/lib/notifications/permission"; import { queryClient } from "@/query-client"; import { BotActivityGlance } from "./activity"; import { BotPausedBanner } from "./pause-banner"; @@ -65,15 +68,8 @@ export function BotProfile({ const lifecycle = useQuery(botLifecycleQueryOptions(agent.id)); const pause = useMutation(setBotPausedMutationOptions(queryClient)); const notify = useMutation(setBotNotifyMutationOptions(queryClient)); - const [permission, setPermission] = useState(() => { - try { - return typeof Notification === "undefined" - ? "unsupported" - : Notification.permission; - } catch { - return "unsupported"; - } - }); + // The browser's answer, shared with onboarding's Get notified step, so a yes there shows here. + const permission = useNotificationPermission(); return ( <> @@ -186,13 +182,7 @@ export function BotProfile({ + ) : null} + + + ); +} diff --git a/app/src/components/onboarding/notify-step.tsx b/app/src/components/onboarding/notify-step.tsx new file mode 100644 index 0000000000..282735c31a --- /dev/null +++ b/app/src/components/onboarding/notify-step.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import AgentOrb from "@/components/agents/orb/agent-orb"; +import { attentionSummary } from "@/components/bot-profile/attention"; +import { appConfig } from "@/lib/generated/application-config"; +import { + requestNotificationPermission, + useNotificationPermission, +} from "@/lib/notifications/permission"; +import { StepIntro } from "./onboarding-frame"; +import type { OnboardingStep } from "./onboarding-wizard"; + +const PRODUCT = appConfig.brand.productName; + +/** How long "Notifications on" shows before the step moves on by itself. */ +export const GRANTED_BEAT_MS = 1_000; + +/** + * The notification the preview shows: the one this web app raises today, word for word. + * + * `AttentionNotifier` (`bot-profile/attention.tsx`) titles it " needs you" and says what is + * waiting with `attentionSummary`, which is called here so the body cannot drift from it. A Bot with + * a question and an approval waiting is the common case it fires for. + */ +export function notificationPreview(botName: string): { + title: string; + body: string; +} { + return { + title: `${botName} needs you`, + body: attentionSummary({ + agentId: "preview", + name: botName, + questions: 1, + approvals: 1, + handoffs: 0, + unread: 0, + paused: false, + notify: "all", + }), + }; +} + +/** + * Onboarding's Get notified step: a preview of the notification, and the button that asks the + * browser for permission. + * + * Its buttons are the frame's, so this returns the whole step. "Turn on notifications" asks the + * browser, and shows a spinner while its prompt is up. A yes turns the button green, "Notifications + * on", for a beat and then moves on by itself. A no, a prompt closed without an answer, or a + * browser with no notifications at all says so on the step, and the button becomes Continue. "Not + * now" moves on and asks nothing. The step is optional, so the button keeps its place when Not now + * goes. + */ +export function useNotifyStep(botName: string): OnboardingStep { + const permission = useNotificationPermission(); + const [asking, setAsking] = useState(false); + const [dismissed, setDismissed] = useState(false); + const granted = permission === "granted"; + // Answered, or nothing to ask: the button only moves on from here. + const done = + permission === "denied" || permission === "unsupported" || dismissed; + + /* + * Why the button now says Continue, said just above it in the frame's notice slot rather than + * beside the preview: on a small screen the preview pushes anything under it out of view, and the + * person would see the button change with no reason. + */ + const said = + permission === "denied" + ? "Notifications are blocked for this site. Allow them in your browser's site settings to turn them on." + : permission === "unsupported" + ? "This browser can't show notifications." + : dismissed && permission === "default" + ? "You can turn them on later from a Bot's page." + : null; + + return { + id: "notify", + title: "Get notified", + intro: () => ( + +

+ Allow notifications so {PRODUCT} can tell you when a Bot needs you. +

+
+ ), + demo: () => , + notice: said ? ( +

+ {said} +

+ ) : undefined, + optional: true, + primaryLabel: granted + ? "Notifications on" + : done + ? "Continue" + : "Turn on notifications", + primaryDone: granted, + primaryPending: asking, + onPrimary: + granted || done + ? undefined + : (next) => { + setAsking(true); + void requestNotificationPermission().then((answer) => { + setAsking(false); + if (answer === "granted") setTimeout(next, GRANTED_BEAT_MS); + // The prompt closed with neither answer: the browser still says "default". + else if (answer === "default") setDismissed(true); + }); + }, + secondary: granted || done ? undefined : { label: "Not now" }, + footnote: `Notifications show while ${PRODUCT} is open in a browser tab.`, + }; +} + +/** + * A ghost phone, so the card reads as something arriving on a screen: taller than it is wide, its + * top fading out rather than drawn, the notification over it and four app tiles under it. + */ +function NotifyPreview({ botName }: { botName: string }) { + const preview = notificationPreview(botName); + return ( +
+
+
+
+ + + + + + + {preview.title} + + + now + + + + {preview.body} + + +
+
+ {[0, 1, 2, 3].map((tile) => ( + + ))} +
+
+
+ ); +} diff --git a/app/src/components/onboarding/setup-checklist.tsx b/app/src/components/onboarding/setup-checklist.tsx new file mode 100644 index 0000000000..ab1d9335ab --- /dev/null +++ b/app/src/components/onboarding/setup-checklist.tsx @@ -0,0 +1,267 @@ +import { + IconApps, + IconBrain, + IconDeviceDesktop, + IconMessages, + IconSparkles, +} from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Fragment } from "react"; +import { PageRows } from "@/components/layout/page-shell"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemFooter, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Separator } from "@/components/ui/separator"; +import { appConfig } from "@/lib/generated/application-config"; +import { recheckSetupMutationOptions } from "@/lib/setup/mutations"; +import { + type SetupFix, + type SetupRow, + type SetupRowId, + setupChecklistQueryOptions, +} from "@/lib/setup/queries"; +import { cn } from "@/lib/utils"; +import { queryClient } from "@/query-client"; + +const ICONS: Record = { + intelligence: IconBrain, + model: IconSparkles, + computer: IconDeviceDesktop, + apps: IconApps, + chat: IconMessages, +}; + +const PRODUCT = appConfig.brand.productName; + +/** + * What this deployment has, and what still needs setting up: one row each, with a pill saying + * Ready, Not checked or Needs setup. + * + * The rows, their sentences and their fixes are the server's (see `lib/setup/queries.ts`): this + * draws them, and holds the one thing only a browser can do, which is asking it to check again. + * An admin is handed the page to open or the variables to set; a member is handed neither, because + * the server never sends them one. + * + * NOTHING HERE BLOCKS ANYTHING. A required gap is drawn plainly, in red, and the step around it still + * lets the person carry on: they cannot set a server's environment from a browser, and keeping them + * out of the app would not set it either. + */ +export function SetupChecklist({ + openPage, +}: { + /** + * Opens one of the app's own screens. Onboarding has to be finished before any other page opens, + * so it carries the page to its last step and opens it from there. + */ + openPage: (target: { label: string; path: string }) => void; +}) { + const checklist = useQuery(setupChecklistQueryOptions()); + const recheck = useMutation(recheckSetupMutationOptions(queryClient)); + + /* + * Check again asks the server to ask Intelligence, the model provider and the computers again, + * which a plain refetch would not: those answers are kept from the last time they were asked. A + * checklist that could not be read at all is read again first. + */ + const checking = checklist.isFetching || recheck.isPending; + const checkAgain = ( + + ); + + return ( +
+ {recheck.error ? ( +

+ Couldn't check again. Try again. +

+ ) : null} + {checklist.isPending ? null : checklist.error ? ( + <> +

+ Couldn't check your setup. Check again, or carry on and look later. +

+ {checkAgain} + + ) : ( + <> +

+ {summaryOf(checklist.data.rows)} +

+ +
    + {checklist.data.rows.map((row, at) => ( +
  • + {at > 0 ? : null} + +
  • + ))} +
+
+ {checkAgain} + + )} +
+ ); +} + +/** One line over the list, so the state of the whole list is said before its rows are read. */ +export function summaryOf(rows: SetupRow[]): string { + const gaps = rows.filter((row) => row.status === "needs_setup"); + const unchecked = rows.filter((row) => row.status === "not_checked").length; + const notChecked = + unchecked === 0 + ? null + : unchecked === 1 + ? "1 thing isn't checked yet." + : `${unchecked} things aren't checked yet.`; + if (gaps.length === 0) return notChecked ?? "Everything is set up."; + const required = gaps.filter((row) => row.required).length; + const things = + gaps.length === 1 ? "1 thing needs" : `${gaps.length} things need`; + const needs = + required > 0 + ? `${things} setup, and ${required === 1 ? "1 is" : `${required} are`} required.` + : `${things} setup. ${PRODUCT} works without ${gaps.length === 1 ? "it" : "them"}.`; + return notChecked ? `${needs} ${notChecked}` : needs; +} + +/** The pill's words and colours: done green, a gap that blocks red, everything else amber. */ +function pillFor(row: SetupRow) { + if (row.status === "ready") + return { label: "Ready", tone: "bg-status-done-soft text-status-done" }; + if (row.status === "not_checked") + return { + label: "Not checked", + tone: "bg-status-needs-soft text-status-needs", + }; + return { + label: "Needs setup", + tone: row.required + ? "bg-status-problem-soft text-status-problem" + : "bg-status-needs-soft text-status-needs", + }; +} + +function SetupItem({ + row, + openPage, +}: { + row: SetupRow; + openPage: (target: { label: string; path: string }) => void; +}) { + const Icon = ICONS[row.id]; + const pill = pillFor(row); + return ( + + + + + + + {row.title} + {row.required ? ( + + Required + + ) : null} + + + {row.detail} + + + + + {pill.label} + + + {!row.ready && row.fix ? ( + + + + ) : null} + + ); +} + +const LINK = + "rounded-sm font-medium text-brand underline-offset-2 outline-none hover:underline focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50"; + +/** How to close one gap: a page to open, or the variables to set and a guide. */ +function Fix({ + fix, + openPage, +}: { + fix: SetupFix; + openPage: (target: { label: string; path: string }) => void; +}) { + const page = (target: { label: string; path: string }) => ( + { + event.preventDefault(); + openPage(target); + }} + > + {target.label} + + ); + const guide = (url: string) => ( + + Setup guide + + ); + + if (fix.kind === "page") { + return ( +

+ Open {page(fix)}. {fix.docsUrl ? guide(fix.docsUrl) : null} +

+ ); + } + + return ( +

+ Set{" "} + {fix.names.map((name, at) => ( + + {at > 0 ? (at === fix.names.length - 1 ? " and " : ", ") : null} + + {name} + + + ))} + , then restart {PRODUCT}. + {fix.page ? <> Or add one in {page(fix.page)}. : null}{" "} + {guide(fix.docsUrl)} +

+ ); +} diff --git a/app/src/lib/notifications/permission.ts b/app/src/lib/notifications/permission.ts new file mode 100644 index 0000000000..8d314f6eae --- /dev/null +++ b/app/src/lib/notifications/permission.ts @@ -0,0 +1,77 @@ +import { useSyncExternalStore } from "react"; + +/** + * Whether this browser may show OpenBot's notifications: the browser's own answer, never a guess. + * + * WHAT A NOTIFICATION IS HERE. The web app raises one when a Bot starts needing the person + * (`AttentionNotifier` in `bot-profile/attention.tsx`), from the page itself. So it shows while + * OpenBot is open in a tab, and not once the tab is closed: there is no web push. Onboarding's Get + * notified step says so rather than promise more. + * + * ONE ANSWER FOR THE WHOLE PAGE. Onboarding's step and a Bot's page both offer to turn them on, + * and a press on either has to show on the other, so the state lives here once and both read it + * through `useNotificationPermission`. + */ +export type NotificationPermissionState = + | "granted" + | "denied" + | "default" + /** No Notification API, as in Safari on iPhone outside a Home Screen app. */ + | "unsupported"; + +/** What the browser says now. A browser that throws on reading it is one that cannot show them. */ +export function notificationPermission(): NotificationPermissionState { + try { + return typeof Notification === "undefined" + ? "unsupported" + : Notification.permission; + } catch { + return "unsupported"; + } +} + +const listeners = new Set<() => void>(); +let current: NotificationPermissionState | null = null; + +function read(): NotificationPermissionState { + current ??= notificationPermission(); + return current; +} + +function set(next: NotificationPermissionState) { + current = next; + for (const listener of listeners) listener(); +} + +/** + * Asks the browser, which shows its own prompt the first time and answers at once after that. + * Resolves with the answer; never throws. + */ +export async function requestNotificationPermission(): Promise { + if (read() === "unsupported") return "unsupported"; + let answer: NotificationPermissionState; + try { + answer = await Notification.requestPermission(); + } catch { + answer = "unsupported"; + } + set(answer); + return answer; +} + +/** Reads the browser's answer again, for a "Check again" after somebody changed a site setting. */ +export function refreshNotificationPermission() { + set(notificationPermission()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** The browser's answer, kept current across every component that draws it. */ +export function useNotificationPermission(): NotificationPermissionState { + return useSyncExternalStore(subscribe, read, () => "unsupported" as const); +} diff --git a/app/src/lib/plugins/mutations.ts b/app/src/lib/plugins/mutations.ts index 02d3bacf34..c26b9579ae 100644 --- a/app/src/lib/plugins/mutations.ts +++ b/app/src/lib/plugins/mutations.ts @@ -390,10 +390,11 @@ export function registerOAuthClientMutationOptions(queryClient: QueryClient) { /** * Start a consent flow, and say which screen it started from. * - * `returnTo` decides where the vendor's callback puts somebody down, because two screens offer this: - * a person's own connected-accounts page, and the connector's admin page where an administrator - * verifies the setup they have just finished. Sending an administrator to their personal settings - * afterwards is the round trip the inline row exists to remove. + * `returnTo` decides where the vendor's callback puts somebody down, because three screens offer this: + * a person's own connected-accounts page, the connector's admin page where an administrator + * verifies the setup they have just finished, and onboarding's Connect your apps step, which is the + * only page somebody who has not finished onboarding may come back to. Sending an administrator to + * their personal settings afterwards is the round trip the inline row exists to remove. * * A name rather than a URL. The server narrows it to a known set before signing it into the state, * so this parameter cannot become an open redirect however it is called. @@ -430,7 +431,7 @@ export function registerOAuthClientMutationOptions(queryClient: QueryClient) { * so a caller has to say what it draws for it. */ export function connectAccountMutationOptions( - returnTo: "settings" | "admin" = "settings", + returnTo: "settings" | "admin" | "onboarding" = "settings", ) { return mutationOptions({ mutationFn: async (serverId: string): Promise => { diff --git a/app/src/lib/setup/mutations.ts b/app/src/lib/setup/mutations.ts new file mode 100644 index 0000000000..7d2b4a1925 --- /dev/null +++ b/app/src/lib/setup/mutations.ts @@ -0,0 +1,21 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { type SetupChecklist, setupKeys } from "./queries"; + +/** + * "Check again": the server asks Intelligence, the model provider and the computers again, then + * answers. Asking them is the server's job, and it holds the keys; the browser only says when. + * + * Invalidated rather than written into the cache, as every write here is. The read that follows is + * answered from what the check just learned, so it costs a round trip and no second check. + */ +export function recheckSetupMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (): Promise => + client("/api/setup/check", "checklist", { + method: "POST", + fallback: "Couldn't check your setup.", + }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: setupKeys.all }), + }); +} diff --git a/app/src/lib/setup/queries.ts b/app/src/lib/setup/queries.ts new file mode 100644 index 0000000000..f4774a9d35 --- /dev/null +++ b/app/src/lib/setup/queries.ts @@ -0,0 +1,70 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** + * The setup checklist, as the server decided it for the person asking. See + * server/src/setup/checklist.ts. + * + * Every sentence and every fix comes from the server, already shaped for its reader: an admin is + * told which page to open or which variable to set, and a member is told only that it works or that + * their admin sets it up. The browser draws what it is given and never decides who may see a fix. + * Nothing on it is a secret's value: a key is reported as there or not there. + */ +export type SetupRowId = + | "intelligence" + | "model" + | "computer" + | "apps" + | "chat"; + +export type SetupFix = + | { + kind: "env"; + names: string[]; + docsUrl: string; + page?: { label: string; path: string }; + } + | { kind: "page"; label: string; path: string; docsUrl?: string }; + +/** + * `ready` works, as far as the server can tell. `needs_setup` does not, and the row says why. + * `not_checked` is set up, but nobody has asked whether it works, such as a model key added since + * the server last asked the provider. + */ +export type SetupStatus = "ready" | "needs_setup" | "not_checked"; + +export type SetupRow = { + id: SetupRowId; + /** Every row is the deployment's, which an admin sets up. */ + scope: "deployment"; + title: string; + /** True only where the product cannot answer without it. */ + required: boolean; + status: SetupStatus; + /** `status === "ready"`. */ + ready: boolean; + /** What it gives you when ready, or what happens without it. */ + detail: string; + fix: SetupFix | null; +}; + +export type SetupChecklist = { + /** Who the server answered as: an admin (or somebody running OpenBot alone) or a member. */ + audience: "admin" | "member"; + rows: SetupRow[]; +}; + +export const setupKeys = { + all: ["setup"] as const, + checklist: () => ["setup", "checklist"] as const, +}; + +export function setupChecklistQueryOptions() { + return queryOptions({ + queryKey: setupKeys.checklist(), + queryFn: (): Promise => + client("/api/setup", "checklist", { + fallback: "Couldn't check your setup.", + }), + }); +} diff --git a/app/tests/onboarding-steps.test.tsx b/app/tests/onboarding-steps.test.tsx new file mode 100644 index 0000000000..0f621e24b3 --- /dev/null +++ b/app/tests/onboarding-steps.test.tsx @@ -0,0 +1,541 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import type { ReactNode } from "react"; +import { + type ConsentApp, + ConnectAppsStep, +} from "@/components/onboarding/connect-apps-step"; +import { + GRANTED_BEAT_MS, + notificationPreview, + useNotifyStep, +} from "@/components/onboarding/notify-step"; +import { StepIntro } from "@/components/onboarding/onboarding-frame"; +import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard"; +import { agentKeys } from "@/lib/agents/queries"; +import type { AppAccess } from "@/lib/app-access/queries"; +import { refreshNotificationPermission } from "@/lib/notifications/permission"; +import { queryClient } from "@/query-client"; +import { settleReactWork } from "./settle-react-work"; + +/** + * Onboarding's two optional steps. Connect your apps lists the apps the default Bot uses as this + * person, connects the ones with a sign-in page from here, and comes back to this step. Get + * notified asks the browser, shows the answer, and moves on. + */ + +beforeAll(() => GlobalRegistrator.register({ url: "http://localhost:3010" })); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const BOT = { + id: "general", + name: "General Assistant", + title: "Everyday work", + roleDescription: "", + avatarSeed: "general", + visibility: "public", + endpoint: null, + builtIn: true, + hasAuth: false, + mine: false, +}; + +/** One app as `GET /api/plugins/for/:agentId/apps` answers it, not connected and connectable. */ +const app = (over: Partial): AppAccess => ({ + key: "notion", + displayName: "Notion", + logo: null, + provider: "catalogue", + account: "personal", + connect: "oauth", + authScheme: null, + accountMode: null, + state: "not_connected", + unavailable: null, + accountHolder: "you", + asksFirst: false, + granted: true, + ownerGrants: "admin", + givenByOwner: false, + needsConnect: false, + connectedAt: null, + requestKind: null, + request: null, + ...over, +}); + +const originalFetch = globalThis.fetch; +let apps: AppAccess[] | "error"; +let connectAnswer: Record; +let confirmed: boolean; +let requests: { path: string; method: string }[]; + +let permission: NotificationPermission; +let prompted: NotificationPermission; +let asked: number; +const originalNotification = (globalThis as { Notification?: unknown }) + .Notification; + +/** The app's one client, without its retries, so a failed read is an answer at once. */ +const defaults = queryClient.getDefaultOptions(); + +beforeEach(() => { + queryClient.clear(); + queryClient.setDefaultOptions({ + ...defaults, + queries: { ...defaults.queries, retry: false }, + }); + requests = []; + apps = [app({})]; + connectAnswer = { + authorizationUrl: "https://accounts.example/consent?state=s", + }; + confirmed = true; + permission = "default"; + prompted = "granted"; + asked = 0; + (globalThis as { Notification?: unknown }).Notification = { + get permission() { + return permission; + }, + requestPermission: async () => { + asked += 1; + permission = prompted; + return prompted; + }, + }; + refreshNotificationPermission(); + globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + const method = init?.method ?? "GET"; + requests.push({ path, method }); + if (path === "/api/plugins/for/general/apps") { + if (apps === "error") + return Response.json({ error: "nope" }, { status: 503 }); + return Response.json({ apps }); + } + if (path.endsWith("/connection/confirm")) + return Response.json({ connected: confirmed }); + if (path.includes("/connect?returnTo=")) + return Response.json(connectAnswer); + if (path.startsWith("/api/agents")) + return Response.json({ agents: [BOT] }); + return Response.json({}); + }, + { preconnect: originalFetch.preconnect }, + ); +}); +afterEach(() => { + cleanup(); + queryClient.clear(); + queryClient.setDefaultOptions(defaults); + globalThis.fetch = originalFetch; + (globalThis as { Notification?: unknown }).Notification = + originalNotification; +}); + +function withQueries(children: ReactNode) { + queryClient.setQueryData(agentKeys.list(false), [BOT]); + return ( + {children} + ); +} + +function drawConnect( + props: { failed?: boolean; returned?: ConsentApp | null } = {}, +) { + const left: Array<[string, ConsentApp]> = []; + const view = render( + withQueries( + left.push([url, left_])} + returned={props.returned ?? null} + />, + ), + ); + return Object.assign(view, { left }); +} + +/** One app's row, once the list has arrived. */ +const rowOf = async (view: ReturnType, key: string) => + within( + await waitFor(() => { + const row = view.container.querySelector( + `[data-app-row="${key}"]`, + ); + if (!row) throw new Error(`no ${key} row`); + return row; + }), + ); + +test("Connect your apps lists the apps the default Bot uses as you, and no team accounts", async () => { + apps = [ + app({}), + app({ key: "linear", displayName: "Linear", provider: "composio" }), + app({ key: "team-drive", displayName: "Team Drive", account: "shared" }), + ]; + const view = drawConnect(); + + expect(await view.findByRole("list", { name: "Apps" })).toBeTruthy(); + const rows = Array.from( + view.container.querySelectorAll("[data-app-row]"), + ).map((row) => row.getAttribute("data-app-row")); + expect(rows).toEqual(["notion", "linear"]); + expect(requests).toContainEqual({ + path: "/api/plugins/for/general/apps", + method: "GET", + }); +}); + +test("Connect starts the real connection, coming back to this step", async () => { + const view = drawConnect(); + + fireEvent.click(await view.findByRole("button", { name: "Connect Notion" })); + + await waitFor(() => + expect(view.left).toEqual([ + [ + "https://accounts.example/consent?state=s", + { serverId: "notion", brokered: false }, + ], + ]), + ); + expect(requests).toContainEqual({ + path: "/api/plugins/servers/notion/connect?returnTo=onboarding", + method: "POST", + }); +}); + +test("a brokered app leaves saying so, so its return is asked of the vendor", async () => { + apps = [ + app({ + key: "composio-linear", + displayName: "Linear", + provider: "composio", + connect: "consent", + }), + ]; + const view = drawConnect(); + + fireEvent.click(await view.findByRole("button", { name: "Connect Linear" })); + + await waitFor(() => + expect(view.left[0]?.[1]).toEqual({ + serverId: "composio-linear", + brokered: true, + }), + ); +}); + +test("connected reads Connected, with nothing to press", async () => { + apps = [app({ state: "ready" })]; + const view = drawConnect(); + + expect( + await (await rowOf(view, "notion")).findByText("Connected"), + ).toBeTruthy(); + expect((await rowOf(view, "notion")).queryByRole("button")).toBeNull(); +}); + +test("a refused account offers Reconnect", async () => { + apps = [app({ state: "reconnect" })]; + const view = drawConnect(); + + expect( + await view.findByRole("button", { name: "Reconnect Notion" }), + ).toBeTruthy(); + expect( + (await rowOf(view, "notion")).getByText("Needs reconnecting."), + ).toBeTruthy(); +}); + +test("an app whose key somebody types is connected later, in Connections", async () => { + apps = [app({ key: "stripe", displayName: "Stripe", connect: "key" })]; + const view = drawConnect(); + + expect( + await (await rowOf(view, "stripe")).findByText( + "Connect it later in Connections.", + ), + ).toBeTruthy(); + expect((await rowOf(view, "stripe")).queryByRole("button")).toBeNull(); +}); + +test("an app the Bot was never given says an admin can give it, with nothing to press", async () => { + apps = [app({ state: "not_granted" })]; + const view = drawConnect(); + + expect( + await (await rowOf(view, "notion")).findByText( + "General Assistant can't use it yet. An admin can give it.", + ), + ).toBeTruthy(); + expect((await rowOf(view, "notion")).queryByRole("button")).toBeNull(); +}); + +test("an account the vendor already held is recorded on the press, with nowhere to go", async () => { + apps = [app({ provider: "composio", connect: "consent" })]; + connectAnswer = { connected: true }; + const view = drawConnect(); + + fireEvent.click(await view.findByRole("button", { name: "Connect Notion" })); + apps = [app({ provider: "composio", connect: "consent", state: "ready" })]; + + expect( + await (await rowOf(view, "notion")).findByText("Connected"), + ).toBeTruthy(); + expect(view.left).toEqual([]); +}); + +test("back from a brokered consent, the vendor is asked once, and the row reads its answer", async () => { + apps = [app({ provider: "composio", connect: "consent", state: "ready" })]; + const view = drawConnect({ + returned: { serverId: "notion", brokered: true }, + }); + + expect( + await (await rowOf(view, "notion")).findByText("Connected"), + ).toBeTruthy(); + await settleReactWork(); + expect( + requests.filter((request) => request.path.endsWith("/connection/confirm")), + ).toEqual([ + { path: "/api/plugins/servers/notion/connection/confirm", method: "POST" }, + ]); + expect(view.queryByRole("alert")).toBeNull(); +}); + +test("a brokered consent the vendor says did not finish is said on the step", async () => { + apps = [app({ provider: "composio", connect: "consent" })]; + confirmed = false; + const view = drawConnect({ + returned: { serverId: "notion", brokered: true }, + }); + + expect((await view.findByRole("alert")).textContent).toBe( + "Couldn't connect Notion. Try again.", + ); +}); + +test("an OAuth return is not asked of a vendor: the callback already recorded it", async () => { + apps = [app({ state: "ready" })]; + const view = drawConnect({ + returned: { serverId: "notion", brokered: false }, + }); + + await (await rowOf(view, "notion")).findByText("Connected"); + await settleReactWork(); + expect( + requests.some((request) => request.path.endsWith("/connection/confirm")), + ).toBe(false); +}); + +test("a consent that came back failed is said on the step", async () => { + const view = drawConnect({ + failed: true, + returned: { serverId: "notion", brokered: false }, + }); + + await view.findByRole("list", { name: "Apps" }); + expect((await view.findByRole("alert")).textContent).toBe( + "Couldn't connect Notion. Try again.", + ); +}); + +test("a list that could not be read says so, and never claims Connected", async () => { + apps = "error"; + const view = drawConnect(); + + expect( + await view.findByText( + "Couldn't load your apps. Continue, and connect them later in Connections.", + ), + ).toBeTruthy(); + expect(view.queryByText("Connected")).toBeNull(); +}); + +test("no apps to connect says how they arrive", async () => { + apps = []; + const view = drawConnect(); + + expect(await view.findByText("No apps to connect yet")).toBeTruthy(); +}); + +/** Get notified between two plain steps, in the real frame. */ +function NotifyOnboarding() { + const notify = useNotifyStep("General Assistant"); + return ( + , + }, + notify, + { + id: "setup", + title: "Check your setup", + intro: () => , + }, + ]} + /> + ); +} + +test("Get notified previews the notification the app really sends", () => { + const view = render(); + + expect(view.getByRole("heading", { name: "Get notified" })).toBeTruthy(); + const preview = notificationPreview("General Assistant"); + expect(preview).toEqual({ + title: "General Assistant needs you", + body: "1 question, 1 approval", + }); + expect( + view.getByRole("img", { + name: `A notification from OpenBot: ${preview.title}. ${preview.body}.`, + }), + ).toBeTruthy(); + expect(view.getByText(preview.title)).toBeTruthy(); + expect( + view.getByText( + "Notifications show while OpenBot is open in a browser tab.", + ), + ).toBeTruthy(); +}); + +test("Turn on notifications asks the browser, reads Notifications on, then moves on", async () => { + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Turn on notifications" })); + + const on = await view.findByRole("button", { name: "Notifications on" }); + expect(on.hasAttribute("data-done")).toBe(true); + expect(asked).toBe(1); + expect(view.queryByRole("button", { name: "Not now" })).toBeNull(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, GRANTED_BEAT_MS + 50)); + }); + expect( + await view.findByRole("heading", { name: "Check your setup" }), + ).toBeTruthy(); +}); + +test("while the browser's prompt is up, the button waits, and can't be pressed twice", async () => { + let answer: (value: NotificationPermission) => void = () => {}; + (globalThis as { Notification?: unknown }).Notification = { + get permission() { + return permission; + }, + requestPermission: () => + new Promise((resolve) => { + asked += 1; + answer = (value) => { + permission = value; + resolve(value); + }; + }), + }; + refreshNotificationPermission(); + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Turn on notifications" })); + const waiting = await waitFor(() => { + const button = view.container.querySelector( + "[data-onboarding-primary][data-pending]", + ); + if (!button) throw new Error("not waiting yet"); + return button; + }); + expect(waiting.disabled).toBe(true); + fireEvent.click(waiting); + expect(asked).toBe(1); + + await act(async () => answer("granted")); + expect( + await view.findByRole("button", { name: "Notifications on" }), + ).toBeTruthy(); +}); + +test("a prompt closed without an answer says where to turn them on, and offers Continue", async () => { + prompted = "default"; + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Turn on notifications" })); + + const why = await view.findByText( + "You can turn them on later from a Bot's page.", + ); + // Said just above the button, in the frame's footer, where a small screen can't scroll it away. + expect(why.closest("footer")).not.toBeNull(); + expect(view.queryByRole("button", { name: "Not now" })).toBeNull(); + // Not now's place is kept, so Continue stays where Turn on notifications was. + expect( + view.container.querySelector("footer [data-onboarding-secondary-slot]"), + ).not.toBeNull(); + fireEvent.click(view.getByRole("button", { name: "Continue" })); + expect( + await view.findByRole("heading", { name: "Check your setup" }), + ).toBeTruthy(); +}); + +test("Not now moves on and asks the browser nothing", async () => { + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Not now" })); + + expect( + await view.findByRole("heading", { name: "Check your setup" }), + ).toBeTruthy(); + expect(asked).toBe(0); +}); + +test("a no from the browser says where to allow them, and the button becomes Continue", async () => { + prompted = "denied"; + const view = render(); + + fireEvent.click(view.getByRole("button", { name: "Turn on notifications" })); + + expect( + await view.findByText( + "Notifications are blocked for this site. Allow them in your browser's site settings to turn them on.", + ), + ).toBeTruthy(); + fireEvent.click(view.getByRole("button", { name: "Continue" })); + expect( + await view.findByRole("heading", { name: "Check your setup" }), + ).toBeTruthy(); +}); + +test("a browser with no notifications says so, and offers Continue", () => { + (globalThis as { Notification?: unknown }).Notification = undefined; + refreshNotificationPermission(); + const view = render(); + + expect(view.getByText("This browser can't show notifications.")).toBeTruthy(); + expect(view.getByRole("button", { name: "Continue" })).toBeTruthy(); + expect(view.queryByRole("button", { name: "Not now" })).toBeNull(); +}); diff --git a/app/tests/setup-checklist.test.tsx b/app/tests/setup-checklist.test.tsx new file mode 100644 index 0000000000..af9b1e0727 --- /dev/null +++ b/app/tests/setup-checklist.test.tsx @@ -0,0 +1,342 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import { StepIntro } from "@/components/onboarding/onboarding-frame"; +import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard"; +import { SetupChecklist } from "@/components/onboarding/setup-checklist"; +import type { + SetupChecklist as Checklist, + SetupRow, +} from "@/lib/setup/queries"; +import { queryClient } from "@/query-client"; +import { settleReactWork } from "./settle-react-work"; + +/** + * The setup checklist onboarding's "Check your setup" step draws. It draws what the server decided, + * row by row, and lets somebody check again and open the page that closes a gap. + */ + +beforeAll(() => GlobalRegistrator.register({ url: "http://localhost:3010" })); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const originalFetch = globalThis.fetch; +let answer: () => Response; +/** What `POST /api/setup/check` does before the read it causes; nothing by default. */ +let onCheck: (() => void) | null; +let requests: { path: string; method: string }[]; +const defaults = queryClient.getDefaultOptions(); + +beforeEach(() => { + queryClient.clear(); + queryClient.setDefaultOptions({ + ...defaults, + queries: { ...defaults.queries, retry: false }, + }); + requests = []; + onCheck = null; + globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + const method = init?.method ?? "GET"; + requests.push({ path, method }); + if (path === "/api/setup/check") onCheck?.(); + return answer(); + }, + { preconnect: originalFetch.preconnect }, + ); +}); +afterEach(() => { + cleanup(); + queryClient.clear(); + queryClient.setDefaultOptions(defaults); + globalThis.fetch = originalFetch; +}); + +const ROW = (row: Partial & Pick): SetupRow => ({ + scope: "deployment", + title: row.id, + required: false, + status: row.ready === false ? "needs_setup" : "ready", + ready: true, + detail: "Works.", + fix: null, + ...row, +}); + +/** What an admin is sent: Intelligence ready, the model key missing, no computer, Slack unset. */ +const ADMIN: Checklist = { + audience: "admin", + rows: [ + ROW({ + id: "intelligence", + title: "CopilotKit Intelligence", + required: true, + detail: "Keeps your conversations and what OpenBot remembers.", + }), + ROW({ + id: "model", + title: "AI model", + required: true, + ready: false, + detail: "Without a model key, OpenBot can't answer.", + fix: { + kind: "env", + names: ["OPENAI_API_KEY"], + docsUrl: "https://github.com/CopilotKit/openbot#quick-start", + page: { label: "Credentials", path: "/admin/credentials" }, + }, + }), + ROW({ + id: "computer", + title: "Bot computer", + ready: false, + detail: "Without it, Bots can't browse the web or work with files.", + fix: { + kind: "env", + names: ["AGENT_COMPUTER_URL", "COMPUTER_TOKEN"], + docsUrl: + "https://github.com/CopilotKit/openbot/blob/main/docs/configuration.md#computer-and-supervisor", + }, + }), + ROW({ id: "apps", title: "More apps" }), + ROW({ + id: "chat", + title: "Slack and Teams", + ready: false, + detail: "Without it, people can talk to OpenBot only here.", + fix: { + kind: "env", + names: ["OPENTAG_SHARED_SECRET", "OPENTAG_URL"], + docsUrl: + "https://github.com/CopilotKit/openbot/blob/main/docs/configuration.md", + }, + }), + ], +}; + +/** What a member is sent for the same deployment: no fix, and no names. */ +const MEMBER: Checklist = { + audience: "member", + rows: ADMIN.rows.map((row) => + row.ready ? row : { ...row, detail: "Your admin sets this up.", fix: null }, + ), +}; + +function serving(checklist: Checklist) { + answer = () => Response.json({ checklist }); +} + +function draw() { + const opened: Array<{ label: string; path: string }> = []; + const view = render( + + opened.push(target)} /> + , + ); + return Object.assign(view, { opened }); +} + +const rowOf = (view: ReturnType, id: string) => { + const row = view.container.querySelector( + `[data-setup-row="${id}"]`, + ); + if (!row) throw new Error(`no ${id} row`); + return within(row); +}; + +test("Ready and Needs setup rows come from the server's answer", async () => { + serving(ADMIN); + const view = draw(); + + await view.findByRole("list", { name: "Setup" }); + expect( + Array.from(view.container.querySelectorAll("[data-setup-row]")).map((row) => + row.getAttribute("data-setup-row"), + ), + ).toEqual(["intelligence", "model", "computer", "apps", "chat"]); + expect(rowOf(view, "intelligence").getByText("Ready")).not.toBeNull(); + expect(rowOf(view, "model").getByText("Needs setup")).not.toBeNull(); + expect(rowOf(view, "model").getByText("Required")).not.toBeNull(); + expect( + rowOf(view, "model").getByText( + "Without a model key, OpenBot can't answer.", + ), + ).not.toBeNull(); + expect( + view.getByText("3 things need setup, and 1 is required."), + ).not.toBeNull(); +}); + +test("a required gap is red, an optional one amber, and Ready green", async () => { + serving(ADMIN); + const view = draw(); + await view.findByRole("list", { name: "Setup" }); + + expect(rowOf(view, "model").getByText("Needs setup").className).toContain( + "text-status-problem", + ); + expect(rowOf(view, "chat").getByText("Needs setup").className).toContain( + "text-status-needs", + ); + expect(rowOf(view, "apps").getByText("Ready").className).toContain( + "text-status-done", + ); +}); + +test("an admin is shown the variable to set, the page, and the guide", async () => { + serving(ADMIN); + const view = draw(); + await view.findByRole("list", { name: "Setup" }); + + const model = rowOf(view, "model"); + expect(model.getByText("OPENAI_API_KEY")).not.toBeNull(); + const guide = model.getByRole("link", { name: "Setup guide" }); + expect(guide.getAttribute("href")).toBe( + "https://github.com/CopilotKit/openbot#quick-start", + ); + expect(guide.getAttribute("target")).toBe("_blank"); + + fireEvent.click(model.getByRole("link", { name: "Credentials" })); + expect(view.opened).toEqual([ + { label: "Credentials", path: "/admin/credentials" }, + ]); + + expect(rowOf(view, "chat").getByText("OPENTAG_SHARED_SECRET")).not.toBeNull(); +}); + +test("a member sees their admin sets it up, and no variable names", async () => { + serving(MEMBER); + const view = draw(); + await view.findByRole("list", { name: "Setup" }); + + const model = rowOf(view, "model"); + expect(model.getByText("Your admin sets this up.")).not.toBeNull(); + expect(model.getByText("Needs setup")).not.toBeNull(); + expect(view.container.textContent).not.toContain("OPENAI_API_KEY"); + expect(view.container.textContent).not.toContain("OPENTAG"); + expect(view.queryByRole("link", { name: "Setup guide" })).toBeNull(); + expect(view.queryByRole("link")).toBeNull(); +}); + +test("Check again asks the server to check again, and draws its answer", async () => { + serving(ADMIN); + const view = draw(); + await view.findByRole("list", { name: "Setup" }); + + onCheck = () => { + serving({ + audience: "admin", + rows: ADMIN.rows.map((row) => ({ + ...row, + status: "ready", + ready: true, + fix: null, + })), + }); + }; + fireEvent.click(view.getByRole("button", { name: "Check again" })); + + expect(await view.findByText("Everything is set up.")).not.toBeNull(); + expect(requests).toContainEqual({ path: "/api/setup/check", method: "POST" }); +}); + +test("a key nobody has checked reads Not checked, never Ready", async () => { + serving({ + audience: "admin", + rows: ADMIN.rows.map((row) => + row.id === "model" + ? { + ...row, + status: "not_checked", + ready: false, + detail: "Key set, not checked yet. Check again to ask OpenAI.", + fix: null, + } + : { ...row, status: "ready", ready: true, fix: null }, + ), + }); + const view = draw(); + await view.findByRole("list", { name: "Setup" }); + + const model = rowOf(view, "model"); + expect(model.getByText("Not checked")).not.toBeNull(); + expect(model.queryByText("Ready")).toBeNull(); + expect(view.getByText("1 thing isn't checked yet.")).not.toBeNull(); +}); + +test("optional gaps alone say OpenBot works without them", async () => { + serving({ + audience: "admin", + rows: ADMIN.rows.map((row) => + row.required ? { ...row, status: "ready", ready: true, fix: null } : row, + ), + }); + const view = draw(); + + expect( + await view.findByText("2 things need setup. OpenBot works without them."), + ).not.toBeNull(); +}); + +test("a check that fails says so, and Check again stays", async () => { + answer = () => Response.json({ error: "nope" }, { status: 503 }); + const view = draw(); + + expect( + await view.findByText( + "Couldn't check your setup. Check again, or carry on and look later.", + ), + ).not.toBeNull(); + expect(view.getByRole("button", { name: "Check again" })).not.toBeNull(); +}); + +test("moving on works with a required gap still open", async () => { + serving(ADMIN); + const view = render( + + , + demo: () => {}} />, + }, + { + id: "name", + title: "Your name", + intro: () => , + }, + ]} + /> + , + ); + + await view.findByText("3 things need setup, and 1 is required."); + const next = view.getByRole("button", { name: "Continue" }); + expect((next as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(next); + await waitFor(() => + expect( + view.getByRole("heading", { name: "Let's get to it." }), + ).toBeTruthy(), + ); +}); From c6d4c262e2df85ef0594666bca195aa7f861893b Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:14:44 -0700 Subject: [PATCH 18/47] feat(onboarding): first run is a set of steps, not a looping intro First run is now welcome, a browser and files of its own, the team of Bots, asking before it acts, running it yourself (only for somebody trying OpenBot alone), connect apps, get notified, check your setup, and the name step. Where somebody is lives in the address and is saved on every move, so a reload, browser Back and a consent's return keep their place. The name step stays last, under the orb: Get started saves the name, completes onboarding and plays the orb's finish, and a checklist link carries its page through it, since nothing else opens until onboarding is done. --- .../components/onboarding/intro-animation.tsx | 51 - app/src/components/settings/profile.tsx | 65 +- app/src/routes/_authed/onboarding.tsx | 947 ++++++++++-------- app/tests/onboarding-route.test.tsx | 653 ++++++++++++ app/tests/profile.test.tsx | 14 + 5 files changed, 1263 insertions(+), 467 deletions(-) delete mode 100644 app/src/components/onboarding/intro-animation.tsx create mode 100644 app/tests/onboarding-route.test.tsx diff --git a/app/src/components/onboarding/intro-animation.tsx b/app/src/components/onboarding/intro-animation.tsx deleted file mode 100644 index 2ed60fa3b1..0000000000 --- a/app/src/components/onboarding/intro-animation.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { type ReactNode, useEffect, useState } from "react"; - -export type IntroScene = { id: string; label: string; content: ReactNode }; - -/** A looping introduction instead of more wizard steps. */ -export function IntroAnimation({ scenes }: { scenes: IntroScene[] }) { - const reducedMotion = useReducedMotion(); - const [scene, setScene] = useState(0); - useEffect(() => { - if (reducedMotion || scenes.length < 2) return; - const timer = window.setInterval( - () => setScene((current) => (current + 1) % scenes.length), - 4000, - ); - return () => window.clearInterval(timer); - }, [reducedMotion, scenes.length]); - - // Keep every scene available without auto-updating content when motion is reduced. - if (reducedMotion) { - return ( -
- {scenes.map((item) => ( -
{item.content}
- ))} -
- ); - } - - return ( -
-
- - - {scenes[scene]?.content} - - -
-
- ); -} diff --git a/app/src/components/settings/profile.tsx b/app/src/components/settings/profile.tsx index 3f4069632d..d26a6d1b55 100644 --- a/app/src/components/settings/profile.tsx +++ b/app/src/components/settings/profile.tsx @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useId, useState } from "react"; -import AgentOrb from "@/components/agents/orb/agent-orb"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; import { PageSection } from "@/components/layout/page-shell"; +import { StepIntro } from "@/components/onboarding/onboarding-frame"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { authKeys, currentUserQueryOptions } from "@/lib/auth/queries"; @@ -43,7 +44,9 @@ export function ProfileNameForm({ if (name.trim() && !save.isPending) save.mutate(name.trim()); }} > - + @@ -95,6 +103,12 @@ export function ProfileSettings() { ); } +/** + * Onboarding's last step, under the orb: the person's name, and Get started, which saves it. + * + * Its heading and form are the step's words; the orb above them is its picture, which the + * onboarding frame draws beside it on a desktop and above it on a phone ({@link OnboardingNameOrb}). + */ export function OnboardingNameStep({ onSaved, }: { @@ -102,19 +116,40 @@ export function OnboardingNameStep({ }) { const { data: user } = useQuery(currentUserQueryOptions()); return ( -
- -

- Let's get to it. -

-

+ + } + title="Let's get to it." + > +

What should we call you? You can change your name later in Settings.

- -
+
+ ); +} + +/** + * OpenBot's orb, listening while somebody types their name. `finished` plays its finish once: the + * check and its burst of rays, which is how onboarding ends. + */ +export function OnboardingNameOrb({ + finished = false, + size = 64, +}: { + finished?: boolean; + size?: number; +}) { + return ( + + + ); } diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 93a0f87072..1220629e69 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -1,31 +1,188 @@ -import { WEB_HARNESSES } from "@openbot/shared/harnesses"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; -import { AnimatePresence, MotionConfig, motion } from "motion/react"; +import { + createFileRoute, + redirect, + useLocation, + useNavigate, + useRouter, +} from "@tanstack/react-router"; +import { motion, useReducedMotion } from "motion/react"; import * as React from "react"; -import useMeasure from "react-use-measure"; -import { AbstractAvatar } from "@/components/agents/abstract-avatar"; -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 { FINISH_MS } from "@/components/agents/orb/bot-orb"; import { SELF_HOST_URL } from "@/components/layout/self-host-banner"; -import { IllustrationFrame } from "@/components/onboarding/illustration-frame"; -import { IntroAnimation } from "@/components/onboarding/intro-animation"; -import { LaptopIllustration } from "@/components/onboarding/laptop-illustration"; -import { OrganizationFlow } from "@/components/onboarding/organization-flow"; -import { OnboardingNameStep } from "@/components/settings/profile"; +import { + type ConsentApp, + ConnectAppsStep, +} from "@/components/onboarding/connect-apps-step"; +import { ApprovalDemo } from "@/components/onboarding/demos/approval-demo"; +import { ComputerDemo } from "@/components/onboarding/demos/computer-demo"; +import { SelfHostDemo } from "@/components/onboarding/demos/self-host-demo"; +import { TeamDemo } from "@/components/onboarding/demos/team-demo"; +import { WelcomeDemo } from "@/components/onboarding/demos/welcome-demo"; +import { useNotifyStep } from "@/components/onboarding/notify-step"; +import { StepIntro } from "@/components/onboarding/onboarding-frame"; +import { + type OnboardingStep, + OnboardingWizard, +} from "@/components/onboarding/onboarding-wizard"; +import { SetupChecklist } from "@/components/onboarding/setup-checklist"; +import { + OnboardingNameOrb, + OnboardingNameStep, +} from "@/components/settings/profile"; import { Button, buttonVariants } from "@/components/ui/button"; -import { type AgentProfile, agentListQueryOptions } from "@/lib/agents/queries"; +import { defaultAgentProfile } from "@/lib/agents/default-agent"; +import { 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 { + advanceOnboardingMutationOptions, + completeOnboardingMutationOptions, +} from "@/lib/onboarding/mutations"; +import { setupChecklistQueryOptions } from "@/lib/setup/queries"; import { cn } from "@/lib/utils"; import { queryClient } from "@/query-client"; +const PRODUCT = appConfig.brand.productName; + +/** + * Every step, by id, with the number the server stores for it. + * + * `POST /api/me/onboarding` keeps a step as an integer, so each id has a number of its own. A + * number per id, never a position in the list: the self-hosting step joins the list only for + * somebody trying OpenBot alone, and a position would mean a different step to them than to + * everybody else. 0 is the server's "not started". + */ +const STEP_NUMBER = { + welcome: 1, + computer: 2, + team: 3, + approval: 4, + selfhost: 5, + apps: 6, + notify: 7, + setup: 8, + name: 9, +} as const; +type StepId = keyof typeof STEP_NUMBER; + +const isStepId = (value: unknown): value is StepId => + typeof value === "string" && Object.hasOwn(STEP_NUMBER, value); + +/** The step a stored number names, or null for "not started" and for a number no step has. */ +function stepForNumber(step: number | undefined): StepId | null { + const found = Object.entries(STEP_NUMBER).find(([, at]) => at === step); + return found ? (found[0] as StepId) : null; +} + +/** + * A page of this app, and nothing else: a path, never another origin, a scheme, a query or a walk + * upward. What a checklist link opens once onboarding is done, carried in the address as `?open=` + * so a reload on the name step keeps it. + */ +const isAppPath = (value: unknown): value is string => + typeof value === "string" && + /^\/(?!\/)[\w/-]*$/.test(value) && + !value.includes(".."); + +/** + * The step Continue was pressed on, kept in the history entry it pushed (`onboardingFrom`). Kept in + * the entry rather than in memory, so it survives a reload: Back after a reload still pops the + * entry instead of adding a second one for the same step. + */ +type OnboardingEntry = { onboardingFrom?: unknown }; + +/** + * A consent in flight: the step it left from, the history entry it left, and the app it was for, + * in this tab's session storage so it survives the trip to the vendor and back. + */ +const CONSENT_KEY = "openbot-onboarding-consent"; +type ConsentDeparture = { + step: StepId; + entry: string | null; + from?: StepId; + app?: ConsentApp; +}; + +/** The key TanStack Router gives the current history entry, or null. */ +function currentEntryKey(): string | null { + const state = window.history.state as { __TSR_key?: unknown } | null; + return typeof state?.__TSR_key === "string" ? state.__TSR_key : null; +} + +/** Leaves for a vendor's consent from a step, noting where from and for which app. */ +function leaveForConsent(step: StepId, url: string, app: ConsentApp) { + try { + sessionStorage.setItem( + CONSENT_KEY, + JSON.stringify({ + step, + entry: currentEntryKey(), + from: (window.history.state as OnboardingEntry | null)?.onboardingFrom, + app, + }), + ); + } catch { + // Storage refused: the return still lands on the step, Back just isn't rewritten. + } + window.location.assign(url); +} + +/** The consent this tab left for, if there is one. Read only; dropped once it has been used. */ +function readConsentDeparture(): ConsentDeparture | null { + try { + const raw = sessionStorage.getItem(CONSENT_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { + step?: unknown; + entry?: unknown; + from?: unknown; + app?: { serverId?: unknown; brokered?: unknown }; + }; + if (!isStepId(parsed.step)) return null; + return { + step: parsed.step, + entry: typeof parsed.entry === "string" ? parsed.entry : null, + from: isStepId(parsed.from) ? parsed.from : undefined, + app: + typeof parsed.app?.serverId === "string" + ? { + serverId: parsed.app.serverId, + brokered: parsed.app.brokered === true, + } + : undefined, + }; + } catch { + return null; + } +} + +function dropConsentDeparture() { + try { + sessionStorage.removeItem(CONSENT_KEY); + } catch { + // Nothing to drop. + } +} + export const Route = createFileRoute("/_authed/onboarding")({ + /* + * WHERE SOMEBODY IS LIVES IN THE ADDRESS. `?step=` is the step on screen, so a reload stays + * on it, browser Back and Forward walk the steps, and a consent started on a step comes back to + * it (`?step=apps`; see `connectedAccountsUrlFor` on the server). `?connected=failed` says that + * consent did not go through, and is dropped once read. `?open=` is the page a checklist link + * asked for, opened once the name step finishes. Anything else is ignored. + */ + validateSearch: ( + search: Record, + ): { step?: StepId; connected?: "failed"; open?: string } => ({ + // Named even when absent: the router lays this over the raw search, so a key left out would + // keep whatever was typed into the address. + step: isStepId(search.step) ? search.step : undefined, + connected: search.connected === "failed" ? "failed" : undefined, + open: isAppPath(search.open) ? search.open : undefined, + }), beforeLoad: async ({ context }) => { const user = await context.queryClient.ensureQueryData( currentUserQueryOptions(), @@ -38,429 +195,417 @@ export const Route = createFileRoute("/_authed/onboarding")({ component: RouteComponent, }); -function WelcomeStep() { - return ( -
-

- Welcome to {appConfig.brand.productName} -

-
- - - {/* - * 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. - */} -
- -
-
-
- ); -} - -function ComputerUseStep() { - return ( -
-

- Each Bot has its own browser and files -

-
-
- - -
-
- ); -} - -/** What a roster card needs — placeholders carry these three fields and nothing more. */ -type RosterCard = Pick; +const welcome: OnboardingStep = { + id: "welcome", + title: "Welcome", + intro: () => ( + +

+ Ask for what you need in plain words. A Bot takes it from there and + tells you when it's done. +

+
+ ), + demo: () => , +}; -/** - * Stand-ins for a deployment that has fewer than three public agents to show. Invented names on - * purpose: they illustrate what a roster looks like without claiming any of these exist here. - */ -const AGENTS_PLACEHOLDER: RosterCard[] = [ - { - id: "placeholder-research", - name: "Research Analyst", - avatarSeed: "research-analyst", - }, - { id: "placeholder-data", name: "Data Analyst", avatarSeed: "data-analyst" }, - { - id: "placeholder-support", - name: "Support Desk", - avatarSeed: "support-agent", - }, -]; +const computer: OnboardingStep = { + id: "computer", + title: "A browser and files of its own", + intro: () => ( + +

+ It opens a browser, reads pages and works with files on a computer + OpenBot runs for you, so yours stays yours. +

+
+ ), + demo: () => , +}; -function RosterStep() { - const { data: agents } = useQuery(agentListQueryOptions()); - const explore = - agents?.filter((a) => !a.mine && a.visibility === "public") ?? []; - // Always three cards: real public agents first, placeholders topping up a sparse deployment. - // slice past the end is just [], so a roster of three or more takes no placeholders at all. - const roster: Array = [ - ...explore.slice(0, 3), - ...AGENTS_PLACEHOLDER.slice(explore.length).map((placeholder) => ({ - ...placeholder, - example: true, - })), - ]; +const team: OnboardingStep = { + id: "team", + title: "Your team of Bots", + intro: () => ( + +

+ Each Bot has its own job. Hand work to the one that fits, or make a new + one whenever you need it. +

+
+ ), + demo: () => , +}; - return ( -
-

- Pick a Bot or make your own -

-
- a.name).join(", ")}, and one of your own`} - > - {roster.map((a) => { - return ( -
- -
-

- {a.name} -

- {a.example ? ( - Example - ) : null} -
-
- ); - })} -
-
-

- Your own Bot -

-
- -
- ); -} +const approval: OnboardingStep = { + id: "approval", + title: "It asks first", + intro: () => ( + +

+ Before a Bot sends, buys or deletes anything, it asks you. Allow it + once, or always allow it here. +

+
+ ), + demo: () => , +}; /* - * These local-evaluation scenes are for somebody trying OpenBot on their own machine - * (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. + * THE SELF-HOSTING STEP IS FOR SOMEBODY TRYING OPENBOT ON THEIR OWN MACHINE, and only they see it + * (see `localEvaluation` in RouteComponent). It answers what that person is deciding: where their + * data is, whether their own agents fit, and what taking it to their organization involves. None of + * it is said to an organization's own people, who are past all three. */ - -function LocalStep() { - return ( -
-

- It runs on your machine -

+const selfhost: OnboardingStep = { + id: "selfhost", + title: "Running it yourself", + intro: () => ( + + Talk to an engineer + + } + title="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. + * Said as it is: files and Bot settings stay here, but every request goes to the model + * provider this install was set up with, and Intelligence keeps the conversations. */} -

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

+ Files and Bot settings stay on this computer. Requests go to your model + provider, and CopilotKit Intelligence keeps your conversations and + memory.

-
- - - -
- ); -} +

+ Any AG-UI agent can join as a Bot. For your organization, run one + container image or a Helm chart. +

+
+ ), + demo: () => , +}; -/** 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 RouteComponent() { + const navigate = useNavigate(); + const router = useRouter(); + const location = useLocation(); + const search = Route.useSearch(); + const complete = useMutation(completeOnboardingMutationOptions(queryClient)); + const advance = useMutation(advanceOnboardingMutationOptions(queryClient)); + const me = useQuery(currentUserQueryOptions()); -function AgentsStep() { - return ( -
-

- Bring the agents you already have -

-

- Any AG-UI agent can join as a Bot. Add one from Bots, then New Bot. -

-
- harness.name).join(", ")}`} - > - {FRAMEWORKS.map((harness) => ( - - - {harness.name} - - ))} - -
+ // A failed consent is told once, on the step it came back to: not after a reload, and not after + // the person has moved on and come back. + const [failedOn, setFailedOn] = React.useState(() => + search.connected === "failed" && isStepId(search.step) ? search.step : null, ); -} -function OrganizationStep() { - return ( -
-

- Deploy it for your organization -

-

- Run one container image, or a Helm chart for EKS, GKE or AKS. Your - people sign in with 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 - -
+ /* + * BACK FROM A CONSENT. Leaving for a vendor's consent leaves its page in this tab's history, so + * browser Back right after the return used to land on the vendor rather than on the step before. + * The browser keeps that entry whatever this page does, so the return rewrites the entries after + * it: the one it arrived on becomes the step before, and the step itself is pushed on top, from + * it. Back then walks to the step before, as it would had the person pressed Continue to get + * here, and the vendor's page is one further back. Not on a return to the very entry that left, + * which is the person pressing Back on the vendor's page rather than finishing there. + */ + const [departure] = React.useState(() => readConsentDeparture()); + const [returned, setReturned] = React.useState(() => { + if (!departure || departure.step !== search.step) return null; + return departure.entry !== null && departure.entry === currentEntryKey() + ? null + : departure.step; + }); + // The app that consent was for, which the apps step asks the vendor about, once. + const [returnedApp] = React.useState(() => + departure && departure.step === search.step + ? (departure.app ?? null) + : null, ); -} -function Introduction({ localEvaluation }: { localEvaluation: boolean }) { - return ( - }, - { - id: "computer", - label: "Each Bot has its own browser and files", - content: , - }, - { - id: "roster", - label: "Pick a Bot or make your own", - content: , - }, - ...(localEvaluation - ? [ - { - id: "local", - label: "It runs on your machine", - content: , - }, - { - id: "agents", - label: "Bring the agents you already have", - content: , - }, - ] - : []), - ]} - /> - ); -} + /* + * The step on screen: the address's, else the one the server saved, else the first. Arriving + * without one (from the gate's redirect, say) writes it into the address in place, so the entry + * the person lands on already names its step. `connected` is dropped the same way, and only it. + */ + const saved = stepForNumber(me.data?.onboarding?.step); + const asked = isStepId(search.step) ? search.step : undefined; + const current: StepId = asked ?? saved ?? "welcome"; + React.useEffect(() => { + if (returned !== null) return; + if (asked !== undefined && search.connected === undefined) return; + navigate({ + to: "/onboarding", + search: { step: current, open: search.open }, + replace: true, + // A consent rewrite may have just pushed this entry before the route caught up. + // Keep its origin when removing the one-shot outcome from the address. + state: router.history.location.state, + }); + }, [ + navigate, + current, + asked, + search.connected, + search.open, + returned, + router, + ]); -/** A pane arrives from the side the journey is moving toward, and leaves out the other. */ -const variants = { - initial: (direction: number) => ({ x: `${110 * direction}%`, opacity: 0 }), - active: { x: "0%", opacity: 1 }, - exit: (direction: number) => ({ x: `${-110 * direction}%`, opacity: 0 }), -}; + // Saved wherever the person goes, by Continue, Back, the browser or a consent's return. + const savedStep = me.data?.onboarding?.step; + const { mutate: save } = advance; + React.useEffect(() => { + if (savedStep === undefined || savedStep === STEP_NUMBER[current]) return; + save(STEP_NUMBER[current]); + }, [current, savedStep, save]); -function RouteComponent() { - const navigate = useNavigate(); - const complete = useMutation(completeOnboardingMutationOptions(queryClient)); + /* + * CONTINUE PUSHES, AND BACK RETURNS THROUGH HISTORY WHEN IT CAN. Continue adds an entry carrying + * the step it was pressed on, so browser Back walks back a step. The frame's Back pops that entry + * when it was pushed from the step behind, and otherwise (arriving by a link, say) replaces this + * entry with the step behind, so history never holds the same step twice in a row. A page a + * checklist link asked for rides along to the name step, and only there. + */ + const cameFrom = (location.state as OnboardingEntry).onboardingFrom; + const move = ( + to: string, + way: "forward" | "back", + open: string | undefined = undefined, + ) => { + if (!isStepId(to)) return; + setFailedOn(null); + // Skip intro pushes Apps directly. Back follows that actual path, including after reload. + if ( + way === "back" && + (cameFrom === to || + (current === "apps" && + isStepId(cameFrom) && + STEP_NUMBER[cameFrom] < STEP_NUMBER.apps)) + ) { + router.history.back(); + return; + } + const state: Record = + way === "forward" ? { onboardingFrom: current } : {}; + navigate({ + to: "/onboarding", + search: { step: to, open: to === "name" ? open : undefined }, + replace: way === "back", + state, + }); + }; - // Browser state on purpose: the step is not persisted while the wizard is being designed. - const [step, setStep] = React.useState<"intro" | "organization" | "name">( - "intro", - ); - const [direction, setDirection] = React.useState(1); - // The way out: set once the completion is saved, it fades the whole page and then navigates. - const [leaving, setLeaving] = React.useState(false); - const [ref, bounds] = useMeasure(); + /* + * THE WAY OUT IS THE NAME STEP'S GET STARTED. It saves the name, then completion is saved, then + * the orb plays its finish, the page fades, and the app opens: home, or the page a checklist link + * asked for. The promise Get started waits on lasts until the fade, so its button says Saving… + * and stays pressed the whole way out. A completion that fails is said by the form, under it. + */ + const reduced = useReducedMotion() === true; + const [finished, setFinished] = React.useState(false); + const [leaving, setLeaving] = React.useState(null); + const finish = async () => { + await complete.mutateAsync(undefined); + setFinished(true); + await new Promise((resolve) => + setTimeout(resolve, reduced ? 150 : FINISH_MS), + ); + setLeaving(search.open ?? "/"); + }; + React.useEffect(() => { + if (leaving === null) return; + // Navigation must also complete in a background tab, where animation frames may pause. + const timer = setTimeout( + () => navigate({ to: leaving as "/" }), + reduced ? 0 : 300, + ); + return () => clearTimeout(timer); + }, [leaving, navigate, reduced]); /* - * Local-evaluation content is never shown to 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 they are left out, so a slow or failed request never - * shows an organization's employees a page about deploying OpenBot themselves. + * The self-hosting step, after the intro, 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 it is left out, and + * the intro's Continue waits for it, so a slow or failed request never shows an organization's + * employees a page about deploying OpenBot themselves, and an evaluator never walks past theirs. * * 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 these slides are for, would never see them. + * evaluation licence, so every CLI evaluator, the person this step is for, would never see it. */ const { data: capabilities, error: capabilitiesError, refetch: retryCapabilities, isFetching: loadingCapabilities, + isPending: askingCapabilities, } = useQuery(deploymentCapabilitiesQueryOptions()); - const localEvaluation = capabilities?.localEvaluation === true; - const last = step === "name"; + const { data: agents } = useQuery(agentListQueryOptions()); + const notify = useNotifyStep(defaultAgentProfile(agents)?.name ?? PRODUCT); + // The checklist's answer names the page a carried link opens, so the name step can say which. + const { data: checklist } = useQuery({ + ...setupChecklistQueryOptions(), + enabled: search.open !== undefined, + }); + const openLabel = checklist?.rows + .map((row) => + row.fix?.kind === "page" + ? row.fix + : row.fix?.kind === "env" + ? row.fix.page + : undefined, + ) + .find((page) => page?.path === search.open)?.label; - const go = (to: typeof step, forward: boolean) => { - setDirection(forward ? 1 : -1); - setStep(to); - }; + const capabilitiesFailed = + capabilitiesError && !capabilities ? ( +
+

+ Couldn't load your setup. Try again to continue. +

+ +
+ ) : undefined; + const intro: OnboardingStep[] = [ + welcome, + computer, + team, + approval, + ...(capabilities?.localEvaluation ? [selfhost] : []), + ].map((step) => ({ + ...step, + primaryDisabled: !capabilities, + notice: capabilitiesFailed, + secondary: { label: "Skip intro", onClick: () => move("apps", "forward") }, + })); + const steps: OnboardingStep[] = [ + ...intro, + /* + * Two optional steps, before the checklist. Continue and Not now both move on, and Not now + * writes nothing; the checklist after them says what is left to set up. + */ + { + id: "apps", + title: "Connect apps", + intro: () => ( + +

+ Connect the accounts your Bots use as you. You can connect more + later in Connections. +

+
+ ), + demo: () => ( + leaveForConsent("apps", url, app)} + returned={returnedApp} + /> + ), + secondary: { label: "Not now" }, + optional: true, + }, + notify, + { + id: "setup", + title: "Check your setup", + intro: () => ( + +

See what's ready and what still needs setting up.

+
+ ), + demo: () => ( + move("name", "forward", target.path)} + /> + ), + }, + { + id: "name", + title: "Your name", + intro: () => , + demo: () => , + demoFirst: true, + ownAction: true, + notice: openLabel ? ( +

+ {openLabel} opens once you get started. +

+ ) : undefined, + }, + ]; + + // A self-hosting step in the address waits for the answer that puts it in the list, rather + // than drawing step 1 for a moment and then jumping. + const waiting = askingCapabilities && current === "selfhost"; + + // The rewrite described at `returned`, once the list of steps is known. The order as one string, + // so the effect runs when the list changes rather than on every render that rebuilds it. + const order = steps.map((step) => step.id).join(" "); + React.useEffect(() => { + if (returned === null || askingCapabilities) return; + dropConsentDeparture(); + setReturned(null); + const ids = order.split(" "); + const at = ids.indexOf(returned); + const before = departure?.from ?? ids[at - 1]; + if (at === -1 || !isStepId(before)) return; + /* + * Through the history itself, flushed between the two: the router queues history writes and + * folds a replace followed by a push into one push, which left the arrival entry as it was. + */ + const href = (step: StepId) => + router.buildLocation({ to: "/onboarding", search: { step } }).href; + router.history.replace(href(before), {}); + router.history.flush(); + const state: Record = { onboardingFrom: before }; + router.history.push(href(returned), state); + }, [returned, askingCapabilities, order, router, departure]); return ( // Outside `_app` on purpose: no sidebar and no chrome until onboarding is done. // The fade runs only after the completion is saved, so a failed save never fades a page the - // person still needs — and navigation waits for the fade, so the home screen never pops in - // over a half-faded wizard. + // person still needs, and navigation waits for the fade, so the next screen never pops in over + // a half-faded onboarding. { - if (leaving) { - navigate({ to: "/" }); - } - }} - transition={{ duration: 0.5, ease: "easeInOut" }} + transition={{ duration: reduced ? 0 : 0.3, ease: "easeOut" }} > -
- - {/* The frame follows each pane's height, so the buttons glide instead of jumping. */} - 0 ? bounds.height : "auto" }} - className="overflow-hidden" - > -
- - - {step === "intro" && ( - - )} - {step === "organization" && } - {step === "name" && ( - { - await complete.mutateAsync(undefined); - setLeaving(true); - }} - /> - )} - - - - {capabilitiesError && !capabilities ? ( -
-

- We couldn't load your setup. Try again to continue. -

- -
- ) : null} - - {complete.error ? ( -

- {complete.error.message} -

- ) : null} - - - {!last && ( - - )} - {step !== "intro" && ( - - )} - -
-
-
-
+ {waiting ? null : ( + move(to, way, search.open)} + stepId={current} + steps={steps} + /> + )}
); } diff --git a/app/tests/onboarding-route.test.tsx b/app/tests/onboarding-route.test.tsx new file mode 100644 index 0000000000..131e3a9762 --- /dev/null +++ b/app/tests/onboarding-route.test.tsx @@ -0,0 +1,653 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { refreshNotificationPermission } from "@/lib/notifications/permission"; +import { queryClient } from "@/query-client"; +import type { RouterContext } from "@/router-context"; +import { Route as OnboardingRoute } from "@/routes/_authed/onboarding"; +import { settleReactWork } from "./settle-react-work"; + +/** + * Where somebody is in onboarding lives in the address: `?step=`. A reload stays on the step, + * browser Back walks back one, a consent comes back to the step it left from, and every move is + * saved, so arriving without a step opens the one they were on. The name step is the way out: Get + * started saves the name, completes onboarding, plays the orb's finish and opens the app. + */ + +beforeAll(() => GlobalRegistrator.register({ url: "http://localhost:3010" })); +afterAll(async () => { + await settleReactWork(); + GlobalRegistrator.unregister(); +}); + +const BOT = { + id: "general", + name: "General Assistant", + title: "Everyday work", + roleDescription: "", + avatarSeed: "general", + visibility: "private", + endpoint: null, + builtIn: true, + hasAuth: false, + mine: false, +}; + +const CHECKLIST = { + audience: "admin", + rows: [ + { + id: "model", + scope: "deployment", + title: "AI model", + required: true, + status: "needs_setup", + ready: false, + detail: "Without a model key, OpenBot can't answer.", + fix: { + kind: "env", + names: ["OPENAI_API_KEY"], + docsUrl: "https://github.com/CopilotKit/openbot#quick-start", + page: { label: "Credentials", path: "/admin/credentials" }, + }, + }, + ], +}; + +const originalFetch = globalThis.fetch; +let saved: number; +let saves: number[]; +let completed: boolean; +let named: string | null; +let localEvaluation: boolean; +/** Whether `/api/capabilities` answers, and how many times it was asked. */ +let capabilitiesFail: boolean; +let capabilitiesAsked: number; +const defaults = queryClient.getDefaultOptions(); + +beforeEach(() => { + // Another file's screens may have cached the signed-in person in the app's one query client. + queryClient.clear(); + queryClient.setDefaultOptions({ + ...defaults, + queries: { ...defaults.queries, retry: false }, + }); + saved = 0; + saves = []; + completed = false; + named = null; + localEvaluation = false; + capabilitiesFail = false; + capabilitiesAsked = 0; + (globalThis as { Notification?: unknown }).Notification = { + permission: "default", + requestPermission: async () => "default", + }; + refreshNotificationPermission(); + globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + const method = init?.method ?? "GET"; + if (path === "/api/me") + return Response.json({ + user: { + id: "person-1", + email: "maya@example.com", + name: named ?? "maya@example.com", + needsName: named === null, + role: "admin", + onboarding: { + step: saved, + completedAt: completed ? "2026-10-23T00:00:00Z" : null, + }, + }, + }); + if (path === "/api/me/profile" && method === "PATCH") { + named = (JSON.parse(String(init?.body)) as { name: string }).name; + return Response.json({ profile: { name: named, needsName: false } }); + } + if (path === "/api/me/onboarding" && method === "POST") { + const body = JSON.parse(String(init?.body)) as { + step?: number; + completed?: boolean; + }; + if (body.completed) { + completed = true; + return Response.json({ + onboarding: { step: saved, completedAt: "2026-10-23T00:00:00Z" }, + }); + } + if (body.step === undefined) throw new Error("Missing onboarding step"); + saves.push(body.step); + saved = body.step; + return Response.json({ + onboarding: { step: saved, completedAt: null }, + }); + } + if (path === "/api/capabilities") { + capabilitiesAsked += 1; + return capabilitiesFail + ? Response.json({ error: "down" }, { status: 503 }) + : Response.json({ localEvaluation }); + } + if (path.startsWith("/api/agents")) + return Response.json({ agents: [BOT] }); + if (path === "/api/plugins/for/general/apps") + return Response.json({ + apps: [ + { + key: "notion", + displayName: "Notion", + logo: null, + account: "personal", + connect: "oauth", + state: "not_connected", + }, + ], + }); + if (path === "/api/setup") return Response.json({ checklist: CHECKLIST }); + return Response.json({}); + }, + { preconnect: originalFetch.preconnect }, + ); +}); +afterEach(() => { + cleanup(); + globalThis.fetch = originalFetch; + queryClient.clear(); + queryClient.setDefaultOptions(defaults); + window.sessionStorage.clear(); +}); + +function open(path: string, given?: ReturnType) { + const rootRoute = createRootRouteWithContext()({ + component: Outlet, + }); + const authedRoute = createRoute({ + id: "/_authed", + getParentRoute: () => rootRoute, + component: Outlet, + }); + const homeRoute = createRoute({ + path: "/", + getParentRoute: () => authedRoute, + component: () =>

Home

, + }); + const credentialsRoute = createRoute({ + path: "/admin/credentials", + getParentRoute: () => authedRoute, + component: () =>

Credentials page

, + }); + const wired = ( + OnboardingRoute as unknown as { + update: (options: unknown) => typeof OnboardingRoute; + } + ).update({ + id: "/onboarding", + path: "/onboarding", + getParentRoute: () => authedRoute, + }); + const history = given ?? createMemoryHistory({ initialEntries: [path] }); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + authedRoute.addChildren([homeRoute, credentialsRoute, wired]), + ]), + history, + context: { queryClient }, + }); + const view = render( + + + , + ); + return Object.assign(view, { router, history }); +} + +const heading = (view: ReturnType, name: string) => + view.findByRole("heading", { name }); +const searchOf = (view: ReturnType) => + view.router.state.location.search as { + step?: string; + connected?: string; + open?: string; + }; +/** The phone's Back, at the top left. The desktop's is the same button beside Continue. */ +const back = (view: ReturnType) => { + const found = view.container.querySelector( + '[data-onboarding-back="phone"]', + ); + if (!found) throw new Error("no Back"); + return found; +}; +/** Continue, once the deployment's answer has let it be pressed. */ +const continueButton = async (view: ReturnType) => { + const button = await view.findByRole("button", { name: "Continue" }); + await waitFor(() => expect(button.hasAttribute("disabled")).toBe(false)); + return button; +}; + +test("arriving without a step writes the first one into the address, in place", async () => { + const view = open("/onboarding"); + + await heading(view, "Welcome to OpenBot"); + await waitFor(() => expect(searchOf(view).step).toBe("welcome")); + // In place: nothing behind it to go Back to inside onboarding. + expect(view.history.length).toBe(1); +}); + +test("Continue adds a step to the address and to history, and saves it", async () => { + const view = open("/onboarding?step=welcome"); + await heading(view, "Welcome to OpenBot"); + + fireEvent.click(await continueButton(view)); + + await heading(view, "Each Bot has its own browser and files"); + expect(searchOf(view).step).toBe("computer"); + expect(view.history.length).toBe(2); + await waitFor(() => expect(saves).toContain(2)); +}); + +test("the steps run welcome, computer, team, approval, apps, notify, setup, name", async () => { + const view = open("/onboarding?step=welcome"); + await heading(view, "Welcome to OpenBot"); + + const list = view.getByRole("navigation", { name: "Onboarding steps" }); + await waitFor(() => + expect( + within(list) + .getAllByRole("listitem") + .map((item) => item.textContent?.replace(/^\d/, "")), + ).toEqual([ + "Welcome", + "A browser and files of its own", + "Your team of Bots", + "It asks first", + "Connect apps", + "Get notified", + "Check your setup", + "Your name", + ]), + ); + for (const next of [ + "Each Bot has its own browser and files", + "Pick a Bot or make your own", + "It asks before it acts", + ]) { + fireEvent.click(await continueButton(view)); + await heading(view, next); + } + fireEvent.click(await continueButton(view)); + await heading(view, "Connect your apps"); +}); + +test("Continue and Back start the new step's picture at the top of its pane", async () => { + const view = open("/onboarding?step=computer"); + await heading(view, "Each Bot has its own browser and files"); + const pane = () => + view.container.querySelector("[data-onboarding-demo]") as HTMLElement; + pane().scrollTop = 120; + + fireEvent.click(await continueButton(view)); + + const team = await heading(view, "Pick a Bot or make your own"); + await waitFor(() => expect(document.activeElement).toBe(team)); + expect(pane().scrollTop).toBe(0); + + pane().scrollTop = 120; + fireEvent.click(back(view)); + const computer = await heading( + view, + "Each Bot has its own browser and files", + ); + await waitFor(() => expect(document.activeElement).toBe(computer)); + expect(pane().scrollTop).toBe(0); +}); + +test("browser Back goes to the step before, not out of onboarding", async () => { + const view = open("/onboarding?step=welcome"); + await heading(view, "Welcome to OpenBot"); + fireEvent.click(await continueButton(view)); + await heading(view, "Each Bot has its own browser and files"); + + await act(async () => view.history.back()); + + expect(await heading(view, "Welcome to OpenBot")).toBeTruthy(); + expect(searchOf(view).step).toBe("welcome"); +}); + +test("Back after Continue returns through history, so Forward still works", async () => { + const view = open("/onboarding?step=welcome"); + await heading(view, "Welcome to OpenBot"); + fireEvent.click(await continueButton(view)); + await heading(view, "Each Bot has its own browser and files"); + + fireEvent.click(back(view)); + + expect(await heading(view, "Welcome to OpenBot")).toBeTruthy(); + expect(view.history.canGoBack()).toBe(false); + await act(async () => view.history.forward()); + expect( + await heading(view, "Each Bot has its own browser and files"), + ).toBeTruthy(); +}); + +test("Back on a step opened by reload replaces it with the step before", async () => { + const view = open("/onboarding?step=team"); + await heading(view, "Pick a Bot or make your own"); + + fireEvent.click(back(view)); + + expect( + await heading(view, "Each Bot has its own browser and files"), + ).toBeTruthy(); + expect(searchOf(view).step).toBe("computer"); + expect(view.history.length).toBe(1); +}); + +test("on a desktop, a step already passed can be picked from the list", async () => { + const view = open("/onboarding?step=approval"); + await heading(view, "It asks before it acts"); + + const list = view.getByRole("navigation", { name: "Onboarding steps" }); + expect(within(list).queryByRole("button", { name: /Connect apps/ })).toBe( + null, + ); + fireEvent.click(within(list).getByRole("button", { name: /Welcome/ })); + + expect(await heading(view, "Welcome to OpenBot")).toBeTruthy(); + expect(searchOf(view).step).toBe("welcome"); + // It has seen the approval step, so the list leads back to it. + fireEvent.click( + within( + view.getByRole("navigation", { name: "Onboarding steps" }), + ).getByRole("button", { name: /It asks first/ }), + ); + expect(await heading(view, "It asks before it acts")).toBeTruthy(); +}); + +test("a reload stays on the step in the address", async () => { + const view = open("/onboarding?step=apps"); + + expect(await heading(view, "Connect your apps")).toBeTruthy(); + expect(searchOf(view).step).toBe("apps"); +}); + +test("arriving with no step opens the one the server saved", async () => { + saved = 7; + const view = open("/onboarding"); + + expect(await heading(view, "Get notified")).toBeTruthy(); + await waitFor(() => expect(searchOf(view).step).toBe("notify")); +}); + +test("a failed consent says so on its step, and only `connected` leaves the address", async () => { + const view = open("/onboarding?step=apps&connected=failed"); + + expect(await heading(view, "Connect your apps")).toBeTruthy(); + expect((await view.findByRole("alert")).textContent).toBe( + "Couldn't connect that app. Try again.", + ); + await waitFor(() => expect(searchOf(view)).toEqual({ step: "apps" })); +}); + +test("a step that isn't one is ignored", async () => { + const view = open("/onboarding?step=nonsense"); + + expect(await heading(view, "Welcome to OpenBot")).toBeTruthy(); + await waitFor(() => expect(searchOf(view).step).toBe("welcome")); +}); + +test("after a reload, Back still returns through history, not a second entry", async () => { + const first = open("/onboarding?step=welcome"); + await heading(first, "Welcome to OpenBot"); + fireEvent.click(await continueButton(first)); + await heading(first, "Each Bot has its own browser and files"); + const history = first.history; + first.unmount(); + queryClient.clear(); + + // The same history, a new page: what a reload keeps. + const reloaded = open("/onboarding?step=computer", history); + await heading(reloaded, "Each Bot has its own browser and files"); + fireEvent.click(back(reloaded)); + + expect(await heading(reloaded, "Welcome to OpenBot")).toBeTruthy(); + expect(history.length).toBe(2); + expect(history.canGoBack()).toBe(false); +}); + +test("back from a consent, browser Back goes to the step before, not to the vendor", async () => { + window.sessionStorage.setItem( + "openbot-onboarding-consent", + JSON.stringify({ + step: "apps", + entry: "the-entry-that-left", + app: { serverId: "notion", brokered: false }, + }), + ); + const view = open("/onboarding?step=apps&connected=failed"); + + expect(await heading(view, "Connect your apps")).toBeTruthy(); + await waitFor(() => expect(view.history.length).toBe(2)); + expect(searchOf(view)).toEqual({ step: "apps" }); + // The app it left for is named, once its row has arrived. + await waitFor(async () => + expect((await view.findByRole("alert")).textContent).toBe( + "Couldn't connect Notion. Try again.", + ), + ); + expect( + window.sessionStorage.getItem("openbot-onboarding-consent"), + ).toBeNull(); + + await act(async () => view.history.back()); + expect(await heading(view, "It asks before it acts")).toBeTruthy(); +}); + +test("the failed line goes once the person moves on, and doesn't come back with Back", async () => { + const view = open("/onboarding?step=apps&connected=failed"); + await view.findByRole("alert"); + + fireEvent.click(view.getByRole("button", { name: "Not now" })); + await heading(view, "Get notified"); + fireEvent.click(back(view)); + + expect(await heading(view, "Connect your apps")).toBeTruthy(); + await settleReactWork(); + expect(view.queryByRole("alert")).toBeNull(); +}); + +test("Get started on the name step saves the name, finishes, plays the orb's finish, then opens the app", async () => { + const view = open("/onboarding?step=name"); + await heading(view, "Let's get to it."); + // The name step's own form carries the way out; the frame draws no Continue. + expect(view.queryByRole("button", { name: "Continue" })).toBeNull(); + // Under the orb: on a phone the picture comes first. + expect( + view.container.querySelector("[data-onboarding-demo]")?.className, + ).toContain("row-start-2"); + + await userEvent.setup({ document }).type(view.getByLabelText("Name"), "Maya"); + fireEvent.click(view.getByRole("button", { name: "Get started" })); + + await waitFor(() => + expect( + view.container.querySelector( + '[data-onboarding-finish] [data-state="finished"]', + ), + ).not.toBeNull(), + ); + expect(named).toBe("Maya"); + expect(completed).toBe(true); + expect(view.queryByText("Home")).toBeNull(); + expect( + view.getByRole("button", { name: "Saving…" }).hasAttribute("disabled"), + ).toBe(true); + await waitFor(() => expect(view.getByText("Home")).toBeTruthy(), { + timeout: 3000, + }); +}); + +test("a checklist link goes through the name step, says what opens, and opens it after", async () => { + const view = open("/onboarding?step=setup"); + await heading(view, "Check your setup"); + + fireEvent.click(await view.findByRole("link", { name: "Credentials" })); + + await heading(view, "Let's get to it."); + expect(searchOf(view)).toEqual({ step: "name", open: "/admin/credentials" }); + expect( + await view.findByText("Credentials opens once you get started."), + ).toBeTruthy(); + expect(completed).toBe(false); + + await userEvent.setup({ document }).type(view.getByLabelText("Name"), "Maya"); + fireEvent.click(view.getByRole("button", { name: "Get started" })); + await waitFor(() => expect(view.getByText("Credentials page")).toBeTruthy(), { + timeout: 3000, + }); + expect(completed).toBe(true); +}); + +test("a page to open that isn't one of this app's is dropped", async () => { + const view = open("/onboarding?step=name&open=https%3A%2F%2Fevil.test"); + + await heading(view, "Let's get to it."); + expect(searchOf(view).open).toBeUndefined(); +}); + +test("Skip intro reaches Connect your apps, survives reload, and Back returns to the step it skipped from", async () => { + localEvaluation = true; + const first = open("/onboarding?step=welcome"); + await heading(first, "Welcome to OpenBot"); + await waitFor(() => + expect(first.getByRole("progressbar").getAttribute("aria-valuemax")).toBe( + "9", + ), + ); + fireEvent.click(first.getByRole("button", { name: "Skip intro" })); + await heading(first, "Connect your apps"); + await waitFor(() => expect(saves).toContain(6)); + expect(completed).toBe(false); + const history = first.history; + first.unmount(); + queryClient.clear(); + + const view = open("/onboarding?step=apps", history); + await heading(view, "Connect your apps"); + fireEvent.click(back(view)); + await heading(view, "Welcome to OpenBot"); + await act(async () => history.forward()); + await heading(view, "Connect your apps"); + fireEvent.click(view.getByRole("button", { name: "Not now" })); + await heading(view, "Get notified"); + fireEvent.click(view.getByRole("button", { name: "Not now" })); + await heading(view, "Check your setup"); + expect(completed).toBe(false); +}); + +test("somebody trying OpenBot alone gets the self-hosting step, with Talk to an engineer", async () => { + localEvaluation = true; + const view = open("/onboarding?step=approval"); + await heading(view, "It asks before it acts"); + + fireEvent.click(await continueButton(view)); + + expect(await heading(view, "It runs on your machine")).toBeTruthy(); + expect(searchOf(view).step).toBe("selfhost"); + const engineer = view.getByRole("link", { name: "Talk to an engineer" }); + expect(engineer.getAttribute("target")).toBe("_blank"); + fireEvent.click(await continueButton(view)); + expect(await heading(view, "Connect your apps")).toBeTruthy(); +}); + +test("an organization's people never see the self-hosting step", async () => { + const view = open("/onboarding?step=approval"); + await heading(view, "It asks before it acts"); + + fireEvent.click(await continueButton(view)); + + expect(await heading(view, "Connect your apps")).toBeTruthy(); + expect(view.queryByText("Running it yourself")).toBeNull(); +}); + +test("the self-hosting step in the address waits for the answer, rather than flashing step 1", async () => { + localEvaluation = true; + const view = open("/onboarding?step=selfhost"); + + expect(await heading(view, "It runs on your machine")).toBeTruthy(); + expect( + view.queryByRole("heading", { name: "Welcome to OpenBot" }), + ).toBeNull(); +}); + +test("until the deployment answers, the intro's Continue waits; a failure says so and offers Try again", async () => { + capabilitiesFail = true; + const view = open("/onboarding?step=welcome"); + await heading(view, "Welcome to OpenBot"); + + expect( + ( + await view.findByText("Couldn't load your setup. Try again to continue.") + ).getAttribute("role"), + ).toBe("alert"); + expect( + view.getByRole("button", { name: "Continue" }).hasAttribute("disabled"), + ).toBe(true); + + capabilitiesFail = false; + const asked = capabilitiesAsked; + fireEvent.click(view.getByRole("button", { name: "Try again" })); + await waitFor(() => expect(capabilitiesAsked).toBeGreaterThan(asked)); + fireEvent.click(await continueButton(view)); + expect( + await heading(view, "Each Bot has its own browser and files"), + ).toBeTruthy(); + expect( + view.queryByText("Couldn't load your setup. Try again to continue."), + ).toBeNull(); +}); + +test("a consent after Skip intro returns Back to the skipped-from step", async () => { + window.sessionStorage.setItem( + "openbot-onboarding-consent", + JSON.stringify({ + step: "apps", + from: "welcome", + entry: "the-entry-that-left", + }), + ); + const view = open("/onboarding?step=apps&connected=failed"); + await heading(view, "Connect your apps"); + await waitFor(() => + expect(view.router.state.location.state).toMatchObject({ + onboardingFrom: "welcome", + }), + ); + fireEvent.click(back(view)); + await heading(view, "Welcome to OpenBot"); +}); diff --git a/app/tests/profile.test.tsx b/app/tests/profile.test.tsx index 421a25c289..1e36399a9b 100644 --- a/app/tests/profile.test.tsx +++ b/app/tests/profile.test.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { + OnboardingNameOrb, OnboardingNameStep, ProfileNameForm, } from "@/components/settings/profile"; @@ -47,6 +48,19 @@ test("onboarding name step starts empty for a placeholder profile", () => { ).toBe(true); }); +test("the name step's orb is OpenBot's violet, listening, and plays its finish when onboarding ends", () => { + const view = render(); + const orb = () => + view.container.querySelector("[data-palette]") as HTMLElement; + expect(orb().getAttribute("data-palette")).toBe("violet"); + expect(orb().getAttribute("data-state")).toBe("listening"); + expect(view.container.querySelector("[data-onboarding-finish]")).toBeNull(); + + view.rerender(); + expect(orb().getAttribute("data-state")).toBe("finished"); + expect(orb().closest("[data-onboarding-finish]")).not.toBeNull(); +}); + test("onboarding name step preserves a saved name when revisited", () => { const view = render( From c0e2e3228b42bea7d37483c75cc5f3660d2fdd7f Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:14:58 -0700 Subject: [PATCH 19/47] docs: changelog for the onboarding steps --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef2661bd6b..d13e2e270e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,21 @@ again when they come back to the end or press the arrow. A rail beside a convers per turn: hovering one previews what was asked and answered, and choosing it goes there. Nothing changes for a deployment. +### First run shows what OpenBot does, one step at a time + +- The looping intro is replaced by OpenDuet's onboarding steps: what OpenBot does (a computer of its + own, a team of Bots, and asking before it acts), then Connect your apps, Get notified, Check your + setup, and the name step, whose Get started opens the app. On a desktop the steps are listed on the + left beside a picture of each; on a phone they come one at a time. A reload, browser Back and a + connection's return all keep the person's place, which is now saved as they go. +- Check your setup asks the new `GET /api/setup` whether Intelligence, the model key, the Bot computer, + more apps and Slack and Teams work, row by row. An admin, or somebody running OpenBot alone, is + shown the page or the variables that close a gap; everybody else only that their admin sets it up. + Check again asks Intelligence and the model provider again, at most every 10 seconds, and pushes + each running Bot computer its network policy to see that it answers; it wakes none. +- A connection started from onboarding, or cancelled at the app's own sign-in page, now returns to + the screen it started on instead of the Connections list. + ### OpenBot has a new look - Every screen has new colours: cool neutral greys, a near-black primary button that turns light From 47b89b30d36da59d5724df79380e793909d72b4b Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:16:10 -0700 Subject: [PATCH 20/47] docs: the drop guard's note and the changelog say what onboarding draws now The root's drop guard named the onboarding poster's composer, which is now a drawing inside onboarding's picture frame. The changelog says the computer check also runs once at start-up. --- CHANGELOG.md | 5 +++-- app/src/routes/__root.tsx | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d13e2e270e..c697128537 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,8 +143,9 @@ changes for a deployment. - Check your setup asks the new `GET /api/setup` whether Intelligence, the model key, the Bot computer, more apps and Slack and Teams work, row by row. An admin, or somebody running OpenBot alone, is shown the page or the variables that close a gap; everybody else only that their admin sets it up. - Check again asks Intelligence and the model provider again, at most every 10 seconds, and pushes - each running Bot computer its network policy to see that it answers; it wakes none. + Check again asks Intelligence and the model provider again, at most every 10 seconds. To check the + Bot computer, the server sends each running computer its network policy, at start-up and on Check + again, and sees that it answers; it wakes none. - A connection started from onboarding, or cancelled at the app's own sign-in page, now returns to the screen it started on instead of the Connections list. diff --git a/app/src/routes/__root.tsx b/app/src/routes/__root.tsx index 932492d23b..f05bcfe1fd 100644 --- a/app/src/routes/__root.tsx +++ b/app/src/routes/__root.tsx @@ -37,9 +37,9 @@ export const Route = createRootRouteWithContext()({ * app-wide guard could not be installed by a leaf: several composers can be mounted at once, so a * `document` listener owned by one of them would be installed once per composer and torn down by * whichever unmounted first. The leaf approach also cannot reach every surface even in principle. - * The onboarding poster (`_authed/onboarding.tsx`) wraps its decorative composer in - * `pointer-events-none` on purpose, so that composer can never receive a drop at all: the event - * goes straight past it to the document. So do the transcript, the sidebar, the page margin, and + * Onboarding's pictures (`components/onboarding/illustration-frame.tsx`), its drawing of a + * composer among them, are `pointer-events-none` on purpose, so nothing in them can ever receive a + * drop at all: the event goes straight past them to the document. So do the transcript, the sidebar, the page margin, and * every screen that will ever be added without thinking about drag and drop. The root is the one * place where "once" is a fact rather than a hope: `RootComponent` mounts once per app. * From 29f1f98ec19e7dd518330011763bfcc351df2095 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:26:22 -0700 Subject: [PATCH 21/47] fix(onboarding): an admin with no apps is shown where to add them Found running the steps against a real install. Connect apps told an admin to wait for an admin; it now offers Plugins, carried through the name step like the checklist's links. The checklist's "Required" tag ran into each title, and the notification preview cut "General Assistant needs you" off at the Bot's name. --- .../onboarding/connect-apps-step.tsx | 28 +++++++++++++-- app/src/components/onboarding/notify-step.tsx | 2 +- .../components/onboarding/setup-checklist.tsx | 5 +-- app/src/routes/_authed/onboarding.tsx | 24 ++++++++----- app/tests/onboarding-steps.test.tsx | 34 +++++++++++++++++-- 5 files changed, 76 insertions(+), 17 deletions(-) diff --git a/app/src/components/onboarding/connect-apps-step.tsx b/app/src/components/onboarding/connect-apps-step.tsx index 8e89a2b842..4a8d83c977 100644 --- a/app/src/components/onboarding/connect-apps-step.tsx +++ b/app/src/components/onboarding/connect-apps-step.tsx @@ -16,6 +16,7 @@ import { Separator } from "@/components/ui/separator"; import { defaultAgentProfile } from "@/lib/agents/default-agent"; import { agentListQueryOptions } from "@/lib/agents/queries"; import { type AppAccess, botAppsQueryOptions } from "@/lib/app-access/queries"; +import { currentUserQueryOptions } from "@/lib/auth/queries"; import { confirmBrokeredConnectionMutationOptions, connectAccountMutationOptions, @@ -75,6 +76,7 @@ export function ConnectAppsStep({ failed, returned, leaveFor, + openPage, }: { /** The last consent came back failed. */ failed: boolean; @@ -82,7 +84,11 @@ export function ConnectAppsStep({ returned?: ConsentApp | null; /** Where the browser goes to approve the connection: the vendor's own page. */ leaveFor: (url: string, app: ConsentApp) => void; + /** Opens an app page once onboarding finishes, as the setup checklist's links do. */ + openPage?: (target: { label: string; path: string }) => void; }) { + // An admin with nothing to connect is the one who adds apps, so is shown where. + const isAdmin = useQuery(currentUserQueryOptions()).data?.role === "admin"; const agents = useQuery(agentListQueryOptions()); const bot = defaultAgentProfile(agents.data); const apps = useQuery({ @@ -157,8 +163,26 @@ export function ConnectAppsStep({ Connections.

) : rows?.length === 0 ? ( - - When an admin adds apps, you can connect them in Connections. + + openPage({ label: "Plugins", path: "/admin/plugins" }) + } + size="sm" + variant="outline" + > + Add apps + + ) : undefined + } + icon={IconPlug} + title="No apps to connect yet" + > + {isAdmin + ? "Add apps for everyone in Plugins, then connect your own here or in Connections." + : "When an admin adds apps, you can connect them in Connections."} ) : ( diff --git a/app/src/components/onboarding/notify-step.tsx b/app/src/components/onboarding/notify-step.tsx index 282735c31a..c7fb86341c 100644 --- a/app/src/components/onboarding/notify-step.tsx +++ b/app/src/components/onboarding/notify-step.tsx @@ -146,7 +146,7 @@ function NotifyPreview({ botName }: { botName: string }) { - + {preview.title} diff --git a/app/src/components/onboarding/setup-checklist.tsx b/app/src/components/onboarding/setup-checklist.tsx index ab1d9335ab..a72ba06fd5 100644 --- a/app/src/components/onboarding/setup-checklist.tsx +++ b/app/src/components/onboarding/setup-checklist.tsx @@ -180,10 +180,11 @@ function SetupItem({ - + {/* `line-clamp-none` makes the title a block, so the tag keeps its own space. */} + {row.title} {row.required ? ( - + Required ) : null} diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 1220629e69..3551918526 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -81,6 +81,9 @@ function stepForNumber(step: number | undefined): StepId | null { * upward. What a checklist link opens once onboarding is done, carried in the address as `?open=` * so a reload on the name step keeps it. */ +/** Pages a step other than the checklist can carry through the name step, by what they're called. */ +const STEP_PAGES: Record = { "/admin/plugins": "Plugins" }; + const isAppPath = (value: unknown): value is string => typeof value === "string" && /^\/(?!\/)[\w/-]*$/.test(value) && @@ -462,15 +465,17 @@ function RouteComponent() { ...setupChecklistQueryOptions(), enabled: search.open !== undefined, }); - const openLabel = checklist?.rows - .map((row) => - row.fix?.kind === "page" - ? row.fix - : row.fix?.kind === "env" - ? row.fix.page - : undefined, - ) - .find((page) => page?.path === search.open)?.label; + const openLabel = + checklist?.rows + .map((row) => + row.fix?.kind === "page" + ? row.fix + : row.fix?.kind === "env" + ? row.fix.page + : undefined, + ) + .find((page) => page?.path === search.open)?.label ?? + (search.open ? STEP_PAGES[search.open] : undefined); const capabilitiesFailed = capabilitiesError && !capabilities ? ( @@ -524,6 +529,7 @@ function RouteComponent() { leaveForConsent("apps", url, app)} + openPage={(target) => move("name", "forward", target.path)} returned={returnedApp} /> ), diff --git a/app/tests/onboarding-steps.test.tsx b/app/tests/onboarding-steps.test.tsx index 0f621e24b3..a77757590d 100644 --- a/app/tests/onboarding-steps.test.tsx +++ b/app/tests/onboarding-steps.test.tsx @@ -29,6 +29,7 @@ import { import { StepIntro } from "@/components/onboarding/onboarding-frame"; import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard"; import { agentKeys } from "@/lib/agents/queries"; +import { authKeys } from "@/lib/auth/queries"; import type { AppAccess } from "@/lib/app-access/queries"; import { refreshNotificationPermission } from "@/lib/notifications/permission"; import { queryClient } from "@/query-client"; @@ -162,19 +163,32 @@ function withQueries(children: ReactNode) { } function drawConnect( - props: { failed?: boolean; returned?: ConsentApp | null } = {}, + props: { + failed?: boolean; + returned?: ConsentApp | null; + role?: "admin" | "member"; + } = {}, ) { const left: Array<[string, ConsentApp]> = []; + const opened: Array<{ label: string; path: string }> = []; + if (props.role) + queryClient.setQueryData(authKeys.currentUser(), { + id: "u1", + email: "jo@example.com", + name: "Jo", + role: props.role, + }); const view = render( withQueries( left.push([url, left_])} + openPage={(target) => opened.push(target)} returned={props.returned ?? null} />, ), ); - return Object.assign(view, { left }); + return Object.assign(view, { left, opened }); } /** One app's row, once the list has arrived. */ @@ -377,9 +391,23 @@ test("a list that could not be read says so, and never claims Connected", async test("no apps to connect says how they arrive", async () => { apps = []; - const view = drawConnect(); + const view = drawConnect({ role: "member" }); expect(await view.findByText("No apps to connect yet")).toBeTruthy(); + expect( + view.getByText( + "When an admin adds apps, you can connect them in Connections.", + ), + ).toBeTruthy(); + expect(view.queryByRole("button", { name: "Add apps" })).toBeNull(); +}); + +test("an admin with no apps to connect is offered the page where apps are added", async () => { + apps = []; + const view = drawConnect({ role: "admin" }); + + fireEvent.click(await view.findByRole("button", { name: "Add apps" })); + expect(view.opened).toEqual([{ label: "Plugins", path: "/admin/plugins" }]); }); /** Get notified between two plain steps, in the real frame. */ From a04b9e5729f9a8211d33637d5ea5d214b6a341c8 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 01:26:22 -0700 Subject: [PATCH 22/47] fix(model): check a key at the provider's own address, even when it is written out Setting OPENAI_BASE_URL to https://api.openai.com/v1 counted as a custom endpoint, so the check was skipped and the setup checklist said the model was not checked. The provider's own host is now checked as if no address were set. --- server/src/model-availability-check.ts | 15 +++++++++++- server/tests/model-availability-check.test.ts | 23 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/server/src/model-availability-check.ts b/server/src/model-availability-check.ts index 3aed32a67d..747a88a000 100644 --- a/server/src/model-availability-check.ts +++ b/server/src/model-availability-check.ts @@ -88,6 +88,18 @@ export function modelCheckOutcome(input: { }; } +/** + * Whether a configured base URL is just the provider's own API, written out. A deployment may well + * spell it out, and the provider's own lookup vouches for its own address as well as for none. + */ +function isProvidersOwn(provider: Provider, base: string): boolean { + try { + return new URL(ENDPOINTS[provider]("m")).host === new URL(base.trim()).host; + } catch { + return false; + } +} + /** Ask once and log what was found. Resolves either way and never throws. */ export async function reportModelAvailability(options: { provider: Provider; @@ -107,7 +119,8 @@ export async function reportModelAvailability(options: { provider === "anthropic" ? environment.ANTHROPIC_BASE_URL : environment.OPENAI_BASE_URL; - if (customBase?.trim()) return { kind: "skipped" }; + if (customBase?.trim() && !isProvidersOwn(provider, customBase)) + return { kind: "skipped" }; let outcome: ModelCheckOutcome; try { diff --git a/server/tests/model-availability-check.test.ts b/server/tests/model-availability-check.test.ts index b97e6214f7..aea3e0a5f0 100644 --- a/server/tests/model-availability-check.test.ts +++ b/server/tests/model-availability-check.test.ts @@ -136,6 +136,29 @@ describe("asking at boot", () => { expect(calls).toHaveLength(0); }); + test("checks the provider's own address the same as no address, wherever a deployment wrote it", async () => { + for (const [provider, base] of [ + ["openai", "https://api.openai.com/v1"], + ["openai", "https://api.openai.com/v1/"], + ["anthropic", "https://api.anthropic.com"], + ] as const) { + const { fetcher, calls } = fakeFetch(200); + const outcome = await reportModelAvailability({ + provider, + model: "m", + apiKey: async () => KEY, + environment: + provider === "openai" + ? { OPENAI_BASE_URL: base } + : { ANTHROPIC_BASE_URL: base }, + fetch: fetcher, + log: () => {}, + }); + expect(outcome).toEqual({ kind: "available" }); + expect(calls).toHaveLength(1); + } + }); + test("says nothing while no key is configured yet", async () => { const { fetcher, calls } = fakeFetch(200); const lines: string[] = []; From cdebb7cc2d426687d7943f47be179a29f4297705 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:05:05 -0700 Subject: [PATCH 23/47] fix(onboarding): say what a Bot asks about as OpenBot behaves out of the box The step promised that a Bot asks before it sends, buys or deletes anything. Ask before making changes starts off, so out of the box a Bot asks only where a rule says to, and paying or changing account security is handed to the person. The step now says that, and where to turn asking on. --- .../onboarding/demos/approval-demo.tsx | 2 +- app/src/routes/_authed/onboarding.tsx | 15 +++++++++++---- app/tests/onboarding-route.test.tsx | 16 ++++++++-------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/app/src/components/onboarding/demos/approval-demo.tsx b/app/src/components/onboarding/demos/approval-demo.tsx index d20de2c3d3..497195f77b 100644 --- a/app/src/components/onboarding/demos/approval-demo.tsx +++ b/app/src/components/onboarding/demos/approval-demo.tsx @@ -12,7 +12,7 @@ export const APPROVAL_CHOICES = [ ] as const; /** - * It asks before it acts: a Bot about to send an email waits for an answer, gets Allow once, and + * When a Bot asks: a Bot about to send an email waits for an answer, gets Allow once, and * sends it. * * A DRAWING OF AN APPROVAL CARD, FOR THIS STEP ONLY. The real card is the conversation's, and it diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 3551918526..c0f6b9c343 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -240,14 +240,21 @@ const team: OnboardingStep = { demo: () => , }; +/* + * Said as OpenBot behaves out of the box: "Ask before making changes" starts off, so a Bot asks only + * where a rule says to, and paying or changing account security is always handed to the person + * (`server/src/approvals/policy.ts`). + */ const approval: OnboardingStep = { id: "approval", - title: "It asks first", + title: "You decide what it asks", intro: () => ( - +

- Before a Bot sends, buys or deletes anything, it asks you. Allow it - once, or always allow it here. + Paying for things and changing account security are always left to you. + For everything else, turn on Ask before making changes in Settings, or + give a Bot its own rules. When a Bot asks, allow it once or always allow + it there.

), diff --git a/app/tests/onboarding-route.test.tsx b/app/tests/onboarding-route.test.tsx index 131e3a9762..3099ae9a52 100644 --- a/app/tests/onboarding-route.test.tsx +++ b/app/tests/onboarding-route.test.tsx @@ -285,7 +285,7 @@ test("the steps run welcome, computer, team, approval, apps, notify, setup, name "Welcome", "A browser and files of its own", "Your team of Bots", - "It asks first", + "You decide what it asks", "Connect apps", "Get notified", "Check your setup", @@ -295,7 +295,7 @@ test("the steps run welcome, computer, team, approval, apps, notify, setup, name for (const next of [ "Each Bot has its own browser and files", "Pick a Bot or make your own", - "It asks before it acts", + "You decide what it asks about", ]) { fireEvent.click(await continueButton(view)); await heading(view, next); @@ -370,7 +370,7 @@ test("Back on a step opened by reload replaces it with the step before", async ( test("on a desktop, a step already passed can be picked from the list", async () => { const view = open("/onboarding?step=approval"); - await heading(view, "It asks before it acts"); + await heading(view, "You decide what it asks about"); const list = view.getByRole("navigation", { name: "Onboarding steps" }); expect(within(list).queryByRole("button", { name: /Connect apps/ })).toBe( @@ -384,9 +384,9 @@ test("on a desktop, a step already passed can be picked from the list", async () fireEvent.click( within( view.getByRole("navigation", { name: "Onboarding steps" }), - ).getByRole("button", { name: /It asks first/ }), + ).getByRole("button", { name: /You decide what it asks/ }), ); - expect(await heading(view, "It asks before it acts")).toBeTruthy(); + expect(await heading(view, "You decide what it asks about")).toBeTruthy(); }); test("a reload stays on the step in the address", async () => { @@ -465,7 +465,7 @@ test("back from a consent, browser Back goes to the step before, not to the vend ).toBeNull(); await act(async () => view.history.back()); - expect(await heading(view, "It asks before it acts")).toBeTruthy(); + expect(await heading(view, "You decide what it asks about")).toBeTruthy(); }); test("the failed line goes once the person moves on, and doesn't come back with Back", async () => { @@ -573,7 +573,7 @@ test("Skip intro reaches Connect your apps, survives reload, and Back returns to test("somebody trying OpenBot alone gets the self-hosting step, with Talk to an engineer", async () => { localEvaluation = true; const view = open("/onboarding?step=approval"); - await heading(view, "It asks before it acts"); + await heading(view, "You decide what it asks about"); fireEvent.click(await continueButton(view)); @@ -587,7 +587,7 @@ test("somebody trying OpenBot alone gets the self-hosting step, with Talk to an test("an organization's people never see the self-hosting step", async () => { const view = open("/onboarding?step=approval"); - await heading(view, "It asks before it acts"); + await heading(view, "You decide what it asks about"); fireEvent.click(await continueButton(view)); From b8b102b2264fa14c6ba32b67bdf5b5fb30b9761c Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:36:01 -0700 Subject: [PATCH 24/47] fix(setup): a set-up Bot computer with none running says so With AGENT_COMPUTER_URL set and no computer running, the usual state and always true on a first run, the row told an admin Bots can't browse the web and to set the variables and restart, for something already set. Not checked now says it is set up and none is running to check, and opens Computers. --- server/src/setup/checklist.ts | 46 ++++++++++++++++++++++------ server/tests/setup-checklist.test.ts | 24 +++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/server/src/setup/checklist.ts b/server/src/setup/checklist.ts index 17a7549063..c4b900b52e 100644 --- a/server/src/setup/checklist.ts +++ b/server/src/setup/checklist.ts @@ -214,16 +214,11 @@ export function setupRows( id: "computer", title: "Bot computer", required: false, - status: facts.computer - ? (facts.computerStatus ?? "not_checked") - : "needs_setup", - readyDetail: "Bots can browse the web and work with files.", - gapDetail: "Without it, Bots can't browse the web or work with files.", - fix: { - kind: "env", - names: ["AGENT_COMPUTER_URL", "COMPUTER_TOKEN"], - docsUrl: `${DOCS}/configuration.md#computer-and-supervisor`, - }, + ...computerRow( + facts.computer + ? (facts.computerStatus ?? "not_checked") + : "needs_setup", + ), }), deployment({ id: "apps", @@ -288,6 +283,37 @@ function intelligenceRow(fact: IntelligenceFact, product: string) { }; } +/** + * The Bot computer row. Not checked is the usual state of one that is set up: a computer starts + * when a Bot first needs one, and until then none is running to ask. Its fix is the page where + * computers show up, because the variables are already set. + */ +function computerRow(status: SetupStatus) { + const readyDetail = "Bots can browse the web and work with files."; + if (status === "not_checked") + return { + status, + readyDetail, + gapDetail: + "Set up. No computer is running yet, so there's nothing to check.", + fix: { + kind: "page" as const, + label: "Computers", + path: "/admin/computers", + }, + }; + return { + status, + readyDetail, + gapDetail: "Without it, Bots can't browse the web or work with files.", + fix: { + kind: "env" as const, + names: ["AGENT_COMPUTER_URL", "COMPUTER_TOKEN"], + docsUrl: `${DOCS}/configuration.md#computer-and-supervisor`, + }, + }; +} + /** The model row, from where its key comes from and what the provider last said about that key. */ function modelRow(model: SetupFacts["model"], product: string) { const provider = PROVIDER_NAME[model.provider]; diff --git a/server/tests/setup-checklist.test.ts b/server/tests/setup-checklist.test.ts index acab0be977..c97d3ffe92 100644 --- a/server/tests/setup-checklist.test.ts +++ b/server/tests/setup-checklist.test.ts @@ -1031,3 +1031,27 @@ test("a configured computer without a reachability verdict is not yet checked", ).status, ).toBe("not_checked"); }); + +/* + * The usual state of a set-up computer: none runs until a Bot first needs one. Told to set the + * variables and restart, an admin would restart for something already set. + */ +test("a configured computer with none running says it is set up, and sends no restart", () => { + const facts: SetupFacts = { ...READY, computerStatus: "not_checked" }; + expect( + row( + setupRows(facts, { productName: PRODUCT, audience: "admin" }), + "computer", + ), + ).toMatchObject({ + status: "not_checked", + detail: "Set up. No computer is running yet, so there's nothing to check.", + fix: { kind: "page", label: "Computers", path: "/admin/computers" }, + }); + expect( + row( + setupRows(facts, { productName: PRODUCT, audience: "member" }), + "computer", + ), + ).toMatchObject({ detail: "Set up, not checked yet.", fix: null }); +}); From c849c5f25da956013b9815054e7b0b89c2210d2d Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:41:06 -0700 Subject: [PATCH 25/47] fix(setup): a read asks again for answers that failed or are old Intelligence, model and computer answers were asked at boot and then served for as long as the server ran. An Intelligence slower to start than OpenBot read as a red required gap until somebody pressed Check again, and a key refused days after boot still read Ready. A read now asks again, in the background and under the shared 10-second limit, when an answer failed or is five minutes old, so the next read has it. Reads are answered from the last answers that are all in, so none waits on a slow fleet. --- CHANGELOG.md | 7 +- .../components/onboarding/setup-checklist.tsx | 5 +- server/src/setup/checklist.ts | 130 +++++++--- server/tests/setup-checklist.test.ts | 235 +++++++++++++++--- 4 files changed, 296 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c697128537..b505df68ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -143,9 +143,10 @@ changes for a deployment. - Check your setup asks the new `GET /api/setup` whether Intelligence, the model key, the Bot computer, more apps and Slack and Teams work, row by row. An admin, or somebody running OpenBot alone, is shown the page or the variables that close a gap; everybody else only that their admin sets it up. - Check again asks Intelligence and the model provider again, at most every 10 seconds. To check the - Bot computer, the server sends each running computer its network policy, at start-up and on Check - again, and sees that it answers; it wakes none. + Check again asks Intelligence and the model provider again, at most every 10 seconds. Reading the + checklist does the same once an answer has failed or is five minutes old, without waiting for it, + so the next read has the new answer. To check the Bot computer, the server sends each running + computer its network policy whenever it asks, and sees that it answers; it wakes none. - A connection started from onboarding, or cancelled at the app's own sign-in page, now returns to the screen it started on instead of the Connections list. diff --git a/app/src/components/onboarding/setup-checklist.tsx b/app/src/components/onboarding/setup-checklist.tsx index a72ba06fd5..70624db64f 100644 --- a/app/src/components/onboarding/setup-checklist.tsx +++ b/app/src/components/onboarding/setup-checklist.tsx @@ -67,8 +67,9 @@ export function SetupChecklist({ /* * Check again asks the server to ask Intelligence, the model provider and the computers again, - * which a plain refetch would not: those answers are kept from the last time they were asked. A - * checklist that could not be read at all is read again first. + * and waits for their answers. A plain refetch is answered from what they last said, and asks + * again only for the read after it, once an answer failed or is a few minutes old. A checklist + * that could not be read at all is read again first. */ const checking = checklist.isFetching || recheck.isPending; const checkAgain = ( diff --git a/server/src/setup/checklist.ts b/server/src/setup/checklist.ts index c4b900b52e..14fa298e4d 100644 --- a/server/src/setup/checklist.ts +++ b/server/src/setup/checklist.ts @@ -23,7 +23,8 @@ import type { RuntimeEntitlementResponse } from "../self-host-banner"; * "set" because the environment or the vault has one, and nothing here reads, masks, measures or * echoes what it is. The two outbound reads, Intelligence's entitlement and one `GET /v1/models/` * at the provider, are the ones the server already makes at boot; "Check again" repeats them, at - * most once every {@link RECHECK_INTERVAL_MS}. + * most once every {@link RECHECK_INTERVAL_MS}. A plain read repeats them too, under the same limit, + * once an answer has failed or is older than {@link ANSWER_KEPT_MS}, for the read after it. * * WHO READS IT DECIDES WHAT IT SAYS. An admin, and somebody trying OpenBot alone on their own * machine, is told how to fix each gap: the page to open, or the variable to set. Everybody else is @@ -43,6 +44,9 @@ const QUICK_START = "https://github.com/CopilotKit/openbot#quick-start"; /** How long a "Check again" is answered from the last one, so a held button is not a flood. */ export const RECHECK_INTERVAL_MS = 10_000; +/** How long a working answer from another service is served before a read asks again. */ +export const ANSWER_KEPT_MS = 5 * 60_000; + /** How long Intelligence has to answer before the row says it could not be reached. */ const INTELLIGENCE_TIMEOUT_MS = 5_000; @@ -444,6 +448,10 @@ export type SetupSources = { productName: string; /** A local install for one person, whose only reader is the person running it. */ localEvaluation: boolean; + /** + * The facts, from the last answers that are all in. Reading them also asks again, in the + * background, when one of those answers failed or is older than {@link ANSWER_KEPT_MS}. + */ facts: () => Promise; /** Ask Intelligence, the model provider and the computers again. Resolves when all have answered. */ recheck: () => Promise; @@ -470,8 +478,12 @@ function withinTime(promise: Promise, ms: number): Promise { * * Built once at boot and asked on every request, so a credential added a moment ago shows up on the * next read. The verdicts that need another service, Intelligence's entitlement, the model - * provider's answer and the computers', are kept from the last time they were asked (at boot) and - * asked again on `recheck`. + * provider's answer and the computers', are kept from the last time they were asked, and asked + * again on `recheck` or by a read that finds one failed or old. + * + * A read is answered from the last round of answers that is all in, never from one still being + * asked, so it waits only at boot, before there is any answer to give. A fleet slow to answer a + * recheck holds up the Check again that asked, and no read. */ export function createSetupSources(input: { productName: string; @@ -520,27 +532,68 @@ export function createSetupSources(input: { return "needs_setup"; } }; - let computerStatus = askComputer(); - let intelligence = askIntelligence(); - let modelCheck: Promise< - { outcome: ModelCheckOutcome; stamp: string | null } | undefined - > = (input.modelCheckAtBoot ?? Promise.resolve(undefined)).catch( - () => undefined, - ); + /** One round of asking, and when it started. */ + type Round = { + at: number; + intelligence: Promise; + modelCheck: Promise< + { outcome: ModelCheckOutcome; stamp: string | null } | undefined + >; + computerStatus: Promise; + }; + // The round a read is answered from: boot's, until a later one is all in. + let kept: Round = { + at: Date.now(), + intelligence: askIntelligence(), + modelCheck: (input.modelCheckAtBoot ?? Promise.resolve(undefined)).catch( + () => undefined, + ), + computerStatus: askComputer(), + }; let lastRecheck: { at: number; done: Promise } | null = null; + const recheck = () => { + const now = Date.now(); + if (lastRecheck && now - lastRecheck.at < RECHECK_INTERVAL_MS) + return lastRecheck.done; + const check = input.model.check; + const round: Round = { + at: now, + intelligence: askIntelligence(), + computerStatus: askComputer(), + modelCheck: check + ? (async () => { + const { stamp } = await modelKeyOf(keyFacts); + return stamp === null + ? undefined + : { outcome: await check(), stamp }; + })().catch(() => undefined) + : kept.modelCheck, + }; + const done = Promise.all([ + round.intelligence, + round.modelCheck, + round.computerStatus, + ]).then(() => { + kept = round; + }); + lastRecheck = { at: now, done }; + return done; + }; + return { productName: input.productName, localEvaluation: input.config.singleUser, facts: async () => { - const [key, check, intelligenceNow, computerNow] = await Promise.all([ + const round = kept; + const [key, check, intelligence, computerStatus] = await Promise.all([ modelKeyOf(keyFacts), - modelCheck, - intelligence, - computerStatus, + round.modelCheck, + round.intelligence, + round.computerStatus, ]); - return { - intelligence: intelligenceNow, + const facts: SetupFacts = { + intelligence, model: { provider: input.model.provider, source: key.source, @@ -548,33 +601,40 @@ export function createSetupSources(input: { model: input.model.defaultModel, }, computer: input.computer, - computerStatus: computerNow, + computerStatus, apps: input.apps, chat: input.chat, }; - }, - recheck: () => { - const now = Date.now(); - if (lastRecheck && now - lastRecheck.at < RECHECK_INTERVAL_MS) - return lastRecheck.done; - intelligence = askIntelligence(); - computerStatus = askComputer(); - const check = input.model.check; - if (check) { - modelCheck = (async () => { - const { stamp } = await modelKeyOf(keyFacts); - return stamp === null ? undefined : { outcome: await check(), stamp }; - })().catch(() => undefined); + if (Date.now() - round.at >= ANSWER_KEPT_MS || failed(facts)) { + // For the next read, so this one never waits on it. A round that throws keeps the answers + // it would have replaced, and Check again reports it. + recheck().catch(() => {}); } - const done = Promise.all([intelligence, modelCheck, computerStatus]).then( - () => {}, - ); - lastRecheck = { at: now, done }; - return done; + return facts; }, + recheck, }; } +/** + * Whether an answer from another service failed in a way asking again could change: Intelligence + * did not confirm, the provider refused the key in use or has not answered about it, or a set-up + * computer did not take its policy. A key set to a custom endpoint, no key, and no computer running + * are not failures, and wait for the answer to grow old. + */ +function failed(facts: SetupFacts): boolean { + const { model } = facts; + const keyInHand = + model.source === "credential" || model.source === "environment"; + return ( + facts.intelligence.state !== "ready" || + (keyInHand && + model.verdict !== "available" && + model.verdict !== "cannot_check") || + (facts.computer && facts.computerStatus === "needs_setup") + ); +} + /** * `GET /` answers the checklist for whoever is signed in. `POST /check` asks Intelligence, the * model provider and the computers again first, then answers the same way. diff --git a/server/tests/setup-checklist.test.ts b/server/tests/setup-checklist.test.ts index c97d3ffe92..c8f7676bc0 100644 --- a/server/tests/setup-checklist.test.ts +++ b/server/tests/setup-checklist.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, jest, test } from "bun:test"; +import { describe, expect, jest, setSystemTime, test } from "bun:test"; import { createApp } from "../src/app"; import { loadConfig } from "../src/config"; import type { CredentialStatus } from "../src/credentials"; import type { ModelCheckOutcome } from "../src/model-availability-check"; import type { RuntimeEntitlementResponse } from "../src/self-host-banner"; import { + ANSWER_KEPT_MS, createSetupSources, intelligenceFact, modelKeyOf, @@ -13,6 +14,7 @@ import { type SetupChecklist, type SetupFacts, type SetupRow, + type SetupSources, setupRows, } from "../src/setup/checklist"; import { createComputerCheck } from "../src/setup/computer-check"; @@ -465,7 +467,7 @@ function appWith(input: { who: keyof typeof PEOPLE; credentials?: CredentialStatus[]; modelCheckAtBoot?: ModelCheckOutcome; - /** What a "Check again" hears from the provider; counted in `checks`. */ + /** What asking the provider again hears; counted in `checks`. */ modelCheck?: () => ModelCheckOutcome; entitlements?: () => Promise; computer?: boolean; @@ -473,39 +475,43 @@ function appWith(input: { apps?: boolean; chat?: boolean; signedIn?: boolean; + /** Another app's sources, for a second reader of the same server. */ + setup?: SetupSources; }) { const config = loadConfig(testEnvironment(input.environment)); const person = PEOPLE[input.who]; const counts = { checks: 0 }; - const setup = createSetupSources({ - productName: PRODUCT, - config, - environment: testEnvironment(input.environment), - model: { - provider: "openai", - keyId: "openai-api-key", - defaultModel: "gpt-5.6-terra", - plan: false, - check: async () => { - counts.checks += 1; - return input.modelCheck?.() ?? { kind: "available" }; + const setup = + input.setup ?? + createSetupSources({ + productName: PRODUCT, + config, + environment: testEnvironment(input.environment), + model: { + provider: "openai", + keyId: "openai-api-key", + defaultModel: "gpt-5.6-terra", + plan: false, + check: async () => { + counts.checks += 1; + return input.modelCheck?.() ?? { kind: "available" }; + }, }, - }, - credentials: { list: async () => input.credentials ?? [] }, - ...(input.modelCheckAtBoot - ? { - modelCheckAtBoot: Promise.resolve({ - outcome: input.modelCheckAtBoot, - stamp: "environment", - }), - } - : {}), - entitlements: input.entitlements ?? (async () => ENTITLED), - computer: input.computer ?? Boolean(config.computer), - computerCheck: input.computerCheck ?? (async () => "ready"), - apps: { configured: input.apps ?? false, provider: "composio" }, - chat: input.chat ?? false, - }); + credentials: { list: async () => input.credentials ?? [] }, + ...(input.modelCheckAtBoot + ? { + modelCheckAtBoot: Promise.resolve({ + outcome: input.modelCheckAtBoot, + stamp: "environment", + }), + } + : {}), + entitlements: input.entitlements ?? (async () => ENTITLED), + computer: input.computer ?? Boolean(config.computer), + computerCheck: input.computerCheck ?? (async () => "ready"), + apps: { configured: input.apps ?? false, provider: "composio" }, + chat: input.chat ?? false, + }); const app = createApp( config, { @@ -523,9 +529,15 @@ function appWith(input: { ...(Array.from({ length: 31 }) as never[]), { setup } as never, ); - return Object.assign(app, { counts }); + return Object.assign(app, { counts, setup }); } +/** + * Lets every ask a read started in the background finish. The stand-ins answer at once, so one + * turn of the event loop is enough. + */ +const settled = () => new Promise((resolve) => setTimeout(resolve, 0)); + async function checklistFrom( app: ReturnType, method: "GET" | "POST" = "GET", @@ -616,7 +628,7 @@ describe("GET /api/setup", () => { }); }); - test("a key added since the boot check is not checked until Check again asks", async () => { + test("a key added since the boot check is not checked, and the next read has its answer", async () => { const app = appWith({ who: "admin", environment: { OPENAI_API_KEY: "sk-set" }, @@ -645,16 +657,17 @@ describe("GET /api/setup", () => { const before = await checklistFrom(app); expect(row(before.checklist.rows, "model").status).toBe("not_checked"); - expect(app.counts.checks).toBe(0); - const after = await checklistFrom(app, "POST"); + // The read asked about the new key for the one after it, without waiting for the answer. + await settled(); expect(app.counts.checks).toBe(1); + const after = await checklistFrom(app); expect(row(after.checklist.rows, "model")).toMatchObject({ status: "needs_setup", detail: "OpenAI refused the model key, so OpenBot can't answer.", }); - // A second press inside the interval is answered from the first, with no second call out. + // Check again inside the interval is answered from that ask, with no second call out. expect(RECHECK_INTERVAL_MS).toBeGreaterThan(1000); await checklistFrom(app, "POST"); expect(app.counts.checks).toBe(1); @@ -775,6 +788,137 @@ describe("GET /api/setup", () => { }); }); +/* + * The answers that need another service are kept between reads, and a read asks again for any + * that failed or are old. Without that, an Intelligence slower to start than OpenBot read red until + * somebody pressed Check again, and a key refused days after boot still read Ready. + */ +describe("answers a read asks again for", () => { + test("a failed answer is asked again, so the next read has the new one", async () => { + let asked = 0; + const app = appWith({ + who: "admin", + environment: {}, + entitlements: async () => { + asked += 1; + if (asked === 1) throw new Error("ECONNREFUSED"); + return ENTITLED; + }, + }); + const first = await checklistFrom(app); + expect(row(first.checklist.rows, "intelligence").status).toBe( + "needs_setup", + ); + await settled(); + expect(asked).toBe(2); + const next = await checklistFrom(app); + expect(row(next.checklist.rows, "intelligence").status).toBe("ready"); + }); + + test("an answer older than a few minutes is asked again, so a later failure shows", async () => { + let refused = false; + let asked = 0; + const app = appWith({ + who: "member", + environment: { OPENAI_API_KEY: "sk-set" }, + modelCheckAtBoot: { kind: "available" }, + modelCheck: () => ({ + kind: "warning", + line: { + type: "model-credential-rejected", + provider: "openai", + model: "gpt-5.6-terra", + status: 401, + fix: "replace it", + }, + }), + entitlements: async () => { + asked += 1; + return refused ? UNLICENSED : ENTITLED; + }, + }); + try { + const fresh = await checklistFrom(app); + await settled(); + expect(row(fresh.checklist.rows, "intelligence").ready).toBe(true); + expect(row(fresh.checklist.rows, "model").ready).toBe(true); + // Working answers inside the window are served as they are. + expect([asked, app.counts.checks]).toEqual([1, 0]); + + refused = true; + setSystemTime(new Date(Date.now() + ANSWER_KEPT_MS + 1)); + const stale = await checklistFrom(app); + expect(row(stale.checklist.rows, "intelligence").ready).toBe(true); + await settled(); + expect([asked, app.counts.checks]).toEqual([2, 1]); + const next = await checklistFrom(app); + expect(row(next.checklist.rows, "intelligence").status).toBe( + "needs_setup", + ); + expect(row(next.checklist.rows, "model").status).toBe("needs_setup"); + } finally { + setSystemTime(); + } + }); + + test("reads inside the interval share one ask, whoever reads", async () => { + let asked = 0; + const admin = appWith({ + who: "admin", + environment: {}, + entitlements: async () => { + asked += 1; + throw new Error("ECONNREFUSED"); + }, + }); + const member = appWith({ + who: "member", + environment: {}, + setup: admin.setup, + }); + await checklistFrom(admin); + await checklistFrom(member); + await settled(); + await checklistFrom(member); + await checklistFrom(admin); + await settled(); + // Once at boot, and once for every read in the interval after it. + expect(asked).toBe(2); + }); + + test("a read never waits on an ask in flight", async () => { + const slow = Promise.withResolvers<"ready">(); + let asked = 0; + const app = appWith({ + who: "admin", + environment: {}, + computer: true, + computerCheck: () => { + asked += 1; + return asked === 1 ? Promise.resolve("needs_setup") : slow.promise; + }, + }); + const first = await checklistFrom(app); + expect(row(first.checklist.rows, "computer").status).toBe("needs_setup"); + await settled(); + expect(asked).toBe(2); + + // The fleet is still answering: the read is given the last answer, at once. + const during = await Promise.race([ + checklistFrom(app), + new Promise<"waited">((resolve) => setTimeout(resolve, 1_000, "waited")), + ]); + expect(during).not.toBe("waited"); + if (during !== "waited") + expect(row(during.checklist.rows, "computer").status).toBe("needs_setup"); + + slow.resolve("ready"); + await settled(); + const after = await checklistFrom(app); + expect(row(after.checklist.rows, "computer").status).toBe("ready"); + }); +}); + test("healthy sequential computer pushes keep their own deadlines in the checklist", async () => { const first = Promise.withResolvers(); const second = Promise.withResolvers(); @@ -892,16 +1036,25 @@ test("a configured computer that refuses its policy is Needs setup, then recover const response = await app.request("/api/setup"); const first = await response.json(); expect(row(first.checklist.rows, "computer").status).toBe("needs_setup"); + // The read asked again in the background, and heard the same refusal. + await settled(); ready = true; - const after = await app.request("/api/setup/check", { - method: "POST", - }); - expect(row((await after.json()).checklist.rows, "computer").status).toBe( - "ready", - ); + // Check again inside the interval would be answered from that ask, so it comes after it. + setSystemTime(new Date(Date.now() + RECHECK_INTERVAL_MS)); + try { + const after = await app.request("/api/setup/check", { + method: "POST", + }); + expect(row((await after.json()).checklist.rows, "computer").status).toBe( + "ready", + ); + } finally { + setSystemTime(); + } expect(tokens).toEqual([ "configured-computer-token", "configured-computer-token", + "configured-computer-token", ]); }); From 6ada9027410ccd67ab31b351860617035db53349 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Thu, 29 Oct 2026 14:42:30 -0700 Subject: [PATCH 26/47] docs(onboarding): describe the steps as OpenBot's own The changelog said the intro was replaced by another product's steps, and two comments cited it, which reads as a skin being added. They now say what OpenBot does. The changelog also said a cancelled connect returns to where it started from anywhere; only Connect your apps changed, so it says that. --- CHANGELOG.md | 7 ++++--- app/src/components/onboarding/onboarding-frame.tsx | 6 +++--- server/src/setup/checklist.ts | 3 +-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b505df68ec..8b5de0ad9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -135,7 +135,7 @@ changes for a deployment. ### First run shows what OpenBot does, one step at a time -- The looping intro is replaced by OpenDuet's onboarding steps: what OpenBot does (a computer of its +- The looping intro is replaced by steps that show what OpenBot does (a browser and files of its own, a team of Bots, and asking before it acts), then Connect your apps, Get notified, Check your setup, and the name step, whose Get started opens the app. On a desktop the steps are listed on the left beside a picture of each; on a phone they come one at a time. A reload, browser Back and a @@ -147,8 +147,9 @@ changes for a deployment. checklist does the same once an answer has failed or is five minutes old, without waiting for it, so the next read has the new answer. To check the Bot computer, the server sends each running computer its network policy whenever it asks, and sees that it answers; it wakes none. -- A connection started from onboarding, or cancelled at the app's own sign-in page, now returns to - the screen it started on instead of the Connections list. +- An app connected from Connect your apps comes back to that step from the app's own sign-in page, + whether it connected, failed or was cancelled there. A connection started from Connections or an + app's admin page comes back where it did before. ### OpenBot has a new look diff --git a/app/src/components/onboarding/onboarding-frame.tsx b/app/src/components/onboarding/onboarding-frame.tsx index 462fa121ec..f58bac9362 100644 --- a/app/src/components/onboarding/onboarding-frame.tsx +++ b/app/src/components/onboarding/onboarding-frame.tsx @@ -13,9 +13,9 @@ import { cn } from "@/lib/utils"; * screen and held in view while the left scrolls, the step's picture. Steps already reached can be * picked from the list; the ones ahead cannot. * - * BELOW `lg`, ONE COLUMN, the way OpenDuet lays a step out on a phone: a round back chevron at the - * top left, the steps as dashes across the top, the heading and its picture, and the buttons pinned - * to the bottom, full width, with a fade above them where the step scrolls under. + * BELOW `lg`, ONE COLUMN, for a phone: a round back chevron at the top left, the steps as dashes + * across the top, the heading and its picture, and the buttons pinned to the bottom, full width, + * with a fade above them where the step scrolls under. * * The frame owns where things are, never what a step says. Back, the list and the buttons sit in * the same place on every step, so moving through onboarding never moves the control somebody is diff --git a/server/src/setup/checklist.ts b/server/src/setup/checklist.ts index 14fa298e4d..c38aaa1782 100644 --- a/server/src/setup/checklist.ts +++ b/server/src/setup/checklist.ts @@ -33,8 +33,7 @@ import type { RuntimeEntitlementResponse } from "../self-host-banner"; * reader rather than by the page: onboarding is a page anybody opens, so the server decides per * reader and a member's browser never holds the deployment's configuration vocabulary. * - * Ported from OpenDuet's checklist without its Google rows: OpenBot does not ship a Google app, and - * a person's own apps are the Connect your apps step's to draw. + * A person's own apps have no row here: the Connect your apps step draws those. */ /** The guides a fix links to. The repository is where the docs ship from. */ From 91db1b2306553455e0354ed1d922291f5694853b Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Fri, 30 Oct 2026 15:46:21 -0700 Subject: [PATCH 27/47] fix(onboarding): a step's buttons stay on a laptop's screen At 1280 by 800 the self-hosting step and the name step ran past the bottom of the window, cutting Continue and Back in half; at 700 tall, four steps did. The left pane stacked the nine-step list, the step's words and its buttons, and the page was left to scroll. On a desktop the frame is now one screen tall and the step list's row is the only one that can shrink, so the step's words and its buttons always keep their full size. When the screen is too short for all of it, the list scrolls within itself, fades at the edge it scrolls under, and keeps the step on screen in view. Its rows and the space under it are a little tighter, so every step still fits whole at 1280 by 800. Only a window too short for the words and buttons themselves scrolls the page. The picture pane now stretches to the screen instead of being set to its height: a set height spanning every row gave that height to the empty last row before the list got any. It drops the phone's margin on a desktop, which would stop it short of the bottom on the name step. --- .../onboarding/onboarding-frame.tsx | 58 +++++++++++++---- app/tests/onboarding-frame.test.tsx | 65 ++++++++++++++++++- 2 files changed, 111 insertions(+), 12 deletions(-) diff --git a/app/src/components/onboarding/onboarding-frame.tsx b/app/src/components/onboarding/onboarding-frame.tsx index f58bac9362..8383db7578 100644 --- a/app/src/components/onboarding/onboarding-frame.tsx +++ b/app/src/components/onboarding/onboarding-frame.tsx @@ -1,5 +1,5 @@ import { IconCheck, IconChevronLeft } from "@tabler/icons-react"; -import type { ReactNode } from "react"; +import { type ReactNode, useLayoutEffect, useRef } from "react"; import AgentOrb from "@/components/agents/orb/agent-orb"; import { buttonVariants } from "@/components/ui/button"; import { appConfig } from "@/lib/generated/application-config"; @@ -10,8 +10,16 @@ import { cn } from "@/lib/utils"; * * FROM `lg` UP, TWO PANES. On the left, the steps as a list with the one on screen marked, then * the step's heading and sentence, then Continue and Back. On the right, filling the rest of the - * screen and held in view while the left scrolls, the step's picture. Steps already reached can be - * picked from the list; the ones ahead cannot. + * screen, the step's picture. Steps already reached can be picked from the list; the ones ahead + * cannot. + * + * ONE SCREEN TALL ON A DESKTOP, AND THE LIST IS WHAT GIVES. Nine steps, a heading, a few lines and + * the buttons are taller than a laptop's window on the longer steps, which used to cut Continue and + * Back off at the bottom. So the frame is the window's height, the step's words and its buttons + * keep their full size, and the step list takes what is left: on a short screen it scrolls within + * itself, with the step on screen kept in view. Its rows are spaced so that every step still fits + * whole in a 1280 by 800 window; the list scrolls only below that, and only a window too short for + * the words and the buttons themselves scrolls the page. * * BELOW `lg`, ONE COLUMN, for a phone: a round back chevron at the top left, the steps as dashes * across the top, the heading and its picture, and the buttons pinned to the bottom, full width, @@ -79,10 +87,14 @@ export function OnboardingFrame({ "h-[52px] w-full rounded-full px-5 type-sub font-semibold lg:h-10 lg:w-auto lg:min-w-32 lg:rounded-lg"; return (
-
+ {/* + * On a desktop the list's row is the one that shrinks (`minmax(10rem,auto)` above, against + * the words' and the buttons' `auto`), down to room for the product's name and a step or two. + */} +
{/* * The phone's chrome. Back is not drawn on the first step, rather than drawn disabled: there * is nowhere behind it, and a control that does nothing is one more thing a screen reader @@ -112,7 +124,7 @@ export function OnboardingFrame({
+ {/* + * On a desktop the picture fills its column by stretching, at most a screen tall, rather than + * being set to the screen's height: a set height spanning every row would hand its height to + * the empty last row before the step list got its own. No margin there either, which would + * stop it short of the bottom. + */} {demo ? (
void; }) { + // On a screen too short for every step, the list scrolls: keep the one on screen in it, before + // the first paint, so arriving on a late step never shows the list's top for a frame. + const list = useRef(null); + useLayoutEffect(() => { + list.current?.children[index]?.scrollIntoView?.({ block: "nearest" }); + }, [index]); return ( -