> Premium standalone component (part of no design system, works in any React + Tailwind app) from AI Canvas. Index of every component and install setup: https://aicanvas.me/llms.txt

# Agent Plan

An agent's step plan you can edit and start, then watch run one step at a time with live activity and a segmented progress bar.

- Category: AI Agent UI
- Built with: Motion, Tailwind CSS
- License: Premium (proprietary, not in the public repo)
- Page: https://aicanvas.me/components/plan-runner

## Install

```bash
npx shadcn@latest add @aicanvas/plan-runner
```

Requires Premium. Set your AI Canvas token as AICANVAS_TOKEN and register the @aicanvas registry with it, as described in the Install section of https://aicanvas.me/llms.txt. Without a Premium token the command writes a placeholder file instead of the component.

AI agents can also get its install command from the read-only AI Canvas MCP server (`npx -y @aicanvas/mcp`) and run it. Setup: https://aicanvas.me/mcp

Peer dependencies:

```bash
npm install framer-motion
```

## Props

### StepActivity

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `string` |  | Activity action shown in the step trail. |
| `target?` | `string` |  | File, command or other activity target; left out, no target is shown. |
| `outcome?` | `string` |  | Activity result shown at the row end; left out, no result is shown. |
| `tone?` | `'neutral'\|'success'\|'red'` |  | Colors the activity result; left out, the neutral treatment is used. |

### Step

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Stable step identifier that must be unique within the steps list. |
| `text` | `string` |  | Plan instruction shown in the step row. |
| `status?` | `StepStatus` |  | Supplies the step state in host-controlled runs; left out, the step starts upcoming. |
| `durationMs?` | `number` |  | Supplies the completed duration and demo playback length; left out, the configured step playback length is used. |
| `addedReason?` | `string` |  | Marks an agent-added step and explains why it was added; left out, no added-step marker or reason is shown. |
| `activity?` | `StepActivity[]` |  | Supplies activity rows revealed beneath the step; left out, the step has no activity trail. |

### PlanRunner

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `goal?` | `string` |  | Sets the plan heading; left out, the demo uses its sample goal and host mode shows an empty heading, while passing it turns the demo off. |
| `label?` | `string` |  | Adds muted text before the goal; left out, no prefix is shown, while passing it turns the demo off. |
| `steps?` | `Step[]` |  | Supplies the editable or running plan; left out, the demo uses sample steps and host mode uses none, while passing it turns the demo off. |
| `doneLooksLike?` | `string` |  | Shows the completion criterion during review; left out, the demo uses sample criteria and host mode hides the section, while passing it turns the demo off. |
| `summary?` | `string` |  | Shows the completion summary when the plan finishes; left out, the demo uses a sample summary and host mode shows none, while passing it turns the demo off. |
| `phase?` | `PlanPhase` |  | Controls the plan phase; left out, phase transitions are managed internally, while passing it turns the demo off. |
| `currentIndex?` | `number` |  | Marks earlier steps done and the first non-skipped step at or after this zero-based index current; left out, the statuses supplied on steps are used, while passing it turns the demo off. |
| `autoStartSeconds?` | `number` |  | Starts the review countdown when positive and onStart is available; left out, the demo uses its tuned delay and host mode does not auto-start, while passing it turns the demo off. |
| `onStart?` | `(steps: Step[]) => void` |  | Receives the final nonblank steps and makes the Start control available outside the demo; left out, host reviews have no Start control. |
| `onRevise?` | `(note: string) => void` |  | Receives a requested revision and makes the Revise control available outside the demo; left out, host reviews have no Revise control. |
| `onPause?` | `() => void` |  | Handles pausing and makes the Pause control available while running; left out, host runs have no Pause control. |
| `onResume?` | `() => void` |  | Handles resuming and makes the Resume control available while paused; left out, host pauses have no Resume control. |
| `onStop?` | `() => void` |  | Handles stopping and makes the Stop control available while running; left out, host runs have no Stop control. |
| `onSkip?` | `(id: string) => void` |  | Receives the current step id and makes its Skip control available outside the demo; left out, host runs have no Skip control. |
| `onStepsChange?` | `(steps: Step[]) => void` |  | Receives edits, additions, removals and reordering; left out, plan edits remain local. |
| `className?` | `string` |  | Adds classes to the outermost root; left out, only the built-in classes are used. |
| `tuning?` | `Partial<Tuning>` |  | Overrides plan measurements, timing and motion values; left out, every value in TUNING is used. |

## About

Plan Runner shows a plan the agent proposed and runs it step by step. Before it starts, steps can be edited, reordered, removed or added, and the plan handed to your code is the one the user shaped. While running, the current step carries a live progress bar and the latest status line, finished steps fold their outcome into one line with a duration, and a failed step offers Retry or Skip. The header keeps an honest elapsed time and only estimates what is left once real steps have finished. Pause freezes everything, Resume continues, and the keyboard drives it all.

Use cases: AI coding agents, Workflow automation, Onboarding flows.

## Source

The source is Premium and is not published. Preview it at https://aicanvas.me/components/plan-runner.

## Remix prompt

The public part of the remix prompt: 1. Setup and 2. Constants. The rest of the build spec (state, markup, how it works) is part of Premium.

```markdown
# PlanRunner
An agent plan card that people review, edit and reorder, then watch run with pause, skip and stop.

## 1. Setup
// npm install framer-motion
File: components-workspace/plan-runner/index.tsx  ·  'use client'  ·  export default function PlanRunner(props: PlanRunnerProps)
Tailwind CSS v4, with the dark: variant following your theme. Colors come from the component variables in THEME_VARS.
The @/app/_icons imports are the AI Canvas icon set. Without it, use any icon components that accept size, weight and SVG props, with a matching IconProps type.

import {
  useEffect,
  useId,
  useLayoutEffect,
  useReducer,
  useRef,
  useState,
  useSyncExternalStore,
} from 'react'
import type * as React from 'react'
import {
  animate,
  AnimatePresence,
  motion,
  Reorder,
  useDragControls,
  useMotionValue,
  useTransform,
} from 'framer-motion'
import type { MotionValue } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { Check } from '@/app/_icons/icons/check'
import { GripVertical } from '@/app/_icons/icons/grip-vertical'
import { LoaderCircle } from '@/app/_icons/icons/loader-circle'
import { Pause } from '@/app/_icons/icons/pause'
import { Play } from '@/app/_icons/icons/play'
import { Plus } from '@/app/_icons/icons/plus'
import { CircleStop } from '@/app/_icons/icons/circle-stop'
import { Pen2 } from '@/app/_icons/icons/pen-2'
import { X } from '@/app/_icons/icons/x'
import { CircleCheckDuotone } from '@/app/_icons/icons/circle-check-duotone'
import { CircleMinusDuotone } from '@/app/_icons/icons/circle-minus-duotone'
import { CircleStopDuotone } from '@/app/_icons/icons/circle-stop-duotone'
import { FilePlusDuotone } from '@/app/_icons/icons/file-plus-duotone'
import { FileTextDuotone } from '@/app/_icons/icons/file-text-duotone'
import { GlobeDuotone } from '@/app/_icons/icons/globe-duotone'
import { ListChecksDuotone } from '@/app/_icons/icons/list-checks-duotone'
import { MessageCircleDuotone } from '@/app/_icons/icons/message-circle-duotone'
import { PauseDuotone } from '@/app/_icons/icons/pause-duotone'
import { Pen2Duotone } from '@/app/_icons/icons/pen-2-duotone'
import { SearchDuotone } from '@/app/_icons/icons/search-duotone'
import { TerminalDuotone } from '@/app/_icons/icons/terminal-duotone'
import type { IconProps } from '@/app/_icons/IconBase'

## 2. Constants
export type Tuning = {
  cardMaxW: number
  glyphPx: number
  iconPx: number
  spinnerPx: number
  actVisible: number
  hoverMs: number
  pressScale: number
  stateMs: number
  panelMs: number
  segmentFillMs: number
  springS: number
  lightMs: number
  shimmerS: number
  spinS: number
  undoMs: number
  defaultAutoSeconds: number
  ringInset: number
  ringStroke: number
  ringFlashMs: number
  ringFadeMs: number
  touchResumeMs: number
  clockTickMs: number
  stepPlayMs: number
  demoTimeScale: number
  actLeadIn: number
  actSpan: number
  actRun: number
  actJitter: number
  streamMs: number
  dotPitch: number
  dotFieldH: number
  introAtMs: number
  introGapMs: number
  introInMs: number
}

export const TUNING: Tuning = {
  cardMaxW: 480,
  glyphPx: 20,
  iconPx: 16,
  spinnerPx: 14,
  actVisible: 3,
  hoverMs: 150,
  pressScale: 0.97,
  stateMs: 200,
  panelMs: 280,
  segmentFillMs: 320,
  springS: 0.35,
  lightMs: 600,
  shimmerS: 2.6,
  spinS: 0.8,
  undoMs: 4000,
  defaultAutoSeconds: 5,
  ringInset: 4,
  ringStroke: 2,
  ringFlashMs: 128,
  ringFadeMs: 200,
  touchResumeMs: 1500,
  clockTickMs: 200,
  stepPlayMs: 1600,
  demoTimeScale: 7,
  actLeadIn: 0.06,
  actSpan: 0.86,
  actRun: 0.72,
  actJitter: 0.22,
  streamMs: 240,
  dotPitch: 6,
  dotFieldH: 64,
  introAtMs: 120,
  introGapMs: 90,
  introInMs: 280,
}

const EASE = [0.23, 1, 0.32, 1] as const

const layoutSpring = (t: Tuning) => ({ type: 'spring' as const, duration: t.springS, bounce: 0 })

// The server and the hydrating client both read false, so the markup always matches.
const COARSE_POINTER = '(pointer: coarse)'

const REDUCED_MOTION = '(prefers-reduced-motion: reduce)'

function subscribeMedia(query: string) {
  return (onChange: () => void) => {
    if (typeof window === 'undefined' || !window.matchMedia) return () => {}
    const mq = window.matchMedia(query)
    mq.addEventListener('change', onChange)
    return () => mq.removeEventListener('change', onChange)
  }
}

function readMedia(query: string) {
  return () => typeof window !== 'undefined' && Boolean(window.matchMedia?.(query).matches)
}

const subscribeCoarsePointer = subscribeMedia(COARSE_POINTER)

const getCoarsePointer = readMedia(COARSE_POINTER)

const subscribeReducedMotion = subscribeMedia(REDUCED_MOTION)

const getReducedMotion = readMedia(REDUCED_MOTION)

function falseOnServer() {
  return false
}

export type StepStatus = 'upcoming' | 'current' | 'done' | 'skipped'

export type PlanPhase = 'review' | 'running' | 'paused' | 'done'

export type StepActivity = {
  label: string
  target?: string
  outcome?: string
  tone?: 'neutral' | 'success' | 'red'
}

export type Step = {
  id: string
  text: string
  status?: StepStatus
  durationMs?: number
  addedReason?: string
  activity?: StepActivity[]
}

type PlanRunnerProps = {
  goal?: string
  label?: string
  steps?: Step[]
  doneLooksLike?: string
  summary?: string
  phase?: PlanPhase
  currentIndex?: number
  autoStartSeconds?: number
  onStart?: (steps: Step[]) => void
  onRevise?: (note: string) => void
  onPause?: () => void
  onResume?: () => void
  onStop?: () => void
  onSkip?: (id: string) => void
  onStepsChange?: (steps: Step[]) => void
  className?: string
  tuning?: Partial<Tuning>
}

type RunStep = {
  id: string
  text: string
  status: StepStatus
  durationMs?: number
  addedReason?: string
  activity: StepActivity[]
  playMs: number
  spentMs?: number
  canceled?: boolean
  removed?: boolean
  fresh?: boolean
}

type Insert = { afterId: string; at: number; step: RunStep }

type Hold = {
  from: PlanPhase | undefined
  source: string
  currentIndex?: number
  to: 'running' | 'paused' | 'stopped'
  steps: RunStep[]
  spent: number
}

type Enter = { at: number; next: number; inS: number }

const DEFAULT_GOAL = 'Fix date parsing across time zones'

const DEFAULT_DONE_LOOKS_LIKE = 'All date tests pass in UTC, New York and Tokyo.'

const DEFAULT_SUMMARY = 'All date tests pass in 3 time zones'

// The six add up to 72s, so the run reads "Done in 1m 12s".
const DEMO_STEPS: Step[] = [
  {
    id: 'read-tests',
    text: 'Read the failing tests',
    durationMs: 11200,
    activity: [
      { label: 'Read', target: 'tests/date.test.ts', outcome: '3 failing' },
      { label: 'Searched', target: 'parseDate', outcome: '4 files' },
    ],
  },
  {
    id: 'repro',
    text: 'Reproduce the bug in a script',
    durationMs: 12000,
    activity: [
      { label: 'Created', target: 'scripts/repro.ts', outcome: '18 lines' },
      { label: 'Ran', target: 'npx tsx scripts/repro.ts', outcome: 'off by 5h' },
    ],
  },
  {
    id: 'fix',
    text: 'Fix the offset handling in parseDate',
    durationMs: 14400,
    activity: [
      { label: 'Read', target: 'src/date.ts', outcome: '212 lines' },
      { label: 'Edited', target: 'src/date.ts', outcome: '+14 −6' },
      { label: 'Ran', target: 'npm test -- date', outcome: '3 passed', tone: 'success' },
    ],
  },
  {
    id: 'suite',
    text: 'Run the full test suite',
    durationMs: 11800,
    activity: [
      { label: 'Ran', target: 'npm test', outcome: '148 passed', tone: 'success' },
      { label: 'Ran', target: 'npm run typecheck', outcome: 'no errors', tone: 'success' },
    ],
  },
  {
    id: 'summary',
    text: 'Summarise the change',
    durationMs: 11200,
    activity: [{ label: 'Ran', target: 'git diff --stat', outcome: '3 files' }],
  },
]

const DEMO_INSERT: { afterId: string; at: number; step: Step } = {
  afterId: 'fix',
  at: 0.6,
  step: {
    id: 'regression',
    text: 'Add a regression test for offsets',
    addedReason: 'Tests had no time zone case',
    durationMs: 11400,
    activity: [
      { label: 'Edited', target: 'tests/date.test.ts', outcome: '+32 −0' },
      { label: 'Ran', target: 'npm test -- date', outcome: '9 passed', tone: 'success' },
    ],
  },
}

const VERBS: Record<string, { ing: string; icon: React.ComponentType<IconProps> }> = {
  Read: { ing: 'Reading', icon: FileTextDuotone },
  Searched: { ing: 'Searching', icon: SearchDuotone },
  Edited: { ing: 'Editing', icon: Pen2Duotone },
  Created: { ing: 'Creating', icon: FilePlusDuotone },
  Ran: { ing: 'Running', icon: TerminalDuotone },
  Fetched: { ing: 'Fetching', icon: GlobeDuotone },
  Asked: { ing: 'Asking', icon: MessageCircleDuotone },
}

const THEME_VARS = [
  '[--pr-page:#EEEEF3] dark:[--pr-page:#0E0E0F]',
  '[--pr-panel:#F4F4FA] dark:[--pr-panel:#1B1B1C]',
  '[--pr-well:#EEEEF3] dark:[--pr-well:#0E0E0F]',
  '[--pr-raised:#DFDFE3] dark:[--pr-raised:#2D2D2E]',
  '[--pr-raised-hover:#CACACD] dark:[--pr-raised-hover:#373738]',
  '[--pr-border:#DFDFE3] dark:[--pr-border:#2D2D2E]',
  '[--pr-border-strong:#CACACD] dark:[--pr-border-strong:#373738]',
  '[--pr-hairline:#DFDFE3] dark:[--pr-hairline:#2D2D2E]',
  '[--pr-rim:#CACACD] dark:[--pr-rim:#F4F4FA2E]',
  '[--pr-text:#1B1B1C] dark:[--pr-text:#F4F4FA]',
  '[--pr-body:#373738] dark:[--pr-body:#CACACD]',
  '[--pr-muted:#575759] dark:[--pr-muted:#9B9B9E]',
  '[--pr-faint:#7B7B7D]',
  '[--pr-selection:#A0A0A233] dark:[--pr-selection:#5E5E6333]',
  '[--pr-success:#047857] dark:[--pr-success:#34D399]',
  '[--pr-ai:#0369A1] dark:[--pr-ai:#7DD3FC]',
  '[--pr-attention:#B45309] dark:[--pr-attention:#FFB574]',
  '[--pr-red:#C8262C] dark:[--pr-red:#E5484D]',
  // Small red words: 20% toward ink on light, 35% toward white on dark, for 4.5:1.
  '[--pr-red-text:color-mix(in_srgb,#C8262C,#1B1B1C_20%)] dark:[--pr-red-text:color-mix(in_srgb,#E5484D,#F4F4FA_35%)]',
  '[--pr-primary:#1B1B1C] dark:[--pr-primary:#F4F4FA]',
  '[--pr-primary-hover:#373738] dark:[--pr-primary-hover:#FFFFFF]',
  '[--pr-on-primary:#F4F4FA] dark:[--pr-on-primary:#0E0E0F]',
  '[--pr-focus:#1B1B1C99] dark:[--pr-focus:#F4F4FA99]',
  '[--pr-focus-gap:#F4F4FA] dark:[--pr-focus-gap:#1B1B1C]',
  '[--pr-dot:#1B1B1C] dark:[--pr-dot:#F4F4FA]',
  '[--pr-elev-card:0_2px_6px_rgba(0,0,0,0.06),0_28px_56px_-16px_rgba(0,0,0,0.25)] dark:[--pr-elev-card:0_2px_6px_rgba(0,0,0,0.5),0_36px_72px_-18px_rgba(0,0,0,0.9)]',
  '[--pr-elev-1:0_1px_2px_rgba(0,0,0,0.08)] dark:[--pr-elev-1:0_1px_2px_rgba(0,0,0,0.5)]',
  '[--pr-elev-menu:0_4px_12px_rgba(0,0,0,0.10),0_16px_32px_-8px_rgba(0,0,0,0.2)] dark:[--pr-elev-menu:0_4px_12px_rgba(0,0,0,0.5),0_16px_32px_-8px_rgba(0,0,0,0.8)]',
].join(' ')

const CARD =
  'relative w-full rounded-[24px] border border-(color:--pr-border) bg-(--pr-panel) p-4 min-[400px]:p-5 shadow-(--pr-elev-card)'

const WELL = 'rounded-2xl border border-(color:--pr-border) bg-(--pr-well)'

const GROUP = 'font-mono text-[11px] leading-4 text-(color:--pr-muted)'

const TITLE = 'min-w-0 flex-1 truncate text-[15px] leading-6 font-semibold text-(color:--pr-text)'

const META = 'shrink-0 text-[12px] leading-4 tabular-nums text-(color:--pr-muted)'

const MONO = 'font-mono text-[12px] leading-[18px]'

const FOCUS =
  'outline-none focus-visible:ring-2 focus-visible:ring-(color:--pr-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(color:--pr-focus-gap)'

// For controls in the card gutter, where an outer ring would be clipped by the edge.
const FOCUS_INSET =
  'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-(color:--pr-focus)'

const HIT = "relative pointer-coarse:after:absolute pointer-coarse:after:content-['']"

const HIT_BUTTON = `${HIT} pointer-coarse:after:-inset-1.5`

const HIT_ROW = `${HIT} pointer-coarse:after:-inset-x-1.5 pointer-coarse:after:-inset-y-1.5`

const HANDLE_GUTTER = 'top-2 -left-4 w-4 min-[400px]:-left-[18px]'

const HANDLE_LANE =
  "top-2.5 left-0 w-7 after:absolute after:-inset-y-2.5 after:-left-4 after:right-0 after:content-['']"

const BTN =
  'inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-4 text-[12px] leading-4 font-semibold whitespace-nowrap transition-[background-color,color,border-color,box-shadow] duration-150 disabled:pointer-events-none disabled:opacity-50'

const BTN_PRIMARY = `${BTN} bg-(--pr-primary) text-(color:--pr-on-primary) shadow-(--pr-elev-1) hover:bg-(--pr-primary-hover)`

const BTN_SECONDARY = `${BTN} border border-(color:--pr-border-strong) text-(color:--pr-text) hover:bg-(--pr-raised)`

const BTN_GHOST = `${BTN} text-(color:--pr-muted) hover:bg-(--pr-raised) hover:text-(color:--pr-text)`

const BTN_ICON =
  'inline-grid size-8 shrink-0 place-items-center rounded-full text-(color:--pr-muted) transition-[background-color,color,opacity] duration-150 hover:bg-(--pr-raised) hover:text-(color:--pr-text)'

const INPUT =
  'border border-(color:--pr-border-strong) bg-(--pr-well) text-[14px] text-(color:--pr-text) caret-(--pr-text) transition-[border-color] duration-150 placeholder:text-(color:--pr-muted) focus:border-(color:--pr-muted)'

type Tone = 'neutral' | 'ai' | 'attention' | 'success' | 'red'

const TONE_VAR: Record<Tone, string> = {
  neutral: 'var(--pr-dot)',
  ai: 'var(--pr-ai)',
  attention: 'var(--pr-attention)',
  success: 'var(--pr-success)',
  red: 'var(--pr-red)',
}

const TONE_STRENGTH: Record<Tone, number> = { neutral: 6, ai: 14, attention: 14, success: 10, red: 12 }

const SHIMMER_BG = 'linear-gradient(90deg, var(--pr-muted) 0%, var(--pr-text) 50%, var(--pr-muted) 100%)'

function fmtClock(ms: number) {
  const s = Math.max(0, Math.floor(ms / 1000))
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
}

function fmtDuration(ms: number) {
  if (ms < 10000) return `${(Math.max(0, ms) / 1000).toFixed(1)}s`
  const s = Math.round(ms / 1000)
  if (s < 60) return `${s}s`
  return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
}

function fmtSpoken(ms: number) {
  const s = Math.round(ms / 1000)
  const m = Math.floor(s / 60)
  const r = s % 60
  const sec = `${r} second${r === 1 ? '' : 's'}`
  return m ? `${m} minute${m === 1 ? '' : 's'}${r ? ` ${sec}` : ''}` : sec
}

function toRun(list: Step[], t: Tuning, keepStatus: boolean): RunStep[] {
  return list.map((s) => ({
    id: s.id,
    text: s.text,
    status: keepStatus ? (s.status ?? 'upcoming') : 'upcoming',
    durationMs: s.durationMs,
    addedReason: s.addedReason,
    activity: s.activity ?? [],
    playMs: s.durationMs !== undefined ? Math.round(s.durationMs / t.demoTimeScale) : t.stepPlayMs,
  }))
}

function toPublic(list: RunStep[]): Step[] {
  return list
    .filter((s) => !s.removed)
    .map((s) => ({
      id: s.id,
      text: s.text,
      status: s.status,
      durationMs: s.durationMs,
      addedReason: s.addedReason,
      activity: s.activity,
    }))
}

function withIndex(list: RunStep[], ci: number | undefined, skipped: string[]): RunStep[] {
  if (ci === undefined) {
    if (skipped.length === 0) return list
    let hasCurrent = list.some((s) => !s.removed && s.status === 'current' && !skipped.includes(s.id))
    return list.map((s) => {
      if (s.removed || s.status === 'skipped') return s
      if (skipped.includes(s.id)) return { ...s, status: 'skipped' }
      if (hasCurrent || s.status !== 'upcoming') return s
      hasCurrent = true
      return { ...s, status: 'current' }
    })
  }
  let k = -1
  let taken = false
  return list.map((s) => {
    if (s.removed) return s
    k += 1
    if (s.status === 'skipped') return s
    if (skipped.includes(s.id)) return { ...s, status: 'skipped' }
    let status: StepStatus = 'upcoming'
    if (k < ci) status = 'done'
    else if (!taken) {
      taken = true
      status = 'current'
    }
    return status === s.status ? s : { ...s, status }
  })
}

function simMs(s: RunStep, scale: number) {
  return s.durationMs ?? s.playMs * scale
}

function actWindow(playMs: number, n: number, i: number, t: Tuning) {
  const weight = (k: number) => 1 + t.actJitter * Math.sin(k * 2.39 + n * 1.7)
  let total = 0
  let before = 0
  for (let k = 0; k < n; k++) {
    total += weight(k)
    if (k < i) before += weight(k)
  }
  const span = playMs * t.actSpan
  const appear = Math.round(playMs * t.actLeadIn + (span * before) / total)
  return { appear, done: Math.round(appear + ((span * weight(i)) / total) * t.actRun) }
}

function stepEvents(step: RunStep, pending: Insert | null, t: Tuning) {
  const out = [step.playMs]
  const n = step.activity.length
  for (let i = 0; i < n; i++) {
    const w = actWindow(step.playMs, n, i, t)
    out.push(w.appear, w.done)
  }
  if (pending && pending.afterId === step.id) out.push(Math.round(pending.at * step.playMs))
  return out
}

type State = {
  phase: PlanPhase
  stopped: boolean
  steps: RunStep[]
  curId: string | null
  stepMs: number
  anchorAt: number
  pending: Insert | null
  scale: number
  note: string
  announce: string
}

type EditOp =
  | { op: 'text'; id: string; text: string }
  | { op: 'add'; step: RunStep }
  | { op: 'order'; ids: string[] }
  | { op: 'remove'; id: string }
  | { op: 'restore'; id: string; curId: string | null }
  | { op: 'purge'; id: string }

type Action =
  | { type: 'start'; at: number }
  | { type: 'event'; ms: number; at: number }
  | { type: 'pause'; ms: number; at: number }
  | { type: 'resume'; at: number }
  | { type: 'skip'; ms: number; at: number }
  | { type: 'stop'; ms: number }
  | { type: 'reset'; state: State }
  | { type: 'steps'; steps: RunStep[] }
  | { type: 'edit'; edit: EditOp }
  | { type: 'note'; note: string }

function initState(stepsProp: Step[] | undefined, t: Tuning, keepStatus: boolean, demo: boolean): State {
  return {
    phase: 'review',
    stopped: false,
    steps: toRun(stepsProp ?? (demo ? DEMO_STEPS : []), t, keepStatus),
    curId: null,
    stepMs: 0,
    anchorAt: 0,
    pending: demo
      ? { afterId: DEMO_INSERT.afterId, at: DEMO_INSERT.at, step: toRun([DEMO_INSERT.step], t, false)[0] }
      : null,
    scale: t.demoTimeScale,
    note: '',
    announce: '',
  }
}

function applyEdit(list: RunStep[], e: EditOp): RunStep[] {
  switch (e.op) {
    case 'text':
      return list.map((s) => (s.id === e.id ? { ...s, text: e.text } : s))
    case 'add':
      return [...list, e.step]
    case 'order': {
      if (e.ids.length !== list.length) return list
      const byId = new Map(list.map((s) => [s.id, s]))
      const next = e.ids.map((id) => byId.get(id)).filter((s): s is RunStep => Boolean(s))
      return next.length === list.length ? next : list
    }
    case 'remove':
      return list.map((s) => (s.id === e.id ? { ...s, removed: true } : s))
    case 'purge':
      return list.filter((s) => s.id !== e.id)
    case 'restore': {
      const i = list.findIndex((s) => s.id === e.id)
      if (i < 0) return list
      const step = { ...list[i], removed: false }
      const ci = list.findIndex((s) => s.id === e.curId)
      if (ci < 0 || i > ci) return list.map((s, j) => (j === i ? step : s))
      // The run already passed its slot: it comes back as the next step.
      const rest = list.filter((s) => s.id !== e.id)
      const at = rest.findIndex((s) => s.id === e.curId) + 1
      return [...rest.slice(0, at), step, ...rest.slice(at)]
    }
  }
}

// A row still being typed has no text: the run passes it by rather than run a blank step.
function runnable(x: RunStep) {
  return x.status === 'upcoming' && !x.removed && x.text.trim() !== ''
}

function finish(s: State): State {
  const steps = s.steps.filter((x) => !x.removed)
  const total = steps.reduce((a, x) => a + (x.spentMs ?? 0), 0)
  return {
    ...s,
    steps,
    phase: 'done',
    curId: null,
    stepMs: 0,
    pending: null,
    announce: s.stopped ? 'Plan stopped' : `Done in ${fmtSpoken(total)}`,
  }
}

function advance(s: State, how: 'done' | 'skipped' | 'canceled', ms: number): State {
  const i = s.steps.findIndex((x) => x.id === s.curId)
  if (i < 0) return s
  const cur = s.steps[i]
  const full = simMs(cur, s.scale)
  const marked: RunStep =
    how === 'done'
      ? { ...cur, status: 'done', durationMs: full, spentMs: full }
      : {
          ...cur,
          status: 'skipped',
          spentMs: Math.round(Math.min(full, (ms / cur.playMs) * full)),
          canceled: how === 'canceled',
        }
  let steps = s.steps.map((x, j) => (j === i ? marked : x))
  if (how === 'canceled') return finish({ ...s, steps, stopped: true })
  const n = steps.findIndex((x, j) => j > i && runnable(x))
  if (n < 0) return finish({ ...s, steps })
  steps = steps.map((x, j) => (j === n ? { ...x, status: 'current' } : x))
  const live = steps.filter((x) => !x.removed)
  const pos = live.findIndex((x) => x.id === steps[n].id) + 1
  return {
    ...s,
    steps,
    curId: steps[n].id,
    stepMs: 0,
    announce: `Step ${pos} of ${live.length}: ${steps[n].text}`,
  }
}

function reducer(s: State, a: Action): State {
  switch (a.type) {
    case 'start': {
      const i = s.steps.findIndex(runnable)
      if (i < 0) return s
      const steps = s.steps.map((x, j) => (j === i ? { ...x, status: 'current' as const } : x))
      const live = steps.filter((x) => !x.removed)
      return {
        ...s,
        phase: 'running',
        steps,
        curId: steps[i].id,
        stepMs: 0,
        anchorAt: a.at,
        announce: `Plan started. Step 1 of ${live.length}: ${steps[i].text}`,
      }
    }
    case 'event': {
      if (s.phase !== 'running') return s
      let next: State = { ...s, stepMs: a.ms, anchorAt: a.at }
      const cur = next.steps.find((x) => x.id === next.curId)
      if (!cur) return next
      const p = next.pending
      if (p && p.afterId === cur.id && a.ms >= p.at * cur.playMs) {
        const i = next.steps.findIndex((x) => x.id === cur.id)
        next = {
          ...next,
          steps: [...next.steps.slice(0, i + 1), { ...p.step, fresh: true }, ...next.steps.slice(i + 1)],
          pending: null,
          announce: `The agent added a step: ${p.step.text}`,
        }
      }
      if (a.ms >= cur.playMs) next = advance({ ...next, stepMs: 0 }, 'done', a.ms)
      return next
    }
    case 'pause': {
      if (s.phase !== 'running') return s
      const cur = s.steps.find((x) => x.id === s.curId)
      const ms = cur ? Math.min(a.ms, cur.playMs - 1) : a.ms
      return { ...s, phase: 'paused', stepMs: ms, anchorAt: a.at, announce: 'Paused' }
    }
    case 'resume':
      if (s.phase !== 'paused') return s
      return { ...s, phase: 'running', anchorAt: a.at, announce: 'Resumed' }
    case 'skip': {
      if (s.phase !== 'running' && s.phase !== 'paused') return s
      return { ...advance(s, 'skipped', a.ms), anchorAt: a.at }
    }
    case 'stop':
      if (s.phase !== 'running' && s.phase !== 'paused') return s
      return advance(s, 'canceled', a.ms)
    case 'reset':
      return a.state
    case 'steps': {
      // A removed row waits out its undo timer even when the host echoes the list without it.
      const gone = new Set(s.steps.filter((x) => x.removed).map((x) => x.id))
      const steps = a.steps.map((x) => (gone.has(x.id) ? { ...x, removed: true } : x))
      s.steps.forEach((x, i) => {
        if (!x.removed || a.steps.some((y) => y.id === x.id)) return
        steps.splice(i ? steps.findIndex((y) => y.id === s.steps[i - 1].id) + 1 : 0, 0, x)
      })
      return { ...s, steps }
    }
    case 'edit':
      return { ...s, steps: applyEdit(s.steps, a.edit) }
    case 'note':
      return { ...s, note: a.note }
  }
}

/**
 * @typedef {object} StepActivity
 * @property {string} label Activity action shown in the step trail.
 * @property {string} [target] File, command or other activity target; left out, no target is shown.
 * @property {string} [outcome] Activity result shown at the row end; left out, no result is shown.
 * @property {'neutral'|'success'|'red'} [tone] Colors the activity result; left out, the neutral treatment is used.
 */

/**
 * @typedef {object} Step
 * @property {string} id Stable step identifier that must be unique within the steps list.
 * @property {string} text Plan instruction shown in the step row.
 * @property {StepStatus} [status] Supplies the step state in host-controlled runs; left out, the step starts upcoming.
 * @property {number} [durationMs] Supplies the completed duration and demo playback length; left out, the configured step playback length is used.
 * @property {string} [addedReason] Marks an agent-added step and explains why it was added; left out, no added-step marker or reason is shown.
 * @property {StepActivity[]} [activity] Supplies activity rows revealed beneath the step; left out, the step has no activity trail.
 */

/**
 * @typedef {object} PlanRunnerProps
 * @property {string} [goal] Sets the plan heading; left out, the demo uses its sample goal and host mode shows an empty heading, while passing it turns the demo off.
 * @property {string} [label] Adds muted text before the goal; left out, no prefix is shown, while passing it turns the demo off.
 * @property {Step[]} [steps] Supplies the editable or running plan; left out, the demo uses sample steps and host mode uses none, while passing it turns the demo off.
 * @property {string} [doneLooksLike] Shows the completion criterion during review; left out, the demo uses sample criteria and host mode hides the section, while passing it turns the demo off.
 * @property {string} [summary] Shows the completion summary when the plan finishes; left out, the demo uses a sample summary and host mode shows none, while passing it turns the demo off.
 * @property {PlanPhase} [phase] Controls the plan phase; left out, phase transitions are managed internally, while passing it turns the demo off.
 * @property {number} [currentIndex] Marks earlier steps done and the first non-skipped step at or after this zero-based index current; left out, the statuses supplied on steps are used, while passing it turns the demo off.
 * @property {number} [autoStartSeconds] Starts the review countdown when positive and onStart is available; left out, the demo uses its tuned delay and host mode does not auto-start, while passing it turns the demo off.
 * @property {(steps: Step[]) => void} [onStart] Receives the final nonblank steps and makes the Start control available outside the demo; left out, host reviews have no Start control.
 * @property {(note: string) => void} [onRevise] Receives a requested revision and makes the Revise control available outside the demo; left out, host reviews have no Revise control.
 * @property {() => void} [onPause] Handles pausing and makes the Pause control available while running; left out, host runs have no Pause control.
 * @property {() => void} [onResume] Handles resuming and makes the Resume control available while paused; left out, host pauses have no Resume control.
 * @property {() => void} [onStop] Handles stopping and makes the Stop control available while running; left out, host runs have no Stop control.
 * @property {(id: string) => void} [onSkip] Receives the current step id and makes its Skip control available outside the demo; left out, host runs have no Skip control.
 * @property {(steps: Step[]) => void} [onStepsChange] Receives edits, additions, removals and reordering; left out, plan edits remain local.
 * @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
 * @property {Partial<Tuning>} [tuning] Overrides plan measurements, timing and motion values; left out, every value in TUNING is used.
 */

type GlyphKind = 'upcoming' | 'current' | 'still' | 'paused' | 'done' | 'skipped'

type RowProps = {
  step: RunStep
  n: number
  first: boolean
  last: boolean
  phase: PlanPhase
  editable: boolean
  canMoveUp: boolean
  canMoveDown: boolean
  editing: string | null
  stepMs: number
  controlled: boolean
  reduce: boolean
  touch: boolean
  moveHintId: string
  editHintId: string
  lane: boolean
  enter?: Enter
  t: Tuning
  onBeginEdit: () => void
  onDraft: (v: string) => void
  onCommit: (keyboard: boolean) => void
  onCancel: () => void
  onRemove: (keyboard: boolean) => void
  onUndo: () => void
  onMove: (dir: -1 | 1, from: 'handle' | 'text' | 'input') => void
  onSkip?: (at: number) => void
}

const RAIL = 'pointer-events-none absolute w-px origin-top bg-(--pr-border-strong)'

// Ambient loops run imperatively: the card re-renders every tick and must not park them.
function useLoop(seconds: number, reduce: boolean) {
  const v = useMotionValue(0)
  useEffect(() => {
    if (reduce) return
    const a = animate(v, [0, 1], { duration: seconds, ease: 'linear', repeat: Infinity })
    return () => a.stop()
  }, [seconds, reduce, v])
  return v
}
```

## Related

- [Permission Request](https://aicanvas.me/components/permission-scope.md): A permission card where an agent asks for access and you allow it once, for this chat or always for a pattern, then revoke it from the receipt.
- [Agent Status](https://aicanvas.me/components/agent-status.md): A status pill that says what the agent is doing, whose move it is, time and cost, and opens to the step history and token bill.
- [Code Block](https://aicanvas.me/components/code-panel.md): Code an agent just wrote streams in line by line, then numbered markers tie its notes to the exact lines, with Copy and Apply.
- [Diff Review](https://aicanvas.me/components/change-review.md): Review an agent's proposed code edits change by change: read the reason, keep or discard each one, then apply only what you kept.
- [Agent Composer](https://aicanvas.me/components/agent-composer.md): The message input an agent sits behind: a textarea that grows to six rows, inline slash commands, @file mentions as removable chips, a drag-and-drop attachment row, a model picker with each provider's logo, and a round send button whose glyph becomes a pulsing stop square while the agent works.
