# Agent queue

> Keep queuing tasks for a coding agent while it works: drag to reorder, pause to add context, answer what it asks.

The side panel of a coding agent that works through a queue of requests while you keep adding more. Each task hangs on a thin line with a dot: blue and shimmering while it runs, hollow while queued, orange when paused, violet when the agent needs answers, green once done. Drag a task and the others move out of its way; pause one to add context; when the agent reaches a task it can’t do yet, it asks a few questions inline and moves on to the next.

A React component by Bruno Delorence: https://brunodelorence.com/components/agent-queue. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Pill**: The running task and how many more are in the turn. It opens into the whole list, and the done group folds into it.
2. **Done group**: Finished tasks, in the order they finished, on a soft green ground.
3. **Running dot**: Blue with a soft halo. The task’s words carry a band of light while the agent works on it.
4. **Queued task**: Hollow grey dot, grey words. Drag it, or press Alt and an arrow key, to change its place.
5. **Needs more context**: The agent skipped this task and asks a few questions, one page each. Answered, the task is queued again.
6. **Composer**: Enter adds what is typed to the end of the queue. The send button darkens once there is text.

## When to use

- A coding or writing agent that works through requests one at a time, while the reader keeps adding more.
- Requests the reader may want to reorder, hold back or add to before the agent reaches them.
- Agents that sometimes need a decision first, where asking inline beats stopping the whole session.
- A side panel next to the thing being built, about 340 to 420 px wide.

## When not to use

- A single request and its answer. A plain chat thread is enough.
- Several agents working in parallel. Use lanes or a board, one column per agent.
- Long-running jobs measured in hours, with retries and logs. Use a jobs table with statuses.
- Questions that block everything else, like approving a payment. Use a modal approval step.

## Installation

Install it with the shadcn CLI, into components/agent-queue of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/agent-queue.json
```

Or add the registry to components.json once, as a namespace that coding agents and the shadcn MCP server search by name:

```json
{
  "registries": {
    "@brunodelorence": "https://brunodelorence.com/r/{name}.json"
  }
}
```

```sh
npx shadcn@latest add @brunodelorence/agent-queue
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
'use client';

import { AgentQueue, type Task } from './agent-queue';

const tasks: Task[] = [
  { id: 'nav', text: 'make the nav sticky on scroll', status: 'running' },
  {
    id: 'empty',
    text: 'add an empty state to the projects list',
    status: 'queued',
    questions: [{ prompt: 'What should it show?', options: ['A short line', 'An illustration'] }],
  },
  { id: 'dark', text: 'check the cards in the dark theme', status: 'queued' },
];

export function AgentPanel() {
  return (
    <AgentQueue
      tasks={tasks}
      onAdd={(task) => console.log('add', task.text)}
      onReorder={(ids) => console.log('order', ids)}
      onAnswer={(task, answers) => console.log(task.id, answers)}
      autoplay={false}
    />
  );
}
```

## States

- **Working**: One task done, one running with its shimmer, three queued.
- **Paused with context**: A paused task: orange dot, and the field for more context open under it.
- **Needs more context**: The agent asks before it does the task: question 1 of 3, three choices or the reader’s own words.
- **Answered**: The answers sit under the task as one line, and the task is back in the queue.
- **Pill open**: The pill lists every task of the turn; the done group has folded into it.
- **All done**: Everything finished: the green group, and a closing line with the time it took.

## How it works

- The agent is a timer. It takes the first queued task from the top, runs it for taskMs and moves it to the done group; it passes over paused tasks. A task with questions runs for a moment, then turns violet and opens its questions while the agent goes on with the next one.
- Dragging measures every row once, when the drag starts. As the pointer moves, the order the rows would take is worked out from those measurements, and each row’s translate is set to the distance to its new slot, with a short transition: the others slide out of the way while the dragged row follows the hand. On release the order is committed, and the dragged row eases from where it was dropped into its slot.
- The running task holds its slot: the new order is built from the movable slots only, so it cannot be dragged and nothing lands in its place. Alt and the arrow keys move a task the same way from the keyboard.
- Rows that change place for any other reason, a task done or removed, a question card opening, slide from where they were: their old offsets are kept after each layout and played back with the Web Animations API on translate (FLIP).
- The running task’s words are painted with a gradient clipped to the text, three times their width, whose position sweeps across them in a loop.
- The done group folds with a grid row going from 1fr to 0fr while the pill is open, and the thread keeps to its bottom, like a chat, unless the reader scrolls up.
- The demo types a task, drags it up two places, pauses another to add context and answers the agent’s three questions, then watches the queue finish. Any input of the reader’s stops it.

## API reference

- **tasks** (`readonly Task[]`, default `TASKS`): The turn’s tasks, in queue order: an id, the text, a status (queued, running, done, paused, needs), an optional context line, and the questions the agent will ask before doing it. Done tasks go to the green group.
- **taskMs** (`number`, default `5200`): How long the agent spends on a task, in ms.
- **working** (`boolean`, default `true`): Whether the agent works through the queue; false holds everything still.
- **previous** (`string | null`, default `PREVIOUS_REPLY`): The agent’s reply to the turn before, above the pill; null leaves it out.
- **placeholder** (`string`, default `'Ask the agent to do something'`): The composer’s placeholder.
- **defaultExpanded** (`boolean`, default `false`): Opens with the pill listing the whole turn.
- **defaultEditing** (`string`): Opens with the context field of this paused task open (its id).
- **onAdd** (`(task: Task) => void`): A task was added from the composer, at the end of the queue.
- **onReorder** (`(ids: string[]) => void`): The reader moved a task: the queue’s ids in their new order.
- **onPause** (`(task: Task) => void`): The reader paused a task; the agent passes over it.
- **onResume** (`(task: Task) => void`): The reader let a paused task run again.
- **onContext** (`(task: Task, context: string) => void`): The reader added context to a task, shown as the indented line under it.
- **onRemove** (`(task: Task) => void`): The reader removed a task.
- **onAnswer** (`(task: Task, answers: string[]) => void`): The reader answered the agent’s questions about a task, one answer per question; the task is queued again.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer shows the queue at work; any input hands it to the reader.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Classes for the stage.

## Accessibility

- The panel is a region named Agent; the done group and the queue are ordered lists. Each task is a button announced as a movable task, with its status and its place (“Queued, 3 of 5”).
- From the keyboard, the arrow keys walk the tasks, Alt with Up or Down moves one past its neighbours (never past the running task), and Delete removes it. Moves, additions, pauses and the agent’s progress are announced in a polite live region.
- Pause, info and remove appear on hover and on focus, are always shown on touch screens, and name the task they act on. On touch, a grip next to them starts a drag, so the panel still scrolls.
- Pausing a task opens its context field; Enter keeps the words, Escape closes it and returns the focus to the task. The agent’s questions are a group named by their question, with the choices as toggle buttons, a field for the reader’s own answer, and Previous, Next and Submit.
- Every control has a visible focus ring. Under reduced motion the demo, the shimmer and the rows’ sliding are off.
- The sketch of the app and the demo pointer are decorative and hidden from assistive technology.

## Source

### agent-queue/agent-queue.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import { DEMO_ANSWERS, DEMO_CONTEXT, DEMO_TASK, PREVIOUS_REPLY, TASKS, type Task } from './data';
import { DemoPointer, pointerAt, runDemo, type Point } from './demo-pointer';
import { Glyph } from './glyphs';
import { useAutoplay } from './use-autoplay';
import styles from './agent-queue.module.css';

/*
 * Agent queue: the side panel of a coding agent, where the reader keeps lining up requests while
 * the agent works through them. The composer at the bottom adds a task to the end of the queue;
 * above it the tasks hang on a thin line, a dot each: blue for the one running (its words carry
 * a sweeping shimmer), hollow grey while queued, orange when paused, violet when the agent needs
 * answers, green once done. Done tasks gather in a soft green group at the top.
 *
 * - The agent is a timer. It takes the first queued task from the top, runs it for `taskMs` and
 *   moves it to the done group. It passes over paused tasks. A task with questions runs for a
 *   moment, then turns violet and opens its questions while the agent goes on with the next one;
 *   answered, it is queued again with the answers as its context line.
 * - Drag a task to reorder: the others slide out of its way while it moves (each row's `translate`
 *   is where it will sit in the new order), and on release the order is committed and the row
 *   eases from under the hand into its slot. The running task holds its slot: it cannot be
 *   dragged, and the others move around it. Alt + arrow keys do the same from the keyboard.
 * - Rows that change place for any other reason (a task done, removed, a card opening) slide
 *   from where they were (FLIP, with the Web Animations API on `translate`).
 * - The pill above the queue names the running task and counts the rest; it opens into the
 *   whole list of the turn, and the done group folds into it meanwhile.
 * - While on screen and untouched, a demo pointer types a task, drags it up, pauses another to
 *   add context and answers the agent's questions, in a loop.
 */

export type { Question, Task, TaskStatus } from './data';

const STATUS_WORD: Record<Task['status'], string> = {
  queued: 'Queued',
  running: 'Running',
  done: 'Done',
  paused: 'Paused',
  needs: 'Needs more context',
};

/** How long the agent looks at a task before it asks its questions. */
const LOOK_MS = 1400;
/** The pause between one task and the next. */
const GAP_MS = 450;
/** How far a press must move before it becomes a drag, in px. */
const SLOP = 4;

type Ask = { page: number; picks: string[]; other: string };
type Drag = {
  id: string;
  pointerId: number;
  /** Where the press started (client px) and how far it has moved. */
  y0: number;
  dy: number;
  /** The rows' layout when the drag started: tops and heights, in the list's px. */
  tops: Record<string, number>;
  heights: Record<string, number>;
  gap: number;
  /** The order the rows had then, and the one they would have if dropped now. */
  from: string[];
  order: string[];
};

let counter = 0;
const newId = () => `task-${Date.now().toString(36)}-${(counter += 1)}`;

/** The rows' order with `id` moved among the movable slots; the running task keeps its slot. */
function arrange(from: string[], id: string, slot: number, fixed: ReadonlySet<string>) {
  const movable = from.filter((each) => !fixed.has(each) && each !== id);
  movable.splice(Math.max(0, Math.min(slot, movable.length)), 0, id);
  let next = 0;
  return from.map((each) => (fixed.has(each) ? each : movable[next++]));
}

function ordinal(n: number) {
  const tail =
    n % 10 === 1 && n !== 11
      ? 'st'
      : n % 10 === 2 && n !== 12
        ? 'nd'
        : n % 10 === 3 && n !== 13
          ? 'rd'
          : 'th';
  return `${n}${tail}`;
}

export function AgentQueue({
  tasks: initialTasks = TASKS,
  taskMs = 5200,
  working = true,
  previous = PREVIOUS_REPLY,
  placeholder = 'Ask the agent to do something',
  defaultExpanded = false,
  defaultEditing,
  onAdd,
  onReorder,
  onPause,
  onResume,
  onContext,
  onRemove,
  onAnswer,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The turn's tasks, in queue order; done ones go to the green group at the top. */
  tasks?: readonly Task[];
  /** How long the agent spends on a task, in ms. */
  taskMs?: number;
  /** Whether the agent works through the queue; false holds everything still. */
  working?: boolean;
  /** The agent's reply to the turn before, above the pill; null leaves it out. */
  previous?: string | null;
  /** The composer's placeholder. */
  placeholder?: string;
  /** Opens with the pill showing the whole turn. */
  defaultExpanded?: boolean;
  /** Opens with the context field of this paused task open (its id). */
  defaultEditing?: string;
  /** A task was added from the composer, at the end of the queue. */
  onAdd?: (task: Task) => void;
  /** The reader moved a task: the queue's ids in their new order. */
  onReorder?: (ids: string[]) => void;
  /** The reader paused a task: the agent will pass over it. */
  onPause?: (task: Task) => void;
  /** The reader let a paused task run again. */
  onResume?: (task: Task) => void;
  /** The reader added context to a task (the indented line under it). */
  onContext?: (task: Task, context: string) => void;
  /** The reader removed a task. */
  onRemove?: (task: Task) => void;
  /** The reader answered the agent's questions about a task: one answer per question. */
  onAnswer?: (task: Task, answers: string[]) => void;
  /** While on screen and untouched, a demo pointer shows the queue at work. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const demo = autoplay && playing;
  const uid = useId().replace(/[^\w-]/g, '');

  const split = useCallback(
    (list: readonly Task[]) => ({
      queue: list.filter((task) => task.status !== 'done').map((task) => ({ ...task })),
      done: list.filter((task) => task.status === 'done').map((task) => ({ ...task })),
      // A task that already needs answers opens on its first question.
      asks: Object.fromEntries(
        list
          .filter((task) => task.status === 'needs' && task.questions?.length)
          .map((task) => [task.id, { page: 0, picks: task.questions!.map(() => ''), other: '' }]),
      ) as Record<string, Ask>,
    }),
    [],
  );
  const [queue, setQueue] = useState<Task[]>(() => split(initialTasks).queue);
  const [done, setDone] = useState<Task[]>(() => split(initialTasks).done);
  const [expanded, setExpanded] = useState(defaultExpanded);
  const [draft, setDraft] = useState('');
  const [editing, setEditing] = useState<string | null>(defaultEditing ?? null);
  const [contextDraft, setContextDraft] = useState('');
  const [asks, setAsks] = useState<Record<string, Ask>>(() => split(initialTasks).asks);
  const [info, setInfo] = useState<string | null>(null);
  const [drag, setDrag] = useState<Drag | null>(null);
  const [message, setMessage] = useState('');
  /** When the turn started and ended, for "Worked for …". */
  const [clock, setClock] = useState<{ start: number; end: number | null }>(() => ({
    start: typeof performance === 'undefined' ? 0 : performance.now(),
    end: null,
  }));
  /** What the demo's pointer is over: a row (hover look), or a control it presses. */
  const [demoOver, setDemoOver] = useState<{ row?: string; press?: string } | null>(null);
  const [demoFocus, setDemoFocus] = useState<string | null>(null);
  const [fading, setFading] = useState(false);

  // New tasks from the props: start over, while rendering, so the old ones never show.
  const givenKey = JSON.stringify(initialTasks);
  const [given, setGiven] = useState(givenKey);
  if (given !== givenKey) {
    setGiven(givenKey);
    const next = split(initialTasks);
    setQueue(next.queue);
    setDone(next.done);
    setAsks(next.asks);
    setEditing(null);
  }

  const list = useRef<HTMLOListElement>(null);
  const rail = useRef<HTMLSpanElement>(null);
  const thread = useRef<HTMLDivElement>(null);
  const threadBody = useRef<HTMLDivElement>(null);
  const composer = useRef<HTMLInputElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const rows = useRef(new Map<string, HTMLLIElement>());
  const tops = useRef(new Map<string, number>());
  /** Set when the next layout is a drop: the dragged row eases in from under the hand. */
  const settle = useRef<{ id: string; offset: number } | null>(null);
  const pinned = useRef(true);
  const press = useRef<{ id: string; pointerId: number; y0: number } | null>(null);
  const reduced = useRef(false);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  useEffect(() => {
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const read = () => (reduced.current = motion.matches);
    read();
    motion.addEventListener('change', read);
    return () => motion.removeEventListener('change', read);
  }, []);

  // ---- The agent ---------------------------------------------------------------------------

  const running = queue.find((task) => task.status === 'running');
  const runningId = running?.id;
  const runningAsks = Boolean(running?.questions?.length);
  const nextId = queue.find((task) => task.status === 'queued')?.id;

  const finish = useEffectEvent((id: string) => {
    const task = queue.find((each) => each.id === id);
    if (!task || task.status !== 'running') return;
    if (task.questions?.length) {
      setQueue((now) => now.map((each) => (each.id === id ? { ...each, status: 'needs' } : each)));
      setAsks((now) => ({
        ...now,
        [id]: { page: 0, picks: task.questions!.map(() => ''), other: '' },
      }));
      announce(`The agent needs more context for: ${task.text}`);
      return;
    }
    setQueue((now) => now.filter((each) => each.id !== id));
    setDone((now) => [...now, { ...task, status: 'done' }]);
    if (queue.length === 1) endTurn();
    if (editing === id) setEditing(null);
    announce(`Done: ${task.text}`);
  });

  const start = useEffectEvent((id: string) => {
    setQueue((now) => now.map((each) => (each.id === id ? { ...each, status: 'running' } : each)));
  });

  useEffect(() => {
    if (!working || !runningId) return;
    const timer = window.setTimeout(() => finish(runningId), runningAsks ? LOOK_MS : taskMs);
    return () => window.clearTimeout(timer);
  }, [working, runningId, runningAsks, taskMs]);

  useEffect(() => {
    if (!working || runningId || !nextId || drag) return;
    const timer = window.setTimeout(() => start(nextId), GAP_MS);
    return () => window.clearTimeout(timer);
  }, [working, runningId, nextId, drag]);

  // The turn ends when nothing is left to do; a new task starts it again.
  const allDone = queue.length === 0 && done.length > 0;
  const endTurn = () => setClock((now) => ({ ...now, end: now.end ?? performance.now() }));

  // ---- Actions -------------------------------------------------------------------------------

  const find = (id: string) => queue.find((task) => task.id === id);

  const add = (text: string) => {
    const words = text.trim();
    if (!words) return;
    const task: Task = { id: newId(), text: words, status: 'queued' };
    pinned.current = true;
    setQueue((now) => [...now, task]);
    setClock((now) =>
      now.end === null ? now : { start: now.start + performance.now() - now.end, end: null },
    );
    setDraft('');
    onAdd?.(task);
    announce(`Added to the queue: ${words}`);
  };

  const pause = (id: string, focusField: boolean) => {
    const task = find(id);
    if (!task || (task.status !== 'queued' && task.status !== 'running')) return;
    setQueue((now) => now.map((each) => (each.id === id ? { ...each, status: 'paused' } : each)));
    setEditing(id);
    setContextDraft('');
    onPause?.(task);
    announce(`Paused: ${task.text}. The agent will skip it.`);
    if (focusField)
      requestAnimationFrame(() => document.getElementById(`${uid}-ctx-${id}`)?.focus());
  };

  const resume = (id: string) => {
    const task = find(id);
    if (!task || task.status !== 'paused') return;
    setQueue((now) => now.map((each) => (each.id === id ? { ...each, status: 'queued' } : each)));
    if (editing === id) setEditing(null);
    onResume?.(task);
    announce(`Back in the queue: ${task.text}`);
  };

  const addContext = (id: string, text: string) => {
    const task = find(id);
    const words = text.trim();
    setEditing(null);
    setContextDraft('');
    if (!task || !words) return;
    setQueue((now) => now.map((each) => (each.id === id ? { ...each, context: words } : each)));
    onContext?.(task, words);
    announce(`Context added to: ${task.text}`);
  };

  const remove = (id: string) => {
    const task = find(id);
    if (!task) return;
    const index = queue.indexOf(task);
    setQueue((now) => now.filter((each) => each.id !== id));
    if (queue.length === 1 && done.length > 0) endTurn();
    if (editing === id) setEditing(null);
    if (info === id) setInfo(null);
    onRemove?.(task);
    announce(`Removed: ${task.text}`);
    // The focus goes to the row that took its place, or the composer.
    const after = queue[index + 1] ?? queue[index - 1];
    requestAnimationFrame(() => {
      const target = after && document.getElementById(`${uid}-task-${after.id}`);
      (target || composer.current)?.focus();
    });
  };

  const answer = (id: string) => {
    const task = find(id);
    const ask = asks[id];
    if (!task || !ask || ask.picks.some((pick) => !pick)) return;
    setQueue((now) =>
      now.map((each) =>
        each.id === id
          ? { ...each, status: 'queued', questions: undefined, context: ask.picks.join(' · ') }
          : each,
      ),
    );
    setAsks((now) => {
      const next = { ...now };
      delete next[id];
      return next;
    });
    onAnswer?.(task, ask.picks);
    announce(`Answered. Back in the queue: ${task.text}`);
  };

  const setAsk = (id: string, change: (ask: Ask) => Ask) =>
    setAsks((now) => (now[id] ? { ...now, [id]: change(now[id]) } : now));

  const pick = (id: string, page: number, value: string) =>
    setAsk(id, (ask) => {
      const picks = [...ask.picks];
      picks[page] = value;
      return { ...ask, picks };
    });

  /** Moves a task by `step` movable slots (keyboard). */
  const move = (id: string, step: number) => {
    const ids = queue.map((task) => task.id);
    const fixed = new Set(runningId ? [runningId] : []);
    const movable = ids.filter((each) => !fixed.has(each));
    const at = movable.indexOf(id);
    const to = at + step;
    if (at < 0 || to < 0 || to >= movable.length) return;
    const order = arrange(ids, id, to, fixed);
    commitOrder(order);
    const task = find(id);
    announce(
      `${task?.text ?? 'Task'}: moved to ${ordinal(order.indexOf(id) + 1)} of ${order.length}.`,
    );
  };

  const commitOrder = (order: string[]) => {
    const byId = new Map(queue.map((task) => [task.id, task]));
    const next = order.map((id) => byId.get(id)).filter((task): task is Task => Boolean(task));
    if (next.every((task, i) => task === queue[i])) return;
    setQueue(next);
    onReorder?.(order);
  };

  // ---- Dragging ------------------------------------------------------------------------------

  const beginDrag = (id: string, pointerId: number, y0: number) => {
    const from = queue.map((task) => task.id);
    const topsNow: Record<string, number> = {};
    const heights: Record<string, number> = {};
    for (const each of from) {
      const row = rows.current.get(each);
      topsNow[each] = row?.offsetTop ?? 0;
      heights[each] = row?.offsetHeight ?? 0;
    }
    const gap = from.length > 1 ? topsNow[from[1]] - topsNow[from[0]] - heights[from[0]] : 0;
    setInfo(null);
    setDrag({ id, pointerId, y0, dy: 0, tops: topsNow, heights, gap, from, order: from });
  };

  const moveDrag = (clientY: number) => {
    setDrag((now) => {
      if (!now) return now;
      const { from, tops: t, heights: h, id } = now;
      const first = t[from[0]];
      const last = t[from[from.length - 1]] + h[from[from.length - 1]];
      // The row stays within the list.
      const dy = Math.max(first - t[id], Math.min(last - h[id] - t[id], clientY - now.y0));
      const middle = t[id] + h[id] / 2 + dy;
      const fixed = new Set(runningId ? [runningId] : []);
      const slot = from.filter(
        (each) => each !== id && !fixed.has(each) && t[each] + h[each] / 2 < middle,
      ).length;
      return { ...now, dy, order: arrange(from, id, slot, fixed) };
    });
  };

  const endDrag = () => {
    if (!drag) return;
    const where = slotTops(drag);
    settle.current = { id: drag.id, offset: drag.tops[drag.id] + drag.dy - where[drag.id] };
    const order = drag.order;
    const moved = order.some((id, i) => id !== drag.from[i]);
    setDrag(null);
    if (moved) {
      commitOrder(order);
      const task = find(drag.id);
      announce(
        `${task?.text ?? 'Task'}: moved to ${ordinal(order.indexOf(drag.id) + 1)} of ${order.length}.`,
      );
    }
  };

  /** Where each row sits in the drag's current order, from the tops and heights it measured. */
  const slotTops = (state: Drag) => {
    const where: Record<string, number> = {};
    let y = state.tops[state.from[0]];
    for (const id of state.order) {
      where[id] = y;
      y += state.heights[id] + state.gap;
    }
    return where;
  };

  const onRowPointerDown = (event: ReactPointerEvent, task: Task) => {
    if (event.button !== 0 || task.status === 'running') return;
    const target = event.target as HTMLElement;
    if (target.closest('button:not([data-part="task-text"]), input, [data-no-drag]')) return;
    // On touch, only the grip starts a drag, so the panel still scrolls.
    const grip = target.closest('[data-grip]');
    if (event.pointerType === 'touch' && !grip) return;
    press.current = { id: task.id, pointerId: event.pointerId, y0: event.clientY };
  };

  useEffect(() => {
    const onMove = (event: PointerEvent) => {
      const held = press.current;
      if (held && event.pointerId === held.pointerId && !drag) {
        if (Math.abs(event.clientY - held.y0) > SLOP) {
          beginDrag(held.id, held.pointerId, held.y0);
          press.current = null;
        }
        return;
      }
      if (drag && event.pointerId === drag.pointerId) {
        event.preventDefault();
        moveDrag(event.clientY);
      }
    };
    const onUp = (event: PointerEvent) => {
      if (press.current?.pointerId === event.pointerId) press.current = null;
      if (drag && event.pointerId === drag.pointerId) endDrag();
    };
    window.addEventListener('pointermove', onMove, { passive: false });
    window.addEventListener('pointerup', onUp);
    window.addEventListener('pointercancel', onUp);
    return () => {
      window.removeEventListener('pointermove', onMove);
      window.removeEventListener('pointerup', onUp);
      window.removeEventListener('pointercancel', onUp);
    };
  });

  // ---- Layout: FLIP, the drop, the rail, the scroll -----------------------------------------

  useLayoutEffect(() => {
    const listEl = list.current;
    if (!listEl) return;
    const landing = settle.current;
    settle.current = null;
    const before = tops.current;
    const after = new Map<string, number>();
    rows.current.forEach((row, id) => after.set(id, row.offsetTop));
    if (!drag && !reduced.current) {
      rows.current.forEach((row, id) => {
        const easing = 'cubic-bezier(0.2, 0.8, 0.2, 1)';
        if (landing?.id === id) {
          if (Math.abs(landing.offset) > 0.5)
            row.animate([{ translate: `0 ${landing.offset}px` }, { translate: '0 0' }], {
              duration: 200,
              easing,
            });
          return;
        }
        // A drop leaves the others where they were already shown: no slide for them.
        if (landing) return;
        const was = before.get(id);
        const now = after.get(id) ?? 0;
        if (was !== undefined && Math.abs(was - now) > 0.5)
          row.animate([{ translate: `0 ${was - now}px` }, { translate: '0 0' }], {
            duration: 240,
            easing,
          });
      });
    }
    if (!drag) tops.current = after;

    // The rail joins the first dot to the last.
    const line = rail.current;
    if (line) {
      const ids = queue.map((task) => task.id);
      const first = rows.current.get(ids[0]);
      const last = rows.current.get(ids[ids.length - 1]);
      if (first && last && first !== last) {
        line.style.top = `${first.offsetTop + 16}px`;
        line.style.height = `${last.offsetTop - first.offsetTop}px`;
        line.style.opacity = '1';
      } else line.style.opacity = '0';
    }
  });

  // The thread keeps to its bottom, like a chat, unless the reader scrolled up.
  useEffect(() => {
    const scroller = thread.current;
    const body = threadBody.current;
    if (!scroller || !body) return;
    const stick = () => {
      if (pinned.current) scroller.scrollTop = scroller.scrollHeight;
    };
    const onScroll = () => {
      pinned.current = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 32;
    };
    stick();
    const observer = new ResizeObserver(stick);
    observer.observe(body);
    scroller.addEventListener('scroll', onScroll, { passive: true });
    return () => {
      observer.disconnect();
      scroller.removeEventListener('scroll', onScroll);
    };
  }, []);

  // A card the agent opens is brought into view.
  const needsIds = queue
    .filter((task) => task.status === 'needs')
    .map((task) => task.id)
    .join();
  useEffect(() => {
    if (!needsIds) return;
    const id = needsIds.split(',').pop()!;
    const card = document.getElementById(`${uid}-ask-${id}`);
    const scroller = thread.current;
    if (!card || !scroller) return;
    const box = card.getBoundingClientRect();
    const view = scroller.getBoundingClientRect();
    if (box.top < view.top + 8) scroller.scrollBy({ top: box.top - view.top - 48 });
  }, [needsIds, uid]);

  // The open pill is brought into view; closed, the thread goes back to its bottom.
  useEffect(() => {
    const scroller = thread.current;
    const pill = scroller?.querySelector('[data-part="pill"]');
    if (!scroller || !pill) return;
    if (expanded) {
      const top = pill.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
      if (top < 40) scroller.scrollBy({ top: top - 40 });
    } else {
      pinned.current = true;
      scroller.scrollTop = scroller.scrollHeight;
    }
  }, [expanded]);

  // The info bubble closes on a press elsewhere.
  useEffect(() => {
    if (!info) return;
    const away = (event: PointerEvent) => {
      if (!(event.target as HTMLElement).closest(`[data-info="${info}"]`)) setInfo(null);
    };
    document.addEventListener('pointerdown', away);
    return () => document.removeEventListener('pointerdown', away);
  }, [info]);

  // ---- The demo ------------------------------------------------------------------------------

  const state = useRef({ queue, done, asks });
  useEffect(() => {
    state.current = { queue, done, asks };
  });

  const demoStep = useEffectEvent((step: DemoStep) => {
    switch (step.kind) {
      case 'fade':
        setFading(step.on);
        break;
      case 'reset': {
        const next = split(initialTasks);
        setQueue(next.queue);
        setDone(next.done);
        setAsks(next.asks);
        setEditing(null);
        setExpanded(false);
        setDraft('');
        setContextDraft('');
        setInfo(null);
        setClock({ start: performance.now(), end: null });
        pinned.current = true;
        break;
      }
      case 'over':
        setDemoOver(step.row || step.press ? { row: step.row, press: step.press } : null);
        break;
      case 'focus':
        setDemoFocus(step.what);
        break;
      case 'type':
        if (step.what === 'composer') setDraft(step.text);
        else setContextDraft(step.text);
        break;
      case 'add':
        add(step.text);
        break;
      case 'pause':
        pause(step.id, false);
        break;
      case 'context':
        addContext(step.id, step.text);
        break;
      case 'resume':
        resume(step.id);
        break;
      case 'pick':
        pick(step.id, step.page, step.value);
        break;
      case 'page':
        setAsk(step.id, (ask) => ({ ...ask, page: step.page }));
        break;
      case 'answer':
        answer(step.id);
        break;
      case 'expand':
        setExpanded(step.on);
        break;
      case 'drag-begin':
        beginDrag(step.id, -1, step.clientY);
        break;
      case 'drag-move':
        moveDrag(step.clientY);
        break;
      case 'drag-end':
        endDrag();
        break;
    }
  });

  /** Whether the queue has moved on from where the demo starts. */
  const dirty = useEffectEvent(
    () =>
      done.length !== initialTasks.filter((task) => task.status === 'done').length ||
      queue.length !== initialTasks.filter((task) => task.status !== 'done').length ||
      queue.some((task) => task.context || task.status === 'paused' || task.status === 'needs'),
  );

  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;

    const scale = () => stage.getBoundingClientRect().width / stage.offsetWidth || 1;
    /** A point on an element, in the stage's px: `fx`, `fy` are shares of its box. */
    const on = (element: Element | null | undefined, fx = 0.5, fy = 0.5): Point => {
      const frame = stage.getBoundingClientRect();
      const rect = element?.getBoundingClientRect() ?? frame;
      return {
        x: (rect.left + rect.width * fx - frame.left) / scale(),
        y: (rect.top + rect.height * fy - frame.top) / scale(),
      };
    };
    const toClientY = (y: number) => stage.getBoundingClientRect().top + y * scale();
    const el = (selector: string) => stage.querySelector(selector);
    const rowText = (id: string) => el(`#${CSS.escape(`${uid}-task-${id}`)}`);
    const rowPart = (id: string, part: string) =>
      rows.current.get(id)?.querySelector(`[data-part="${part}"]`);

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      /** Waits until `test` holds, checking every tenth of a second, for `ms` at most. */
      const until = async (test: () => boolean, ms: number) => {
        for (let spent = 0; spent < ms && !test(); spent += 100) await wait(100);
      };
      const typeOut = async (what: 'composer' | 'context', text: string) => {
        for (let i = 1; i <= text.length; i += 1) {
          demoStep({ kind: 'type', what, text: text.slice(0, i) });
          await wait(text[i - 1] === ' ' ? 70 : 38 + ((i * 17) % 29));
        }
      };
      const click = async (
        row: string | undefined,
        press: string,
        target: Element | null | undefined,
        ms = 520,
      ) => {
        glyph('arrow');
        await glide(on(target), ms);
        glyph('hand');
        demoStep({ kind: 'over', row, press });
        await wait(220);
      };

      for (;;) {
        if (dirty()) {
          demoStep({ kind: 'fade', on: true });
          await wait(320);
          demoStep({ kind: 'reset' });
          await wait(80);
          demoStep({ kind: 'fade', on: false });
        }
        await wait(700);

        // 1. A new task from the composer.
        glyph('text');
        await glide(on(composer.current, 0.35, 0.5), 700);
        demoStep({ kind: 'focus', what: 'composer' });
        await wait(250);
        await typeOut('composer', DEMO_TASK);
        await wait(300);
        await click(undefined, 'send', el('[data-part="send"]'), 480);
        demoStep({ kind: 'add', text: DEMO_TASK });
        demoStep({ kind: 'over' });
        demoStep({ kind: 'focus', what: null });
        await wait(700);

        // 2. Drag it up, above the two tasks before it.
        const added = state.current.queue.find((task) => task.text === DEMO_TASK);
        if (added) {
          glyph('arrow');
          await glide(on(rowText(added.id), 0.3, 0.5), 600);
          glyph('hand');
          demoStep({ kind: 'over', row: added.id });
          await wait(350);
          const ids = state.current.queue.map((task) => task.id);
          const target = ids[Math.max(0, ids.indexOf(added.id) - 2)];
          if (
            target &&
            target !== added.id &&
            state.current.queue.find((t) => t.id === target)?.status !== 'running'
          ) {
            glyph('grab');
            const start = on(rowText(added.id), 0.3, 0.5);
            demoStep({ kind: 'drag-begin', id: added.id, clientY: toClientY(start.y) });
            const goal = on(rows.current.get(target), 0.3, 0.3);
            let follow = 0;
            const track = () => {
              demoStep({ kind: 'drag-move', clientY: toClientY(pointerAt(arrow).y) });
              follow = requestAnimationFrame(track);
            };
            follow = requestAnimationFrame(track);
            try {
              await wait(120);
              await glide({ x: start.x + 6, y: goal.y }, 1100);
              await wait(260);
            } finally {
              cancelAnimationFrame(follow);
            }
            demoStep({ kind: 'drag-end' });
            glyph('hand');
            await wait(500);
          }
          demoStep({ kind: 'over' });
        }

        // 3. Pause the last task and add context to it.
        const last = [...state.current.queue].reverse().find((task) => task.status === 'queued');
        if (last) {
          glyph('arrow');
          await glide(on(rowText(last.id), 0.4, 0.5), 650);
          demoStep({ kind: 'over', row: last.id });
          await wait(300);
          await click(last.id, 'pause', rowPart(last.id, 'pause'), 420);
          demoStep({ kind: 'pause', id: last.id });
          await wait(450);
          glyph('text');
          await glide(on(document.getElementById(`${uid}-ctx-${last.id}`), 0.3, 0.5), 520);
          demoStep({ kind: 'focus', what: `ctx-${last.id}` });
          await wait(250);
          await typeOut('context', DEMO_CONTEXT);
          await wait(400);
          demoStep({ kind: 'context', id: last.id, text: DEMO_CONTEXT });
          demoStep({ kind: 'focus', what: null });
          await wait(700);
          await click(last.id, 'pause', rowPart(last.id, 'pause'), 460);
          demoStep({ kind: 'resume', id: last.id });
          await wait(500);
          demoStep({ kind: 'over' });
        }

        // 4. Answer the agent's questions.
        glyph('arrow');
        await until(() => state.current.queue.some((task) => task.status === 'needs'), 12000);
        const asking = state.current.queue.find((task) => task.status === 'needs');
        if (asking) {
          await wait(500);
          const pages = asking.questions?.length ?? 0;
          for (let page = 0; page < pages; page += 1) {
            const choice = DEMO_ANSWERS[page] ?? asking.questions![page].options[0];
            const option = el(
              `#${CSS.escape(`${uid}-ask-${asking.id}`)} [data-option="${CSS.escape(choice)}"]`,
            );
            await click(asking.id, `option-${choice}`, option, 560);
            demoStep({ kind: 'pick', id: asking.id, page, value: choice });
            await wait(380);
            const isLast = page === pages - 1;
            await click(
              asking.id,
              isLast ? 'submit' : 'next',
              el(
                `#${CSS.escape(`${uid}-ask-${asking.id}`)} [data-part="${isLast ? 'submit' : 'next'}"]`,
              ),
              480,
            );
            if (isLast) demoStep({ kind: 'answer', id: asking.id });
            else demoStep({ kind: 'page', id: asking.id, page: page + 1 });
            demoStep({ kind: 'over' });
            await wait(420);
          }
        }

        // 5. Watch the rest finish, then open the pill and close it.
        glyph('arrow');
        await glide(on(stage, 0.9, 0.55), 900);
        await until(() => state.current.queue.length === 0, 45000);
        await wait(1400);
        await click(undefined, 'pill', el('[data-part="pill-count"]'), 800);
        demoStep({ kind: 'expand', on: true });
        demoStep({ kind: 'over' });
        await wait(2200);
        await click(undefined, 'pill', el('[data-part="pill-count"]'), 400);
        demoStep({ kind: 'expand', on: false });
        demoStep({ kind: 'over' });
        glyph('arrow');
        await glide(on(stage, 0.9, 0.55), 700);
        await wait(2200);
      }
    });
    return () => {
      stop();
      setDemoOver(null);
      setDemoFocus(null);
      setFading(false);
      setDrag((now) => (now?.pointerId === -1 ? null : now));
      // Half-typed words of the demo's are not left for the reader.
      setDraft('');
      setContextDraft('');
    };
  }, [demo, root, uid]);

  // ---- Drawing -------------------------------------------------------------------------------

  const total = done.length + queue.length;
  const headline =
    running ?? queue.find((task) => task.status !== 'paused') ?? queue[0] ?? done[done.length - 1];
  const others = Math.max(0, total - 1);
  const slots = drag ? slotTops(drag) : null;
  const worked = clock.end !== null ? Math.max(1, Math.round((clock.end - clock.start) / 1000)) : 0;

  const onTaskKey = (event: ReactKeyboardEvent, task: Task) => {
    if (event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
      event.preventDefault();
      if (task.status !== 'running') move(task.id, event.key === 'ArrowUp' ? -1 : 1);
      requestAnimationFrame(() => document.getElementById(`${uid}-task-${task.id}`)?.focus());
    } else if (event.key === 'Delete' || event.key === 'Backspace') {
      event.preventDefault();
      remove(task.id);
    } else if (!event.altKey && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
      // Arrow keys walk the tasks.
      event.preventDefault();
      const ids = queue.map((each) => each.id);
      const next = ids[ids.indexOf(task.id) + (event.key === 'ArrowUp' ? -1 : 1)];
      if (next) document.getElementById(`${uid}-task-${next}`)?.focus();
    }
  };

  const infoLine = (task: Task) => {
    const place = queue.filter((each) => each.status === 'queued');
    switch (task.status) {
      case 'running':
        return 'Running now. It cannot be moved.';
      case 'queued':
        return `Queued, ${ordinal(place.indexOf(task) + 1)} in line.`;
      case 'paused':
        return 'Paused. The agent passes over it.';
      case 'needs':
        return `Skipped until you answer ${task.questions?.length ?? 0} questions.`;
      default:
        return 'Done.';
    }
  };

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="stage"
      data-color-scheme={colorScheme}
      data-demo={demo || undefined}
      onPointerDownCapture={(event) => {
        if (event.isTrusted) take();
      }}
      onKeyDownCapture={(event) => {
        take();
        if (event.key === 'Escape') {
          if (info) setInfo(null);
          else if (expanded) setExpanded(false);
        }
      }}
      onPointerMove={(event) => {
        // The reader's own pointer on the panel takes over from the demo's.
        if (event.pointerType !== 'touch') take();
      }}
    >
      <div className={styles.layout}>
        <div className={styles.app} aria-hidden="true" data-part="app">
          <div className={styles.appBar}>
            <span className={styles.appTab} />
            <span className={styles.appUrl}>/index</span>
            <span className={styles.appDevice}>Tablet</span>
          </div>
          <div className={styles.appBody}>
            <div className={styles.appCanvas}>
              <div className={styles.appTop}>
                <span style={{ width: 54 }} />
                <span className={styles.appGrow} />
                <span className={styles.appPill} style={{ width: 52 }} />
                <span className={styles.appPill} style={{ width: 44 }} />
                <span className={styles.appPill} style={{ width: 74 }} />
                <span className={styles.appPill} data-strong style={{ width: 84 }} />
              </div>
              <div className={styles.appHero}>
                <i style={{ left: '4%', top: '18%' }} />
                <i style={{ left: '72%', top: '14%' }} />
                <i style={{ left: '48%', top: '74%' }} />
              </div>
              <span className={styles.appCaption} />
            </div>
            <div className={styles.appInspector}>
              {[0, 1, 2, 3].map((n) => (
                <div key={n} className={styles.appField}>
                  <i />
                  <span />
                  <b />
                </div>
              ))}
              <span className={styles.appDashed} />
              <span className={styles.appRule} />
              <span className={styles.appLabel} />
              <div className={styles.appSplit}>
                <span />
                <i />
                <span />
              </div>
              <div className={styles.appSegments}>
                <span />
                <span />
                <span />
                <span />
              </div>
              <span className={styles.appSlider} style={{ '--at': '38%' } as CSSProperties} />
              <span className={styles.appSlider} style={{ '--at': '22%' } as CSSProperties} />
              <span className={styles.appRule} />
              <div className={styles.appTiles}>
                <span />
                <span />
                <span />
              </div>
            </div>
          </div>
        </div>

        <section
          className={styles.panel}
          data-part="panel"
          aria-label="Agent"
          data-fading={fading || undefined}
        >
          <div ref={thread} className={styles.thread}>
            <div ref={threadBody} className={styles.threadBody}>
              {previous && (
                <div className={styles.reply} aria-hidden="true">
                  <p>{previous}</p>
                  <Meta seconds={8} />
                </div>
              )}

              {total > 0 && (
                <div className={styles.pillWrap}>
                  <button
                    type="button"
                    className={styles.pill}
                    data-part="pill"
                    data-open={expanded || undefined}
                    aria-expanded={expanded}
                    aria-controls={`${uid}-turn`}
                    onClick={() => setExpanded((now) => !now)}
                    data-press={demoOver?.press === 'pill' || undefined}
                  >
                    {expanded ? (
                      <span id={`${uid}-turn`} className={styles.pillList}>
                        {[...done, ...queue].map((task) => (
                          <span key={task.id} className={styles.pillItem}>
                            {task.text}
                          </span>
                        ))}
                      </span>
                    ) : (
                      <span className={styles.pillText} key={headline?.id}>
                        {headline?.text}
                      </span>
                    )}
                    {(others > 0 || expanded) && (
                      <span className={styles.pillCount} data-part="pill-count">
                        {expanded ? 'hide' : `+${others}`}
                      </span>
                    )}
                    <span className={styles.hidden}>
                      {expanded ? ', hide the list' : `, and ${others} more: show them all`}
                    </span>
                  </button>
                </div>
              )}

              <div
                className={styles.doneFold}
                data-folded={expanded || done.length === 0 || undefined}
              >
                <div className={styles.doneClip}>
                  <ol className={styles.doneGroup} aria-label="Done" data-part="done-group">
                    {done.map((task) => (
                      <li key={task.id} className={styles.doneRow} data-status="done">
                        <span className={styles.dot} data-status="done" aria-hidden="true" />
                        <div className={styles.main}>
                          <span className={styles.text}>
                            {task.text}
                            <span className={styles.hidden}>, done</span>
                          </span>
                          {task.context && <p className={styles.sub}>{task.context}</p>}
                        </div>
                      </li>
                    ))}
                  </ol>
                </div>
              </div>

              <div className={styles.queueWrap}>
                <span ref={rail} className={styles.rail} aria-hidden="true" />
                <ol
                  ref={list}
                  className={styles.queue}
                  aria-label="Queue"
                  data-dragging={drag ? 'true' : undefined}
                  data-part="queue"
                >
                  {queue.map((task, index) => {
                    const ask = asks[task.id];
                    const offset =
                      drag && slots
                        ? task.id === drag.id
                          ? drag.dy
                          : slots[task.id] - drag.tops[task.id]
                        : 0;
                    const lifted = drag?.id === task.id;
                    const canPause = task.status === 'queued' || task.status === 'running';
                    return (
                      <li
                        key={task.id}
                        ref={(node) => {
                          if (node) rows.current.set(task.id, node);
                          else rows.current.delete(task.id);
                        }}
                        className={styles.row}
                        data-part="task"
                        data-status={task.status}
                        data-lifted={lifted || undefined}
                        data-over={demoOver?.row === task.id || undefined}
                        data-info={task.id}
                        style={offset ? { translate: `0 ${offset}px` } : undefined}
                        onPointerDown={(event) => onRowPointerDown(event, task)}
                      >
                        <span
                          className={styles.dot}
                          data-status={task.status}
                          data-part="dot"
                          aria-hidden="true"
                        />
                        <div className={styles.main}>
                          <div className={styles.line}>
                            <button
                              type="button"
                              id={`${uid}-task-${task.id}`}
                              className={styles.text}
                              data-part="task-text"
                              aria-roledescription={
                                task.status === 'running' ? 'task' : 'movable task'
                              }
                              aria-describedby={`${uid}-keys`}
                              onKeyDown={(event) => onTaskKey(event, task)}
                            >
                              <span className={styles.words} data-text={task.text}>
                                {task.text}
                              </span>
                              <span className={styles.hidden}>
                                , {STATUS_WORD[task.status]}, {index + 1} of {queue.length}
                              </span>
                            </button>
                            <span className={styles.actions}>
                              <span className={styles.grip} data-grip aria-hidden="true">
                                <Glyph name="grip" />
                              </span>
                              {(canPause || task.status === 'paused') && (
                                <button
                                  type="button"
                                  className={styles.action}
                                  data-part="pause"
                                  data-press={
                                    demoOver?.press === 'pause' && demoOver.row === task.id
                                      ? true
                                      : undefined
                                  }
                                  aria-label={
                                    task.status === 'paused'
                                      ? `Resume: ${task.text}`
                                      : `Pause: ${task.text}`
                                  }
                                  onClick={() =>
                                    task.status === 'paused'
                                      ? resume(task.id)
                                      : pause(task.id, true)
                                  }
                                >
                                  <Glyph name={task.status === 'paused' ? 'play' : 'pause'} />
                                </button>
                              )}
                              <button
                                type="button"
                                className={styles.action}
                                aria-label={`About: ${task.text}`}
                                aria-expanded={info === task.id}
                                onClick={() => setInfo((now) => (now === task.id ? null : task.id))}
                              >
                                <Glyph name="info" />
                              </button>
                              <button
                                type="button"
                                className={styles.action}
                                data-remove
                                aria-label={`Remove: ${task.text}`}
                                onClick={() => remove(task.id)}
                              >
                                <Glyph name="close" />
                              </button>
                            </span>
                            {info === task.id && (
                              <span className={styles.info} role="status">
                                {infoLine(task)}
                              </span>
                            )}
                          </div>

                          {task.context && editing !== task.id && (
                            <p className={styles.sub}>{task.context}</p>
                          )}

                          {editing === task.id && task.status === 'paused' && (
                            <form
                              className={styles.contextForm}
                              data-no-drag
                              onSubmit={(event) => {
                                event.preventDefault();
                                addContext(task.id, contextDraft);
                              }}
                            >
                              <input
                                id={`${uid}-ctx-${task.id}`}
                                className={styles.contextField}
                                data-part="context-field"
                                data-focus={demoFocus === `ctx-${task.id}` || undefined}
                                value={contextDraft}
                                placeholder="Add more context for the agent"
                                aria-label={`More context for: ${task.text}`}
                                autoComplete="off"
                                onChange={(event) => setContextDraft(event.target.value)}
                                onKeyDown={(event) => {
                                  if (event.key === 'Escape') {
                                    event.stopPropagation();
                                    setEditing(null);
                                    document.getElementById(`${uid}-task-${task.id}`)?.focus();
                                  }
                                }}
                              />
                            </form>
                          )}

                          {task.status === 'needs' && ask && task.questions && (
                            <AskCard
                              id={`${uid}-ask-${task.id}`}
                              task={task}
                              ask={ask}
                              press={demoOver?.row === task.id ? demoOver.press : undefined}
                              onPick={(value) => pick(task.id, ask.page, value)}
                              onOther={(value) =>
                                setAsk(task.id, (now) => ({ ...now, other: value }))
                              }
                              onPage={(page) =>
                                setAsk(task.id, (now) => ({ ...now, page, other: '' }))
                              }
                              onSubmit={() => answer(task.id)}
                            />
                          )}
                        </div>
                      </li>
                    );
                  })}
                </ol>
              </div>

              {allDone && (
                <div className={styles.summary}>
                  <p>
                    All done. Finished {done.length} {done.length === 1 ? 'request' : 'requests'}.
                  </p>
                  <Meta seconds={worked} />
                </div>
              )}
            </div>
          </div>

          <form
            className={styles.composer}
            data-part="composer"
            data-focus={demoFocus === 'composer' || undefined}
            onSubmit={(event) => {
              event.preventDefault();
              add(draft);
            }}
          >
            <input
              ref={composer}
              className={styles.input}
              value={draft}
              placeholder={placeholder}
              aria-label="New task for the agent"
              autoComplete="off"
              enterKeyHint="send"
              onChange={(event) => setDraft(event.target.value)}
            />
            <div className={styles.tools}>
              <span className={styles.tool} aria-hidden="true">
                <Glyph name="plus" />
              </span>
              <span className={styles.tool} data-slash aria-hidden="true">
                /
              </span>
              <span className={styles.tool} aria-hidden="true">
                <Glyph name="attach" />
              </span>
              <span className={styles.grow} />
              <span className={styles.mode} aria-hidden="true">
                Auto
                <Glyph name="chevron" />
              </span>
              <span className={styles.tool} aria-hidden="true">
                <Glyph name="mic" />
              </span>
              <button
                type="submit"
                className={styles.send}
                data-part="send"
                data-ready={draft.trim() ? true : undefined}
                data-press={demoOver?.press === 'send' || undefined}
                aria-label="Add to the queue"
                disabled={!draft.trim()}
              >
                <Glyph name="send" />
              </button>
            </div>
          </form>
        </section>
      </div>

      <p id={`${uid}-keys`} className={styles.hidden}>
        Alt and the arrow keys move a task in the queue; Delete removes it.
      </p>
      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
          grabClass={styles.grab}
          textClass={styles.caret}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

/** "Worked for …" under a reply, with the two rating glyphs, as drawn (not buttons). */
function Meta({ seconds }: { seconds: number }) {
  return (
    <div className={styles.meta} aria-hidden="true">
      <Glyph name="pulse" />
      <span>Worked for {seconds}s</span>
      <span className={styles.grow} />
      <Glyph name="up" />
      <Glyph name="down" />
    </div>
  );
}

/** The agent's questions about a task: one page each, a few choices or the reader's own words. */
function AskCard({
  id,
  task,
  ask,
  press,
  onPick,
  onOther,
  onPage,
  onSubmit,
}: {
  id: string;
  task: Task;
  ask: Ask;
  press?: string;
  onPick: (value: string) => void;
  onOther: (value: string) => void;
  onPage: (page: number) => void;
  onSubmit: () => void;
}) {
  const questions = task.questions ?? [];
  const question = questions[ask.page];
  const last = ask.page === questions.length - 1;
  const chosen = ask.picks[ask.page];
  const complete = ask.picks.every(Boolean);
  if (!question) return null;
  return (
    <div
      id={id}
      className={styles.ask}
      role="group"
      aria-labelledby={`${id}-title ${id}-q`}
      data-part="ask"
      data-no-drag
    >
      <div className={styles.askHead}>
        <span id={`${id}-title`} className={styles.askTitle}>
          Needs more context
        </span>
        <span
          className={styles.askCount}
          aria-label={`Question ${ask.page + 1} of ${questions.length}`}
        >
          {ask.page + 1}/{questions.length}
        </span>
      </div>
      <p id={`${id}-q`} className={styles.askPrompt} key={ask.page}>
        {question.prompt}
      </p>
      <div className={styles.options} key={`o${ask.page}`}>
        {question.options.map((option) => (
          <button
            key={option}
            type="button"
            className={styles.option}
            data-option={option}
            data-part="option"
            aria-pressed={chosen === option}
            data-press={press === `option-${option}` || undefined}
            onClick={() => onPick(option)}
          >
            {option}
          </button>
        ))}
        <input
          className={styles.option}
          data-other
          data-picked={chosen && !question.options.includes(chosen) ? true : undefined}
          placeholder="Something else…"
          aria-label="Something else"
          autoComplete="off"
          value={ask.other || (chosen && !question.options.includes(chosen) ? chosen : '')}
          onChange={(event) => {
            onOther(event.target.value);
            onPick(event.target.value.trim());
          }}
          onKeyDown={(event) => {
            if (event.key === 'Enter') {
              event.preventDefault();
              if (!event.currentTarget.value.trim()) return;
              if (last) onSubmit();
              else onPage(ask.page + 1);
            }
          }}
        />
      </div>
      <div className={styles.askFoot}>
        <button
          type="button"
          className={styles.pager}
          aria-label="Previous question"
          disabled={ask.page === 0}
          onClick={() => onPage(ask.page - 1)}
        >
          <Glyph name="left" />
        </button>
        {last ? (
          <button
            type="button"
            className={styles.submit}
            data-part="submit"
            data-press={press === 'submit' || undefined}
            disabled={!complete}
            onClick={onSubmit}
          >
            Submit
          </button>
        ) : (
          <button
            type="button"
            className={styles.pager}
            data-part="next"
            data-press={press === 'next' || undefined}
            aria-label="Next question"
            onClick={() => onPage(ask.page + 1)}
          >
            <Glyph name="right" />
          </button>
        )}
      </div>
    </div>
  );
}

type DemoStep =
  | { kind: 'fade'; on: boolean }
  | { kind: 'reset' }
  | { kind: 'over'; row?: string; press?: string }
  | { kind: 'focus'; what: string | null }
  | { kind: 'type'; what: 'composer' | 'context'; text: string }
  | { kind: 'add'; text: string }
  | { kind: 'pause'; id: string }
  | { kind: 'context'; id: string; text: string }
  | { kind: 'resume'; id: string }
  | { kind: 'pick'; id: string; page: number; value: string }
  | { kind: 'page'; id: string; page: number }
  | { kind: 'answer'; id: string }
  | { kind: 'expand'; on: boolean }
  | { kind: 'drag-begin'; id: string; clientY: number }
  | { kind: 'drag-move'; clientY: number }
  | { kind: 'drag-end' };
```

### agent-queue/agent-queue.module.css

```css
/* Agent queue (agent-queue.tsx): a light grey stage with a faint sketch of the app being built on
   the left, bleeding off the edge, and the agent's panel on the right, 376 px wide: the thread
   (the last reply, the pill, the done group, the queue) over the composer. Under 640 px of stage
   the sketch goes and the panel takes the width.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). */

.root {
  /* The stage and the sketch of the app. */
  --stage: #f3f3f3;
  --window: #ffffff;
  --window-edge: #e6e6e6;
  --canvas: #f6f6f5;
  --skel: #e4e3e0;
  --skel-2: #efeeec;
  /* Surfaces: the pill, the composer, the question card and its choices. */
  --surface: #ffffff;
  --edge: #e5e5e5;
  --edge-strong: #d4d4d4;
  --hover: #fbfbfb;
  --shadow: rgb(0 0 0 / 0.045);
  --lift-shadow: rgb(0 0 0 / 0.1);
  /* Text. */
  --ink: #1b1b1b;
  --ink-2: #6c6c6c;
  --ink-3: #a1a1a1;
  --done-ink: #7d7d7d;
  --sub-ink: #3f3f3f;
  --shine: #c4c4c4;
  /* The rail and the dots, by status. */
  --line: #d6d6d6;
  --ring: #b3b3b3;
  --run: #1f54f2;
  --run-halo: rgb(31 84 242 / 0.16);
  --done: #1cac4f;
  --done-bg: #eaf4ed;
  --done-edge: #cfe3d5;
  --done-line: #b6d9c0;
  --pause: #ec8236;
  --needs: #8a3ffc;
  --needs-ink: #7a2fe8;
  /* The pill's count, a picked choice, the buttons. */
  --chip: #eeedea;
  --chip-ink: #6e6e6e;
  --pick: #e6f4e9;
  --pick-edge: #9ed2ab;
  --send: #9c9793;
  --send-on: #1b1b1b;
  --send-ink: #ffffff;
  --submit-off: #dadada;
  --danger: #e5484d;
  /* The same on both schemes: the focus ring, and the demo's pointer, as the system draws it. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);

  position: relative;
  container-type: inline-size;
  width: 100%;
  height: 660px;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--stage);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --stage: light-dark(#f3f3f3, #151516);
    --window: light-dark(#ffffff, #1b1b1d);
    --window-edge: light-dark(#e6e6e6, #29292c);
    --canvas: light-dark(#f6f6f5, #18181a);
    --skel: light-dark(#e4e3e0, #2a2a2d);
    --skel-2: light-dark(#efeeec, #222225);
    --surface: light-dark(#ffffff, #212124);
    --edge: light-dark(#e5e5e5, #303034);
    --edge-strong: light-dark(#d4d4d4, #45454a);
    --hover: light-dark(#fbfbfb, #1d1d20);
    --shadow: light-dark(rgb(0 0 0 / 0.045), rgb(0 0 0 / 0.35));
    --lift-shadow: light-dark(rgb(0 0 0 / 0.1), rgb(0 0 0 / 0.5));
    --ink: light-dark(#1b1b1b, #ececee);
    --ink-2: light-dark(#6c6c6c, #a0a0a5);
    --ink-3: light-dark(#a1a1a1, #6f6f74);
    --done-ink: light-dark(#7d7d7d, #8d8d92);
    --sub-ink: light-dark(#3f3f3f, #c4c4c8);
    --shine: light-dark(#c4c4c4, #5f5f64);
    --line: light-dark(#d6d6d6, #37373b);
    --ring: light-dark(#b3b3b3, #5d5d62);
    --run: light-dark(#1f54f2, #5b8bff);
    --run-halo: light-dark(rgb(31 84 242 / 0.16), rgb(91 139 255 / 0.22));
    --done: light-dark(#1cac4f, #35c46b);
    --done-bg: light-dark(#eaf4ed, #15241a);
    --done-edge: light-dark(#cfe3d5, #24402d);
    --done-line: light-dark(#b6d9c0, #2d5a3a);
    --pause: light-dark(#ec8236, #f2964f);
    --needs: light-dark(#8a3ffc, #ae7bff);
    --needs-ink: light-dark(#7a2fe8, #bf98ff);
    --chip: light-dark(#eeedea, #2e2e31);
    --chip-ink: light-dark(#6e6e6e, #a6a6ab);
    --pick: light-dark(#e6f4e9, #183020);
    --pick-edge: light-dark(#9ed2ab, #2f6b42);
    --send: light-dark(#9c9793, #55524f);
    --send-on: light-dark(#1b1b1b, #ececee);
    --send-ink: light-dark(#ffffff, #151516);
    --submit-off: light-dark(#dadada, #38383c);
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.root :where(p, ol, li) {
  margin: 0;
  padding: 0;
}
.root :where(button, input) {
  font: inherit;
  color: inherit;
}

/* The sketch and the panel side by side, the sketch bleeding off the left edge. */
.layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 376px;
  column-gap: 22px;
  height: 100%;
  padding: 44px 48px 44px 0;
}

/* ---- The sketch of the app ------------------------------------------------------------------ */

.app {
  display: flex;
  flex-direction: column;
  min-width: 0;
  margin-left: -40px;
  overflow: hidden;
  border: 1px solid var(--window-edge);
  border-radius: 14px;
  background: var(--window);
  box-shadow: 0 1px 3px var(--shadow);
  pointer-events: none;
  user-select: none;
}
.appBar {
  display: flex;
  flex: none;
  align-items: center;
  gap: 12px;
  height: 50px;
  padding: 0 12px;
  border-bottom: 1px solid var(--window-edge);
  font-size: 12px;
  color: var(--ink-3);
}
.appTab {
  flex: none;
  width: 104px;
  height: 26px;
  border: 1px solid var(--window-edge);
  border-radius: 7px;
}
.appUrl {
  flex: 1;
  display: flex;
  align-items: center;
  height: 27px;
  padding: 0 10px;
  border: 1px solid var(--window-edge);
  border-radius: 7px;
}
.appDevice {
  flex: none;
}
.appBody {
  display: grid;
  flex: 1;
  grid-template-columns: minmax(0, 1fr) 214px 120px;
  min-height: 0;
}
.appCanvas {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--canvas);
}
.appTop {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 48px;
  padding: 0 14px 0 120px;
  border-bottom: 1px solid var(--window-edge);
  background: var(--window);
}
.appTop > span:first-child {
  height: 8px;
  border-radius: 4px;
  background: var(--skel);
}
.appGrow {
  flex: 1;
}
.appPill {
  height: 24px;
  border-radius: 7px;
  background: var(--skel-2);
}
.appPill[data-strong] {
  background: var(--skel);
}
.appHero {
  position: relative;
  height: 150px;
  margin: 178px 14px 0 -40px;
  border-radius: 10px;
  background: var(--skel-2);
}
.appHero i {
  position: absolute;
  width: 18px;
  height: 18px;
  border: 1px solid var(--window);
  border-radius: 50%;
}
.appCaption {
  width: 170px;
  height: 8px;
  margin: 120px auto 0;
  border-radius: 4px;
  background: var(--skel-2);
}
.appInspector {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 18px 14px;
  border-left: 1px solid var(--window-edge);
  border-right: 1px solid var(--window-edge);
  background: var(--window);
}
.appInspector > span,
.appField > *,
.appSplit > *,
.appSegments > *,
.appTiles > * {
  display: block;
  border-radius: 4px;
  background: var(--skel-2);
}
.appField {
  display: flex;
  align-items: center;
  gap: 8px;
}
.appField i {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--skel);
}
.appField span {
  width: 54%;
  height: 7px;
}
.appField b {
  width: 22px;
  height: 7px;
  margin-left: auto;
}
.appInspector > .appDashed {
  height: 26px;
  border: 1px dashed var(--skel);
  background: none;
}
.appInspector > .appRule {
  height: 1px;
  margin: 4px -14px;
  border-radius: 0;
  background: var(--window-edge);
}
.appInspector > .appLabel {
  width: 40px;
  height: 7px;
  background: var(--skel);
}
.appSplit {
  display: flex;
  align-items: center;
  gap: 10px;
}
.appSplit span {
  flex: 1;
  height: 24px;
  border-radius: 7px;
}
.appSplit i {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--skel);
}
.appSegments {
  display: flex;
  gap: 2px;
  padding: 2px;
  border-radius: 7px;
  background: var(--skel-2);
}
.appSegments span {
  flex: 1;
  height: 18px;
  background: var(--window);
}
.appSegments span:first-child {
  background: var(--skel);
}
.appInspector > .appSlider {
  position: relative;
  height: 1px;
  margin: 10px 0;
  background: var(--skel);
}
.appSlider::after {
  content: '';
  position: absolute;
  top: -6px;
  left: var(--at);
  width: 12px;
  height: 12px;
  border: 1px solid var(--ring);
  border-radius: 50%;
  background: var(--window);
}
.appTiles {
  display: flex;
  gap: 6px;
}
.appTiles span {
  flex: 1;
  height: 32px;
  border-radius: 7px;
}

/* ---- The panel ------------------------------------------------------------------------------ */

.panel {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
  min-height: 0;
  transition: opacity 0.3s ease;
}
.panel[data-fading] {
  opacity: 0;
}
.thread {
  flex: 1;
  min-height: 0;
  margin: 0 -14px;
  padding: 0 14px;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: none;
  mask-image: linear-gradient(to bottom, transparent 0, #000 40px);
}
.thread::-webkit-scrollbar {
  display: none;
}
.threadBody {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  min-height: 100%;
  padding: 40px 0 6px;
}

.reply p,
.summary p {
  font-size: 13.5px;
  line-height: 21px;
}
.meta {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 12px;
  font-size: 13.5px;
  color: var(--ink-3);
}
.meta svg {
  width: 15px;
  height: 15px;
}
.meta svg:last-child {
  margin-left: 6px;
}
.summary {
  margin-top: 18px;
}

/* The pill: the running task and how many more; it opens into the whole turn. */
.pillWrap {
  display: flex;
  justify-content: flex-end;
  margin: 34px 0 12px;
}
.pill {
  display: flex;
  align-items: center;
  gap: 10px;
  width: calc(100% - 30px);
  min-height: 41px;
  padding: 0 9px 0 14px;
  border: 1px solid var(--edge);
  border-radius: 10px;
  background: var(--surface);
  font-size: 14px;
  line-height: 20px;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.15s ease;
}
.pill:hover {
  border-color: var(--edge-strong);
}
.pill[data-open] {
  align-items: flex-start;
  padding-top: 0;
  padding-bottom: 0;
  box-shadow: 0 2px 10px var(--shadow);
}
.pillText {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  animation: rise 0.3s ease backwards;
}
.pillList {
  display: flex;
  flex: 1;
  flex-direction: column;
  min-width: 0;
  animation: rise 0.25s ease backwards;
}
.pillItem {
  padding: 10px 0;
  border-bottom: 1px solid var(--edge);
}
.pillItem:last-child {
  border-bottom: 0;
}
.pillCount {
  flex: none;
  height: 20px;
  margin-top: 0;
  padding: 0 7px;
  border-radius: 999px;
  background: var(--chip);
  color: var(--chip-ink);
  font-size: 11.5px;
  line-height: 20px;
  transition: scale 0.12s ease;
}
.pill[data-open] .pillCount {
  margin-top: 10px;
}
.pill[data-press] .pillCount {
  scale: 0.92;
}

/* The done group: soft green, at the top; it folds into the pill while that is open. */
.doneFold {
  display: grid;
  grid-template-rows: 1fr;
  margin-bottom: 8px;
  transition:
    grid-template-rows 0.32s cubic-bezier(0.2, 0.8, 0.2, 1),
    opacity 0.25s ease,
    margin 0.32s ease;
}
.doneFold[data-folded] {
  grid-template-rows: 0fr;
  margin-bottom: 0;
  opacity: 0;
}
.doneClip {
  min-height: 0;
  overflow: hidden;
}
.doneGroup {
  --row-fill: var(--done-bg);
  padding: 8px 12px;
  border: 1px solid var(--done-edge);
  border-radius: 10px;
  background: var(--done-bg);
  list-style: none;
}
.doneRow {
  position: relative;
  display: grid;
  grid-template-columns: 12px minmax(0, 1fr);
  column-gap: 6px;
  padding: 7px 0;
  animation: rise 0.35s ease backwards;
}
.doneRow:not(:last-child)::before {
  content: '';
  position: absolute;
  top: 16px;
  left: 5.5px;
  width: 1px;
  height: 100%;
  background: var(--done-line);
}
.doneRow .text {
  color: var(--done-ink);
}

/* ---- The queue ------------------------------------------------------------------------------ */

.queueWrap {
  position: relative;
}
.rail {
  position: absolute;
  top: 0;
  left: 5.5px;
  width: 1px;
  height: 0;
  background: var(--line);
  opacity: 0;
  pointer-events: none;
}
.queue {
  position: relative;
  display: flex;
  flex-direction: column;
  list-style: none;
}
.row {
  --row-fill: var(--stage);
  position: relative;
  display: grid;
  grid-template-columns: 12px minmax(0, 1fr);
  column-gap: 6px;
  margin: 0 -10px;
  padding: 8px 10px;
  border-radius: 9px;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
  animation: rise 0.3s ease backwards;
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}
.row[data-status='running'] {
  cursor: default;
}
.row:hover,
.row[data-over],
.row:focus-within,
.row[data-status='paused'] {
  --row-fill: var(--hover);
  background: var(--hover);
}
.queue[data-dragging] .row {
  transition:
    translate 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}
.queue[data-dragging] .row:not([data-lifted]) {
  --row-fill: var(--stage);
  background: transparent;
}
.queue[data-dragging] .row:not([data-lifted]) .actions {
  opacity: 0;
}
.queue .row[data-lifted] {
  --row-fill: var(--surface);
  z-index: 2;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--edge),
    0 8px 22px var(--lift-shadow);
  cursor: grabbing;
  transition:
    background-color 0.15s ease,
    box-shadow 0.15s ease;
}

.dot {
  position: relative;
  z-index: 1;
  justify-self: center;
  width: 8px;
  height: 8px;
  margin-top: 5px;
  box-sizing: border-box;
  border-radius: 50%;
  background: var(--row-fill);
  transition:
    background-color 0.25s ease,
    border-color 0.25s ease,
    box-shadow 0.25s ease;
}
.dot[data-status='queued'] {
  border: 1.3px solid var(--ring);
}
.dot[data-status='running'] {
  background: var(--run);
  box-shadow: 0 0 0 3px var(--run-halo);
}
.dot[data-status='done'] {
  background: var(--done);
}
.dot[data-status='paused'],
.dot[data-status='needs'] {
  border: 1.3px solid var(--dot-ink);
  background: radial-gradient(circle, var(--dot-ink) 0 1.5px, var(--row-fill) 2px);
}
.dot[data-status='paused'] {
  --dot-ink: var(--pause);
}
.dot[data-status='needs'] {
  --dot-ink: var(--needs);
}

.main {
  min-width: 0;
}
.line {
  position: relative;
  display: flex;
  align-items: flex-start;
  padding-right: 64px;
}
.text {
  flex: 1;
  min-width: 0;
  padding: 0;
  border: 0;
  background: none;
  text-align: left;
  cursor: inherit;
  font-size: 13.5px;
  line-height: 18px;
  color: var(--ink-3);
  overflow-wrap: anywhere;
  border-radius: 4px;
  outline: none;
  transition: color 0.25s ease;
}
.text:focus-visible {
  box-shadow: 0 0 0 2px var(--focus);
}
.row[data-status='running'] .text,
.row[data-status='needs'] .text {
  color: var(--ink);
}
/* The running task's words carry a band of light sweeping across them. */
.row[data-status='running'] .words {
  background: linear-gradient(100deg, var(--ink) 0 42%, var(--shine) 50%, var(--ink) 58% 100%) 0 0 /
    300% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: shimmer 1.9s linear infinite;
}
@keyframes shimmer {
  from {
    background-position: 100% 0;
  }
  to {
    background-position: 0 0;
  }
}

.actions {
  position: absolute;
  top: -3px;
  right: -4px;
  display: flex;
  gap: 2px;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.row:hover .actions,
.row[data-over] .actions,
.row:focus-within .actions,
.row[data-status='paused'] .actions,
.row[data-status='needs'] .actions {
  opacity: 1;
}
.action,
.grip {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--ink-3);
  cursor: pointer;
  transition:
    color 0.12s ease,
    scale 0.12s ease;
}
.action svg,
.grip svg {
  width: 15px;
  height: 15px;
}
.action:hover {
  color: var(--ink);
}
.action[data-remove]:hover {
  color: var(--danger);
}
.action:focus-visible,
.pill:focus-visible,
.option:focus-visible,
.pager:focus-visible,
.submit:focus-visible,
.send:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 1px;
}
.action[data-press],
.send[data-press],
.option[data-press],
.pager[data-press],
.submit[data-press] {
  scale: 0.92;
}
.grip {
  display: none;
  cursor: grab;
  touch-action: none;
}
.info {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 3;
  max-width: 220px;
  padding: 6px 9px;
  border-radius: 7px;
  background: var(--send-on);
  color: var(--send-ink);
  font-size: 12px;
  line-height: 16px;
  box-shadow: 0 4px 14px var(--lift-shadow);
  animation: rise 0.18s ease backwards;
}

/* The indented line under a task: the reader's context, or their answers. */
.sub {
  position: relative;
  margin: 5px 0 0 14px;
  font-size: 13px;
  line-height: 18px;
  color: var(--sub-ink);
  animation: rise 0.3s ease backwards;
}
.sub::before {
  content: '';
  position: absolute;
  top: 9px;
  left: -24px;
  width: 19px;
  height: 1px;
  background: var(--line);
}
.doneRow .sub {
  color: var(--done-ink);
}
.doneRow .sub::before {
  background: var(--done-line);
}

.contextForm {
  margin-top: 7px;
  cursor: default;
}
.contextField {
  width: 100%;
  height: 34px;
  padding: 0 10px;
  border: 1px solid var(--edge);
  border-radius: 8px;
  background: var(--surface);
  font-size: 13px;
  user-select: text;
  outline: none;
  animation: rise 0.25s ease backwards;
  transition: border-color 0.15s ease;
}
.contextField::placeholder,
.input::placeholder,
.option[data-other]::placeholder {
  color: var(--ink-3);
}
.contextField:focus,
.contextField[data-focus] {
  border-color: var(--edge-strong);
}
.contextField:focus-visible {
  box-shadow: 0 0 0 2px var(--focus);
}

/* The agent's questions. */
.ask {
  margin-top: 10px;
  padding: 12px 12px 8px;
  border: 1px solid var(--edge);
  border-radius: 10px;
  background: var(--hover);
  cursor: default;
  user-select: text;
  animation: rise 0.3s ease backwards;
}
.askHead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
}
.askTitle {
  font-size: 13.5px;
  font-weight: 500;
  color: var(--needs-ink);
}
.askCount {
  font-size: 12px;
  color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
.askPrompt {
  margin: 9px 0 8px;
  font-size: 13.5px;
  line-height: 18px;
  animation: rise 0.25s ease backwards;
}
.options {
  display: grid;
  gap: 6px;
  animation: rise 0.25s ease backwards;
}
.option {
  width: 100%;
  height: 32px;
  padding: 0 11px;
  border: 1px solid var(--edge);
  border-radius: 7px;
  background: var(--surface);
  font-size: 13.5px;
  text-align: left;
  cursor: pointer;
  transition:
    background-color 0.15s ease,
    border-color 0.15s ease,
    scale 0.12s ease;
}
.option:hover {
  border-color: var(--edge-strong);
}
.option[aria-pressed='true'],
.option[data-picked] {
  border-color: var(--pick-edge);
  background: var(--pick);
}
.option[data-other] {
  cursor: text;
  outline: none;
}
.option[data-other]:focus {
  border-color: var(--edge-strong);
}
.option[data-other]:focus-visible {
  box-shadow: 0 0 0 2px var(--focus);
}
.askFoot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-top: 8px;
}
.pager {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  margin: 0 -6px;
  padding: 0;
  border: 0;
  border-radius: 7px;
  background: transparent;
  color: var(--ink-2);
  cursor: pointer;
  transition: scale 0.12s ease;
}
.pager:disabled {
  color: var(--ink-3);
  opacity: 0.6;
  cursor: default;
}
.pager svg {
  width: 16px;
  height: 16px;
}
.submit {
  height: 30px;
  padding: 0 14px;
  border: 0;
  border-radius: 7px;
  background: var(--send-on);
  color: var(--send-ink);
  font-size: 13.5px;
  font-weight: 500;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    scale 0.12s ease;
}
.submit:disabled {
  background: var(--submit-off);
  cursor: default;
}

/* ---- The composer --------------------------------------------------------------------------- */

.composer {
  flex: none;
  padding: 15px 14px 10px 16px;
  border: 1px solid var(--edge);
  border-radius: 16px;
  background: var(--surface);
  box-shadow:
    0 1px 2px var(--shadow),
    0 6px 20px var(--shadow);
  cursor: text;
  transition: border-color 0.15s ease;
}
.composer:focus-within,
.composer[data-focus] {
  border-color: var(--edge-strong);
}
.composer:has(.input:focus-visible) {
  box-shadow:
    0 0 0 2px var(--focus),
    0 6px 20px var(--shadow);
}
.input {
  display: block;
  width: 100%;
  height: 24px;
  padding: 0;
  border: 0;
  background: transparent;
  font-size: 14px;
  outline: none;
}
.tools {
  display: flex;
  align-items: center;
  gap: 4px;
  margin: 12px 0 0 -6px;
}
.tool {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  color: var(--ink-2);
  font-size: 16px;
  cursor: default;
}
.tool svg {
  width: 16px;
  height: 16px;
}
.grow {
  flex: 1;
}
.mode {
  display: flex;
  align-items: center;
  gap: 3px;
  margin-right: 4px;
  font-size: 13.5px;
  color: var(--ink-2);
  cursor: default;
}
.mode svg {
  width: 13px;
  height: 13px;
}
.send {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  margin-left: 6px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--send);
  color: #fff;
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    scale 0.12s ease;
}
.send[data-ready] {
  background: var(--send-on);
  color: var(--send-ink);
}
.send:disabled {
  cursor: default;
}
.send svg {
  width: 16px;
  height: 16px;
}

@keyframes rise {
  from {
    opacity: 0;
    translate: 0 3px;
  }
}

/* ---- Narrow: the panel alone ---------------------------------------------------------------- */

@container (max-width: 640px) {
  .layout {
    grid-template-columns: minmax(0, 1fr);
    padding: 16px;
  }
  .app {
    display: none;
  }
}
@media (hover: none) {
  .grip {
    display: grid;
  }
  .actions {
    opacity: 1;
  }
}

/* ---- The demo's pointer --------------------------------------------------------------------- */

.pointerLayer {
  position: absolute;
  inset: 0;
  z-index: 5;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand,
.grab,
.caret {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow,
.pointer[data-glyph='grab'] .arrow,
.pointer[data-glyph='text'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand,
.pointer[data-glyph='grab'] .grab,
.pointer[data-glyph='text'] .caret {
  visibility: visible;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .row,
  .doneRow,
  .sub,
  .pillText,
  .pillList,
  .ask,
  .askPrompt,
  .options,
  .contextField,
  .info {
    animation: none;
  }
  .row[data-status='running'] .words {
    animation: none;
    background: none;
    color: var(--ink);
  }
  .doneFold,
  .panel,
  .queue[data-dragging] .row {
    transition: none;
  }
}
```

### agent-queue/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The shapes the queue works with, and the queue the lab shows: a coding agent halfway through a
 * turn of small requests about the very panel it sits in. One is done, one runs, three wait, and
 * one of those will need answers before the agent can do it.
 */

/**
 * Where a task stands. The agent runs one at a time, top to bottom; it skips a paused task, and
 * one it needs answers for, which it marks `needs` and leaves for the reader.
 */
export type TaskStatus = 'queued' | 'running' | 'done' | 'paused' | 'needs';

/** A question the agent asks before it can do a task: a few choices, or the reader’s own words. */
export type Question = { prompt: string; options: readonly string[] };

export type Task = {
  id: string;
  text: string;
  status: TaskStatus;
  /** What the reader added: shown as an indented line under the task. */
  context?: string;
  /** What the agent will ask once it reaches the task; once answered, the answers are its context. */
  questions?: readonly Question[];
};

export const TASKS: readonly Task[] = [
  { id: 'sticky', text: 'keep the composer pinned to the bottom of the panel', status: 'done' },
  { id: 'height', text: 'make the panel 80% of the viewport height', status: 'running' },
  {
    id: 'shimmer',
    text: 'drop the “Working…” label, and sweep a gradient across the task while it runs',
    status: 'queued',
    questions: [
      {
        prompt: 'Where should the gradient show?',
        options: ['On the title', 'Across the row', 'Behind the dot'],
      },
      { prompt: 'How fast should it move?', options: ['Slow', 'Medium', 'Fast'] },
      { prompt: 'Which direction?', options: ['Left to right', 'Right to left', 'Diagonal'] },
    ],
  },
  {
    id: 'locked',
    text: 'the running task should never be draggable, nor take a drop',
    status: 'queued',
  },
  {
    id: 'live',
    text: 'while dragging, the other tasks should move out of the way live',
    status: 'queued',
  },
];

/** What the demo types into the composer, and the context it adds to a paused task. */
export const DEMO_TASK = 'round the corners of the hover state a little more';
export const DEMO_CONTEXT = 'match the 8px radius the composer uses';

/** What the demo picks on each page of the questions. */
export const DEMO_ANSWERS = ['On the title', 'Fast', 'Left to right'];

/** The agent's reply to the turn before this one, at the top of the thread. */
export const PREVIOUS_REPLY =
  'Done. The panel sits on the right at 380px, with the composer pinned to its bottom.';
```

### agent-queue/glyphs.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The small line glyphs of the queue and its composer, drawn on a 16 px grid in currentColor.
 * Every one is decorative: the button around it carries the name.
 */

import type { ReactNode } from 'react';

export type GlyphName =
  | 'plus'
  | 'attach'
  | 'chevron'
  | 'mic'
  | 'send'
  | 'pause'
  | 'play'
  | 'info'
  | 'close'
  | 'left'
  | 'right'
  | 'pulse'
  | 'up'
  | 'down'
  | 'grip';

const PATHS: Record<GlyphName, ReactNode> = {
  plus: <path d="M8 3v10M3 8h10" />,
  attach: (
    <>
      <path d="M12.5 8.5V4.2c0-.7-.5-1.2-1.2-1.2H4.2C3.5 3 3 3.5 3 4.2v7.1c0 .7.5 1.2 1.2 1.2h4.3" />
      <path d="M10 10.2l3 3M13 10.2v3h-3" />
    </>
  ),
  chevron: <path d="M4.5 6.5 8 10l3.5-3.5" />,
  mic: (
    <>
      <rect x="6" y="2.5" width="4" height="7" rx="2" />
      <path d="M4 8a4 4 0 0 0 8 0M8 12v1.8" />
    </>
  ),
  send: <path d="M8 13V3.5M4 7.2 8 3.2l4 4" />,
  pause: <path d="M6 4v8M10 4v8" />,
  play: <path d="M5.2 3.6v8.8l7-4.4z" strokeLinejoin="round" />,
  info: (
    <>
      <circle cx="8" cy="8" r="5.6" />
      <path d="M8 7.4v3.4" />
      <circle cx="8" cy="5.3" r=".4" fill="currentColor" />
    </>
  ),
  close: <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />,
  left: <path d="M13 8H3.5M7 4.5 3.5 8 7 11.5" />,
  right: <path d="M3 8h9.5M9 4.5l3.5 3.5L9 11.5" />,
  pulse: <path d="M2 8.5h2.6l1.6-4 2.6 7.5 1.7-3.5H14" strokeLinejoin="round" />,
  up: (
    <path d="M5.6 7.2V13H3.4a.9.9 0 0 1-.9-.9V8.1c0-.5.4-.9.9-.9zm0 0L8 2.8c.9 0 1.6.7 1.6 1.6v1.9h2.9c.8 0 1.4.7 1.3 1.5l-.7 4.1c-.1.6-.6 1.1-1.3 1.1H5.6" />
  ),
  down: (
    <path d="M10.4 8.8V3h2.2c.5 0 .9.4.9.9v4c0 .5-.4.9-.9.9zm0 0L8 13.2c-.9 0-1.6-.7-1.6-1.6V9.7H3.5c-.8 0-1.4-.7-1.3-1.5l.7-4.1C3 3.5 3.5 3 4.2 3h6.2" />
  ),
  grip: (
    <>
      <circle cx="6" cy="4.5" r=".9" fill="currentColor" stroke="none" />
      <circle cx="10" cy="4.5" r=".9" fill="currentColor" stroke="none" />
      <circle cx="6" cy="8" r=".9" fill="currentColor" stroke="none" />
      <circle cx="10" cy="8" r=".9" fill="currentColor" stroke="none" />
      <circle cx="6" cy="11.5" r=".9" fill="currentColor" stroke="none" />
      <circle cx="10" cy="11.5" r=".9" fill="currentColor" stroke="none" />
    </>
  ),
};

export function Glyph({ name, className }: { name: GlyphName; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="0 0 16 16"
      width="16"
      height="16"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.4"
      strokeLinecap="round"
      aria-hidden="true"
      focusable="false"
    >
      {PATHS[name]}
    </svg>
  );
}
```

### agent-queue/demo-pointer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The arrow and the
 * hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

export type Point = { x: number; y: number };

/** The pointer's looks. */
export type Glyph = 'arrow' | 'hand' | 'grab' | 'text';

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, the hand a button shows, the closed hand of a drag or the text caret. */
  glyph: (name: Glyph) => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Where the pointer's tip is right now, mid-flight too, in the stage's px. */
export function pointerAt(pointer: HTMLElement): Point {
  const matrix = new DOMMatrixReadOnly(getComputedStyle(pointer).transform);
  return {
    x: matrix.m41 + pointer.offsetWidth * TIP.x,
    y: matrix.m42 + pointer.offsetHeight * TIP.y,
  };
}

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (agent-queue.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, a pointing hand over what it can press, a closed
 * hand while it drags a task, or a text caret over a field. It is decorative: what it does shows
 * on the queue itself. The stylesheet shows one glyph at a time, from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
  grabClass,
  textClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
  grabClass?: string;
  textClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
      <span className={grabClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* A closed hand, knuckles up, hanging from the point it holds. */}
          <path
            d="M-1.5 3V1.8a1.3 1.3 0 0 1 2.6 0v-.4a1.3 1.3 0 0 1 2.6 0v.3a1.3 1.3 0 0 1 2.6 0v.6a1.3 1.3 0 0 1 2.6 0V7c0 3-2 5-4.8 5h-1c-2 0-3.3-1-4.3-2.6L-3.6 6.6c-.4-.7-.1-1.4.5-1.7.6-.3 1.2 0 1.6.6z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(0 -2)"
          />
        </svg>
      </span>
      <span className={textClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The text caret: its middle stands where the arrow's tip would. */}
          <path
            d="M1 -5.5h1.6c.8 0 1.4.4 1.4.4s.6-.4 1.4-.4H7M4 -5v13M1 8.5h1.6c.8 0 1.4-.4 1.4-.4s.6.4 1.4.4H7"
            fill="none"
            stroke="var(--pointer-edge)"
            strokeWidth="3"
            strokeLinecap="round"
          />
          <path
            d="M1 -5.5h1.6c.8 0 1.4.4 1.4.4s.6-.4 1.4-.4H7M4 -5v13M1 8.5h1.6c.8 0 1.4-.4 1.4-.4s.6.4 1.4.4H7"
            fill="none"
            stroke="var(--pointer)"
            strokeWidth="1.2"
            strokeLinecap="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### agent-queue/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, visible, reduced, playing: visible && !reduced && !taken };
}
```