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..b36f0f8cd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -133,6 +133,67 @@ 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.
+- The sidebar takes the same look, with OpenBot's orb beside its name. A row lifts to a card under
+ the pointer and while it is open, and you can always see where the keyboard is. While a Bot is
+ working, its orb in the list shows it, and so does a still blue dot. Each conversation has a "…"
+ button with Pin and Delete, which used to be on right-click alone. ⌘K, or Ctrl+K, opens the
+ sidebar and puts you in its search box.
+- A conversation reads like a chat. A Bot's reply arrives one bubble per thought, and a bubble holding
+ a code block or a table takes the full width on a wide screen; your messages sit in a tinted bubble,
+ and three dots show a Bot thinking. A step is marked done only when it worked: one that was refused,
+ failed or declined by you is named for what it would have done ("Send your email"), and one held on
+ its approval card says it is waiting for you. Mail and calendar steps, through any app, are named
+ for what happened to yours ("Searched your email").
+- A conversation with one Bot shows that Bot's orb in its header. The orb works while the Bot does,
+ watches while you type, and smiles when an answer lands. Under the conversation's name, "Working ·
+ Searching the web" says which step the Bot is on, and "Needs you" says an approval is waiting there.
+ A run that goes on after you allow something shows as working too. The message box is a rounded
+ pill with larger buttons, and it says "Message" and the Bot's name until you type.
+- A Bot's computer opens in a wider pane beside the conversation. Its header shows the Bot's orb,
+ whose computer it is and Take control, with a dot while the conversation needs you. Under the
+ screen, every step the Bot took in its browser, approved clicks included, sits among the commands
+ it ran, newest last, marked done, waiting for you or didn't work. The pane opens by itself only
+ when it fits beside the conversation; on a narrow screen the computer button lights up instead.
+ The box for a password or a code says the value goes straight to the page and the Bot never sees
+ it.
+- When a Bot asks before acting, its card says in plain words what it will do, in which app or
+ where, to whom, and what it will send, with everything else under Details. Decline and Allow once
+ answer it, and a quieter Always allow here saves a rule for that Bot, action and place; a step
+ handed to you asks "I did it myself" or "Don't do it". The same card is in the conversation, on
+ the Bot's page and in the list of what needs you. Once you answer, it folds to how it went
+ (Allowed, Sent, Done, Declined, Didn't work) and says who decided. A request for a step that is
+ alone in its turn now shows in the conversation too, where before it waited only on the Bot's
+ page. Under the message box, every conversation says that OpenBot is AI and can make mistakes.
+
### 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/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx
index 27fe657b18..2f15b783ce 100644
--- a/app/src/components/app-sidebar/app-sidebar.tsx
+++ b/app/src/components/app-sidebar/app-sidebar.tsx
@@ -3,7 +3,8 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, type LinkOptions, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type * as React from "react";
-import { useRef, useState } from "react";
+import { type RefObject, useRef, useState } from "react";
+import AgentOrb from "@/components/agents/orb/agent-orb";
import {
DropdownMenu,
DropdownMenuContent,
@@ -25,7 +26,13 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
+ useSidebar,
} from "@/components/ui/sidebar";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import {
type ChannelSummary,
@@ -33,8 +40,10 @@ import {
} from "@/lib/channels/queries";
import { useChannelEvents } from "@/lib/channels/use-channel-events";
import { appConfig } from "@/lib/generated/application-config";
+import { getHotkey, hotkeyLabel } from "@/lib/hotkeys/hotkeys";
+import { useHotkey } from "@/lib/hotkeys/use-hotkey";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
-import { navigationFor } from "@/lib/navigation";
+import { type NavigationItem, useNavigationItems } from "@/lib/navigation";
import { relativeTime } from "@/lib/relative-time";
import {
type MessageListEmphasis,
@@ -44,7 +53,7 @@ import { BotsNavBadge } from "../bot-profile/attention";
import { BrowserNavItem } from "../browser/browser-nav-item";
import { Button } from "../ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
-import { Channel } from "./channel";
+import { Channel, LIT } from "./channel";
import { ChannelPagination } from "./channel-pagination";
import { useSignOut } from "./use-sign-out";
@@ -52,6 +61,11 @@ const appLinkOptions = { to: "/" } satisfies LinkOptions;
const userMenuItemClassName = "gap-2 px-2 py-1.5";
+/** A control in the sidebar lifts to the card's fill and shadow under the pointer, as a row does. */
+const LIT_ON_HOVER = "hover:bg-sidebar-accent hover:shadow-(--shadow-card)";
+
+const NEW_CHAT_KEYS = hotkeyLabel(getHotkey("new-chat").combo);
+
function UserAvatar() {
const { data: currentUser } = useQuery(currentUserQueryOptions());
const initials =
@@ -198,6 +212,61 @@ function ChannelRow({
);
}
+/**
+ * A count beside a destination, drawn as the Bots count is so the two read as one kind of thing.
+ * Nothing for zero.
+ */
+function NavigationBadge({ count }: { count: number }) {
+ if (count <= 0) return null;
+ return (
+
+ {count}
+
+ );
+}
+
+/** One destination in the sidebar's footer, from the shared list, with its count when it has one. */
+export function NavigationEntry({ item }: { item: NavigationItem }) {
+ return (
+
+ (
+
+ )}
+ >
+
+
+
+ {item.label}
+ {/* What waits on you across every Bot; Bots lists them first. */}
+ {item.id === "bots" ? : null}
+ {item.badge ? : null}
+
+
+ );
+}
+
+/**
+ * Puts the cursor in the search box as soon as it is on the page, with what it holds selected so
+ * typing replaces the last search. On a narrow window the sidebar is a sheet that only mounts once
+ * it opens, and a frame later moves focus to its first control; trying again on each of the next
+ * few frames lands after both.
+ */
+function focusWhenDrawn(box: RefObject, frames = 10) {
+ const input = box.current;
+ if (input?.isConnected) {
+ input.focus();
+ input.select();
+ if (document.activeElement === input) return;
+ }
+ if (frames > 0) requestAnimationFrame(() => focusWhenDrawn(box, frames - 1));
+}
+
export function AppSidebar({ ...props }: React.ComponentProps) {
const emphasis = useMessageListEmphasis();
const { data: currentUser } = useQuery(currentUserQueryOptions());
@@ -206,7 +275,19 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
// One socket for the app, opened where the roster is kept live.
useChannelEvents();
const [search, setSearch] = useState("");
+ const searchBox = useRef(null);
const scrollRoot = useRef(null);
+ const { isMobile, setOpen, setOpenMobile } = useSidebar();
+ /*
+ * ⌘K opens the sidebar, or its sheet on a narrow window, with the cursor in its search box. Bound
+ * here, so it works wherever this sidebar is: not on Settings or Admin, which have sidebars of
+ * their own, and not under a skin, which draws its own frame.
+ */
+ useHotkey("search", () => {
+ if (isMobile) setOpenMobile(true);
+ else setOpen(true);
+ focusWhenDrawn(searchBox);
+ });
const searching = search.trim().length > 0;
const visibleChannels = pinnedFirst(matchingChannels(channels.data, search));
/*
@@ -217,7 +298,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
*/
const animateOrder =
!searching && (channels.data?.length ?? 0) <= MAX_ANIMATED_ROWS;
- const navigation = navigationFor(currentUser?.role);
+ const navigation = useNavigationItems();
return (
@@ -225,29 +306,44 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
(
- {appConfig.brand.productName}
+ {/* OpenBot's own mark, clipped to its circle as every face below it is. */}
+
+
+
+ {appConfig.brand.productName}
)}
/>
-
+
+ (
+
+ )}
+ />
+ }
+ >
+
+
+ {/* An accelerator nobody is told about is not a feature. */}
+
+ New conversation
+ {NEW_CHAT_KEYS}
+
+
@@ -255,9 +351,10 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
-
+ setSearch(event.target.value)}
placeholder="Search…"
value={search}
@@ -324,28 +421,11 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
+ {/* In the list's own order, so a destination goes where `lib/navigation.ts` puts it. */}
{navigation
.filter((item) => item.section === "primary")
.map((item) => (
-
- (
-
- )}
- >
-
-
-
- {item.label}
- {/* What waits on you across every Bot; Bots lists them first. */}
- {item.id === "bots" ? : null}
-
-
+
))}
{/* Your own browser, with the Recorder in it. Drawn only where OpenBot can run one for you. */}
@@ -353,11 +433,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
+
}
>
-
+
{currentUser?.name || currentUser?.email}
diff --git a/app/src/components/app-sidebar/channel-item-content.tsx b/app/src/components/app-sidebar/channel-item-content.tsx
index ece35115c2..1a8538b810 100644
--- a/app/src/components/app-sidebar/channel-item-content.tsx
+++ b/app/src/components/app-sidebar/channel-item-content.tsx
@@ -26,8 +26,9 @@ export function ChannelItemContent({
pinned?: boolean;
revealing?: boolean;
}) {
- const primaryText = `text-[0.9rem] leading-5 tracking-[-1%] ${unread ? "font-semibold" : "font-medium"}`;
- const secondaryText = "text-[12px] leading-4 text-muted-foreground";
+ // On the type scale: the line that leads at the sub size, the other a caption.
+ const primaryText = `type-sub ${unread ? "font-semibold" : "font-medium"}`;
+ const secondaryText = "type-caption text-muted-foreground";
return (
@@ -46,15 +47,23 @@ export function ChannelItemContent({
>
{name}
-
- {lastMessageAt}
+ {/*
+ * The sidebar row's "…" button sits over this end of the line while it shows: under the
+ * pointer, with focus in the row, or with its menu open. The time and marks step aside
+ * for it. Under 768px the button always shows and the row keeps room for it instead. The
+ * preview in Settings is in no menu item, so none of this reaches it.
+ */}
+
+
+ {lastMessageAt}
+
+ {unread ? (
+
+ ) : null}
+ {pinned ? (
+
+ ) : null}
- {unread ? (
-
- ) : null}
- {pinned ? (
-
- ) : null}
void;
+ onDelete: () => void;
+}) {
+ return (
+ <>
+
+ {pinned ? : }
+ {pinned ? "Unpin conversation" : "Pin conversation"}
+
+
+
+ Delete conversation…
+
+ >
+ );
+}
+
/**
* Memoized roster row. `use-channel-events` preserves unchanged row identity, and
* `content-visibility` keeps off-screen rows cheap without virtualization.
*
- * Right-click opens Pin and Delete. Deleting is confirmed in a dialog that names the channel,
- * because the row it was invoked on is one of several identical-looking rows.
+ * Pin and Delete open from right-click, and from the "…" button over the row's end. Right-click
+ * alone was found by nobody who was not told, and reached by no keyboard. The button shows under
+ * the pointer and while focus is in the row, and comes straight after it in tab order. Deleting is
+ * confirmed in a dialog that names the channel, because the row it was invoked on is one of several
+ * identical-looking rows.
*/
export const Channel = memo(function Channel({
channelId,
@@ -70,8 +128,18 @@ export const Channel = memo(function Channel({
*/
const [pinProblem, setPinProblem] = useState(null);
+ const title = summary || lastMessage || "New conversation";
+ const pin = () => {
+ setPinProblem(null);
+ setPinned.mutate(
+ { channelId, pinned: !pinned },
+ { onError: (thrown) => setPinProblem(thrown.message) },
+ );
+ };
+ const askToDelete = () => setConfirming(true);
+
return (
- <>
+
- {
- setPinProblem(null);
- setPinned.mutate(
- { channelId, pinned: !pinned },
- { onError: (thrown) => setPinProblem(thrown.message) },
- );
- }}
- >
- {pinned ? : }
- {pinned ? "Unpin conversation" : "Pin conversation"}
-
- setConfirming(true)}
- >
-
- Delete conversation…
-
+
+
+
+ }
+ >
+
+
+
+
+
+
{pinProblem ? (
);
});
diff --git a/app/src/components/approvals/approval-card.tsx b/app/src/components/approvals/approval-card.tsx
new file mode 100644
index 0000000000..f3f55adfe3
--- /dev/null
+++ b/app/src/components/approvals/approval-card.tsx
@@ -0,0 +1,301 @@
+import {
+ IconCheck,
+ IconChevronRight,
+ IconCircleCheck,
+ IconMail,
+ IconShieldCheck,
+ IconTerminal2,
+ IconWorld,
+ IconX,
+} from "@tabler/icons-react";
+import { Fragment, type ReactNode, useId } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ type ApprovalView,
+ alwaysAllowReach,
+ approvalOutcome,
+ approvalPlace,
+ clockTime,
+ decisionReceipt,
+ describeApproval,
+ type OutcomeTone,
+} from "@/lib/approval-view";
+import type { ApprovalDecision, ApprovalRequest } from "@/lib/approvals";
+import { cn } from "@/lib/utils";
+
+const KIND_ICON = {
+ mail: IconMail,
+ browser: IconWorld,
+ command: IconTerminal2,
+ other: IconShieldCheck,
+} as const;
+
+const PILL_TONES: Record = {
+ done: "bg-status-done-soft text-status-done",
+ working: "bg-status-working-soft text-status-working",
+ needs: "bg-status-needs-soft text-status-needs",
+ problem: "bg-status-problem-soft text-status-problem",
+ neutral: "bg-muted text-muted-foreground",
+};
+
+/** Decline and Allow once: half the card wide each, a finger's height tall. */
+const CHOICE =
+ "h-(--target) min-w-0 flex-1 basis-28 rounded-full px-4 type-sub font-semibold";
+
+/**
+ * One action a Bot wants to take, and the person's answer to it: what it is, where, who it goes to
+ * and what it says, then Decline and Allow once. "Always allow here" is a quiet button under the
+ * two, because it saves a rule for this Bot, this action and this place rather than answering only
+ * this request; the line under it says how far that rule reaches. A hand-off is the person's to do,
+ * so it asks "I did it myself" or "Don't do it".
+ *
+ * Once decided the card folds: the title, a pill saying how it ended (Allowed, Sent, Done,
+ * Declined, Didn't work), and a line saying who decided and when. While it waits, everything the
+ * request carries is one tap away under Details.
+ *
+ * `frame="card"` draws it as a card of its own, for a conversation. `frame="row"` leaves the frame
+ * to the list it sits in, a row of a page's `PageRows` card.
+ */
+export function ApprovalCard({
+ request,
+ botName,
+ busy = false,
+ error,
+ onDecide,
+ frame = "card",
+ className,
+}: {
+ request: ApprovalRequest;
+ /** Named where the card is shown away from the Bot's own conversation. */
+ botName?: string;
+ busy?: boolean;
+ error?: string | null;
+ onDecide?: (choice: ApprovalDecision) => void;
+ frame?: "card" | "row";
+ className?: string;
+}) {
+ const view = describeApproval(request);
+ const waiting = request.status === "pending";
+ const handOff = request.action.policy?.behaviour === "hand_off";
+ const outcome = approvalOutcome(request);
+ const receipt = waiting ? null : decisionReceipt(request);
+ const place = approvalPlace(request);
+ const caption = [botName, view.app].filter(Boolean).join(" · ");
+ const Icon = KIND_ICON[view.kind];
+ const reachId = useId();
+
+ return (
+
+
+
+ >
+ );
+}
+
+/** The line between a card's parts, short of its edges like the rows of a list. */
+function Hairline() {
+ return ;
+}
+
+/** Who decided and when, under a decided card. */
+function DecidedLine({
+ children,
+ at,
+ declined,
+}: {
+ children: ReactNode;
+ at: string | null;
+ declined: boolean;
+}) {
+ const Mark = declined ? IconX : IconCircleCheck;
+ return (
+ <>
+
+
+
+ {children}
+ {at ? (
+
+ ) : null}
+
+ >
+ );
+}
diff --git a/app/src/components/approvals/inline-approval.tsx b/app/src/components/approvals/inline-approval.tsx
index ab179e1f8b..dd0bfdecd9 100644
--- a/app/src/components/approvals/inline-approval.tsx
+++ b/app/src/components/approvals/inline-approval.tsx
@@ -1,18 +1,27 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ReactNode } from "react";
-import { Button } from "@/components/ui/button";
+import { personWasAsked } from "@/lib/approval-view";
import {
- type ApprovalDecision,
- approvalInboxOptions,
- decideApproval,
+ type ApprovalRequest,
+ approvalInboxCacheOptions,
+ decideApprovalMutationOptions,
} from "@/lib/approvals";
+import { ApprovalCard } from "./approval-card";
+
+/** A request the person decided on a card, which keeps its folded card in the conversation. */
+const decidedByPerson = (request: ApprovalRequest) =>
+ request.status !== "pending" &&
+ Boolean(request.decision) &&
+ personWasAsked(request);
/**
- * A pending approval, drawn in the conversation where the action it holds would have been.
+ * An approval, drawn in the conversation where the action it holds would have been.
*
- * The same request as the Approvals page, read from the same owner-scoped inbox, so a decision made
- * in either place is the one decision. Once decided the server runs the action exactly once and
- * continues the conversation, and this gives the line back to the tool's own renderer.
+ * The same request as the Bot's page, read from the same owner-scoped inbox, so a decision made in
+ * either place is the one decision. Once decided the server runs the action exactly once and
+ * continues the conversation, and this gives the line back to the tool's own renderer, under the
+ * folded card saying how it ended and who decided. A request the person was never asked about (a
+ * saved rule allowed it) leaves no card behind.
*/
export function InlineApproval({
toolCallId,
@@ -22,89 +31,59 @@ export function InlineApproval({
fallback: ReactNode;
}) {
const cache = useQueryClient();
- const inbox = useQuery(approvalInboxOptions());
- const decision = useMutation({
- mutationFn: (choice: ApprovalDecision) =>
- decideApproval(request?.id ?? "", choice),
- onSuccess: () => cache.invalidateQueries({ queryKey: ["approvals"] }),
- });
+ const inbox = useQuery(approvalInboxCacheOptions());
+ const decision = useMutation(decideApprovalMutationOptions(cache));
const request = toolCallId
? inbox.data?.requests.find(
(candidate) => candidate.action.toolCallId === toolCallId,
)
: undefined;
- if (request?.status !== "pending") return <>{fallback}>;
- const handOff = request.action.policy?.behaviour === "hand_off";
- const choices: [ApprovalDecision, string][] = handOff
- ? [
- ["handled", "I did it myself"],
- ["deny", "Don't do it"],
- ]
- : [
- ["allow_once", "Allow once"],
- ["allow_always", "Always allow here"],
- ["deny", "Deny"],
- ];
- return (
-
-
- {handOff ? "This needs you" : "Waiting for your approval"}:{" "}
- {request.action.toolRef
- .replace(/^computer_|^host\//, "")
- .replaceAll("_", " ")}{" "}
- on {request.action.scope}
-
- {request.action.policy ? (
-
{request.action.policy.reason}
- ) : null}
- {decision.error ? (
-
- {decision.error.message}
-
- ) : null}
-
- {choices.map(([choice, label], index) => (
-
- ))}
+ if (!request) return <>{fallback}>;
+ if (request.status !== "pending") {
+ if (!decidedByPerson(request)) return <>{fallback}>;
+ return (
+
+
+ {fallback}
-
+ );
+ }
+ return (
+ decision.mutate({ id: request.id, choice })}
+ request={request}
+ />
);
}
/**
- * The pending approvals for a group of tool calls, for a transcript that draws those calls itself
- * rather than through each tool's renderer: the browser-activity group folds every click and field
- * into one collapsed line, so a card inside a step would never be seen. Drawn beside the group,
- * outside the fold, one card per waiting call; nothing at all when none is waiting.
+ * The approval cards for tool calls a transcript draws itself rather than through each tool's
+ * renderer: a run of steps folded into one line, whose cards would never be seen inside the fold,
+ * and a server call alone in its turn, which has no renderer to draw one. Drawn beside the line, one
+ * card per call the person was asked about: waiting to be decided, or folded once it was. Nothing
+ * at all for calls nobody asked about.
*/
-export function PendingApprovals({
+export function ToolCallApprovals({
toolCallIds,
}: {
toolCallIds: readonly string[];
}) {
- const inbox = useQuery(approvalInboxOptions());
- const waiting = new Set(
+ const inbox = useQuery(approvalInboxCacheOptions());
+ const asked = new Set(
(inbox.data?.requests ?? [])
- .filter((request) => request.status === "pending")
+ .filter(
+ (request) => request.status === "pending" || decidedByPerson(request),
+ )
.flatMap((request) =>
request.action.toolCallId ? [request.action.toolCallId] : [],
),
);
- const ids = toolCallIds.filter((id) => waiting.has(id));
+ const ids = toolCallIds.filter((id) => asked.has(id));
if (ids.length === 0) return null;
return (
-
+
{ids.map((id) => (
))}
diff --git a/app/src/components/approvals/waiting.tsx b/app/src/components/approvals/waiting.tsx
index b101dddd51..b9b209f23e 100644
--- a/app/src/components/approvals/waiting.tsx
+++ b/app/src/components/approvals/waiting.tsx
@@ -1,4 +1,4 @@
-import { IconHandStop, IconMessageQuestion } from "@tabler/icons-react";
+import { IconMessageQuestion } from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Fragment, useState } from "react";
import { PageRows, PageSection } from "@/components/layout/page-shell";
@@ -20,37 +20,54 @@ import {
decideApprovalMutationOptions,
} from "@/lib/approvals";
import { queryClient } from "@/query-client";
+import { ApprovalCard } from "./approval-card";
/**
- * Questions and actions waiting on the person, one row each, decided where it is shown: the answer
- * box and the decision buttons sit on the row's own footer line. Given a Bot, only that Bot's. The
- * same requests the conversation draws inline, so a decision made in either place is the one
- * decision.
+ * Questions and actions waiting on the person, one row each, decided where it is shown: a question's
+ * answer box sits on its row's footer line, and an action is the same approval card the
+ * conversation draws, so a decision made in either place is the one decision. An action answered
+ * here stays, folded to how it went, until the page is left. Given a Bot, only that Bot's.
+ *
+ * Given a `title`, it is its own section of a page, drawn only while something is listed.
*/
-export function WaitingForYou({ agentId }: { agentId?: string }) {
+export function WaitingForYou({
+ agentId,
+ title,
+}: {
+ agentId?: string;
+ title?: string;
+}) {
const inbox = useQuery(approvalInboxOptions());
const agents = useQuery(agentListQueryOptions());
const botName = (id: string) =>
agents.data?.find((agent) => agent.id === id)?.name ?? id;
const decision = useMutation(decideApprovalMutationOptions(queryClient));
+ const [decidedHere, setDecidedHere] = useState>(
+ () => new Set(),
+ );
const [answers, setAnswers] = useState>({});
const answer = useMutation(answerPersonQuestionMutationOptions(queryClient));
const ours = (botId: string) => agentId === undefined || botId === agentId;
const questions = (inbox.data?.questions ?? []).filter((question) =>
ours(question.conversationBotId ?? question.botId),
);
- const pending = (inbox.data?.requests ?? []).filter(
- (request) => request.status === "pending" && ours(request.action.botId),
+ const requests = (inbox.data?.requests ?? []).filter(
+ (request) =>
+ ours(request.action.botId) &&
+ (request.status === "pending" || decidedHere.has(request.id)),
);
- const error = inbox.error ?? decision.error ?? answer.error;
- return (
+ // A decision's failure is said on its own card.
+ const error = inbox.error ?? answer.error;
+ const listed = questions.length + requests.length > 0;
+ if (title && !listed) return null;
+ const list = (
<>
{error ? (
-
- {request.action.policy?.behaviour === "hand_off" ? (
-
-
-
-
- ) : (
-
-
-
-
-
- )}
-
+ {
+ // Kept here, folded, once answered: the person sees how it went.
+ setDecidedHere((prior) => new Set(prior).add(request.id));
+ decision.mutate({ id: request.id, choice });
+ }}
+ request={request}
+ />
))}
) : null}
>
);
+ return title ? {list} : list;
}
/**
@@ -201,19 +147,5 @@ export function WaitingForYou({ agentId }: { agentId?: string }) {
* waits: the sidebar's badge already said whether to look.
*/
export function BotNeedsYou({ agentId }: { agentId: string }) {
- const inbox = useQuery(approvalInboxOptions());
- const waiting =
- (inbox.data?.questions ?? []).some(
- (question) => (question.conversationBotId ?? question.botId) === agentId,
- ) ||
- (inbox.data?.requests ?? []).some(
- (request) =>
- request.status === "pending" && request.action.botId === agentId,
- );
- if (!waiting) return null;
- return (
-
-
-
- );
+ return ;
}
diff --git a/app/src/components/bot-profile/access.tsx b/app/src/components/bot-profile/access.tsx
index 6b53a54dce..755791f57d 100644
--- a/app/src/components/bot-profile/access.tsx
+++ b/app/src/components/bot-profile/access.tsx
@@ -76,7 +76,8 @@ function connectorTools(plugins: GrantedPlugins): Map {
const connectors = new Map();
for (const tool of plugins.tools) {
const key = tool.ref.split("/")[0] ?? tool.ref;
- let label = readToolName(tool.toolName).label;
+ // What the tool does, not what it did: "Send your email", never "Sent your email".
+ let label = readToolName(tool.toolName, "plain").label;
const prefix = `${key.toLowerCase()} `;
if (label.toLowerCase().startsWith(prefix)) {
const rest = label.slice(prefix.length);
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({
);
})}
@@ -106,25 +114,15 @@ function GroupFaces({
}
/**
- * Three bouncing dots in a small badge, ringed in the sidebar's own colour so it sits on the
- * avatar as a badge rather than floating over it. The staggered negative delays start each dot at
- * a different point in the same bounce, which is what makes the three read as one wave.
+ * A still dot in the working colour, ringed so it sits on the avatar as a badge rather than floating
+ * over it. Still on purpose: an orb this small only blinks, and reduced motion stills it entirely, so
+ * the faces working is too slight to read as busy from across the roster. The ring is the ground the
+ * avatar sits on, the sidebar's own unless a row lit behind it sets `--avatar-ground`.
*/
-function TypingBadge() {
+function BusyDot() {
return (
-
+ Working…
-
-
-
-
- );
-}
-
-function Dot({ className }: { className?: string }) {
- return (
-
+
);
}
diff --git a/app/src/components/channels/browser-activity.tsx b/app/src/components/channels/browser-activity.tsx
index 4995fdf2d8..9476433d66 100644
--- a/app/src/components/channels/browser-activity.tsx
+++ b/app/src/components/channels/browser-activity.tsx
@@ -1,17 +1,12 @@
+import {
+ BROWSER_STEP_DONE as labels,
+ BROWSER_STEP_PLAIN as plainLabels,
+} from "@/lib/computers/step-names";
+import { toolResultHeldBack } from "@/lib/plugins/tool-result";
import type { VisibleChatItem } from "./chat-messages";
type BrowserStep = Extract;
-const labels: Record = {
- computer_navigate: "Opened page",
- computer_read: "Read page",
- computer_snapshot: "Inspected page",
- computer_click: "Clicked on page",
- computer_type: "Filled in field",
- computer_key: "Pressed key",
- computer_scroll: "Scrolled page",
-};
-
/** Whether a tool call is one of the browser steps a tool group draws with its page link. */
export function isBrowserStep(item: BrowserStep): boolean {
return Object.hasOwn(labels, item.toolCall.function.name);
@@ -49,6 +44,7 @@ export function browserStepDetails(step: BrowserStep) {
step.result?.startsWith("Error:") === true;
return {
label: labels[step.toolCall.function.name],
+ plain: plainLabels[step.toolCall.function.name],
href: url?.href,
title:
navigation && typeof result.title === "string" && result.title.trim()
@@ -60,3 +56,41 @@ export function browserStepDetails(step: BrowserStep) {
pending: step.result === undefined,
};
}
+
+/**
+ * How a browser step came out, from its result alone.
+ *
+ * ONE READING FOR BOTH PLACES A STEP IS SHOWN. The conversation's tool line (`stepDetails`) and the
+ * computer pane's step log (`step-log.tsx`) both read a step here, so a click the person declined
+ * never reads as done in one of them and declined in the other.
+ *
+ * A step the person held back is read before any of the browser's own fields, with
+ * `toolResultHeldBack`: declined on its card, or not asked again because the same request was still
+ * waiting (`alreadyWaiting`). The server says either in words for the model, sometimes inside the
+ * browser's JSON, and it would otherwise read as a step that worked or one that failed. `pending` is
+ * a step with no result yet, or only the note that it went to its approval card. Whether that is
+ * running, waiting for the person or cut off is for the caller to say, from the turn and the
+ * approvals inbox, and so is whether a request `alreadyWaiting` repeated is still waiting.
+ */
+export type BrowserOutcome =
+ | "pending"
+ | "declined"
+ | "alreadyWaiting"
+ | "stopped"
+ | "blocked"
+ | "notYet"
+ | "failed"
+ | "done";
+
+export function browserOutcome(step: BrowserStep): BrowserOutcome {
+ if (step.result === undefined) return "pending";
+ const result = objectOf(step.result);
+ if (result.waiting === true) return "pending";
+ const heldBack = toolResultHeldBack(step.result);
+ if (heldBack) return heldBack;
+ // Cut off, like a call that never got its result: nothing went wrong with the page.
+ if (result.stopped === true) return "stopped";
+ if (result.refused === true) return "blocked";
+ if (result.ok === false && result.starting === true) return "notYet";
+ return browserStepDetails(step).failed ? "failed" : "done";
+}
diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx
index 317647c970..f162f5a89b 100644
--- a/app/src/components/channels/channel-chat.tsx
+++ b/app/src/components/channels/channel-chat.tsx
@@ -1012,6 +1012,11 @@ export function ChannelChat({
// Keep `seed` in state; transcriptMessages gives it up once the agent holds a user turn.
}, []);
+ /** The Bot's own name, once its profile is in. The conversation's name may be its title instead. */
+ const botName = agentProfiles?.find(
+ (profile) => profile.id === runtimeAgentId,
+ )?.name;
+
return (
// Activity renderers resolve their agent through this SDK context. It must match useAgent's
// channel instance so an action continues this thread instead of looking for a default agent.
@@ -1055,6 +1060,8 @@ export function ChannelChat({
commands={skillCommands}
// Readiness is handled by `say`; deletion is the only disabled-chat state.
disabled={!channel.active}
+ // Until the Bot's name is in, the composer keeps its own words.
+ {...(botName ? { placeholder: `Message ${botName}` } : {})}
messages={withVoiceChats(
transcriptMessages(agent.messages, seed),
voiceArchive.entries,
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index ba7002775d..2bc5e34c0d 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -31,6 +31,7 @@ import {
useState,
} from "react";
import { Streamdown } from "streamdown";
+import { ToolCallApprovals } from "@/components/approvals/inline-approval";
import { ArgumentsArrivingContext } from "@/components/gallery/streaming";
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import {
@@ -67,6 +68,7 @@ import {
forDisplay,
REFUSAL_MARKER,
toolResultFailed,
+ toolResultHeldBack,
} from "@/lib/plugins/tool-result";
import { readTeamBotConsent } from "@/lib/team-bots";
import { cn } from "@/lib/utils";
@@ -81,9 +83,11 @@ import {
} from "./chat-messages";
import { splitChatOrigin } from "./chat-origin";
import type { QueuedMessage } from "./composer";
+import { useConversationWaiting } from "./conversation-presence";
import { FollowScroll, RESUME_PX, useFollowIntent } from "./follow-intent";
import { LateRenderersContext, LateRenderersWatch } from "./late-renderers";
import { usePacedReveal } from "./paced-reveal";
+import { holdsBlock, splitBubbles } from "./split-bubbles";
import { TeamBotConsentCard } from "./team-bot-consent";
import {
dropRepeatedAppCards,
@@ -92,6 +96,7 @@ import {
type ToolGroup,
type TranscriptItem,
useDrawnTool,
+ useHeldForPerson,
} from "./tool-activity";
import { ToolRenderBoundary, toolCallFinal } from "./tool-boundary";
import { shouldDrawEarlier, useTranscriptPages } from "./transcript-pages";
@@ -139,6 +144,32 @@ type ChatTranscriptProps = {
/** One shared empty array, so a screen without a queue does not hand down a new one per render. */
const EMPTY_QUEUE: readonly QueuedMessage[] = [];
+/**
+ * A conversation's bubbles: body text, one radius on every corner and no tail, the same padding for
+ * the Bot and the person. Their colours are `--bubble` and `--bubble-user` (theme.css).
+ */
+const BUBBLE_CONTENT = "type-body rounded-(--radius-bubble) px-4 py-3";
+/** The person's words, in the accent's tint, on the right. */
+const USER_BUBBLE = "max-w-[85%] *:data-[slot=bubble-content]:bg-bubble-user";
+/*
+ * A Bot's thought, in a bubble of its own. STILL THE GHOST VARIANT, because a skin finds a Bot's
+ * words by `[data-variant="ghost"][data-align="start"]` (`skins//skin.css`); so these put back
+ * the fill, the radius and the padding the variant takes off, rather than choose another variant
+ * and leave every skin's rule matching nothing.
+ *
+ * Capped like a chat's bubble, so a short answer reads as a message rather than a page.
+ */
+const AGENT_BUBBLE =
+ "data-[variant=ghost]:max-w-[82%] *:data-[slot=bubble-content]:rounded-(--radius-bubble) *:data-[slot=bubble-content]:bg-bubble *:data-[slot=bubble-content]:px-4 *:data-[slot=bubble-content]:py-3";
+/**
+ * A bubble holding a code block or a table spans the column once there is room. On a phone it keeps
+ * the cap, and the block scrolls inside it.
+ */
+const WIDE_BUBBLE = "md:w-full md:data-[variant=ghost]:max-w-full";
+/** Streamdown's prose, tightened to a bubble: closer paragraphs, bullets hung outside the text. */
+const AGENT_PROSE =
+ "[&>div]:space-y-2.5 [&_ol]:list-outside [&_ol]:pl-5 [&_ul]:list-outside [&_ul]:pl-5 [&_li]:py-0.5";
+
/**
* Split a person's message into the skill they invoked and the rest of what they typed.
*
@@ -205,19 +236,27 @@ function RestoringTranscript() {
* A person who has just asked something watches their own message sit there, and a Bot that is
* thinking is indistinguishable from a Bot that failed silently — which this app has shipped before.
*
- * It borrows the shimmer a running tool line uses, so "working on it" reads the same whether the
- * work is a tool call or a model that has not spoken yet.
+ * It is the Bot's bubble with three dots in it, where its first words will land, the way a chat
+ * shows somebody typing. The dots hold still with reduced motion (`.typing-dot` in styles.css).
*/
function Thinking() {
return (
-
- Thinking
-
+ Thinking
+ {[0, 1, 2].map((dot) => (
+
+ ))}
+
);
}
@@ -366,8 +405,12 @@ function Queued({
* message sent by mistake rather than as a file waiting its turn.
*/}
{text ? (
-
-
+
+
{/* Shown exactly as typed, for the same reason a sent message is. */}
{text}
@@ -752,23 +795,16 @@ const TranscriptMessage = memo(function TranscriptMessage({
- {/*
- A Bot's message takes the whole column, not the width of its words: block content
- inside it — a fenced code block, a table — should span the transcript rather than
- shrink to its own text. A person's bubble keeps fitting what they said.
- */}
-
-
- {isUser ? (
- // A person's own message is shown exactly as they typed it. Rendering it as markdown
- // would silently reformat what they said, and an asterisk in a sentence is not
- // emphasis. The chip is the one exception, and it is not reformatting: it is drawing
- // the thing that was already a chip in the composer as a chip here too, so the
- // transcript shows a skill was used rather than a slash that was typed.
+ {isUser ? (
+
+
+ {/*
+ * A person's own message is shown exactly as they typed it. Rendering it as
+ * markdown would silently reformat what they said, and an asterisk in a sentence
+ * is not emphasis. The chip is the one exception, and it is not reformatting: it is
+ * drawing the thing that was already a chip in the composer as a chip here too, so
+ * the transcript shows a skill was used rather than a slash that was typed.
+ */}
{origin && (
@@ -792,28 +828,75 @@ const TranscriptMessage = memo(function TranscriptMessage({
said
)}
- ) : (
- /*
- * A Bot's prose is markdown, and it arrives in pieces.
- *
- * Rendered with a streaming-aware renderer rather than an ordinary one: half a fenced
- * code block or an unclosed bold marker is the NORMAL state for most of a run, and a
- * plain markdown parser draws that as literal asterisks and backticks until the
- * closing token arrives, so the answer visibly rewrites itself as it lands. This
- * closes them for the duration.
- */
-
- {revealed}
-
- )}
-
-
+
+
+ ) : (
+
+ )}
);
});
+/**
+ * A Bot's reply as separate bubbles, one per thought and 8px apart, the way a chat sends them
+ * (`splitBubbles`).
+ *
+ * ONE MEMOISED BUBBLE PER PART. A streamed reply changes only its last part on each chunk, so the
+ * parts before it compare equal and their markdown is not parsed again: the same reasoning as
+ * `TranscriptMessage`, one level down. The split itself is one pass over the text and runs whenever
+ * the text changes, which is the only time this draws at all.
+ */
+function AgentReply({
+ mode,
+ text,
+}: {
+ mode: "static" | "streaming";
+ text: string;
+}) {
+ const parts = splitBubbles(text);
+ return (
+
+ {parts.map((part, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: a reply's thoughts keep their order as it streams.
+
+ ))}
+
+ );
+}
+
+const AgentBubble = memo(function AgentBubble({
+ mode,
+ part,
+}: {
+ mode: "static" | "streaming";
+ part: string;
+}) {
+ return (
+
+
+ {/*
+ * A Bot's prose is markdown, and it arrives in pieces.
+ *
+ * Rendered with a streaming-aware renderer rather than an ordinary one: half a fenced code
+ * block or an unclosed bold marker is the NORMAL state for most of a run, and a plain
+ * markdown parser draws that as literal asterisks and backticks until the closing token
+ * arrives, so the answer visibly rewrites itself as it lands. This closes them for the
+ * duration.
+ */}
+
+ {part}
+
+
+
+ );
+});
+
/**
* The one shape an attachment url is allowed to have, taken from the helper that builds it rather
* than written out again, so the check cannot drift from the route that serves the file.
@@ -1407,6 +1490,9 @@ const ToolCallBody = memo(function ToolCallBody({
spokeAfter: boolean;
}) {
const renderToolCall = useRenderToolCall();
+ // Held on its approval card: the same inbox the card under it reads.
+ const waiting = useHeldForPerson().has(toolCallId);
+ const conversationWaiting = useConversationWaiting();
const toolCall = useMemo(
() => ({
id: toolCallId,
@@ -1450,13 +1536,23 @@ const ToolCallBody = memo(function ToolCallBody({
*
* What was called, shimmering until its result arrives, and then the server's own words
* drawn the way a Bot's prose is drawn.
+ *
+ * WITH ITS APPROVAL CARD UNDER IT when the server held the call for the person. A run of
+ * steps gets its cards beside the folded line (`ToolActivity`); a call alone in its turn
+ * keeps this line instead, and without the card here the request waited only on the Bot's
+ * page. A tool the app draws itself is left alone: its own renderer places its card.
*/}
{drawn ?? (
-
+ <>
+
+
+ >
)}
@@ -1546,14 +1642,27 @@ export function ServerToolLine({
name,
result,
place,
+ waiting = false,
+ conversationWaiting = false,
}: {
name: string;
result?: string;
/** Where this call sits in the conversation, for an app card that may carry the task on. */
place?: CardPlace;
+ /** Held on its approval card for the person's answer, so it is not running yet. */
+ waiting?: boolean;
+ /** An approval in this conversation waits on the person, which a call repeating it waits with. */
+ conversationWaiting?: boolean;
}) {
- const running = result === undefined;
- const { label, detail } = readToolName(name, running);
+ const heldBack = toolResultHeldBack(result);
+ // Said no on its card: nothing was done, and the line says so rather than naming it as done.
+ const declined = heldBack === "declined";
+ // Not asked again, because the same request already waits on its card. Not done either way.
+ const alreadyWaiting = heldBack === "alreadyWaiting";
+ const held =
+ (waiting && result === undefined) ||
+ (alreadyWaiting && conversationWaiting);
+ const running = result === undefined && !held;
/*
* A refusal is not a result, and must not read like one.
*
@@ -1562,8 +1671,13 @@ export function ServerToolLine({
* any guess made here. See REFUSAL_MARKER in server/src/plugins/tools.ts.
*/
const answer = result === undefined ? undefined : asText(result);
- const refused = answer?.startsWith(REFUSAL_MARKER) ?? false;
- const failed = !refused && toolResultFailed(result);
+ const refused = !heldBack && (answer?.startsWith(REFUSAL_MARKER) ?? false);
+ const failed = !refused && !heldBack && toolResultFailed(result);
+ // A call that did not happen, or waits on its card, is named plainly ("Send your email").
+ const { label, detail } = readToolName(
+ name,
+ refused || failed || heldBack !== null || held ? "plain" : running,
+ );
/*
* The marker is for this component, not for the reader. Left in, a refusal reads "Blocked" in the
* label and then "Refused." again in the first two words of the body, which is the same fact three
@@ -1590,16 +1704,21 @@ export function ServerToolLine({
* THAT TOOL'S OTHER ANSWERS ARE FOR THE MODEL. An app nobody can name comes back as plain text
* telling the Bot to say so plainly and not to browse to it — an instruction, which read by the
* person looks like the Bot being told off in front of them. The line still says the check
- * happened; the Bot's own reply says what it found.
+ * happened; the Bot's own reply says what it found. A call the person held back answers the same
+ * kind of thing: it tells the Bot not to try again, and the line has already said what happened.
*/
- const shown = name === SHOW_APP_ACCESS_TOOL ? undefined : body;
+ const shown =
+ name === SHOW_APP_ACCESS_TOOL || heldBack !== null ? undefined : body;
return (
{shown ? (
diff --git a/app/src/components/channels/composer/attachment-strip.tsx b/app/src/components/channels/composer/attachment-strip.tsx
index 90ba773333..b752533518 100644
--- a/app/src/components/channels/composer/attachment-strip.tsx
+++ b/app/src/components/channels/composer/attachment-strip.tsx
@@ -144,8 +144,8 @@ export function AttachmentStrip({
* ON THE SIZE LINE, NOT UNDER IT, AND THAT IS A LAYOUT CONSTRAINT RATHER THAN A
* PREFERENCE. The tile's height is fixed so that a file and a thumbnail read as
* one row. A third line does not fit: with three lines the two in-flow children
- * need 65px (a 20px icon and three 15px lines) inside a 62px box. Something
- * has to give, and the only choices are the icon shrinking to 17px or the text
+ * need 68px (a 20px icon and three 16px lines) inside a 62px box. Something
+ * has to give, and the only choices are the icon shrinking to 14px or the text
* spilling past the border. Folding the warning onto this line keeps two.
*
* `truncate` is the guarantee, not the wording: `formatBytes` is unbounded, so a
diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx
index f33ca00e3a..665560d0e3 100644
--- a/app/src/components/channels/composer/composer.tsx
+++ b/app/src/components/channels/composer/composer.tsx
@@ -13,6 +13,7 @@ import {
isSegmentsEmpty,
mergeAdjacentTextSegments,
type Segment,
+ segmentsToPlainText,
text,
} from "prompt-area/helpers";
import {
@@ -115,9 +116,9 @@ function stagedModality(attachment: Attachment) {
const MAX_HEIGHT_PX = 220;
/**
- * Tracks the compact `text-sm` line box so PromptArea stays vertically centered in one row.
+ * Tracks the compact editor's 23px line box so PromptArea stays vertically centered in one row.
*/
-const COMPACT_MIN_HEIGHT_PX = 19;
+const COMPACT_MIN_HEIGHT_PX = 23;
const COMPACT_MAX_HEIGHT_PX = 96;
export type ComposerProps = {
@@ -243,6 +244,14 @@ export type ComposerProps = {
* that can sum this; a caller with no queue leaves it out and nothing changes.
*/
queuedAttachmentCount?: number;
+ /** What the empty box says. "Message Ana" in a conversation with one Bot; "Ask anything" elsewhere. */
+ placeholder?: string;
+ /**
+ * Called with the draft's plain text whenever it changes: typing, dictation, a send clearing the
+ * box and a failed send putting the words back alike. A conversation's header orb listens
+ * through it.
+ */
+ onTextChange?: (text: string) => void;
};
/**
@@ -328,6 +337,8 @@ export function Composer({
droppedAttachments,
// Nothing parked is the right answer for every caller without a queue, which is most of them.
queuedAttachmentCount = 0,
+ placeholder = "Ask anything",
+ onTextChange,
}: ComposerProps) {
const [value, setValue] = useState(
initialValue ? [{ type: "text", text: initialValue }] : [],
@@ -1046,6 +1057,9 @@ export function Composer({
[agents, commands],
);
const draft = useMemo(() => toDraft(value, staged), [staged, value]);
+ useEffect(() => {
+ onTextChange?.(segmentsToPlainText(value));
+ }, [onTextChange, value]);
/**
* MORE FILES ON THIS STRIP THAN ONE MESSAGE MAY CARRY — a state the screening is supposed to make
@@ -1337,9 +1351,10 @@ export function Composer({
const sendLabel = parking ? "Queue message" : "Send message";
const callAction =
draft.isEmpty && draft.attachments.length === 0 ? voiceCall : undefined;
+ // The compact composer's controls are 44px, the smallest thing a finger can hit.
const actionClassName = cn(
"rounded-full p-0",
- compact ? "size-8 self-end" : "size-7",
+ compact ? "size-(--target) shrink-0 self-end" : "size-7",
);
const primaryAction = canStop ? (
) : callAction ? (
) : (
);
@@ -1484,10 +1499,10 @@ export function Composer({
data-slot="composer"
className={cn(
/*
- * `py-3` IS LOAD-BEARING ONCE THE TEXT WRAPS. On one line `min-h-14` and the controls
- * row's own `items-center` fake the vertical padding, so it read as correct for as long
- * as nobody typed a paragraph. Past that the box grows to fit its content exactly and
- * the glyphs sit against the border.
+ * THE PADDING IS LOAD-BEARING ONCE THE TEXT WRAPS. On one line the controls row's own
+ * `items-center` fakes the vertical padding, so it read as correct for as long as
+ * nobody typed a paragraph. Past that the box grows to fit its content exactly and the
+ * glyphs sit against the border.
*
* It goes on the form rather than on the editor because the editor scrolls internally
* at COMPACT_MAX_HEIGHT_PX: padding inside that box would scroll away with the text, so
@@ -1499,7 +1514,12 @@ export function Composer({
* started it 42px in from the frame's left edge with nothing under it. The strip is
* its own row across the top now, and the three controls keep their row below it.
*/
- "flex min-h-14 flex-col rounded-2xl border border-border bg-card px-3 py-3 focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
+ /*
+ * A pill on a soft shadow, 62px tall around its 44px controls.
+ * The border is there but clear, so a skin can draw it in a colour of its own, and it
+ * takes the ring colour, with the ring around it, while the caret is inside.
+ */
+ "flex min-h-[62px] flex-col rounded-[27px] border border-transparent bg-card p-2 shadow-(--shadow-float) focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
className,
)}
onSubmit={handleFormSubmit}
@@ -1518,11 +1538,11 @@ export function Composer({
* the middle of that block rather than sitting on the line the person is typing. Empty,
* the row is exactly a button tall, so centred and bottom are the same pixel.
*/}
-
+
{attachmentsEnabled ? (
) : (
)}
{/* The slot is ours: `PromptArea` passes no attribute through to its own box. */}
-
+
@@ -1621,7 +1650,7 @@ export function Composer({
onChange={handleChange}
onImagePaste={canAttach ? stagePastedImage : undefined}
onSubmit={submitDraft}
- placeholder="Ask anything"
+ placeholder={placeholder}
ref={promptAreaRef}
triggers={triggers}
value={value}
diff --git a/app/src/components/channels/composer/dictation-controls.tsx b/app/src/components/channels/composer/dictation-controls.tsx
index 2cd2a7c6af..35976001a4 100644
--- a/app/src/components/channels/composer/dictation-controls.tsx
+++ b/app/src/components/channels/composer/dictation-controls.tsx
@@ -18,9 +18,12 @@ type Dictation = ReturnType;
export function DictationButton({
dictation,
disabled,
+ className,
}: {
dictation: Dictation;
disabled: boolean;
+ /** Merged over the button's own: the compact composer makes it a 44px control. */
+ className?: string;
}) {
if (!dictation.available) return null;
return (
@@ -32,7 +35,7 @@ export function DictationButton({
? "Dictate a message"
: "Dictation needs a supported browser and a secure (HTTPS) connection"
}
- className="size-8 shrink-0 self-end rounded-full"
+ className={cn("size-8 shrink-0 self-end rounded-full", className)}
disabled={disabled || !dictation.supported || dictation.busy}
onClick={() => void dictation.session.start()}
size="icon"
diff --git a/app/src/components/channels/conversation-presence.tsx b/app/src/components/channels/conversation-presence.tsx
new file mode 100644
index 0000000000..fcd4b1afc6
--- /dev/null
+++ b/app/src/components/channels/conversation-presence.tsx
@@ -0,0 +1,496 @@
+import type { Message } from "@ag-ui/core";
+import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ useSyncExternalStore,
+} from "react";
+import type { BotOrbState } from "@/components/agents/orb/bot-orb";
+import { ChannelAvatar } from "@/components/channels/avatar";
+import { approvalInboxOptions } from "@/lib/approvals";
+import { channelListQueryOptions } from "@/lib/channels/queries";
+import {
+ type BrowserStep,
+ browserSteps,
+ type StepApproval,
+ stepFor,
+ turnWaitsOnPerson,
+} from "@/lib/channels/step";
+import { cn } from "@/lib/utils";
+
+/**
+ * What one conversation is doing, for its header: the Bot's orb works while the Bot has the turn,
+ * listens while the person types and lands an answer, and the line under the conversation's name
+ * says which step the Bot is on, or that it needs the person.
+ *
+ * The conversation route provides it and the conversation writes into it (`useReportConversation`),
+ * so everything on the route reads one conversation and never another's. Readers:
+ *
+ * - `useConversationOrb()`: the Bot's orb state. `ConversationOrb` draws it.
+ * - `useConversationStatus()`: resting, working (with the step, or null for work the server is doing
+ * that this tab did not start), or needs you. `ConversationStatusLine` draws it.
+ * - `useConversationSteps()`: the browser steps the Bot took, for the computer pane's step log.
+ *
+ * The conversation's reports live in a store outside React, and each reader subscribes to the
+ * fields it uses, so a keystroke reaches the orb and nothing else, and a new step reaches the line
+ * and not the orb. What the server says (the conversation list's busy flag, an approval waiting in
+ * this thread) is read here from the queries the app already keeps.
+ */
+
+/** How long the orb holds "done": its smile springs up and settles away. */
+export const LANDING_MS = 2600;
+
+/** What the conversation last reported. */
+type Reported = {
+ /**
+ * The conversation reporting, null when none is mounted. Readers count a report only from the
+ * conversation on screen: on the way to another one, the last one's report is still here for a
+ * moment, until it unmounts and takes it away.
+ */
+ channelId: string | null;
+ /** The Bot has the turn: asked and not answered yet, browser actions in the middle included. */
+ working: boolean;
+ /** What `stepFor` says about the turn, null when there is none. */
+ step: string | null;
+ /** The composer has something typed in it. */
+ drafting: boolean;
+ /** Counts the turns that ended in an answer, rather than a failure or a Stop. */
+ answers: number;
+ /** The browser steps in the conversation, oldest first. */
+ steps: readonly ConversationStep[];
+};
+
+/** A browser step, and when this tab first saw it, which is where it sits among the commands. */
+export type ConversationStep = BrowserStep & { at: number };
+
+const NO_STEPS: readonly ConversationStep[] = [];
+
+const AT_REST: Reported = {
+ channelId: null,
+ working: false,
+ step: null,
+ drafting: false,
+ answers: 0,
+ steps: NO_STEPS,
+};
+
+/**
+ * When this tab first saw each browser step, by its call.
+ *
+ * MODULE SCOPE, SO A STEP KEEPS ITS PLACE. The command log beside these steps lives for the session,
+ * and a conversation opened again would otherwise stamp every step it already showed with the moment
+ * it came back, after commands that ran later. A step from before this tab opened is stamped when
+ * the conversation first showed it. Bounded, oldest out.
+ */
+const FIRST_SEEN = new Map();
+const MAX_FIRST_SEEN = 1000;
+
+function firstSeen(callId: string, now: number): number {
+ const seen = FIRST_SEEN.get(callId);
+ if (seen !== undefined) return seen;
+ FIRST_SEEN.set(callId, now);
+ while (FIRST_SEEN.size > MAX_FIRST_SEEN) {
+ const oldest = FIRST_SEEN.keys().next().value;
+ if (oldest === undefined) break;
+ FIRST_SEEN.delete(oldest);
+ }
+ return now;
+}
+
+/** The same steps, unchanged: a streamed token rewrites the array and usually nothing in it. */
+function sameSteps(
+ current: readonly ConversationStep[],
+ next: readonly BrowserStep[],
+): boolean {
+ return (
+ current.length === next.length &&
+ current.every((step, index) => {
+ const other = next[index];
+ return (
+ other !== undefined &&
+ step.item.id === other.item.id &&
+ step.item.result === other.item.result &&
+ step.item.toolCall.function.arguments ===
+ other.item.toolCall.function.arguments &&
+ step.latest === other.latest
+ );
+ })
+ );
+}
+
+type Store = {
+ read: () => Reported;
+ write: (next: (current: Reported) => Reported) => void;
+ subscribe: (listener: () => void) => () => void;
+};
+
+function createStore(): Store {
+ let current = AT_REST;
+ const listeners = new Set<() => void>();
+ return {
+ read: () => current,
+ write(next) {
+ const value = next(current);
+ if (value === current) return;
+ current = value;
+ for (const listener of listeners) listener();
+ },
+ subscribe(listener) {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ };
+}
+
+/** What the server says about this conversation. */
+type ServerFacts = {
+ /** The server has a turn running here that this tab did not start. */
+ working: boolean;
+ /** An approval in this conversation is waiting on the person. */
+ waiting: boolean;
+ /** This conversation's approvals, so a declined step stops being named before its result. */
+ approvals: readonly StepApproval[];
+};
+
+const NOTHING_ON_SERVER: ServerFacts = {
+ working: false,
+ waiting: false,
+ approvals: [],
+};
+
+/** The store, and the conversation on screen. */
+type Presence = { store: Store; channelId: string };
+
+const StoreContext = createContext(null);
+const ServerContext = createContext(NOTHING_ON_SERVER);
+
+const unsubscribed = () => () => {};
+
+/**
+ * One field of what the conversation on screen reported, at rest if it has not. Re-renders only
+ * when that field changes.
+ */
+function useReported(
+ presence: Presence | null,
+ key: K,
+): Reported[K] {
+ return useSyncExternalStore(presence?.store.subscribe ?? unsubscribed, () => {
+ const reported = presence?.store.read();
+ return reported && reported.channelId === presence?.channelId
+ ? reported[key]
+ : AT_REST[key];
+ });
+}
+
+/**
+ * `threadId` is the conversation's thread, for the approvals waiting in it; undefined while the
+ * conversation loads. The route stays mounted from one conversation to the next, and so does this:
+ * the conversation leaving takes its reports with it, and `channelId` moves what the server says.
+ */
+export function ConversationPresenceProvider({
+ channelId,
+ threadId,
+ children,
+}: {
+ channelId: string;
+ threadId: string | undefined;
+ children: ReactNode;
+}) {
+ const [store] = useState(createStore);
+ const presence = useMemo(() => ({ store, channelId }), [store, channelId]);
+ const channels = useInfiniteQuery(channelListQueryOptions());
+ // The conversation's one poll of the inbox. Its rows and cards read the cache this keeps fresh.
+ const inbox = useQuery(approvalInboxOptions());
+ const row = channels.data?.find((channel) => channel.id === channelId);
+ // The sidebar's rule (`ChannelRow`), so the header and the conversation's row agree.
+ const busy = (row?.busy ?? false) || (row?.work?.length ?? 0) > 0;
+ const working = useUnownedBusy(
+ channelId,
+ busy,
+ useReported(presence, "working"),
+ );
+ const approvals = useMemo(
+ () =>
+ threadId === undefined
+ ? []
+ : (inbox.data?.requests ?? []).filter(
+ (request) => request.action.threadId === threadId,
+ ),
+ [inbox.data, threadId],
+ );
+ const waiting = approvals.some((request) => request.status === "pending");
+ const facts = useMemo(
+ () => ({ working, waiting, approvals }),
+ [working, waiting, approvals],
+ );
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * The busy flag for work this tab did not start: false while the conversation's own turn runs, and
+ * for the rest of that busy spell. The server's flag comes down a moment after the tab's own turn
+ * ends, and without this every answer would end on "Working" as the flag caught up. A run the server
+ * starts on its own afterwards, such as the one after Allow once, raises a new spell, and shows.
+ * A spell belongs to one conversation, so opening another starts again.
+ */
+function useUnownedBusy(
+ channelId: string,
+ busy: boolean,
+ ownTurn: boolean,
+): boolean {
+ const spell = useRef({ channelId, owned: false });
+ if (spell.current.channelId !== channelId)
+ spell.current = { channelId, owned: false };
+ if (!busy) spell.current.owned = false;
+ else if (ownTurn) spell.current.owned = true;
+ return busy && !spell.current.owned;
+}
+
+/**
+ * The Bot's orb state for what the conversation is doing. The Bot working outranks the person
+ * typing, since a correction typed mid-answer waits for the turn to end; typing cuts a landing
+ * short, and so does an approval waiting here, since the line then says "Needs you". Resting
+ * outside a conversation route.
+ */
+export function useConversationOrb(): BotOrbState {
+ const presence = useContext(StoreContext);
+ const server = useContext(ServerContext);
+ const present = useReported(presence, "channelId") !== null;
+ const working = useReported(presence, "working");
+ const drafting = useReported(presence, "drafting");
+ // Read whatever conversation it came from: it only counts, and a count from before is landed.
+ const answers = useSyncExternalStore(
+ presence?.store.subscribe ?? unsubscribed,
+ () => presence?.store.read().answers ?? 0,
+ );
+ // The answer whose landing has played out. Behind `answers` while the orb holds "done".
+ const [landed, setLanded] = useState(answers);
+ useEffect(() => {
+ if (answers === landed) return;
+ // With the conversation gone the landing is over, so coming straight back does not replay it.
+ if (!present) {
+ setLanded(answers);
+ return;
+ }
+ const timer = setTimeout(() => setLanded(answers), LANDING_MS);
+ return () => clearTimeout(timer);
+ }, [answers, present, landed]);
+ if (working || server.working) return "thinking";
+ if (drafting) return "listening";
+ if (server.waiting) return "idle";
+ return present && answers !== landed ? "done" : "idle";
+}
+
+/** How the conversation stands, for the line under its name. */
+export type ConversationStatus =
+ | { kind: "resting" }
+ /** `step` is what the Bot is on, or null for a turn the server is running that this tab did not start. */
+ | { kind: "working"; step: string | null }
+ | { kind: "needs-you" };
+
+/**
+ * How the conversation stands. An approval waiting comes first, since the Bot cannot go on with it
+ * until the person decides. Keystrokes don't reach it.
+ */
+export function useConversationStatus(): ConversationStatus {
+ const presence = useContext(StoreContext);
+ const server = useContext(ServerContext);
+ const working = useReported(presence, "working");
+ const step = useReported(presence, "step");
+ if (server.waiting) return { kind: "needs-you" };
+ if (working) return { kind: "working", step };
+ if (server.working) return { kind: "working", step: null };
+ return { kind: "resting" };
+}
+
+/**
+ * Whether an approval in this conversation is waiting on the person. A call the server did not ask
+ * about again, because the same request was already waiting, waits with it while this is true.
+ * False outside a conversation route.
+ */
+export function useConversationWaiting(): boolean {
+ return useContext(ServerContext).waiting;
+}
+
+/**
+ * The browser steps the conversation's Bot took, oldest first, each with when this tab first saw it.
+ * None outside a conversation route.
+ */
+export function useConversationSteps(): readonly ConversationStep[] {
+ return useReported(useContext(StoreContext), "steps");
+}
+
+/**
+ * Report a conversation's turn, draft and browser steps, for as long as it is mounted. Outside a
+ * conversation route there is nothing to report to, and it returns undefined.
+ *
+ * `working` is the turn, not the run, for the reason `ConversationView` gives: a browser action ends
+ * one run and starts another, and the orb would land in the gap. `failed` is the turn having ended
+ * without an answer. The step is read off `messages` on every render, as the transcript reads them,
+ * because the agent grows the same array in place. Hand the composer `onTextChange`, and call
+ * `onStop` when somebody presses Stop, so a stopped answer does not land either. Nor does a turn
+ * that ended on a call held for the person, which has no answer yet.
+ */
+export function useReportConversation({
+ working,
+ failed,
+ messages,
+}: {
+ working: boolean;
+ failed: boolean;
+ messages: readonly Message[];
+}): { onTextChange: (text: string) => void; onStop: () => void } | undefined {
+ const presence = useContext(StoreContext);
+ const { approvals } = useContext(ServerContext);
+ const step = presence ? stepFor(messages, working, approvals) : null;
+ // Read on every render for the same reason as the step.
+ const steps = presence ? browserSteps(messages) : NO_STEPS;
+ // Asked only as the turn ends, so it is not read again on every token of the answer.
+ const waitsOnPerson =
+ presence && !working ? turnWaitsOnPerson(messages) : false;
+ const wasWorking = useRef(false);
+ const stopped = useRef(false);
+ /*
+ * Every report is this conversation's, by the channel on screen when it mounted. The route keys
+ * the conversation on its channel, so that is the channel it stays.
+ */
+ const store = presence?.store;
+ const mine = presence?.channelId ?? null;
+ const report = useCallback(
+ (change: (current: Reported) => Partial | null) =>
+ store?.write((current) => {
+ // What another conversation reported is not this one's, apart from the count of answers.
+ const own =
+ current.channelId === mine
+ ? current
+ : { ...AT_REST, answers: current.answers, channelId: mine };
+ const changed = change(own);
+ return changed ? { ...own, ...changed } : own;
+ }),
+ [store, mine],
+ );
+
+ // First, so leaving takes everything with it: an answer nobody is there to see does not land.
+ useEffect(() => {
+ if (!store) return;
+ report(() => null);
+ return () =>
+ store.write((current) =>
+ current.channelId === mine
+ ? { ...AT_REST, answers: current.answers }
+ : current,
+ );
+ }, [store, mine, report]);
+
+ useEffect(() => {
+ if (working && !wasWorking.current) stopped.current = false;
+ const answered =
+ wasWorking.current &&
+ !working &&
+ !failed &&
+ !stopped.current &&
+ !waitsOnPerson;
+ wasWorking.current = working;
+ report((current) =>
+ current.working === working && !answered
+ ? null
+ : { working, answers: current.answers + (answered ? 1 : 0) },
+ );
+ }, [report, working, failed, waitsOnPerson]);
+
+ useEffect(() => {
+ report((current) => (current.step === step ? null : { step }));
+ }, [report, step]);
+
+ useEffect(() => {
+ report((current) => {
+ if (sameSteps(current.steps, steps)) return null;
+ const now = Date.now();
+ return {
+ steps: steps.map((browser) => ({
+ ...browser,
+ at: firstSeen(browser.item.id, now),
+ })),
+ };
+ });
+ }, [report, steps]);
+
+ const onTextChange = useCallback(
+ (text: string) => {
+ const drafting = text.trim() !== "";
+ report((current) =>
+ current.drafting === drafting ? null : { drafting },
+ );
+ },
+ [report],
+ );
+ const onStop = useCallback(() => {
+ stopped.current = true;
+ }, []);
+
+ return store ? { onTextChange, onStop } : undefined;
+}
+
+/**
+ * The Bot's orb, in the state its conversation is in. Its own component, so a keystroke redraws
+ * the orb and nothing around it.
+ */
+export function ConversationOrb({
+ participantIds,
+ size,
+}: {
+ participantIds: string[];
+ size: number;
+}) {
+ return (
+
+ );
+}
+
+/**
+ * The line under the conversation's name: "Working · Searching the web" while the Bot works,
+ * "Working" alone for a run the server resumed, "Needs you" while an approval waits here, and
+ * nothing at rest. Empty, it takes no height, so the name sits level with the orb.
+ *
+ * Always mounted, so the live region is there before the first step arrives: a region added with
+ * its text already in it is not announced. The step shimmers as a running tool line does, and stops
+ * for somebody who asked for less motion.
+ */
+export function ConversationStatusLine({ className }: { className?: string }) {
+ const status = useConversationStatus();
+ return (
+
+ );
+}
diff --git a/app/src/components/channels/conversation-view.tsx b/app/src/components/channels/conversation-view.tsx
index f0ff124adf..ea63f06ee9 100644
--- a/app/src/components/channels/conversation-view.tsx
+++ b/app/src/components/channels/conversation-view.tsx
@@ -7,6 +7,7 @@ import {
useRef,
useState,
} from "react";
+import { AiNotice } from "@/components/channels/ai-notice";
import { ChatTranscript } from "@/components/channels/chat-transcript";
import {
type AgentOption,
@@ -18,6 +19,7 @@ import {
type QueuedMessage,
reduceQueue,
} from "@/components/channels/composer";
+import { useReportConversation } from "@/components/channels/conversation-presence";
import { attachmentUrl } from "@/lib/channels/attachments";
import { newId } from "../../lib/new-id";
@@ -39,6 +41,7 @@ export function ConversationView({
onSubmit,
onStop,
voiceCall,
+ placeholder,
}: {
messages: readonly Message[];
busy?: boolean;
@@ -97,6 +100,8 @@ export function ConversationView({
/** Stop the Bot mid-answer; forwarded to turn the send button into a stop button. */
onStop?: () => void;
voiceCall?: { active: boolean; supported: boolean; onStart(): void };
+ /** The composer's empty-state text, e.g. "Message Ana". The composer's own when absent. */
+ placeholder?: string;
}) {
/*
* THE QUEUE LIVES HERE BECAUSE BOTH HALVES OF IT DO.
@@ -154,6 +159,16 @@ export function ConversationView({
*/
const [running, setRunning] = useState(false);
const inFlight = pending || running;
+ /*
+ * The conversation's header follows it: the orb thinks for the turn and listens to the draft, and
+ * the line under the name says the step. Not while history is restoring: joining the thread is a
+ * run of its own, and it is not an answer. Outside a conversation route nothing is reported.
+ */
+ const presence = useReportConversation({
+ working: (busy || inFlight) && !restoring,
+ failed: Boolean(stopped),
+ messages,
+ });
/**
* Every change to the queue goes through here, so the ref and the state can never disagree.
@@ -439,7 +454,7 @@ export function ConversationView({
/>
{notice}
@@ -460,8 +475,17 @@ export function ConversationView({
}
: undefined
}
- onStop={onStop}
+ onStop={
+ onStop && presence
+ ? () => {
+ presence.onStop();
+ onStop();
+ }
+ : onStop
+ }
onSubmit={(draft) => submit(draft, false)}
+ {...(presence ? { onTextChange: presence.onTextChange } : {})}
+ {...(placeholder ? { placeholder } : {})}
/*
* `inFlight` rather than the `pending` this was given. A drained turn is started from the
* effect above rather than from the composer, so the composer's own send tracking knows
@@ -502,6 +526,7 @@ export function ConversationView({
*/
stoppable={stoppable ?? pending}
/>
+
);
diff --git a/app/src/components/channels/group-chat.tsx b/app/src/components/channels/group-chat.tsx
index 2515ce93d2..df032f2e03 100644
--- a/app/src/components/channels/group-chat.tsx
+++ b/app/src/components/channels/group-chat.tsx
@@ -16,6 +16,7 @@ import {
} from "@/lib/groups";
import { markdownComponents } from "@/lib/markdown";
import { newId } from "@/lib/new-id";
+import { AiNotice } from "./ai-notice";
import { ChannelAvatar } from "./avatar";
import { Composer, toAgentOptions } from "./composer";
import { TeamBotConsentCard } from "./team-bot-consent";
@@ -142,6 +143,7 @@ export function GroupChat({ channelId }: { channelId: string }) {
});
}}
/>
+
);
diff --git a/app/src/components/channels/split-bubbles.ts b/app/src/components/channels/split-bubbles.ts
new file mode 100644
index 0000000000..af3ab4c216
--- /dev/null
+++ b/app/src/components/channels/split-bubbles.ts
@@ -0,0 +1,66 @@
+/**
+ * A Bot's reply as the separate thoughts a chat draws as separate bubbles: one bubble per paragraph,
+ * 8px apart, as a person texting would send them, instead of one tall bubble holding everything.
+ *
+ * Paragraphs are split on blank lines, with three exceptions that keep markdown whole:
+ * - nothing is split inside a fenced code block, including one still streaming in;
+ * - a list stays with the line that introduces it ("Three things I could start on:"), and its items
+ * stay together even when the model put blank lines between them;
+ * - a table stays in one piece.
+ */
+export function splitBubbles(text: string): string[] {
+ const blocks: string[] = [];
+ let current: string[] = [];
+ let fenced = false;
+ const flush = () => {
+ const block = current.join("\n").trim();
+ if (block) blocks.push(block);
+ current = [];
+ };
+ for (const line of text.split("\n")) {
+ if (FENCE.test(line)) fenced = !fenced;
+ if (!fenced && line.trim() === "") {
+ flush();
+ continue;
+ }
+ current.push(line);
+ }
+ flush();
+
+ const bubbles: string[] = [];
+ for (const block of blocks) {
+ const previous = bubbles.at(-1);
+ if (previous !== undefined && belongsWith(previous, block)) {
+ bubbles[bubbles.length - 1] = `${previous}\n\n${block}`;
+ } else {
+ bubbles.push(block);
+ }
+ }
+ return bubbles.length > 0 ? bubbles : [text];
+}
+
+/**
+ * Whether a bubble holds a fenced code block or a table, which wants the whole column on a wide
+ * screen: a line of code or a row of cells cut at a prose width scrolls sideways inside its bubble.
+ */
+export function holdsBlock(bubble: string): boolean {
+ return bubble
+ .split("\n")
+ .some((line) => FENCE.test(line) || TABLE_ROW.test(line));
+}
+
+const FENCE = /^\s{0,3}(```|~~~)/;
+const LIST_ITEM = /^\s{0,3}([-*+]|\d+[.)])\s/;
+const TABLE_ROW = /^\s*\|/;
+
+/** Whether a block continues the bubble before it rather than starting one of its own. */
+function belongsWith(previous: string, block: string): boolean {
+ const lastLine = previous.split("\n").at(-1) ?? "";
+ if (LIST_ITEM.test(block))
+ return LIST_ITEM.test(lastLine) || lastLine.trimEnd().endsWith(":");
+ if (TABLE_ROW.test(block)) return TABLE_ROW.test(lastLine);
+ // An indented block under a list item is that item's continuation.
+ if (/^\s{2,}\S/.test(block))
+ return previous.split("\n").some((line) => LIST_ITEM.test(line));
+ return false;
+}
diff --git a/app/src/components/channels/tool-activity.tsx b/app/src/components/channels/tool-activity.tsx
index 013b3a9986..571f544dc7 100644
--- a/app/src/components/channels/tool-activity.tsx
+++ b/app/src/components/channels/tool-activity.tsx
@@ -1,4 +1,5 @@
import { CopilotKitContext } from "@copilotkit/react-core/v2/context";
+import { useQuery } from "@tanstack/react-query";
import {
readConnectPrompt,
SHOW_APP_ACCESS_TOOL,
@@ -13,12 +14,14 @@ import {
} from "@tabler/icons-react";
import { useCallback, useContext, useMemo, useSyncExternalStore } from "react";
import { Streamdown } from "streamdown";
-import { PendingApprovals } from "@/components/approvals/inline-approval";
+import { ToolCallApprovals } from "@/components/approvals/inline-approval";
+import { approvalInboxCacheOptions } from "@/lib/approvals";
import { markdownComponents } from "@/lib/markdown";
import {
connectorName,
humanise,
ownToolLabel,
+ personToolLabel,
readToolName,
} from "@/lib/plugins/tool-name";
import {
@@ -26,10 +29,16 @@ import {
forDisplay,
REFUSAL_MARKER,
toolResultFailed,
+ toolResultHeldBack,
} from "@/lib/plugins/tool-result";
import { readTeamBotConsent } from "@/lib/team-bots";
-import { browserStepDetails, isBrowserStep } from "./browser-activity";
+import {
+ browserOutcome,
+ browserStepDetails,
+ isBrowserStep,
+} from "./browser-activity";
import type { VisibleChatItem } from "./chat-messages";
+import { useConversationWaiting } from "./conversation-presence";
import { ToolLine } from "./tool-line";
type ToolItem = Extract;
@@ -218,18 +227,34 @@ type StepKind = "search" | "fetch" | "browser" | "connector" | "tool";
export type StepDetails = {
id: string;
kind: StepKind;
+ /** What it is doing while it runs, and what it did once it has. */
label: string;
+ /** What it would do, for a step that did not happen or has not yet: "Send your email". */
+ plain: string;
/** What it was done to: a query, a page, a connector. Muted beside the label. */
detail?: string;
href?: string;
pending: boolean;
failed: boolean;
refused: boolean;
+ /** The person said no on its card. Their decision, not an issue. */
+ declined: boolean;
+ /**
+ * Not done, because the same request was already waiting on its card and was not asked again. It
+ * waits with that one, and is not an issue.
+ */
+ alreadyWaiting: boolean;
+ /** A browser step cut off before it finished, by Stop or by its computer stopping. Not an issue. */
+ stopped: boolean;
+ /** A browser step a computer still starting turned away, so nothing was done yet. Not an issue. */
+ notYet: boolean;
reason?: string;
/** Pages a browser step opened, or a fetch read; zero for anything else. */
pages: number;
/** The connector a connector step reached, said the way a person would. */
connector?: string;
+ /** What it did to the person's own things, once done: "Searched your email". */
+ person?: string;
inputs: [string, string][];
output?: string;
};
@@ -286,15 +311,26 @@ export function stepDetails(step: ToolItem): StepDetails {
);
if (isBrowserStep(step)) {
const browser = browserStepDetails(step);
+ // Read the way the computer pane's step log reads it, so the two never disagree.
+ const outcome = browserOutcome(step);
+ // A step that didn't happen is named for what it would have done, never as done.
+ const happened = outcome === "done" || outcome === "pending";
+ const plain = browser.plain ?? "Use the browser";
+ const label = happened ? (browser.label ?? "Used the browser") : plain;
return {
id: step.id,
kind: "browser",
- label: browser.label ?? "Used the browser",
+ label,
+ plain,
...(browser.title ? { detail: browser.title } : {}),
...(browser.href ? { href: browser.href } : {}),
- pending: browser.pending,
- failed: browser.failed,
- refused: false,
+ pending: outcome === "pending",
+ failed: outcome === "failed",
+ refused: outcome === "blocked",
+ declined: outcome === "declined",
+ alreadyWaiting: outcome === "alreadyWaiting",
+ stopped: outcome === "stopped",
+ notYet: outcome === "notYet",
...(browser.reason ? { reason: browser.reason } : {}),
pages: browser.visited ? 1 : 0,
inputs,
@@ -303,33 +339,50 @@ export function stepDetails(step: ToolItem): StepDetails {
const name = step.toolCall.function.name;
const answer = step.result === undefined ? undefined : asText(step.result);
- const refused = answer?.startsWith(REFUSAL_MARKER) ?? false;
+ // The person holding a call back is not a result either, and its words are for the model.
+ const heldBack = toolResultHeldBack(step.result);
+ const declined = heldBack === "declined";
+ const alreadyWaiting = heldBack === "alreadyWaiting";
+ const refused = !heldBack && (answer?.startsWith(REFUSAL_MARKER) ?? false);
const body = refused ? answer?.slice(REFUSAL_MARKER.length).trim() : answer;
// This deployment's own tools answer the model, not the person; see `ServerToolLine`.
const own = ownToolLabel(name) !== null;
+ const failed = !refused && !heldBack && toolResultFailed(step.result);
const outcome = {
id: step.id,
pending: step.result === undefined,
- failed: !refused && toolResultFailed(step.result),
+ failed,
refused,
+ declined,
+ alreadyWaiting,
+ stopped: false,
+ notYet: false,
inputs,
- ...(body && !own ? { output: body } : {}),
+ ...(body && !own && !heldBack ? { output: body } : {}),
};
+ // Running, a step says what is happening now; done, what happened. One that did not happen is
+ // named plainly ("Send your email"), never as though it had.
+ const state =
+ failed || refused || heldBack !== null ? "plain" : outcome.pending;
// Not a connector: one of this deployment's own tools, or one an external agent runs itself.
// Named in words, never as an identifier.
- if (!name.startsWith("mcp__"))
+ if (!name.startsWith("mcp__")) {
+ const plain = humanise(name);
return {
...outcome,
kind: "tool",
- label: ownToolLabel(name, outcome.pending) ?? humanise(name),
+ label: (state === "plain" ? null : ownToolLabel(name, state)) ?? plain,
+ plain,
pages: 0,
};
+ }
const [, server = "", ...rest] = name.split("__");
const role = researchRoleOfTool(server, rest.join("__"));
- const { label, detail } = readToolName(name);
+ const { label, detail } = readToolName(name, state);
+ const plain = readToolName(name, "plain").label;
const urls = role === "fetch" ? urlsOf(args) : [];
- const base = { ...outcome, label };
+ const base = { ...outcome, label, plain };
if (role === "search") {
const query = firstString(
args.query,
@@ -361,15 +414,30 @@ export function stepDetails(step: ToolItem): StepDetails {
pages: Math.max(urls.length, 1),
};
}
+ const person = personToolLabel(name);
return {
...base,
kind: "connector",
...(detail ? { detail } : {}),
pages: 0,
connector: connectorName(server),
+ ...(person ? { person } : {}),
};
}
+/** It ran and it worked: the only kind of step a summary may say was done. */
+function isDone(step: StepDetails): boolean {
+ return (
+ !step.pending &&
+ !step.failed &&
+ !step.refused &&
+ !step.declined &&
+ !step.alreadyWaiting &&
+ !step.stopped &&
+ !step.notYet
+ );
+}
+
function joinPhrases(phrases: string[]): string {
if (phrases.length <= 1) return phrases[0] ?? "";
return `${phrases.slice(0, -1).join(", ")} and ${phrases.at(-1)}`;
@@ -384,10 +452,15 @@ function plural(count: number, one: string, many: string): string {
*
* Counted by kind rather than listed, because the list is what the line opens to. Research is
* never attributed to a provider; a connector is named, because which system was reached is the
- * person's own fact.
+ * person's own fact, and what it did to the person's own mail or calendar is named in their words.
+ *
+ * ONLY WHAT HAPPENED. A step that was refused, failed or declined, or never finished, is not called
+ * done: the first two are counted as issues beside the line, and all of them say how they stand
+ * when it is opened. With nothing done, the line names the first step plainly.
*/
export function summarise(steps: readonly StepDetails[]): string {
- const of = (kind: StepKind) => steps.filter((step) => step.kind === kind);
+ const done = steps.filter(isDone);
+ const of = (kind: StepKind) => done.filter((step) => step.kind === kind);
const phrases: string[] = [];
if (of("search").length) phrases.push("searched the web");
const read = of("fetch").reduce((sum, step) => sum + step.pages, 0);
@@ -395,9 +468,23 @@ export function summarise(steps: readonly StepDetails[]): string {
const browser = of("browser");
const browsed = browser.reduce((sum, step) => sum + step.pages, 0);
if (browsed) phrases.push(`browsed ${plural(browsed, "page", "pages")}`);
- else if (browser.length) phrases.push("used the browser");
+ else if (browser.length) {
+ // Named as the computer's step log names them, up to two; past that, one phrase for all.
+ const actions = [...new Set(browser.map((step) => step.label))];
+ if (actions.length <= 2)
+ phrases.push(...actions.map((label) => label.toLowerCase()));
+ else phrases.push("used the browser");
+ }
+ for (const person of new Set(
+ of("connector").flatMap((step) => (step.person ? [step.person] : [])),
+ ))
+ phrases.push(person.charAt(0).toLowerCase() + person.slice(1));
const connectors = [
- ...new Set(of("connector").map((step) => step.connector ?? "")),
+ ...new Set(
+ of("connector")
+ .filter((step) => !step.person)
+ .map((step) => step.connector ?? ""),
+ ),
].filter(Boolean);
if (connectors.length === 1) phrases.push(`used ${connectors[0]}`);
else if (connectors.length > 1)
@@ -406,6 +493,7 @@ export function summarise(steps: readonly StepDetails[]): string {
);
const tools = of("tool").length;
if (tools) phrases.push(`used ${plural(tools, "tool", "tools")}`);
+ if (phrases.length === 0) return steps[0]?.plain ?? "";
const sentence = joinPhrases(phrases);
return sentence.charAt(0).toUpperCase() + sentence.slice(1);
}
@@ -453,29 +541,85 @@ function IconStack({ kinds }: { kinds: StepKind[] }) {
);
}
-function StepStatus({ step, active }: { step: StepDetails; active: boolean }) {
+/**
+ * How a step stands, beside its words, for any step that did not simply work. The computer pane's
+ * step log says the same words (`step-log.tsx`).
+ */
+export const STEP_STATUS = {
+ waiting: "Waiting for you",
+ running: "In progress",
+ stopped: "Not completed",
+ notYet: "Not done yet",
+ declined: "Declined",
+ blocked: "Blocked",
+ failed: "Didn't work",
+} as const;
+
+function StepStatus({
+ step,
+ active,
+ waiting,
+}: {
+ step: StepDetails;
+ active: boolean;
+ waiting: boolean;
+}) {
if (step.pending)
return (
- {active ? "In progress" : "Not completed"}
+ {waiting
+ ? STEP_STATUS.waiting
+ : active
+ ? STEP_STATUS.running
+ : STEP_STATUS.stopped}
+
+ );
+ // Waits with the request it repeated, and once that is answered it was simply not done.
+ if (step.alreadyWaiting)
+ return (
+
+ {waiting ? STEP_STATUS.waiting : STEP_STATUS.stopped}
+
+ );
+ if (step.stopped || step.notYet || step.declined)
+ return (
+
+ {step.stopped
+ ? STEP_STATUS.stopped
+ : step.notYet
+ ? STEP_STATUS.notYet
+ : STEP_STATUS.declined}
);
if (step.refused)
- return Blocked;
- if (step.failed)
return (
-
- Didn't work
+
+ {STEP_STATUS.blocked}
);
+ if (step.failed)
+ return (
+ {STEP_STATUS.failed}
+ );
return null;
}
-function StepRow({ step, active }: { step: StepDetails; active: boolean }) {
+function StepRow({
+ step,
+ active,
+ waiting,
+}: {
+ step: StepDetails;
+ active: boolean;
+ /** Held on its approval card until the person answers, or waiting with the request it repeated. */
+ waiting: boolean;
+}) {
+ // Only a step that is running now says it is happening; one stopped or held says what it would do.
+ const label = step.pending && (waiting || !active) ? step.plain : step.label;
const line = (
- {step.label}
+ {label}
{step.href ? (
{step.detail}
) : null}
-
+
);
- const reason =
- step.failed && step.kind === "browser" ? (
-
- {step.reason ?? "Couldn't finish this step."}
-
- ) : null;
+ // Why a browser step did not work, or the boundary's own words for why it was refused.
+ const why =
+ step.kind !== "browser"
+ ? undefined
+ : step.failed
+ ? (step.reason ?? "Couldn't finish this step.")
+ : step.refused
+ ? step.reason
+ : undefined;
+ const reason = why ? (
+
{why}
+ ) : null;
// A browser step says everything it has in its line. Anything else opens to what it was given
// and what came back, which no other place in the transcript shows.
if (step.kind === "browser" || (!step.inputs.length && !step.output))
@@ -509,10 +659,10 @@ function StepRow({ step, active }: { step: StepDetails; active: boolean }) {
return (
-
+
▸
@@ -542,6 +692,27 @@ function StepRow({ step, active }: { step: StepDetails; active: boolean }) {
);
}
+/**
+ * The tool calls the server is holding on an approval card, read from the same inbox the cards are.
+ *
+ * A held step has no result yet, the same as one still running or one a stopped turn left behind,
+ * and only the inbox can tell them apart. Every row asks, so it reads the cache the screen polls.
+ */
+export function useHeldForPerson(): ReadonlySet {
+ const inbox = useQuery(approvalInboxCacheOptions());
+ return useMemo(
+ () =>
+ new Set(
+ (inbox.data?.requests ?? []).flatMap((request) =>
+ request.status === "pending" && request.action.toolCallId
+ ? [request.action.toolCallId]
+ : [],
+ ),
+ ),
+ [inbox.data],
+ );
+}
+
export function ToolActivity({
group,
active,
@@ -551,10 +722,22 @@ export function ToolActivity({
active: boolean;
}) {
const steps = group.steps.map(stepDetails);
+ const held = useHeldForPerson();
+ const waitingHere = useConversationWaiting();
+ // On its own card, or with the identical request it repeated while that one still waits.
+ const waitsForPerson = (step: StepDetails) =>
+ held.has(step.id) || (step.alreadyWaiting && waitingHere);
const running = active && steps.some((step) => step.pending);
const current = steps.find((step) => step.pending);
const issues = steps.filter((step) => step.failed || step.refused).length;
const kinds = [...new Set(steps.map((step) => step.kind))];
+ /*
+ * NOTHING DONE IS NOT DONE. With no step that worked, the line is named for the first one plainly
+ * and marked for how the group stands, so it never carries the done mark.
+ */
+ const nothingDone = !running && !steps.some(isDone);
+ // Nothing done yet because a step waits on its card, which is drawn beside this line.
+ const waiting = nothingDone && steps.some(waitsForPerson);
// The folded line draws its own steps, so a waiting approval gets its card beside it, not inside.
return (
<>
@@ -563,14 +746,33 @@ export function ToolActivity({
label={running && current ? `${current.label}…` : summarise(steps)}
detail={issues ? plural(issues, "issue", "issues") : undefined}
running={running}
+ failed={nothingDone && steps.some((step) => step.failed)}
+ refused={
+ nothingDone &&
+ !steps.some((step) => step.failed) &&
+ steps.some((step) => step.refused)
+ }
+ declined={nothingDone && !issues && steps.some((step) => step.declined)}
+ notYet={
+ nothingDone &&
+ !issues &&
+ !waiting &&
+ !steps.some((step) => step.declined)
+ }
+ waiting={waiting && !issues && !steps.some((step) => step.declined)}
>
{steps.map((step) => (
-
+
))}
- step.toolCall.id)}
/>
>
diff --git a/app/src/components/channels/tool-line.tsx b/app/src/components/channels/tool-line.tsx
index 528d1d794e..dbc533f35f 100644
--- a/app/src/components/channels/tool-line.tsx
+++ b/app/src/components/channels/tool-line.tsx
@@ -1,4 +1,6 @@
+import { IconCircleCheck, IconClock, IconX } from "@tabler/icons-react";
import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
/**
* One line for one thing a Bot did.
@@ -17,10 +19,12 @@ export function ToolLine({
running,
refused,
failed,
+ declined,
notYet,
+ waiting,
children,
}: {
- /** Drawn before the label, small and muted. A group of steps puts its kinds here. */
+ /** Drawn after the label, small and muted. A group of steps puts its kinds here. */
icon?: ReactNode;
/** What was done, in a couple of words: "Searched Slack", "Filled in", "Read the page". */
label: string;
@@ -31,63 +35,121 @@ export function ToolLine({
refused?: boolean;
/** It was permitted and did not work. A different request might. */
failed?: boolean;
+ /** The person said no on its card, so it was not done. Their decision, not a failure. */
+ declined?: boolean;
/**
* Nothing was done yet, for an ordinary reason: a computer still starting turned the call away
* before it reached anything. Muted like a success, because nothing went wrong, and labelled so it
* does not read as one.
*/
notYet?: boolean;
+ /**
+ * Held on its approval card until the person answers. Not running, and not done: muted, with the
+ * card under the line saying what it would do.
+ */
+ waiting?: boolean;
/** Shown when the line is expanded. Without it the line is not expandable. */
children?: ReactNode;
}) {
const tone = refused
- ? "text-destructive"
+ ? "text-status-problem"
: failed
- ? "text-amber-600 dark:text-amber-500"
+ ? "text-status-needs"
: "text-muted-foreground";
-
+ /*
+ * A hairline hook, then a mark saying how it went, then what was done. Only a line that worked
+ * gets the check, in the done colour; a group's own kinds come after the label, where they no
+ * longer compete with the outcome.
+ */
+ const Mark =
+ refused || failed || declined
+ ? IconX
+ : running || notYet || waiting
+ ? IconClock
+ : IconCircleCheck;
+ const markTone =
+ refused || failed
+ ? tone
+ : running || notYet || waiting || declined
+ ? "text-muted-foreground"
+ : "text-status-done";
const text = (
-
- {icon ? {icon} : null}
-
- {refused
- ? `Blocked: ${label}`
- : failed
- ? `${label}, didn't work`
- : notYet
- ? `${label}, not done yet`
- : label}
+
+
+
+
+ {/* Cut with an ellipsis rather than run past its row: a summary can be long. */}
+
+ {declined
+ ? `You declined: ${label}`
+ : refused
+ ? `Blocked: ${label}`
+ : failed
+ ? `${label}, didn't work`
+ : notYet
+ ? `${label}, not done yet`
+ : waiting
+ ? `${label}, waiting for you`
+ : label}
+
+ {detail ? {detail} : null}
- {detail ? {detail} : null}
+ {icon ? (
+
+ {icon}
+
+ ) : null}
);
- if (!children) return
{text}
;
+ if (!children) return
{text}
;
return (
-
+
{/*
* Native owns expansion because SDK tool renderers can be re-invoked independently
- * of this component's React state.
+ * of this component's React state. The summary is the line, a finger's height tall, and the
+ * chevron after the label says it opens.
*/}
-
+
+ {text}
▸
- {text}
{/*
+ * Indented to the label, past the hook and the mark, so the detail reads as belonging to it.
* Headings are cut down to the size of the surrounding text. A tool result is markdown a
* server wrote for a model, so its `#` is a section marker rather than a page title.
*/}
-
+
{children}
diff --git a/app/src/components/computer/activity-log.tsx b/app/src/components/computer/activity-log.tsx
index 417dc47b41..ffb75fbd74 100644
--- a/app/src/components/computer/activity-log.tsx
+++ b/app/src/components/computer/activity-log.tsx
@@ -9,33 +9,35 @@
* This session only. The record is the audit trail, which is on the server and survives a reload;
* this is a window, and it is allowed to be empty when a tab is opened on a conversation that
* already happened.
+ *
+ * The pane draws these among the Bot's browser steps, in one list (`step-log.tsx`). What is here is
+ * what only a command log can say: what each call is called, how it ended, and what it printed.
*/
-import { IconFile, IconFolder, IconTerminal2 } from "@tabler/icons-react";
-import { useSyncExternalStore } from "react";
-import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
-import {
- activityFor,
- type ComputerActivity,
- subscribeToActivity,
-} from "@/lib/computers/activity";
+import type { ComputerActivity } from "@/lib/computers/activity";
import { CommandOutput } from "./command-output";
-const ICONS = {
- command: IconTerminal2,
- read_file: IconFile,
- write_file: IconFile,
- list_files: IconFolder,
+/**
+ * What the line says was done, in the words the call's own line in the conversation uses
+ * (`computer-tools.tsx`). The subject beside it says what it was done to.
+ */
+export const ACTIVITY_LABELS = {
+ command: "Ran a command",
+ read_file: "Read file",
+ write_file: "Saved file",
+ list_files: "Listed files",
} as const;
-/** What the line says was done. The subject beside it says what it was done to. */
-const LABELS = {
- command: "Ran",
- read_file: "Read",
- write_file: "Saved",
- list_files: "Listed",
-} as const;
+/** How a call ended: a boundary refused it, it exited non-zero, or it worked. */
+export function activityStatus(
+ entry: ComputerActivity,
+): "done" | "blocked" | "failed" {
+ if (entry.refused === true) return "blocked";
+ if (typeof entry.exitCode === "number" && entry.exitCode !== 0)
+ return "failed";
+ return "done";
+}
-function timeOf(at: number): string {
+export function timeOf(at: number): string {
return new Date(at).toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
@@ -43,84 +45,22 @@ function timeOf(at: number): string {
});
}
-function Entry({ entry }: { entry: ComputerActivity }) {
- const Icon = ICONS[entry.kind];
- const failed =
- entry.refused === true ||
- (typeof entry.exitCode === "number" && entry.exitCode !== 0);
-
- return (
-
-
-
-
- {LABELS[entry.kind]}
-
- {/*
- The command wraps rather than truncating. In the transcript one line per call is what keeps
- it readable; here the whole point is to see exactly what ran, and a long command with its
- middle cut out is the one thing this pane must not do.
- */}
-
- {entry.subject}
-
-
- {timeOf(entry.at)}
-
-
-
- {entry.refused === true ? (
-
- {entry.output || "A boundary refused it."}
-
- ) : (
-
-
-
- )}
-
- );
-}
-
-export function ActivityLog({ computerId }: { computerId: string }) {
- const entries = useSyncExternalStore(
- subscribeToActivity,
- () => activityFor(computerId),
- () => activityFor(computerId),
- );
-
- if (entries.length === 0) {
+/** What came back: the refusal's reason, or what the call printed. */
+export function ActivityOutput({ entry }: { entry: ComputerActivity }) {
+ if (entry.refused === true)
return (
-
-
-
- Nothing yet. Commands the Bot runs and files it reads appear here.
-
-
-
+
+ {entry.output || "A boundary refused it."}
+
);
- }
-
- // Newest first: what somebody watching wants is what just happened, without scrolling for it.
- const newestFirst = [...entries].reverse();
-
return (
-
- {newestFirst.map((entry) => (
-
- ))}
-
+
+
+
);
}
diff --git a/app/src/components/computer/computer-controls.tsx b/app/src/components/computer/computer-controls.tsx
index 328ef03d2f..8f262cae2a 100644
--- a/app/src/components/computer/computer-controls.tsx
+++ b/app/src/components/computer/computer-controls.tsx
@@ -7,29 +7,39 @@ import { useComputerControl } from "@/lib/computers/use-control";
export function ComputerControlButton({
computerId,
onTakeControl,
+ inline = false,
}: {
computerId: string;
onTakeControl?: () => void;
+ /**
+ * The button alone, for a row with no room under it such as the computer pane's header. How a
+ * change is going is then the screen's to say (`ComputerView`'s `wheelInHeader`).
+ */
+ inline?: boolean;
}) {
const { control, busy, problem, change, someoneElse } =
useComputerControl(computerId);
// Someone else's wheel is not the person's to hand back, and not theirs to take from under them either.
const human = control?.holder === "human" && !someoneElse;
+ const button = (
+
+ );
+ if (inline) return button;
return (
-
+ {button}
{control?.transitioning ? (
Finishing the current action…
@@ -48,15 +58,20 @@ export function ComputerControlButton({
* The header's way into the Computer: an icon that opens and closes it, with a dot when the
* Computer wants the person. Taking and handing back control happen inside the Computer, where the
* person can see what they are taking over; the Computer opens itself once for each new prompt.
+ *
+ * A quieter dot, `unseen`, says the Bot has been using its computer while it stayed shut, on a
+ * screen with no room to open it beside the conversation. A prompt for the person outranks it.
*/
export function ComputerChatControls({
computerId,
open,
onOpenChange,
+ unseen = false,
}: {
computerId?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
+ unseen?: boolean;
}) {
const { control, someoneElse } = useComputerControl(
computerId ?? "",
@@ -93,7 +108,13 @@ export function ComputerChatControls({
{needsYou ? (
+ ) : unseen && !open ? (
+
) : null}
diff --git a/app/src/components/computer/computer-panel.tsx b/app/src/components/computer/computer-panel.tsx
index d9dc715094..bba1c639e3 100644
--- a/app/src/components/computer/computer-panel.tsx
+++ b/app/src/components/computer/computer-panel.tsx
@@ -1,21 +1,73 @@
-import { ActivityLog } from "./activity-log";
-import { ComputerView } from "./computer-view";
+import type { Ref } from "react";
+import {
+ ConversationOrb,
+ useConversationStatus,
+} from "@/components/channels/conversation-presence";
+import { ComputerView, type ComputerViewHandle } from "./computer-view";
+import { StepLog } from "./step-log";
/** The current live computer, separate from screenshots kept in historical tool results. */
export function ComputerViewPanel({
agentId,
name,
+ wheelInHeader = false,
+ screen,
}: {
agentId: string;
name?: string;
+ /** Take control is in the pane's header, which opens the screen full size through `screen`. */
+ wheelInHeader?: boolean;
+ screen?: Ref;
}) {
return (
-
+
Activity
-
+
);
}
+
+/** Small enough for the pane's 48px header, so it blinks and watches and otherwise keeps still. */
+const TITLE_ORB_SIZE = 28;
+
+/**
+ * The computer pane's title: the Bot's orb, in the state its conversation is in, whose computer this
+ * is, and a dot while the conversation needs the person. "Computer" until the Bot's name arrives.
+ *
+ * The title alone, which the skins dress as the pane's tab. Take control sits beside it, in the
+ * header's actions.
+ */
+export function ComputerPaneTitle({
+ agentId,
+ name,
+}: {
+ agentId: string;
+ name: string | undefined;
+}) {
+ const needsYou = useConversationStatus().kind === "needs-you";
+ return (
+ <>
+
+
+ {name ? `${name}'s computer` : "Computer"}
+
+ {needsYou ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/app/src/components/computer/computer-view.tsx b/app/src/components/computer/computer-view.tsx
index c50e055579..f2319ea445 100644
--- a/app/src/components/computer/computer-view.tsx
+++ b/app/src/components/computer/computer-view.tsx
@@ -1,6 +1,14 @@
import type { BotBrowserApiState } from "@openbot/shared/bot-browser";
+import { IconEyeOff, IconLock, IconShieldCheck } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
-import { type ReactNode, useEffect, useRef, useState } from "react";
+import {
+ type ReactNode,
+ type Ref,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from "react";
import { createPortal } from "react-dom";
import {
botBrowserKeys,
@@ -311,6 +319,18 @@ type Props = {
toolCallId?: string;
/** How long to wait for a first frame before calling it a problem. Shortened by tests. */
startupGraceMs?: number;
+ /**
+ * Take control is in the pane's header, so the bar under the screen says who has control and
+ * what went wrong, without a second button. The header asks for the full-size view through `ref`.
+ */
+ wheelInHeader?: boolean;
+ ref?: Ref;
+};
+
+/** What a header holding the wheel can ask of the screen under it. */
+export type ComputerViewHandle = {
+ /** The readable full-size view, where a person who has just taken control drives. */
+ openFullSize: () => void;
};
export function ComputerView({
@@ -325,6 +345,8 @@ export function ComputerView({
finished,
toolCallId,
startupGraceMs = STARTUP_GRACE_MS,
+ wheelInHeader = false,
+ ref,
}: Props) {
const [shot, setShot] = useState(null);
/**
@@ -338,6 +360,11 @@ export function ComputerView({
const [expanded, setExpanded] = useState(false);
/** The full-size view's live screen, which the bar hands the keyboard back to once an address goes. */
const screen = useRef(null);
+ useImperativeHandle(
+ ref,
+ () => ({ openFullSize: () => setExpanded(true) }),
+ [],
+ );
const [streamProblem, setStreamProblem] = useState(null);
/** A dropped live stream being retried on its own. A badge, never the overlay. */
const [reconnecting, setReconnecting] = useState(false);
@@ -375,6 +402,7 @@ export function ComputerView({
const {
control,
busy: changingControl,
+ problem: controlProblem,
change: changeControl,
refresh: refreshControl,
someoneElse,
@@ -762,8 +790,13 @@ export function ComputerView({
Audit records that a secret was supplied, not the value.
*/}
{control?.secretWanted ? (
+ /*
+ * The password box: what the page wants, a masked field, and the promise that the value
+ * goes to the page and nowhere else. "The page", not a site's name: the value goes to the
+ * element the Bot pointed at, and the frame drawn here can lag behind or show another page.
+ */