# Thought Trail: Deploy Log

> A CI pipeline with every step listed up front as pending. Logs stream into the running step, pinned to the newest line, and each step keeps its time. Turn on Break a test: the run stops at the tests, which turn red and stay open, and the steps after them never start.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/thought-trail-deploy-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/thought-trail-deploy-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/thought-trail-deploy-demo-light-be7ae91dbba4e5ab.png
- Registry item: https://keyframeui.com/r/thought-trail-deploy-demo.json
- Categories: ai, status, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/thought-trail-deploy-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/thought-trail.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/thought-trail#thought-trail-deploy-demo

## Files

### `registry/demos/thought-trail-deploy-demo.tsx`

```tsx
"use client";

import * as React from "react";
import { useInView } from "motion/react";

import { cn } from "@/lib/utils";
import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";

type Stage = {
  id: string;
  title: string;
  summary: string;
  ms: number;
  log: string[];
  /** With "Break a test" on: the log it stops on, and its summary. */
  fails?: { log: string[]; summary: string };
};

const stages: Stage[] = [
  {
    id: "clone",
    title: "Cloning repository",
    summary: "Cloned 8f3c2a1",
    ms: 1400,
    log: [
      "Cloning github.com/acme/storefront (branch: main)",
      "HEAD is now at 8f3c2a1 Fix cart badge overflow",
      "Cloned in 1.1s",
    ],
  },
  {
    id: "install",
    title: "Installing dependencies",
    summary: "Installed 1,284 packages",
    ms: 3000,
    log: [
      "$ npm ci",
      "npm warn deprecated inflight@1.0.6",
      "added 1284 packages in 2.7s",
      "214 packages are looking for funding",
      "found 0 vulnerabilities",
    ],
  },
  {
    id: "build",
    title: "Building",
    summary: "Built 42 routes",
    ms: 4200,
    log: [
      "$ next build",
      "▲ Next.js 16.4.0",
      "Creating an optimized production build …",
      "✓ Compiled successfully in 2.9s",
      "✓ Linting and checking validity of types",
      "✓ Collecting page data",
      "✓ Generating static pages (42/42)",
      "Route (app)              Size   First Load JS",
      "┌ ○ /                   4.1 kB        118 kB",
      "├ ○ /cart               2.7 kB        116 kB",
      "├ ● /products/[slug]    3.4 kB        117 kB",
      "└ ○ /checkout           5.2 kB        119 kB",
    ],
  },
  {
    id: "test",
    title: "Running tests",
    summary: "212 tests passed",
    ms: 2800,
    log: [
      "$ vitest run",
      " ✓ cart/badge.test.ts (12)",
      " ✓ cart/totals.test.ts (31)",
      " ✓ checkout/address.test.ts (24)",
      " ✓ products/search.test.ts (58)",
      " Test Files  38 passed (38)",
      "      Tests  212 passed (212)",
    ],
    fails: {
      summary: "1 of 212 tests failed",
      log: [
        "$ vitest run",
        " ✓ cart/totals.test.ts (31)",
        " ✗ cart/badge.test.ts > caps the count at 99+",
        '     Expected: "99+"',
        '     Received: "100"',
        " Test Files  1 failed | 37 passed (38)",
        "      Tests  1 failed | 211 passed (212)",
      ],
    },
  },
  {
    id: "deploy",
    title: "Deploying to production",
    summary: "Live at storefront.acme.app",
    ms: 2200,
    log: [
      "Uploading build output (18.4 MB)",
      "Promoting to production",
      "Assigning storefront.acme.app",
      "✓ Ready in 2.1s",
    ],
  },
];

const LEAD = 700; // ms the pipeline sits queued before the first step
const starts = stages.map((_, i) => LEAD + stages.slice(0, i).reduce((sum, s) => sum + s.ms, 0));
const END = LEAD + stages.reduce((sum, s) => sum + s.ms, 0);
const testIndex = stages.findIndex((s) => s.fails);
const FAIL_AT = starts[testIndex] + stages[testIndex].ms; // the broken run stops here

/** Milliseconds into the current run, from when the demo first scrolls into view. Stops at `end`. */
function usePlayhead(run: number, playing: boolean, end: number) {
  const [clock, setClock] = React.useState({ run, t: 0 });
  React.useEffect(() => {
    if (!playing) return;
    const start = performance.now();
    const timer = window.setInterval(() => {
      const t = Math.min(end, performance.now() - start);
      setClock({ run, t });
      if (t >= end) window.clearInterval(timer);
    }, 50);
    return () => window.clearInterval(timer);
  }, [run, playing, end]);
  return clock.run === run ? clock.t : 0;
}

const seconds = (ms: number) => `${(ms / 1000).toFixed(1)}s`;

// A CI pipeline with every step listed up front. Logs stream into the step
// that's running and stay pinned to the newest line; each step keeps its
// time. Turn on "Break a test" and the run stops at the tests, with the
// failure left open.
export function ThoughtTrailDeployDemo() {
  const ref = React.useRef<HTMLDivElement>(null);
  const playing = useInView(ref, { once: true, amount: 0.4 });
  const [broken, setBroken] = React.useState(false);
  const [run, setRun] = React.useState(0);
  const end = broken ? FAIL_AT : END;
  const t = usePlayhead(run, playing, end);

  const redeploy = (nextBroken = broken) => {
    setBroken(nextBroken);
    setRun((r) => r + 1);
  };

  return (
    <div
      ref={ref}
      className="flex h-[32rem] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
    >
      <div className="flex flex-wrap items-center gap-x-4 gap-y-3 border-b border-zinc-100 px-5 py-3.5 dark:border-zinc-800/80">
        <div className="flex min-w-0 flex-1 items-center gap-3">
          <span
            aria-hidden
            className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-zinc-900 text-sm font-semibold text-white dark:bg-zinc-100 dark:text-zinc-900"
          >
            a
          </span>
          <div className="min-w-0">
            <p className="truncate text-sm font-medium text-zinc-900 dark:text-zinc-100">acme/storefront</p>
            <p className="flex min-w-0 items-center gap-1.5 text-xs text-zinc-500 dark:text-zinc-400">
              <span className="rounded bg-zinc-100 px-1 font-mono text-[11px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
                main
              </span>
              <span className="truncate">8f3c2a1 · Fix cart badge overflow</span>
            </p>
          </div>
        </div>
        <div className="flex items-center gap-1">
          <label className="inline-flex h-8 cursor-pointer items-center gap-2 rounded-lg px-2.5 text-xs font-medium text-zinc-600 select-none hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800">
            <input
              type="checkbox"
              checked={broken}
              onChange={(e) => redeploy(e.target.checked)}
              className="peer sr-only"
            />
            <span
              aria-hidden
              className="relative h-4 w-7 rounded-full bg-zinc-200 transition-colors duration-200 peer-checked:bg-rose-500 peer-focus-visible:ring-2 peer-focus-visible:ring-zinc-400 dark:bg-zinc-700 dark:peer-checked:bg-rose-500 dark:peer-focus-visible:ring-zinc-600"
            >
              <span
                className={cn(
                  "absolute top-0.5 left-0.5 size-3 rounded-full bg-white shadow-sm transition-transform duration-200",
                  broken && "translate-x-3",
                )}
              />
            </span>
            Break a test
          </label>
          <button
            type="button"
            onClick={() => redeploy()}
            className="inline-flex h-8 items-center rounded-lg px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600"
          >
            Redeploy
          </button>
        </div>
      </div>

      <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
        {playing ? (
          <ThoughtTrail
            running={t < end}
            label="Deploying"
            collapseOnDone={false}
            doneLabel={({ seconds, failed }) => `${failed ? "Failed after" : "Deployed in"} ${seconds ?? 0}s`}
          >
            {stages.map((stage, i) => {
              const start = starts[i];
              const fails = broken && stage.fails;
              const ms = stage.ms;
              // A broken run never reaches the steps after the tests.
              const skipped = broken && i > testIndex;
              const status =
                skipped || t < start ? "pending" : t < start + ms ? "active" : fails ? "error" : "done";
              const log = fails ? stage.fails!.log : stage.log;
              const shown = Math.ceil(Math.min(1, (t - start) / (ms * 0.9)) * log.length);
              return (
                <ThoughtTrailStep
                  key={stage.id}
                  status={status}
                  title={stage.title}
                  summary={fails ? stage.fails!.summary : stage.summary}
                  meta={status === "pending" ? undefined : seconds(Math.min(t - start, ms))}
                  contentClassName="max-h-32 overflow-y-auto pt-3 [mask-image:linear-gradient(to_bottom,transparent,#000_0.75rem)]"
                >
                  <pre className="font-mono text-[11.5px] leading-5 whitespace-pre">
                    {log.slice(0, shown).map((line, j) => (
                      <span
                        key={j}
                        className={cn(
                          "block",
                          line.includes("✓") && "text-emerald-700 dark:text-emerald-400",
                          (line.includes("✗") || line.includes("failed") || line.includes("Received")) &&
                            "text-rose-600 dark:text-rose-400",
                        )}
                      >
                        {line}
                      </span>
                    ))}
                  </pre>
                </ThoughtTrailStep>
              );
            })}
          </ThoughtTrail>
        ) : null}
      </div>
    </div>
  );
}
```
