> 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 Activity

The audit trail of one agent run: every action on a rail of icons, one line each, ending in what it produced.

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

## Install

```bash
npx shadcn@latest add @aicanvas/tool-ledger
```

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

### Action

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` |  | Stable action identifier that must be unique within its list. |
| `kind` | `ActionKind` |  | Selects the tool icon and action treatment. |
| `label` | `string` |  | Present-tense action text used while queued or running. |
| `doneLabel` | `string` |  | Past-tense action text used after the action settles. |
| `target?` | `string` |  | File, query, command or URL shown after the action label; left out, no target is shown. |
| `outcome?` | `string` |  | Result summary shown at the row end; left out, no result summary is shown. |
| `tone?` | `'neutral'\|'success'\|'failed'` |  | Colors a settled action that did not fail; left out, the neutral treatment is used. |
| `diff?` | `{added: number, removed: number}` |  | Added and removed counts shown instead of outcome; left out, outcome is used when present. |
| `durationMs?` | `number` |  | Action duration shown after it settles; left out, no duration is shown. |
| `status` | `ActionStatus` |  | Sets whether the action is queued, running, done, failed or stopped. |
| `input?` | `Record<string, string>` |  | Tool input fields shown in the expandable details; left out, no input section is shown. |
| `output?` | `string[]` |  | Tool output lines previewed in the expandable details; left out, no output section is shown. |
| `outputTotal?` | `number` |  | Total output line count used when output is a preview; left out, the supplied output length is used. |
| `children?` | `Action[]` |  | Nested actions shown beneath this action; left out, the action is rendered as a leaf. |
| `parallelGroup?` | `string` |  | Groups adjacent actions with the same value on a shared parallel rail; left out, the action is shown sequentially. |

### ToolLedger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `actions?` | `Action[]` |  | Supplies the tool action tree; left out, host mode has no actions, while passing it turns the demo off. |
| `status?` | `RunStatus` |  | Sets the overall run state; left out, it is inferred from the actions, while passing it turns the demo off. |
| `elapsedMs?` | `number` |  | Shows the run duration in milliseconds; left out, no host duration is shown, while passing it turns the demo off. |
| `label?` | `string` |  | Adds text beside the ledger heading; left out, only the default heading is shown in host mode, while passing it turns the demo off. |
| `summary?` | `string` |  | Shows a result row after a completed run; left out, no result row is shown in host mode, while passing it turns the demo off. |
| `defaultCollapsed?` | `boolean` | `false` | Sets whether the action list starts collapsed. |
| `onActionToggle?` | `(id: string, expanded: boolean) => void` |  | Reports action detail expansion changes while expansion remains local; left out, toggles are not reported. |
| `onStop?` | `() => void` |  | Handles stopping and makes the stop control available during a host run; left out, host runs have no stop control. |
| `className?` | `string` |  | Adds classes to the outermost root; left out, only the built-in classes are used. |
| `tuning?` | `Partial<Tuning>` |  | Overrides ledger measurements, timing and motion values; left out, every value in TUNING is used. |

## About

Tool Ledger is a timeline of the tool calls an agent makes: the name, the arguments it passed, how long each took and its status, with the result one click away. Every row carries a thin duration bar against the slowest call, running calls grow live, concurrent calls share a bracket, and a failed call shows its error line with a Retry control. Arguments render as a compact mono line that expands to pretty printed args and the result with Copy. Arrow keys move between rows, Enter expands, and a sticky footer totals calls, time and failures.

Use cases: AI coding agents, Agent observability, Automation dashboards.

## Source

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

## 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
# ToolLedger
A live agent activity card listing tool calls, nested groups, parallel runs, failures and expandable details.

## 1. Setup
// npm install framer-motion
File: components-workspace/tool-ledger/index.tsx  ·  'use client'  ·  export default function ToolLedger(props: ToolLedgerProps)
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 { Fragment, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react'
import type * as React from 'react'
import { animate, AnimatePresence, motion, useIsPresent, useMotionValue, useTransform } from 'framer-motion'
import { RotateCcw } from '@/app/_icons/icons/rotate-ccw'
import { ChevronRight } from '@/app/_icons/icons/chevron-right'
import { Check } from '@/app/_icons/icons/check'
import { LoaderCircle } from '@/app/_icons/icons/loader-circle'
import { CircleCheckDuotone } from '@/app/_icons/icons/circle-check-duotone'
import { CircleDashedDuotone } from '@/app/_icons/icons/circle-dashed-duotone'
import { CircleStopDuotone } from '@/app/_icons/icons/circle-stop-duotone'
import { CircleXDuotone } from '@/app/_icons/icons/circle-x-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 { MessageCircleDuotone } from '@/app/_icons/icons/message-circle-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
  rowH: number
  subRowH: number
  iconPx: number
  spinnerPx: number
  previewLines: number
  failureLines: number
  fullOutputLines: number
  codeLineH: number
  hoverMs: number
  stateMs: number
  enterMs: number
  enterRisePx: number
  rowRisePx: number
  staggerMs: number
  stopPx: number
  panelMs: number
  lightMs: number
  holdMs: number
  pressScale: number
  spinS: number
  shimmerS: number
  tickMs: number
  demoSpeed: number
  dotPitch: number
  dotFieldH: number
  dotRoomPx: number
}

export const TUNING: Tuning = {
  cardMaxW: 480,
  rowH: 36,
  subRowH: 32,
  iconPx: 16,
  spinnerPx: 14,
  previewLines: 4,
  failureLines: 4,
  fullOutputLines: 12,
  codeLineH: 18,
  hoverMs: 150,
  stateMs: 200,
  enterMs: 280,
  enterRisePx: 6,
  rowRisePx: 4,
  staggerMs: 40,
  stopPx: 32,
  panelMs: 280,
  lightMs: 600,
  holdMs: 8000,
  pressScale: 0.97,
  spinS: 0.8,
  shimmerS: 2.6,
  tickMs: 100,
  demoSpeed: 1,
  dotPitch: 6,
  dotFieldH: 64,
  dotRoomPx: 12,
}

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

// Rail geometry: must match the rows' px-3 padding and gap-2.5.
const PAD_X = 12

const TEXT_GAP = 10

const RAIL_GAP = 4

const LABEL_H = 24

export type ActionKind = 'read' | 'search' | 'edit' | 'create' | 'run' | 'fetch' | 'ask'

export type ActionStatus = 'queued' | 'running' | 'done' | 'failed' | 'stopped'

export type RunStatus = 'running' | 'done' | 'failed' | 'stopped'

export type Action = {
  id: string
  kind: ActionKind
  label: string
  doneLabel: string
  target?: string
  outcome?: string
  tone?: 'neutral' | 'success' | 'failed'
  diff?: { added: number; removed: number }
  durationMs?: number
  status: ActionStatus
  input?: Record<string, string>
  output?: string[]
  outputTotal?: number
  children?: Action[]
  parallelGroup?: string
}

type ToolLedgerProps = {
  actions?: Action[]
  status?: RunStatus
  elapsedMs?: number
  label?: string
  summary?: string
  defaultCollapsed?: boolean
  onActionToggle?: (id: string, expanded: boolean) => void
  onStop?: () => void
  className?: string
  tuning?: Partial<Tuning>
}

const KIND_ICON: Record<ActionKind, React.ComponentType<IconProps>> = {
  read: FileTextDuotone,
  search: SearchDuotone,
  edit: Pen2Duotone,
  create: FilePlusDuotone,
  run: TerminalDuotone,
  fetch: GlobeDuotone,
  ask: MessageCircleDuotone,
}

type Beat = Omit<Action, 'status' | 'durationMs' | 'children'> & {
  at: number
  end: number
  fails?: boolean
  children?: Beat[]
}

const FAILED_RUN = [
  'FAIL  src/date.test.ts',
  '✗ parses an offset date',
  '  expected 09:00, got 07:00',
  '  at src/date.test.ts:4:5',
  '✗ keeps the local day across midnight',
  '  expected 2026-03-09, got 2026-03-08',
  '  at src/date.test.ts:12:5',
  '✓ parses a plain date',
  '✓ formats a range',
  '✓ rejects an empty string',
  'Tests  2 failed, 56 passed (58)',
  'Time   2.5s',
]

const STORY: Beat[] = [
  {
    id: 'search',
    kind: 'search',
    label: 'Searching',
    doneLabel: 'Searched',
    target: 'parseDate',
    outcome: '6 matches',
    at: 400,
    end: 1000,
    input: { pattern: '"parseDate"', path: '"src"' },
    output: [
      'src/date.ts:3        export function parseDate(',
      'src/format.ts:1      import { parseDate } from "./date"',
      'src/format.ts:4      const start = parseDate(a)',
      'src/date.test.ts:1   import { parseDate } from "./date"',
      'src/date.test.ts:4   expect(parseDate("2026-03-08T09:00+02:00"))',
      'src/date.test.ts:12  expect(parseDate("2026-03-08"))',
    ],
  },
  {
    id: 'read',
    kind: 'read',
    label: 'Reading',
    doneLabel: 'Read',
    target: '2 files',
    outcome: '278 lines',
    at: 1200,
    end: 2200,
    children: [
      {
        id: 'read-date',
        kind: 'read',
        label: 'Reading',
        doneLabel: 'Read',
        target: 'src/date.ts',
        outcome: '212 lines',
        at: 1200,
        end: 1700,
        input: { path: '"src/date.ts"', limit: '400' },
        output: [
          'import { parseOffset } from "./offset"',
          '',
          'export function parseDate(input: string): Date {',
          '  const [day, time] = input.split("T")',
        ],
        outputTotal: 212,
      },
      {
        id: 'read-test',
        kind: 'read',
        label: 'Reading',
        doneLabel: 'Read',
        target: 'src/date.test.ts',
        outcome: '66 lines',
        at: 1700,
        end: 2200,
        input: { path: '"src/date.test.ts"', limit: '400' },
        output: [
          'import { parseDate } from "./date"',
          '',
          'it("parses an offset date", () => {',
          '  expect(parseDate("2026-03-08T09:00+02:00"))',
        ],
        outputTotal: 66,
      },
    ],
  },
  {
    id: 'fetch-zones',
    kind: 'fetch',
    label: 'Fetching',
    doneLabel: 'Fetched',
    target: 'docs.example.dev/time-zones',
    outcome: '14 KB',
    parallelGroup: 'docs',
    at: 2400,
    end: 3400,
    input: { url: '"https://docs.example.dev/time-zones"' },
    output: [
      '# Time zones',
      'A date without an offset is read as local time.',
      'An offset like +02:00 moves the instant, not the wall clock.',
      'Convert at the edge and store the instant.',
    ],
    outputTotal: 186,
  },
  {
    id: 'fetch-parsing',
    kind: 'fetch',
    label: 'Fetching',
    doneLabel: 'Fetched',
    target: 'docs.example.dev/parsing',
    outcome: '9 KB',
    parallelGroup: 'docs',
    at: 2400,
    end: 3900,
    input: { url: '"https://docs.example.dev/parsing"' },
    output: [
      '# Parsing',
      'A date string takes a day, a time and an optional offset.',
      '2026-03-08T09:00+02:00 is 07:00 at offset zero.',
      'Missing seconds default to 00.',
    ],
    outputTotal: 122,
  },
  {
    id: 'edit-1',
    kind: 'edit',
    label: 'Editing',
    doneLabel: 'Edited',
    target: 'src/date.ts',
    diff: { added: 12, removed: 3 },
    at: 4200,
    end: 4900,
    input: { path: '"src/date.ts"' },
    output: [
      '@@ parseDate @@',
      '-  const offset = 0',
      '+  const offset = parseOffset(time)',
      '+  const instant = toInstant(day, time, offset)',
    ],
    outputTotal: 16,
  },
  {
    id: 'test-1',
    kind: 'run',
    label: 'Running',
    doneLabel: 'Ran',
    target: 'npm test',
    outcome: '2 failed',
    fails: true,
    at: 5200,
    end: 7700,
    input: { command: '"npm test"' },
    output: FAILED_RUN,
  },
  {
    id: 'edit-2',
    kind: 'edit',
    label: 'Editing',
    doneLabel: 'Edited',
    target: 'src/date.ts',
    diff: { added: 2, removed: 1 },
    at: 8400,
    end: 9000,
    input: { path: '"src/date.ts"' },
    output: [
      '@@ toInstant @@',
      '-  return new Date(ms - offset)',
      '+  const local = ms + offset',
      '+  return new Date(local)',
    ],
  },
  {
    id: 'test-2',
    kind: 'run',
    label: 'Running',
    doneLabel: 'Ran',
    target: 'npm test',
    outcome: '58 passed',
    tone: 'success',
    at: 9300,
    end: 11200,
    input: { command: '"npm test"' },
    output: [
      '✓ src/date.test.ts (24 tests)',
      '✓ src/format.test.ts (34 tests)',
      'Tests  58 passed (58)',
      'Time   1.9s',
    ],
  },
]

const STORY_DONE_AT = 11400

const STORY_LABEL = 'fix-date-parsing'

const STORY_SUMMARY = 'Changed 1 file, all 58 tests pass'

function beatAt(b: Beat, e: number, stopped: boolean): Action {
  const { at, end, fails, children, ...rest } = b
  const live: ActionStatus = e < at ? 'queued' : e < end ? 'running' : fails ? 'failed' : 'done'
  const status: ActionStatus = stopped && (live === 'queued' || live === 'running') ? 'stopped' : live
  const settled = status === 'done' || status === 'failed'
  return {
    ...rest,
    status,
    outcome: settled ? rest.outcome : undefined,
    diff: settled ? rest.diff : undefined,
    output: settled ? rest.output : undefined,
    durationMs: e < at ? undefined : (settled ? end : e) - at,
    children: children?.map((c) => beatAt(c, e, stopped)),
  }
}

function storyAt(e: number, stopped: boolean): Action[] {
  return STORY.filter((b) => e >= b.at).map((b) => beatAt(b, e, stopped))
}

// Reduced motion as an external store: server and hydrating render both read false.
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'

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

function getReduced() {
  return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(REDUCED_QUERY).matches
}

function getReducedServer() {
  return false
}

function stopAll(list: Action[]): Action[] {
  return list.map((a) => ({
    ...a,
    status: a.status === 'running' || a.status === 'queued' ? 'stopped' : a.status,
    children: a.children && stopAll(a.children),
  }))
}

function isBusy(a: Action): boolean {
  return a.status === 'running' || a.status === 'queued' || (a.children ?? []).some(isBusy)
}

function runningCalls(list: Action[]): Action[] {
  return list.flatMap((a) =>
    a.children?.length ? runningCalls(a.children) : a.status === 'running' ? [a] : [],
  )
}

function countActions(list: Action[]): number {
  return list.reduce((n, a) => n + (a.children?.length ? countActions(a.children) : 1), 0)
}

function sumDiff(list: Action[]): { added: number; removed: number } | undefined {
  let added = 0
  let removed = 0
  let any = false
  const walk = (a: Action) => {
    if (a.diff) {
      any = true
      added += a.diff.added
      removed += a.diff.removed
    }
    a.children?.forEach(walk)
  }
  list.forEach(walk)
  return any ? { added, removed } : undefined
}

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

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

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

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

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

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

const GROUP = 'flex items-center gap-1.5 font-mono text-[11px] leading-4 text-(color:--tl-muted)'

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

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

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

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

// Rows sit flush in the well, so their ring draws inside the row.
const ROW_FOCUS = 'outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-(color:--tl-focus)'

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

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

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

const HIT_ABS = "pointer-coarse:after:absolute pointer-coarse:after:-inset-2 pointer-coarse: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 transition-[background-color,color,border-color,box-shadow] duration-150 disabled:opacity-50'

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

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

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

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

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

/**
 * @typedef {object} Action
 * @property {string} id Stable action identifier that must be unique within its list.
 * @property {ActionKind} kind Selects the tool icon and action treatment.
 * @property {string} label Present-tense action text used while queued or running.
 * @property {string} doneLabel Past-tense action text used after the action settles.
 * @property {string} [target] File, query, command or URL shown after the action label; left out, no target is shown.
 * @property {string} [outcome] Result summary shown at the row end; left out, no result summary is shown.
 * @property {'neutral'|'success'|'failed'} [tone] Colors a settled action that did not fail; left out, the neutral treatment is used.
 * @property {{added: number, removed: number}} [diff] Added and removed counts shown instead of outcome; left out, outcome is used when present.
 * @property {number} [durationMs] Action duration shown after it settles; left out, no duration is shown.
 * @property {ActionStatus} status Sets whether the action is queued, running, done, failed or stopped.
 * @property {Record<string, string>} [input] Tool input fields shown in the expandable details; left out, no input section is shown.
 * @property {string[]} [output] Tool output lines previewed in the expandable details; left out, no output section is shown.
 * @property {number} [outputTotal] Total output line count used when output is a preview; left out, the supplied output length is used.
 * @property {Action[]} [children] Nested actions shown beneath this action; left out, the action is rendered as a leaf.
 * @property {string} [parallelGroup] Groups adjacent actions with the same value on a shared parallel rail; left out, the action is shown sequentially.
 */

/**
 * @typedef {object} ToolLedgerProps
 * @property {Action[]} [actions] Supplies the tool action tree; left out, host mode has no actions, while passing it turns the demo off.
 * @property {RunStatus} [status] Sets the overall run state; left out, it is inferred from the actions, while passing it turns the demo off.
 * @property {number} [elapsedMs] Shows the run duration in milliseconds; left out, no host duration is shown, while passing it turns the demo off.
 * @property {string} [label] Adds text beside the ledger heading; left out, only the default heading is shown in host mode, while passing it turns the demo off.
 * @property {string} [summary] Shows a result row after a completed run; left out, no result row is shown in host mode, while passing it turns the demo off.
 * @property {boolean} [defaultCollapsed=false] Sets whether the action list starts collapsed.
 * @property {(id: string, expanded: boolean) => void} [onActionToggle] Reports action detail expansion changes while expansion remains local; left out, toggles are not reported.
 * @property {() => void} [onStop] Handles stopping and makes the stop control available during a host run; left out, host runs have no stop control.
 * @property {string} [className] Adds classes to the outermost root; left out, only the built-in classes are used.
 * @property {Partial<Tuning>} [tuning] Overrides ledger measurements, timing and motion values; left out, every value in TUNING is used.
 */

type CardProps = {
  actions: Action[]
  run: RunStatus
  elapsed?: number
  label?: string
  summary?: string
  defaultCollapsed: boolean
  onActionToggle?: (id: string, expanded: boolean) => void
  onStop?: () => void
  onReplay?: () => void
  reduce: boolean
  t: Tuning
}

type RowState = {
  open: Record<string, boolean>
  full: Record<string, boolean>
  toggle: (id: string) => void
  toggleFull: (id: string) => void
}

// Over the header's reserved slot: 6px chevron overhang, 32px chevron, 8px right gap.
const STOP_RIGHT = 34

type Par = 'first' | 'mid' | 'last'

const OUTPUT_BOX = 'rounded-xl border border-(color:--tl-border) bg-(--tl-panel) p-3 font-mono text-[12px]'

// 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.
- [Agent Plan](https://aicanvas.me/components/plan-runner.md): 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.
- [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.
