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/CHANGELOG.md b/CHANGELOG.md index 27745c6f84..8b5de0ad9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,36 @@ 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 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 + 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. 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. +- 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 + +- 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 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 With generative UI on, a Bot builds an interface from the A2UI 1.0 basic components instead of the 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/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/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index 9ca9845070..d746511206 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,5 +1,14 @@ -import Avatar from "boring-avatars"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +/** + * 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. + * + * 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 +25,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..4f953f922b 100644 --- a/app/src/components/agents/orb/agent-orb.tsx +++ b/app/src/components/agents/orb/agent-orb.tsx @@ -1,437 +1,52 @@ -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 { + className?: string; /** - * Live audio level, 0–1. Pass the `MotionValue` from `useAudioAmplitude` so - * the signal never re-renders React. + * 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. */ - 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; - }; - size?: string; - /** Shared AI state driving speed, scale, saturation and reactivity. */ + size?: `${number}px`; 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) { + if (!/^\d+(\.\d+)?px$/.test(size)) { + throw new Error(`AgentOrb's size is in px, as "64px", not "${size}".`); + } 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/src/components/agents/orb/bot-orb-draw.ts b/app/src/components/agents/orb/bot-orb-draw.ts new file mode 100644 index 0000000000..f3e3f2b370 --- /dev/null +++ b/app/src/components/agents/orb/bot-orb-draw.ts @@ -0,0 +1,782 @@ +/** + * 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 + * 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 + * 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: 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: { + 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, 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"); + +/** + * 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 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); + } + 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. */ +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, turn: number) { + const rot = turn + (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, 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, 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, turn).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); +} + +/** 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) { + 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( + 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(); +} + +/** 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, + R: number, + S: OrbPose, + pal: Palette, + look: OrbLook, + dark: boolean, + size: number, +) { + const na = S.noticeAge; + 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(); + 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, 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); + 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 * look.eyes; + 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 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. + */ +export function drawBotOrb( + ctx: Ctx, + size: number, + S: OrbPose, + { dark, look }: { dark: boolean; look: OrbLook }, +) { + const finish = finishPhase(S.finishAge); + const pal = palette( + look.palette, + 0.9 + 0.14 * clamp(S.listen) + 0.08 * S.think + 0.12 * clamp(S.done), + look.hue - 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, 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. */ +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 new file mode 100644 index 0000000000..d628b9cd45 --- /dev/null +++ b/app/src/components/agents/orb/bot-orb.tsx @@ -0,0 +1,542 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { cn } from "@/lib/utils"; +import { + blinkCurve, + clamp, + drawBotOrb, + FINISH_HELD, + FINISH_S, + mulberry32, + nodCurve, + OPENBOT_LOOK, + type OrbLook, + type OrbPose, + orbLookFor, + restLook, + samePicture, + 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, 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, + 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, 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, + 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: small ? STILL_T : 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 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, look: OrbLook) { + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const box = size * (1 + 2 * SPILL); + let dpr = 0; + 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); + 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); + drawBotOrb(ctx, size, pose, { dark, look }); + }; +} + +/** + * 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 staggers = 0; +const secondsSince = (start: number) => (performance.now() - start) / 1000; + +/** + * 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 + * state's pose, drawn again only when the state or the theme changes, and its eyes stay put. + */ +export function BotOrb({ + className, + seed, + size, + state = "idle", + still = false, +}: { + className?: string; + seed?: string; + size: number; + state?: BotOrbState; + still?: boolean; +}) { + const boxRef = useRef(null); + const canvasRef = useRef(null); + const reduced = usePrefersReducedMotion() || still; + 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); + 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, look) : null; + if (!draw || !clock.current) return; + const { stagger } = clock.current; + const held = + state === "finished" + ? { ...settled(shown.current ?? "idle"), finishedAt: 0 } + : settled(state); + const paint = () => + draw( + poseAt(held, STILL_T, stagger, { reduced: true, small: size < SMALL }), + ); + paint(); + const theme = new MutationObserver(paint); + theme.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + const unwatch = watchResolution(paint); + return () => { + theme.disconnect(); + unwatch(); + }; + }, [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 + // 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, look) : null; + if (!draw || !box || !clock.current || !gaze.current) return; + const { start, stagger } = 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) { + motion.current = { ...motion.current, noticedAt: t }; + } + const pose = poseAt(motion.current ?? settled(state), t, stagger, { + reduced: false, + small, + }); + draw(lookAt(pose, eyes)); + }; + 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, look]); + + 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/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({ + ) : 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."} + + ) : ( + +
    + {rows?.map((app, at) => ( +
  • + {at > 0 ? : null} + { + setProblem(null); + connect.mutate(app.key); + }} + /> +
  • + ))} +
+
+ )} + + ); +} + +/** One app: its mark, its name, and Connected, Connect, or why neither. */ +function AppRow({ + app, + botName, + busy, + connecting, + checking, + onConnect, +}: { + app: AppAccess; + botName: string; + busy: boolean; + connecting: boolean; + checking: boolean; + onConnect: () => void; +}) { + const note = noteFor(app, botName); + return ( + + + + + + {app.displayName} + {note ? {note} : null} + + + {checking ? ( + Checking… + ) : app.state === "ready" ? ( + + + Connected + + ) : connectsHere(app) ? ( + + ) : null} + + + ); +} 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/demos/approval-demo.tsx b/app/src/components/onboarding/demos/approval-demo.tsx new file mode 100644 index 0000000000..497195f77b --- /dev/null +++ b/app/src/components/onboarding/demos/approval-demo.tsx @@ -0,0 +1,113 @@ +import { IconCheck, IconMail } from "@tabler/icons-react"; +import { BotOrb } from "@/components/agents/orb/bot-orb"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { DemoStage } from "../demo-stage"; + +/** The three answers an approval offers, in the words the real card uses. */ +export const APPROVAL_CHOICES = [ + "Decline", + "Allow once", + "Always allow here", +] as const; + +/** + * 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 + * decides a real request; this one decides nothing, so it is drawn here rather than borrowed. The + * buttons say what the real card says, Allow once first and loudest, Always allow here the quietest. + * + * Three beats: waiting for you, Allow once pressed, sent. With reduced motion it waits, because the + * question is the point. + */ +export function ApprovalDemo() { + 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"} + +

+
+ + ); +} 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/onboarding/notify-step.tsx b/app/src/components/onboarding/notify-step.tsx new file mode 100644 index 0000000000..c7fb86341c --- /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/onboarding-frame.tsx b/app/src/components/onboarding/onboarding-frame.tsx new file mode 100644 index 0000000000..8383db7578 --- /dev/null +++ b/app/src/components/onboarding/onboarding-frame.tsx @@ -0,0 +1,399 @@ +import { IconCheck, IconChevronLeft } from "@tabler/icons-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"; +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, 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, + * 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 ( +
+ {/* + * 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 + * stops on. + */} +
+ {index > 0 ? ( + + ) : null} + +
+ +
+ +
+ {intro} +
+ + {/* + * 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 ? ( +
+
{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; +}) { + // 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 ( + + ); +} + +/** + * 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/src/components/onboarding/setup-checklist.tsx b/app/src/components/onboarding/setup-checklist.tsx new file mode 100644 index 0000000000..70624db64f --- /dev/null +++ b/app/src/components/onboarding/setup-checklist.tsx @@ -0,0 +1,269 @@ +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, + * 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 = ( + + ); + + 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 ( + + + + + + {/* `line-clamp-none` makes the title a block, so the tag keeps its own space. */} + + {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/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/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/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/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/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/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. * diff --git a/app/src/routes/_authed/onboarding.tsx b/app/src/routes/_authed/onboarding.tsx index 68d318767f..c0f6b9c343 100644 --- a/app/src/routes/_authed/onboarding.tsx +++ b/app/src/routes/_authed/onboarding.tsx @@ -1,31 +1,191 @@ -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 { + 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 { 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. + */ +/** 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) && + !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 +198,427 @@ 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. - */} -
- -
-
-
- ); -} +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: () => , +}; -function ComputerUseStep() { - return ( -
-

- Each Bot has its own browser and files -

-
-
- - -
-
- ); -} +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: () => , +}; -/** What a roster card needs — placeholders carry these three fields and nothing more. */ -type RosterCard = Pick; +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: () => , +}; -/** - * 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. +/* + * 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 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", - }, -]; - -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, - })), - ]; - - 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: "You decide what it asks", + intro: () => ( + +

+ 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. +

+
+ ), + 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 ?? + (search.open ? STEP_PAGES[search.open] : undefined); - 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)} + openPage={(target) => move("name", "forward", target.path)} + 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/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/src/skins/opendots/sidebar.tsx b/app/src/skins/opendots/sidebar.tsx index 7011d53dca..22599a8ef4 100644 --- a/app/src/skins/opendots/sidebar.tsx +++ b/app/src/skins/opendots/sidebar.tsx @@ -207,8 +207,6 @@ function BotsSection({ className={cn( navItem, "min-h-[42px] gap-[9px] px-2.5 py-1.5 text-[12px] text-(--opendots-dot-ink) hover:bg-(--opendots-hover)", - // The row's only drawing is the avatar, which keeps its own size. - "[&_svg]:size-full", active && "bg-(--opendots-active) hover:bg-(--opendots-active)", )} diff --git a/app/src/skins/openduet/header.tsx b/app/src/skins/openduet/header.tsx index 85e81c0618..1880ffc8ad 100644 --- a/app/src/skins/openduet/header.tsx +++ b/app/src/skins/openduet/header.tsx @@ -9,7 +9,7 @@ import { } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import AgentOrb from "@/components/agents/orb/agent-orb"; -import type { AIState } from "@/components/agents/orb/ai-core"; +import type { AIState } from "@/components/agents/orb/agent-orb"; import { currentUserQueryOptions } from "@/lib/auth/queries"; import { type ChannelWork, diff --git a/app/src/skins/openmuse/frame.tsx b/app/src/skins/openmuse/frame.tsx index 5108ce9f4b..c996720ee9 100644 --- a/app/src/skins/openmuse/frame.tsx +++ b/app/src/skins/openmuse/frame.tsx @@ -10,7 +10,12 @@ import { } from "@tabler/icons-react"; import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { Link, useParams, useRouterState } from "@tanstack/react-router"; -import { type ReactNode, useEffect, useState } from "react"; +import { + type ReactNode, + useEffect, + useState, + useSyncExternalStore, +} from "react"; import { AbstractAvatar } from "@/components/agents/abstract-avatar"; import AgentOrb from "@/components/agents/orb/agent-orb"; import { BotsNavBadge } from "@/components/bot-profile/attention"; @@ -53,6 +58,25 @@ const TABS: { id: MuseTab; label: string; icon: MuseIcon }[] = [ { id: "apps", label: "Apps", icon: IconTriangleSquareCircle }, ]; +const WIDE = "(min-width: 900px)"; + +/** + * Whether the window is wide enough for the header's larger face, kept current as it is resized. + * The face is drawn on a canvas at a size in px, so the header picks the size here rather than in + * its classes. + */ +function useWide(): boolean { + return useSyncExternalStore( + (onChange) => { + 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/src/theme.css b/app/src/theme.css index 8e30caa8b0..d7e320b202 100644 --- a/app/src/theme.css +++ b/app/src/theme.css @@ -1,76 +1,151 @@ @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`, kept for focus, + * selection and links and never for headings (shadcn's `--accent` stays the neutral hover fill); it + * reaches the focus ring today. Four status colours for how work stands, and the glass and shadow + * below, are here for the screens that adopt them. 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 +177,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 +223,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 +305,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/bot-orb.test.tsx b/app/tests/bot-orb.test.tsx new file mode 100644 index 0000000000..2ca0690592 --- /dev/null +++ b/app/tests/bot-orb.test.tsx @@ -0,0 +1,794 @@ +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 { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import AgentOrb, { type AIState } from "@/components/agents/orb/agent-orb"; +import { + BotOrb, + type BotOrbState, + followPointer, + restingGaze, +} from "@/components/agents/orb/bot-orb"; +import { + drawBotOrb, + FINISH_HELD, + FINISH_S, + finishPhase, + OPENBOT_LOOK, + ORB_PALETTES, + type OrbLook, + type OrbPose, + oklch, + orbLookFor, + palette, + RAYS, + samePicture, + 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, + 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, 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 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(x: number, y: number) { + marks.push(`blob ${x.toFixed(2)},${y.toFixed(2)}`); + return 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; + }, + }); +} + +/** 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, + look, + }); + 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", + "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 look", () => { + for (const seed of TEAM) { + expect(orbLookFor(seed)).toEqual(orbLookFor(seed)); + expect(ORB_PALETTES).toContain(orbLookFor(seed).palette); + } + // 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); + 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", () => { + 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); + }); + + 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", () => { + 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); + }); +}); + +/** 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, 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(); + 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(now = performance.now()) { + const due = [...queue.values()]; + queue.clear(); + 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("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(); + 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(); + } + }); + }); + + 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(); + 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(); + } + }); +}); + +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(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(); + }); + + 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; + }; + // 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")); + }); + + 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"); + }); + + 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/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", () => { diff --git a/app/tests/onboarding-frame.test.tsx b/app/tests/onboarding-frame.test.tsx new file mode 100644 index 0000000000..0207a0042f --- /dev/null +++ b/app/tests/onboarding-frame.test.tsx @@ -0,0 +1,644 @@ +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"); + // Without the phone's margin under it, which would stop the stretched pane short of the bottom. + expect(demo?.className).toContain("lg:my-0"); +}); + +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:flex"); + expect(progress(view).parentElement?.className).toContain("lg:hidden"); +}); + +/* + * A SHORT SCREEN. Nine steps, a heading, a few lines and the buttons used to run past the bottom of + * a laptop's window on the longer steps, cutting Continue and Back in half. + * + * WHAT THIS CAN'T PROVE. happy-dom lays nothing out, so no test here can show that the buttons fit + * on any given screen. It pins the structure that makes them fit: on a desktop the frame is one + * screen tall, the step list's row is the only one that can shrink, the list is what scrolls, and + * the step on screen is scrolled into it. That this adds up to every step's buttons on screen at + * 1280 by 800 and 1280 by 700 was checked in a browser, step by step. + */ +test("on a desktop, a short screen scrolls the step list, never the step's words or its buttons", () => { + const scrolled: Array<{ row: Element; options: unknown }> = []; + const original = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = function ( + this: Element, + options?: boolean | ScrollIntoViewOptions, + ) { + scrolled.push({ row: this, options }); + }; + try { + const view = draw({ initialStepId: "two" }); + + // One screen tall, and of the left's rows only the list's can shrink. + const frame = view.container.querySelector("[data-onboarding-frame]"); + expect(frame?.className).toContain("lg:h-svh"); + expect(frame?.className).toContain( + "lg:grid-rows-[minmax(10rem,auto)_auto_auto_1fr]", + ); + // The picture stretches to the screen rather than being set to its height, which would give + // that height to the empty last row before the list got its own. + const demo = view.container.querySelector("[data-onboarding-demo]"); + expect(demo?.className).toContain("lg:max-h-svh"); + expect(demo?.className).not.toContain("lg:h-svh"); + + // The list is what scrolls, inside a header and a nav that let it shrink. + const list = stepList(view).querySelector("ol"); + expect(list?.className).toContain("overflow-y-auto"); + expect(list?.className).toContain("min-h-0"); + expect(stepList(view).className).toContain("lg:min-h-0"); + expect(view.container.querySelector("header")?.className).toContain( + "lg:min-h-0", + ); + // The step's words and its buttons neither scroll nor shrink. + const intro = view.container.querySelector("[data-onboarding-intro]"); + for (const part of [intro, footer(view)]) { + expect(part?.className).not.toMatch(/overflow|min-h-0|max-h-/); + } + + // The step on screen is kept in the list's view, and the next one once Continue is pressed. + expect(scrolled.at(-1)?.row.textContent).toContain("Two"); + expect( + scrolled.at(-1)?.row.querySelector('[aria-current="step"]'), + ).not.toBeNull(); + expect(scrolled.at(-1)?.options).toEqual({ block: "nearest" }); + fireEvent.click(primary(view)); + expect(scrolled.at(-1)?.row.textContent).toContain("Three"); + } finally { + Element.prototype.scrollIntoView = original; + } +}); + +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); +}); diff --git a/app/tests/onboarding-route.test.tsx b/app/tests/onboarding-route.test.tsx new file mode 100644 index 0000000000..3099ae9a52 --- /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", + "You decide what it asks", + "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", + "You decide what it asks about", + ]) { + 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, "You decide what it asks about"); + + 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: /You decide what it asks/ }), + ); + expect(await heading(view, "You decide what it asks about")).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, "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 () => { + 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, "You decide what it asks about"); + + 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, "You decide what it asks about"); + + 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/onboarding-steps.test.tsx b/app/tests/onboarding-steps.test.tsx new file mode 100644 index 0000000000..a77757590d --- /dev/null +++ b/app/tests/onboarding-steps.test.tsx @@ -0,0 +1,569 @@ +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 { authKeys } from "@/lib/auth/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; + 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, opened }); +} + +/** 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({ 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. */ +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/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( diff --git a/app/tests/settings-appearance.test.tsx b/app/tests/settings-appearance.test.tsx new file mode 100644 index 0000000000..e822beed02 --- /dev/null +++ b/app/tests/settings-appearance.test.tsx @@ -0,0 +1,102 @@ +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, + skin: "openbot", + }; + 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/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(), + ); +}); diff --git a/app/tests/skins-opendots-frame.test.tsx b/app/tests/skins-opendots-frame.test.tsx index 9cb46255b1..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", () => { - /** What the drawing of one avatar looks like, without the ids React gives each instance. */ - const drawing = (root: Element | null) => - (root?.querySelector("svg")?.outerHTML ?? "").replace(/_r_[\w]+_/g, ""); + /* + * 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"); }); }); 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 () => { 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", () => { 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"); + } }); }); 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/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=="], 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"; 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/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/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/src/setup/checklist.ts b/server/src/setup/checklist.ts new file mode 100644 index 0000000000..c38aaa1782 --- /dev/null +++ b/server/src/setup/checklist.ts @@ -0,0 +1,682 @@ +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}. 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 + * 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. + * + * 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. */ +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 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; + +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, + ...computerRow( + facts.computer + ? (facts.computerStatus ?? "not_checked") + : "needs_setup", + ), + }), + 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 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]; + 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; + /** + * 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; +}; + +/** 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, 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; + 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"; + } + }; + /** 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 round = kept; + const [key, check, intelligence, computerStatus] = await Promise.all([ + modelKeyOf(keyFacts), + round.modelCheck, + round.intelligence, + round.computerStatus, + ]); + const facts: SetupFacts = { + intelligence, + model: { + provider: input.model.provider, + source: key.source, + verdict: modelVerdict(check, key.stamp), + model: input.model.defaultModel, + }, + computer: input.computer, + computerStatus, + apps: input.apps, + chat: input.chat, + }; + 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(() => {}); + } + 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. + */ +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/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[] = []; 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. diff --git a/server/tests/setup-checklist.test.ts b/server/tests/setup-checklist.test.ts new file mode 100644 index 0000000000..c8f7676bc0 --- /dev/null +++ b/server/tests/setup-checklist.test.ts @@ -0,0 +1,1210 @@ +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, + modelVerdict, + RECHECK_INTERVAL_MS, + type SetupChecklist, + type SetupFacts, + type SetupRow, + type SetupSources, + 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 asking the provider again hears; counted in `checks`. */ + modelCheck?: () => ModelCheckOutcome; + entitlements?: () => Promise; + computer?: boolean; + computerCheck?: () => Promise<"ready" | "needs_setup" | "not_checked">; + 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 = + 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, + }); + 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, 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", +) { + 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, and the next read has its answer", 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"); + + // 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.", + }); + + // 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); + + // 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)); + } + } + }); +}); + +/* + * 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(); + 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"); + // The read asked again in the background, and heard the same refusal. + await settled(); + ready = true; + // 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", + ]); +}); + +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"); +}); + +/* + * 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 }); +});