Skip to main content

ns-ui

Timeline Reasoning Rail

Collapsible agent-reasoning timeline: status lives entirely in the node glyph on a 1px vertical rail, steps stream in over time, and completed steps fold to a single line so a long run stays readable.

Usage

registry/core/timeline-reasoning-rail/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { SoundingRail, type SoundingRailStep } from "./component";

interface StepDef {
  id: string;
  label: string;
  time: string;
  detail?: string;
  evidence?: { label: string; value: string }[];
}

// The "restart run" control sits in the run header, above the rail, so it is
// the first interactive element on the page: a generic pass that pokes the
// first control it finds resets the run to frame 0 rather than silently
// toggling the fold state of the rail's first step out from under whatever
// looks at it next.
//
// Six real steps plus a trailing queued placeholder — kept short on purpose:
// every frame's tallest state (5 folded rows + 1 expanded row) stays well
// under the rail's max-h-[420px], so the list never scrolls and the gate's
// target step (read-task, always folded) never gets carried out of view by
// the component's own auto-scroll-to-current behavior.
const DEFS: Record<string, StepDef> = {
  "read-task": {
    id: "read-task",
    label: "Read task description",
    time: "0:00",
    detail: "Parsed the linked issue and the reported repro steps.",
    evidence: [{ label: "source", value: "issue #482" }],
  },
  "locate-files": {
    id: "locate-files",
    label: "Locate relevant files",
    time: "0:02",
    detail: "Searched the repo for the checkout flow and its test file.",
    evidence: [
      { label: "tool", value: "grep" },
      { label: "files", value: "3 matched" },
    ],
  },
  reproduce: {
    id: "reproduce",
    label: "Reproduce the failure",
    time: "0:05",
    detail: "Running the suite twice to confirm the test fails intermittently.",
    evidence: [{ label: "tool", value: "vitest" }],
  },
  "patch-fix": {
    id: "patch-fix",
    label: "Patch debounce wait",
    time: "0:11",
    detail: "Increased the settle delay before asserting.",
    evidence: [{ label: "file", value: "checkout.test.ts" }],
  },
  diagnose: {
    id: "diagnose",
    label: "Diagnose remaining flake",
    time: "0:16",
    detail:
      "The delay wasn't the cause — a stale closure over cart state was.",
    evidence: [{ label: "result", value: "still flaky" }],
  },
  "verify-fix": {
    id: "verify-fix",
    label: "Fix stale closure, re-run suite",
    time: "0:22",
    detail:
      "Moved cart state into a ref and ran the suite 10 times to confirm.",
    evidence: [{ label: "result", value: "43 passed x10" }],
  },
  changelog: {
    id: "changelog",
    label: "Write changelog entry",
    time: "",
  },
};

function s(id: keyof typeof DEFS, status: SoundingRailStep["status"]): SoundingRailStep {
  const d = DEFS[id]!;
  return {
    id: d.id,
    label: d.label,
    time: d.time || undefined,
    detail: d.detail,
    evidence: d.evidence,
    status,
  };
}

const FRAMES: SoundingRailStep[][] = [
  [s("read-task", "done"), s("locate-files", "done"), s("reproduce", "running")],
  [
    s("read-task", "done"),
    s("locate-files", "done"),
    s("reproduce", "done"),
    s("patch-fix", "running"),
  ],
  [
    s("read-task", "done"),
    s("locate-files", "done"),
    s("reproduce", "done"),
    s("patch-fix", "failed"),
    s("diagnose", "running"),
  ],
  [
    s("read-task", "done"),
    s("locate-files", "done"),
    s("reproduce", "done"),
    s("patch-fix", "failed"),
    s("diagnose", "done"),
    s("verify-fix", "running"),
  ],
  [
    s("read-task", "done"),
    s("locate-files", "done"),
    s("reproduce", "done"),
    s("patch-fix", "failed"),
    s("diagnose", "done"),
    s("verify-fix", "done"),
    s("changelog", "pending"),
  ],
];

export default function SoundingRailDemo() {
  const [frame, setFrame] = useState(0);
  // Pause is real state, not a ref read inside the timeout: reading a ref there
  // meant the tick fired, saw "paused", and returned *without rescheduling*, so
  // hovering the panel once froze the run permanently instead of pausing it.
  const [paused, setPaused] = useState(false);

  useEffect(() => {
    if (paused) return;
    const wait = frame === FRAMES.length - 1 ? 3200 : 2200;
    const t = window.setTimeout(() => {
      setFrame((f) => (f + 1) % FRAMES.length);
    }, wait);
    return () => window.clearTimeout(t);
  }, [frame, paused]);

  const steps = FRAMES[frame] ?? [];
  const finished = frame === FRAMES.length - 1;

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / timeline-reasoning-rail
      </p>

      <div className="w-full max-w-xl">
        <div className="mb-3 flex items-center justify-between gap-3 px-1">
          <h2 className="text-sm font-medium text-foreground">
            Fix flaky checkout test
          </h2>
          <div className="flex shrink-0 items-center gap-3">
            <span className="font-mono text-[11px] text-ns-muted">
              {finished ? "waiting" : "running"}
            </span>
            <button
              onClick={() => setFrame(0)}
              className="rounded-sm border border-border px-2.5 py-1 font-mono text-[11px] text-ns-muted transition-colors duration-150 hover:border-ns-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
            >
              restart run
            </button>
          </div>
        </div>

        <div
          onPointerEnter={() => setPaused(true)}
          onPointerLeave={() => setPaused(false)}
        >
          <SoundingRail steps={steps} label="Fix flaky checkout test — agent run" />
        </div>
      </div>

      <p className="font-mono text-[10px] text-ns-muted">
        hover the panel to pause · click a folded step to expand it
      </p>
    </div>
  );
}

Component

registry/core/timeline-reasoning-rail/component.tsx
"use client";

import { useEffect, useRef, useState } from "react";

// A collapsible agent-reasoning timeline. Status lives entirely in the node
// glyph strung on a 1px vertical rail — no colored badges, no status pills:
// a hollow ring is pending, a half-filled disc is running, a filled dot is
// done, and a stroked diamond with a cross is failed (a distinct FORM, not a
// color — --ns-accent stays reserved for the fold affordance and focus rings).
// Steps stream in over time as the `steps` array grows; the currently
// running step (or, once nothing is running, the most recent step) stays
// expanded by default while every earlier step folds to a single line, so a
// 40-step run reads as a scrollable log rather than a wall of open text.
// Any step can be expanded or re-collapsed by hand — that choice is kept
// per step id even as new steps arrive. New arrivals and status changes are
// announced through a polite (not assertive) visually-hidden live region.
// Pure DOM: the fold uses a CSS grid-rows transition (no JS height
// measuring), the running glyph uses Tailwind's built-in ping keyframes.
// Zero dependencies.

export type SoundingRailStatus = "pending" | "running" | "done" | "failed";

export interface SoundingRailEvidence {
  /** short field name, e.g. "tool" or "files" */
  label: string;
  value: string;
}

export interface SoundingRailStep {
  /** stable id — used for the expand/collapse memory and the aria-live diff */
  id: string;
  label: string;
  status: SoundingRailStatus;
  /** short elapsed/absolute stamp shown beside the label, e.g. "0:14" */
  time?: string;
  /** longer explanation, only rendered once the step is expanded */
  detail?: string;
  /** flat bordered chips of tool output, only rendered once expanded */
  evidence?: SoundingRailEvidence[];
}

export interface SoundingRailProps {
  /** the run, oldest first — append to stream new steps in */
  steps: SoundingRailStep[];
  /** accessible name for the timeline region */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

function statusWord(status: SoundingRailStatus) {
  switch (status) {
    case "pending":
      return "pending";
    case "running":
      return "running";
    case "done":
      return "done";
    case "failed":
      return "failed";
  }
}

function StepGlyph({ status }: { status: SoundingRailStatus }) {
  if (status === "pending") {
    return (
      <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
        <circle
          cx="5"
          cy="5"
          r="3.25"
          fill="var(--surface)"
          stroke="var(--border)"
          strokeWidth="1.5"
        />
      </svg>
    );
  }
  if (status === "running") {
    return (
      // Running needs a resting FORM, not only a resting animation. The ping
      // halo spends most of its cycle fully transparent, so a bare dot inside
      // it screenshots as — and, glanced at, reads as — the plain solid dot
      // that means "done". The resting signal is a HALF-FILLED disc (a 50%
      // pie): a full --foreground ring with one half painted solid. It reads
      // as "in progress" at any scale and is unmistakable against pending's
      // empty ring, done's fully solid dot, and failed's crossed diamond,
      // whether or not the ping halo happens to be mid-cycle.
      <span className="relative inline-flex h-[10px] w-[10px] items-center justify-center">
        <span
          aria-hidden="true"
          className="absolute inline-flex h-full w-full rounded-full border border-foreground/50 motion-safe:animate-ping"
        />
        <svg
          width="10"
          height="10"
          viewBox="0 0 10 10"
          aria-hidden="true"
          className="relative"
        >
          <circle
            cx="5"
            cy="5"
            r="4"
            fill="var(--surface)"
            stroke="var(--foreground)"
            strokeWidth="1.3"
          />
          <path d="M5 1 A4 4 0 0 1 5 9 Z" fill="var(--foreground)" />
        </svg>
      </span>
    );
  }
  if (status === "failed") {
    return (
      <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
        <rect
          x="1.4"
          y="1.4"
          width="7.2"
          height="7.2"
          rx="1"
          transform="rotate(45 5 5)"
          fill="var(--surface)"
          stroke="var(--foreground)"
          strokeWidth="1.3"
        />
        <path
          d="M3.3 3.3l3.4 3.4M6.7 3.3l-3.4 3.4"
          stroke="var(--foreground)"
          strokeWidth="1.1"
          strokeLinecap="round"
        />
      </svg>
    );
  }
  // done
  return (
    <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
      <circle cx="5" cy="5" r="4" fill="var(--foreground)" />
    </svg>
  );
}

export function SoundingRail({
  steps,
  label = "Agent reasoning timeline",
  className = "",
}: SoundingRailProps) {
  // per-id manual expand/collapse override — survives new steps streaming in
  const [overrides, setOverrides] = useState<Record<string, boolean>>({});
  const [live, setLive] = useState("");
  const prevRef = useRef<Map<string, SoundingRailStatus>>(new Map());
  const scrollRef = useRef<HTMLOListElement>(null);
  const firstRun = useRef(true);

  // the step that should be open with nothing asked of the user: the
  // running one, or — once the run has nothing in flight — the last step
  let autoId: string | null = null;
  for (let i = steps.length - 1; i >= 0; i--) {
    const s = steps[i];
    if (s && s.status === "running") {
      autoId = s.id;
      break;
    }
  }
  if (autoId === null && steps.length > 0) {
    autoId = steps[steps.length - 1]!.id;
  }

  // announce arrivals and status flips politely, diffed against last render
  useEffect(() => {
    const prev = prevRef.current;
    const next = new Map<string, SoundingRailStatus>();
    const messages: string[] = [];
    for (const s of steps) {
      next.set(s.id, s.status);
      const before = prev.get(s.id);
      if (before === undefined) {
        messages.push(
          s.status === "pending" ? `Queued: ${s.label}` : s.label
        );
      } else if (before !== s.status) {
        messages.push(`${s.label} ${statusWord(s.status)}`);
      }
    }
    prevRef.current = next;
    if (!firstRun.current && messages.length > 0) {
      setLive(messages.join(". "));
    }
    firstRun.current = false;
  }, [steps]);

  // keep the current step in view as the run streams — only on new/changed
  // steps, never on a manual expand/collapse, so exploring history doesn't
  // get yanked back down
  useEffect(() => {
    const el = scrollRef.current;
    if (!el) return;
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    el.scrollTo({ top: el.scrollHeight, behavior: reduced ? "auto" : "smooth" });
  }, [steps]);

  return (
    <div
      role="region"
      aria-label={label}
      className={`rounded-md border border-border bg-surface ${className}`}
    >
      <ol ref={scrollRef} className="max-h-[420px] overflow-y-auto p-4">
        {steps.map((step, i) => {
          const expanded = overrides[step.id] ?? step.id === autoId;
          const hasDetail = Boolean(
            step.detail || (step.evidence && step.evidence.length > 0)
          );
          const detailId = `timeline-reasoning-rail-detail-${step.id}`;
          const toggle = () =>
            setOverrides((o) => ({ ...o, [step.id]: !expanded }));

          return (
            <li key={step.id} className="relative flex">
              {/* rail: a single continuous line, the glyph rides on top */}
              <div className="relative mr-3 flex w-4 shrink-0 justify-center">
                {i > 0 && (
                  <span className="absolute left-1/2 top-0 h-[7px] w-px -translate-x-1/2 bg-border" />
                )}
                {i < steps.length - 1 && (
                  <span className="absolute left-1/2 top-[17px] bottom-0 w-px -translate-x-1/2 bg-border" />
                )}
                <span className="relative z-10 mt-[7px]">
                  <StepGlyph status={step.status} />
                </span>
              </div>

              <div className="min-w-0 flex-1 pb-4 last:pb-0">
                {hasDetail ? (
                  <button
                    type="button"
                    onClick={toggle}
                    aria-expanded={expanded}
                    aria-controls={detailId}
                    aria-label={`${expanded ? "Collapse" : "Expand"} ${step.label}`}
                    className="group -mx-1 flex min-h-6 w-full items-center gap-2 rounded-sm px-1 text-left outline-none transition-colors duration-150 hover:bg-foreground/[0.04] focus-visible:ring-2 focus-visible:ring-ns-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface motion-reduce:transition-none"
                  >
                    <span className="min-w-0 flex-1 truncate text-sm text-foreground">
                      {step.label}
                    </span>
                    {step.time ? (
                      <span className="shrink-0 font-mono text-[10px] text-ns-muted">
                        {step.time}
                      </span>
                    ) : null}
                    <svg
                      width="10"
                      height="10"
                      viewBox="0 0 10 10"
                      aria-hidden="true"
                      className={`shrink-0 text-ns-muted transition-transform duration-200 motion-reduce:transition-none ${
                        expanded ? "rotate-90" : ""
                      } group-hover:text-foreground`}
                    >
                      <path
                        d="M3 1.5l4 3.5-4 3.5"
                        fill="none"
                        stroke="currentColor"
                        strokeWidth="1.4"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </button>
                ) : (
                  <div className="flex min-h-6 w-full items-center gap-2">
                    <span className="min-w-0 flex-1 truncate text-sm text-foreground">
                      {step.label}
                    </span>
                    {step.time ? (
                      <span className="shrink-0 font-mono text-[10px] text-ns-muted">
                        {step.time}
                      </span>
                    ) : null}
                  </div>
                )}

                {hasDetail ? (
                  <div
                    className="grid transition-[grid-template-rows] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none motion-reduce:duration-0"
                    style={{ gridTemplateRows: expanded ? "1fr" : "0fr" }}
                  >
                    <div className="overflow-hidden">
                      <div id={detailId} className="pt-1.5">
                        {step.detail ? (
                          <p className="max-w-[60ch] text-sm leading-relaxed text-ns-muted">
                            {step.detail}
                          </p>
                        ) : null}
                        {step.evidence && step.evidence.length > 0 ? (
                          <div className="mt-2 flex flex-wrap gap-1.5">
                            {step.evidence.map((e, ei) => (
                              <span
                                key={ei}
                                className="inline-flex items-center gap-1 rounded-sm border border-border bg-background px-1.5 py-0.5 font-mono text-[11px] text-ns-muted"
                              >
                                <span className="text-foreground">
                                  {e.label}
                                </span>
                                <span aria-hidden="true">·</span>
                                <span>{e.value}</span>
                              </span>
                            ))}
                          </div>
                        ) : null}
                      </div>
                    </div>
                  </div>
                ) : null}
              </div>
            </li>
          );
        })}
      </ol>

      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {live}
      </div>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/timeline-reasoning-rail.tsx, so point the import there.

Use when one agent's reasoning steps arrive over time with no known total count, and completed steps should fold to a single line so a long run stays readable, with status living in the node glyph rather than in color; pick timeline-agent-lanes instead when several agents run concurrently and lane occupancy is the question, or wizard-canal-lock when the steps are a known fixed sequence the user advances through rather than an open-ended run they watch.

Install

npx shadcn add https://design.helpmarq.com/r/timeline-reasoning-rail.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Build a collapsible agent-reasoning timeline for streaming, open-ended step lists (not a fixed known-step wizard — see stepper-needle's spinbutton and any fixed-step form stepper for that case; this one has no notion of a total step count and steps arrive one at a time). DATA: the consumer owns a `steps` array (oldest first) they append to and mutate in place over time — each step is {id, label, status: pending|running|done|failed, time?, detail?, evidence?: {label,value}[]}; the component holds no timer or generation logic of its own, only fold/expand and announcement state. RENDER: a self-contained rounded-md border-border bg-surface panel around a role=region (aria-label from the `label` prop) scrollable ol (max-h-[420px] overflow-y-auto) so a 40-step run reads as a compact scrollable log, not a wall — every row folds to a single flex line (glyph + truncated label + optional mono time stamp + chevron) once its step is no longer the active one. STATUS GLYPH — the ONLY place status appears, no colored badges or pills anywhere: pending is a hollow ring (stroke --border, fill --surface), running is a HALF-FILLED disc — a full --foreground ring (fill --surface) with exactly one half painted solid --foreground, i.e. a 50% pie — with a motion-safe Tailwind `animate-ping` halo layered over it; the half-filled form is deliberately the resting signal, because the ping halo is fully transparent for most of its cycle and a bare dot inside it is indistinguishable from done's solid dot in any still frame, whereas a half-filled disc reads as 'in progress' at any scale, done is a solid 8px --foreground dot, failed is a distinct FORM — a stroked --foreground diamond (rotated square) with a crossing X, deliberately shaped rather than colored so the failure reads even in grayscale. Glyphs sit on a continuous 1px --border rail drawn per-row as two absolutely centered segments (above and below the glyph, skipped for the first/last row) so the connector reads as one unbroken line threaded through every node. FOLD LOGIC: the step currently running is expanded by default; once nothing is running, the most recently arrived step stays expanded — that is 'the current step is what you should see, with history collapsed above it'. Every other step folds. Clicking a folded row's header (the whole header is the hit target, not just the chevron) expands it and that per-id choice is kept in a Record<id,boolean> override that survives new steps streaming in, so a user reviewing history doesn't get overridden by the next arrival; folding a currently-auto-expanded step likewise sticks. Steps with neither `detail` nor `evidence` render as a plain non-interactive line (nothing to disclose, so no button is rendered for them — an inert row is not a broken control). EXPAND ANIMATION: pure CSS grid-template-rows 0fr/1fr transition (300ms, cubic-bezier(0.22,1,0.36,1)) on a wrapper around an overflow-hidden div — no JS height measurement, no layout thrash; the chevron rotates 90deg over 200ms in sync. DETAIL: a max-w-[60ch] text-sm text-ns-muted paragraph plus, if present, evidence rendered as flat bordered chips (rounded-sm border-border bg-background font-mono text-[11px], label in --foreground, value in --ns-muted, separated by a middot) — never colored badges, evidence is data, not a verdict. STREAMING + A11Y: a diff effect compares the previous `steps` array to the next by id, emitting one polite (never assertive — aria-live=polite, aria-atomic=true, visually-hidden) announcement per arrival ("Queued: <label>" for a step arriving pending, else the bare label) and per status flip ("<label> running/done/failed"), joined into a single live-region update per render so a burst of simultaneous changes announces once; a companion effect scrolls the ol to bottom on every `steps` change (instant under prefers-reduced-motion, smooth otherwise) so the newest arrival stays in view, but NEVER fires on a manual expand/collapse — exploring folded history must not get yanked back down. KEYBOARD/A11Y: every fold toggle is a real <button aria-expanded aria-controls> with an accessible name ("Expand/Collapse <label>"), reachable and operable by Tab/Enter/Space with no custom key handling needed; focus-visible ring is --ns-accent, --ns-accent's only appearance in the whole component. REDUCED MOTION: the grid-rows transition and chevron rotation both go to duration 0 (still expands/collapses, just instantly) and the running glyph's ping halo is dropped, leaving the half-filled disc that already carries the meaning — every state stays fully legible and operable, nothing is hidden. Zero dependencies, pure DOM + SVG + CSS, no canvas.

Props

PropTypeDefaultDescription
stepsSoundingRailStep[]—the run, oldest first — append to stream new steps in
label?string"Agent reasoning timeline"accessible name for the timeline region
className?string—extra classes merged onto the rendered root element