Aadi

Research Progress

AI chats

About

Progress for a deep research agent: planning, searching, reading and writing phases, live counts of sources found and read, the page it is on, an activity log and the report once it is done.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Research Progress to your project and fits it to your theme.

Code

How Research Progress is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client"

import * as React from "react"
import { useMemo, useSyncExternalStore, useEffect } from "react"
import { ResearchActivity, ResearchCurrent, ResearchPhase, ResearchPlanStep, ResearchProgress, ResearchStatus } from "@/components/ui/research-progress"

/** What a research agent streams: one `data-research` part, replaced on every update. */
interface ResearchRunData {
  status: ResearchStatus
  phase?: ResearchPhase
  plan: ResearchPlanStep[]
  sourcesRead: number
  sourcesFound: number
  current?: ResearchCurrent
  activity: ResearchActivity[]
}

const PLAN = [
  "Collect typographic guidance on line length",
  "Find controlled studies of reading on screen",
  "Compare speed, comprehension and preference",
  "Turn the findings into interface defaults",
]

function plan(active: number): ResearchPlanStep[] {
  return PLAN.map((title, i) => ({
    id: `step-${i}`,
    title,
    status: i < active ? "done" : i === active ? "active" : "pending",
  }))
}

/** Streaming state shared by text and reasoning parts. */
type ChatcnTextState = "streaming" | "done"

/** Assistant or user prose. */
type ChatcnTextPart = { type: "text"; text: string; state?: ChatcnTextState }

/** Model reasoning or thinking text. */
type ChatcnReasoningPart = {
  type: "reasoning"
  text: string
  id?: string
  state?: ChatcnTextState
}

/**
 * chatcn message contract.
 *
 * A structural mirror of AI SDK 7 `UIMessage` parts with no runtime import, so an AI SDK message
 * can be passed straight in while assistant-ui, LangGraph, AG-UI or a static mock can be adapted
 * to the same shape. A type test pins this file to the installed `ai` package.
 */

/** Lifecycle of a tool call, identical to AI SDK 7 tool part states. */
type ToolState =
  | "input-streaming"
  | "input-available"
  | "approval-requested"
  | "approval-responded"
  | "output-available"
  | "output-error"
  | "output-denied"

/** Human approval attached to a tool call. */
interface ChatcnApproval {
  id: string
  approved?: boolean
  /** Reason the user gave when responding (usually a denial). */
  reason?: string
  /** Reason the agent gave when asking. */
  requestReason?: string
}

/** A tool call: static (`tool-${name}`) or dynamic (`dynamic-tool` with `toolName`). */
type ChatcnToolPart = {
  type: `tool-${string}` | "dynamic-tool"
  toolCallId: string
  /** Present on `dynamic-tool` parts. Use `getToolName` to read the name of any tool part. */
  toolName?: string
  state: ToolState
  title?: string
  /** Partial while `input-streaming`, complete afterwards. */
  input?: unknown
  /** Raw input text received so far: a string while `input-streaming` (AI SDK types it loosely). */
  rawInput?: unknown
  output?: unknown
  errorText?: string
  approval?: ChatcnApproval
  providerExecuted?: boolean
  /** True while an `output-available` result is still being refined. */
  preliminary?: boolean
}

/** A web source cited by the response. */
type ChatcnSourceUrlPart = {
  type: "source-url"
  sourceId: string
  url: string
  title?: string
}

/** A document source cited by the response. */
type ChatcnSourceDocumentPart = {
  type: "source-document"
  sourceId: string
  mediaType: string
  title: string
  filename?: string
}

/** An attached or generated file (hosted URL or data URL). */
type ChatcnFilePart = { type: "file"; mediaType: string; url: string; filename?: string }

/** App-defined structured data (`data-${name}`), reconciled by `id`. */
type ChatcnDataPart<D = unknown> = { type: `data-${string}`; id?: string; data: D }

/** Boundary between agent steps. */
type ChatcnStepStartPart = { type: "step-start" }

/** Every part type chatcn renders natively. */
type ChatcnKnownPart =
  | ChatcnTextPart
  | ChatcnReasoningPart
  | ChatcnToolPart
  | ChatcnSourceUrlPart
  | ChatcnSourceDocumentPart
  | ChatcnFilePart
  | ChatcnDataPart
  | ChatcnStepStartPart

/** Any other part (for example AI SDK `custom` or `reasoning-file`): render a fallback, never crash. */
type ChatcnUnknownPart = { type: string; [key: string]: unknown }

/** Any message part. Narrow with `isKnownPart` first, then switch on `part.type`. */
type ChatcnPart = ChatcnKnownPart | ChatcnUnknownPart

/** A chat message, structurally compatible with AI SDK `UIMessage`. */
interface ChatcnMessage<P extends ChatcnPart = ChatcnPart> {
  id: string
  role: "system" | "user" | "assistant"
  parts: P[]
  metadata?: unknown
}

/** Points at a part: by index for prose, by `toolCallId` for tool calls. */
type ChatcnPartRef = { messageId: string; partIndex?: number; toolCallId?: string }

type Resolved = Readonly<Record<string, unknown>>

/** One timeline operation. */
type ChatcnStreamOp =
  | { op: "message"; message: ChatcnMessage }
  | { op: "part"; messageId: string; part: ChatcnPart }
  | ({ op: "text-delta"; delta: string } & ChatcnPartRef)
  | ({ op: "tool-input-delta"; delta: string } & ChatcnPartRef)
  | ({
      op: "patch"
      patch: Record<string, unknown> | ((resolved: Resolved) => Record<string, unknown>)
    } & ChatcnPartRef)
  | { op: "await"; key: string; auto?: { after: number; value: unknown } }

/** A timed, optionally guarded operation. */
type ChatcnStreamStep = ChatcnStreamOp & {
  at: number
  when?: (resolved: Resolved) => boolean
}

type Guard = ChatcnStreamStep["when"]

/** Options for chunked streaming builder steps. */
interface ChunkOptions {
  /** Delay between chunks in ms. */
  chunkMs?: number
  /** Approximate characters per chunk. */
  chunkSize?: number
}

/** Split text into chunks of about `size` characters, breaking after whitespace when possible. */
function chunkText(text: string, size: number, byWord = true): string[] {
  const chunks: string[] = []
  let i = 0
  while (i < text.length) {
    let end = Math.min(text.length, i + size)
    if (byWord && end < text.length) {
      const space = text.indexOf(" ", end)
      if (space !== -1 && space - end < size) end = space + 1
    }
    chunks.push(text.slice(i, end))
    i = end
  }
  return chunks
}

/**
 * Tolerant JSON parser for streaming tool input.
 *
 * Parses as much of a truncated JSON document as possible: unterminated strings keep their text,
 * open objects and arrays are closed, partial literals (`tr`, `nul`) are completed, partial numbers
 * (`12.`, `3e`) are trimmed and trailing commas are ignored. Keys without a value are dropped.
 * Single pass, no eval, no dependencies.
 */

/** Result of `parsePartialJson`. */
interface PartialJsonResult {
  /** Best-effort value, `undefined` when nothing could be parsed yet. */
  value: unknown
  /** True when the text held one finished JSON value (trailing commas tolerated). */
  complete: boolean
}

const NONE = Symbol("none")

type Parsed = unknown | typeof NONE

const LITERALS: Record<string, [string, unknown]> = {
  t: ["true", true],
  f: ["false", false],
  n: ["null", null],
}

const ESCAPES: Record<string, string> = {
  '"': '"',
  "\\": "\\",
  "/": "/",
  b: "\b",
  f: "\f",
  n: "\n",
  r: "\r",
  t: "\t",
}

const NUMBER_CHAR = /[-+.eE0-9]/

class Parser {
  i = 0
  /** Set when input ran out or a syntax error was hit; unwinds every open container. */
  stopped = false

  readonly s: string

  constructor(s: string) {
    this.s = s
  }

  ws(): void {
    const s = this.s
    while (this.i < s.length) {
      const c = s.charCodeAt(this.i)
      if (c !== 32 && c !== 10 && c !== 13 && c !== 9) return
      this.i++
    }
  }

  value(): Parsed {
    this.ws()
    const c = this.s[this.i]
    if (c === undefined) return this.stop()
    if (c === "{") return this.object()
    if (c === "[") return this.array()
    if (c === '"') return this.string()
    if (c === "-" || (c >= "0" && c <= "9")) return this.number()
    if (c in LITERALS) return this.literal(c)
    return this.stop()
  }

  stop(): typeof NONE {
    this.stopped = true
    return NONE
  }

  object(): Record<string, unknown> {
    const obj: Record<string, unknown> = {}
    this.i++
    while (true) {
      this.ws()
      const c = this.s[this.i]
      if (c === "}") {
        this.i++
        return obj
      }
      if (c === ",") {
        this.i++
        continue
      }
      if (c !== '"') {
        this.stop()
        return obj
      }
      const key = this.string()
      if (this.stopped) return obj
      this.ws()
      if (this.s[this.i] !== ":") {
        this.stop()
        return obj
      }
      this.i++
      const v = this.value()
      if (v !== NONE) {
        // defineProperty keeps a "__proto__" key from replacing the prototype.
        Object.defineProperty(obj, key, {
          value: v,
          enumerable: true,
          writable: true,
          configurable: true,
        })
      }
      if (this.stopped) return obj
      this.ws()
      const next = this.s[this.i]
      if (next === ",") this.i++
      else if (next !== "}") {
        this.stop()
        return obj
      }
    }
  }

  array(): unknown[] {
    const arr: unknown[] = []
    this.i++
    while (true) {
      this.ws()
      const c = this.s[this.i]
      if (c === "]") {
        this.i++
        return arr
      }
      if (c === ",") {
        this.i++
        continue
      }
      const v = this.value()
      if (v !== NONE) arr.push(v)
      if (this.stopped) return arr
      this.ws()
      const next = this.s[this.i]
      if (next === ",") this.i++
      else if (next !== "]") {
        this.stop()
        return arr
      }
    }
  }

  string(): string {
    const s = this.s
    let out = ""
    let start = ++this.i
    while (this.i < s.length) {
      const c = s[this.i]
      if (c === '"') {
        out += s.slice(start, this.i++)
        return out
      }
      if (c !== "\\") {
        this.i++
        continue
      }
      out += s.slice(start, this.i)
      start = this.i
      const e = s[this.i + 1]
      if (e === undefined) break
      if (e === "u") {
        const hex = s.slice(this.i + 2, this.i + 6)
        if (hex.length < 4) break
        const code = Number.parseInt(hex, 16)
        out += Number.isNaN(code) ? hex : String.fromCharCode(code)
        this.i += 6
      } else {
        out += ESCAPES[e] ?? e
        this.i += 2
      }
      start = this.i
    }
    // Ran out of input inside the string. A partial escape (start sits on it) is dropped.
    if (s[start] !== "\\") out += s.slice(start)
    this.stopped = true
    const last = out.charCodeAt(out.length - 1)
    return last >= 0xd800 && last <= 0xdbff ? out.slice(0, -1) : out
  }

  number(): Parsed {
    const s = this.s
    const start = this.i
    while (this.i < s.length && NUMBER_CHAR.test(s[this.i] as string)) this.i++
    const token = s.slice(start, this.i)
    // Trim a dangling sign, dot or exponent left by truncation (`12.`, `3e`, `-`).
    const trimmed = token.replace(/[-+.eE]+$/, "")
    if (trimmed !== token) this.stopped = true
    const n = Number(trimmed)
    return trimmed === "" || Number.isNaN(n) ? this.stop() : n
  }

  literal(first: string): Parsed {
    const [word, value] = LITERALS[first] as [string, unknown]
    const rest = this.s.slice(this.i, this.i + word.length)
    if (rest === word) {
      this.i += word.length
      return value
    }
    if (this.i + rest.length === this.s.length && word.startsWith(rest)) {
      this.i += rest.length
      this.stopped = true
      return value
    }
    return this.stop()
  }
}

/** Parse possibly truncated JSON, returning the best-effort value and whether it was complete. */
function parsePartialJson(text: string): PartialJsonResult {
  const p = new Parser(text)
  const v = p.value()
  if (!p.stopped) p.ws()
  return { value: v === NONE ? undefined : v, complete: !p.stopped && p.i === text.length }
}

/** True when an approval resolution value means yes (`true` or `{ approved: true }`). */
function isApproved(value: unknown): boolean {
  return (
    value === true ||
    (typeof value === "object" && value !== null && "approved" in value && value.approved === true)
  )
}

/** A built script. */
interface ChatcnScript {
  steps: ChatcnStreamStep[]
  duration: number
}

/** Fluent script builder. Every method appends steps at the builder's current time. */
class ChatcnScriptBuilder {
  private steps: ChatcnStreamStep[] = []
  private t = 0
  private messageId: string | null = null
  private partCount = new Map<string, number>()
  private tool: { id: string; guard: Guard } | null = null
  private ids = { message: 0, call: 0, approval: 0 }

  private push(op: ChatcnStreamOp, when?: Guard): this {
    this.steps.push(when ? { ...op, at: this.t, when } : { ...op, at: this.t })
    return this
  }

  private addMessage(role: ChatcnMessage["role"], id?: string): string {
    const messageId = id ?? `msg_${++this.ids.message}`
    this.partCount.set(messageId, 0)
    this.push({ op: "message", message: { id: messageId, role, parts: [] } })
    return messageId
  }

  private addPart(part: ChatcnPart): number {
    if (!this.messageId) this.messageId = this.addMessage("assistant")
    const messageId = this.messageId
    const index = this.partCount.get(messageId) ?? 0
    this.partCount.set(messageId, index + 1)
    this.push({ op: "part", messageId, part })
    return index
  }

  private toolRef(): { messageId: string; toolCallId: string } {
    if (!this.tool || !this.messageId) throw new Error("Call toolCall() before tool steps.")
    return { messageId: this.messageId, toolCallId: this.tool.id }
  }

  /** Advance the builder clock. */
  wait(ms: number): this {
    this.t += ms
    return this
  }

  /** Add a finished user message. */
  user(text: string, options: { id?: string } = {}): this {
    const id = this.addMessage("user", options.id)
    this.messageId = id
    this.addPart({ type: "text", text, state: "done" })
    this.messageId = null
    return this
  }

  /** Start an assistant message; later parts go into it. */
  assistant(options: { id?: string } = {}): this {
    this.messageId = this.addMessage("assistant", options.id)
    return this
  }

  /** Stream a text or reasoning part chunk by chunk, then mark it done. */
  text(text: string, options: ChunkOptions & { type?: "text" | "reasoning" } = {}): this {
    const { chunkMs = 30, chunkSize = 12, type = "text" } = options
    const partIndex = this.addPart({ type, text: "", state: "streaming" })
    const messageId = this.messageId as string
    for (const delta of chunkText(text, chunkSize)) {
      this.wait(chunkMs).push({ op: "text-delta", messageId, partIndex, delta })
    }
    return this.push({ op: "patch", messageId, partIndex, patch: { state: "done" } })
  }

  /** Stream a reasoning part. */
  reasoning(text: string, options: ChunkOptions = {}): this {
    return this.text(text, { ...options, type: "reasoning" })
  }

  /** Append any part as is. */
  part(part: ChatcnPart): this {
    this.addPart(part)
    return this
  }

  /** Start a tool call. Without `input` it begins in `input-streaming`. */
  toolCall(call: {
    name: string
    toolCallId?: string
    input?: unknown
    title?: string
    dynamic?: boolean
  }): this {
    const id = call.toolCallId ?? `call_${++this.ids.call}`
    const part: ChatcnToolPart = {
      type: call.dynamic ? "dynamic-tool" : `tool-${call.name}`,
      toolCallId: id,
      state: call.input === undefined ? "input-streaming" : "input-available",
    }
    if (call.dynamic) part.toolName = call.name
    if (call.title) part.title = call.title
    if (call.input !== undefined) part.input = call.input
    this.addPart(part)
    this.tool = { id, guard: undefined }
    return this
  }

  /** Stream the tool input as JSON in character chunks, then mark it `input-available`. */
  streamInput(input: unknown, options: ChunkOptions = {}): this {
    const { chunkMs = 40, chunkSize = 6 } = options
    const ref = this.toolRef()
    const raw = typeof input === "string" ? input : JSON.stringify(input)
    for (const delta of chunkText(raw, chunkSize, false)) {
      this.wait(chunkMs).push({ op: "tool-input-delta", ...ref, delta })
    }
    const value = typeof input === "string" ? parsePartialJson(input).value : input
    return this.push({
      op: "patch",
      ...ref,
      patch: { state: "input-available", input: value, rawInput: undefined },
    })
  }

  /**
   * Ask for approval and wait for `resolve(approvalId, value)`. Later tool steps only run when the
   * value is approving; a denial moves the call to `output-denied`. `auto` resolves on its own.
   */
  awaitApproval(
    options: {
      approvalId?: string
      requestReason?: string
      auto?: { after: number; value: unknown }
    } = {},
  ): this {
    const ref = this.toolRef()
    const id = options.approvalId ?? `approval_${++this.ids.approval}`
    const approval = options.requestReason ? { id, requestReason: options.requestReason } : { id }
    const approved: Guard = (r) => isApproved(r[id])
    this.push({ op: "patch", ...ref, patch: { state: "approval-requested", approval } })
    this.push(
      options.auto ? { op: "await", key: id, auto: options.auto } : { op: "await", key: id },
    )
    this.push(
      {
        op: "patch",
        ...ref,
        patch: { state: "approval-responded", approval: { ...approval, approved: true } },
      },
      approved,
    )
    this.push(
      {
        op: "patch",
        ...ref,
        patch: (r) => {
          const v = r[id]
          const reason = typeof v === "object" && v !== null && "reason" in v ? v.reason : undefined
          return { state: "output-denied", approval: { ...approval, approved: false, reason } }
        },
      },
      (r) => !isApproved(r[id]),
    )
    ;(this.tool as { guard: Guard }).guard = approved
    return this
  }

  /** Finish the tool call with output after `delayMs`. */
  output(output: unknown, options: { delayMs?: number } = {}): this {
    const ref = this.toolRef()
    return this.wait(options.delayMs ?? 400).push(
      { op: "patch", ...ref, patch: { state: "output-available", output } },
      this.tool?.guard,
    )
  }

  /** Fail the tool call after `delayMs`. */
  error(errorText: string, options: { delayMs?: number } = {}): this {
    const ref = this.toolRef()
    return this.wait(options.delayMs ?? 400).push(
      { op: "patch", ...ref, patch: { state: "output-error", errorText } },
      this.tool?.guard,
    )
  }

  /** Append a raw step at the current time. */
  step(op: ChatcnStreamOp, when?: Guard): this {
    return this.push(op, when)
  }

  /** Finish the script. */
  build(): ChatcnScript {
    return { steps: [...this.steps], duration: this.t }
  }
}

/** Start a script builder. */
function script(): ChatcnScriptBuilder {
  return new ChatcnScriptBuilder()
}

const SEARCHES = [
  "optimal line length reading on screen",
  "characters per line comprehension experiment",
  "line length preference online news study",
  "WCAG line length 80 characters",
]

const READS: { url: string; text: string }[] = [
  { url: "https://webtypography.net/2.1.2", text: "Choose a comfortable measure" },
  {
    url: "https://baymard.com/blog/line-length-readability",
    text: "Readability: the optimal line length",
  },
  {
    url: "https://usabilitynews.org/the-effect-of-line-length-on-reading-online-news/",
    text: "The effect of line length on reading online news",
  },
  {
    url: "https://doi.org/10.1006/ijhc.2000.0442",
    text: "The influence of reading speed and line length on reading from screen",
  },
  {
    url: "https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html",
    text: "Understanding Success Criterion 1.4.8: Visual Presentation",
  },
  { url: "https://practicaltypography.com/line-length.html", text: "Line length" },
  {
    url: "https://doi.org/10.1037/0033-2909.124.3.372",
    text: "Eye movements in reading and information processing",
  },
  { url: "https://developer.mozilla.org/en-US/docs/Web/CSS/length", text: "length, CSS reference" },
]

const WRITES = [
  "Summary",
  "What typographers recommend",
  "What the screen studies found",
  "Recommendations for interfaces",
]

/**
 * Builds the scripted run as a chatcn stream: the agent sends a `data-research` part and replaces
 * its data as the run moves on, exactly like an AI SDK data part reconciled by id.
 */
function buildResearchRun({ pace = 1 }: { pace?: number } = {}) {
  let data: ResearchRunData = {
    status: "queued",
    plan: plan(-1),
    sourcesRead: 0,
    sourcesFound: 0,
    activity: [],
  }
  let n = 0
  const b = script()
    .assistant({ id: "run" })
    .part({ type: "data-research", id: "run", data } satisfies ChatcnDataPart<ResearchRunData>)
  const next = (
    ms: number,
    patch: Partial<ResearchRunData>,
    log?: Omit<ResearchActivity, "id">,
  ) => {
    data = {
      ...data,
      ...patch,
      activity: log ? [...data.activity, { ...log, id: `a${++n}` }] : data.activity,
    }
    b.wait(ms * pace).step({ op: "patch", messageId: "run", partIndex: 0, patch: { data } })
  }

  next(700, {
    status: "running",
    phase: "planning",
    plan: plan(-1),
    current: { kind: "plan", text: "Breaking the question into four steps" },
  })
  next(1300, { plan: plan(0) }, { kind: "plan", text: "Drafted a four step plan" })

  // Searching: queries go out, results pile up.
  SEARCHES.forEach((q, i) => {
    next(
      i === 0 ? 600 : 750,
      {
        phase: "searching",
        current: { kind: "search", text: q },
        sourcesFound: data.sourcesFound + ([38, 27, 31, 16][i] ?? 0),
        plan: plan(i < 2 ? 0 : 1),
      },
      { kind: "search", text: q },
    )
  })

  // Reading: one page at a time, the count changes once per finished page.
  READS.forEach((r, i) => {
    next(
      i === 0 ? 700 : 820,
      {
        phase: "reading",
        current: { kind: "read", text: r.text, url: r.url },
        sourcesRead: [6, 13, 21, 28, 37, 44, 52, 58][i] ?? 0,
        plan: plan(i < 3 ? 1 : 2),
      },
      { kind: "read", text: r.text, url: r.url },
    )
  })

  // Writing: the live line names the section in progress.
  WRITES.forEach((w, i) => {
    next(
      i === 0 ? 700 : 900,
      {
        phase: "writing",
        sourcesRead: 64,
        current: { kind: "write", text: w },
        plan: plan(3),
      },
      i === 0 ? { kind: "write", text: "Started the report" } : undefined,
    )
  })

  next(1100, { status: "complete", current: undefined, plan: plan(4) })
  return b.wait(4200 * pace).build()
}

/** Options for `useChatcnStream`. */
interface UseChatcnStreamOptions {
  /** Start playing on mount. Defaults to true. */
  autoplay?: boolean
  loop?: boolean
  speed?: number
}

/** Playback status. */
type ChatcnStreamStatus = "idle" | "playing" | "paused" | "awaiting" | "done"

/** Return value of `useChatcnStream`. */
interface UseChatcnStreamResult {
  messages: ChatcnMessage[]
  /** Parts of all messages, in order. */
  parts: ChatcnPart[]
  state: ChatcnStreamStatus
  time: number
  /** Key of the `await` step blocking playback, for example an approval id. */
  awaiting: string | null
  play: () => void
  pause: () => void
  reset: () => void
  seek: (ms: number) => void
  resolve: (key: string, value?: unknown) => void
}

/** Time source. The default uses `performance.now()` and `setTimeout`. */
interface ChatcnClock {
  now(): number
  setTimeout(fn: () => void, ms: number): unknown
  clearTimeout(handle: unknown): void
}

/** Options for `createChatcnStream`. */
interface ChatcnStreamOptions {
  speed?: number
  loop?: boolean
  /** Hold on the last frame before looping, in script ms. Defaults to 1200. */
  loopDelay?: number
  clock?: ChatcnClock
}

/** Immutable engine state; a new object is created on every change. */
interface ChatcnStreamSnapshot {
  messages: ChatcnMessage[]
  /** Parts of all messages, in order. */
  parts: ChatcnPart[]
  time: number
  status: ChatcnStreamStatus
  /** Key of the unresolved `await` step blocking the timeline. */
  awaiting: string | null
}

/** Controller returned by `createChatcnStream`. */
interface ChatcnStream {
  play(): void
  pause(): void
  reset(): void
  seek(ms: number): void
  resolve(key: string, value?: unknown): void
  getSnapshot(): ChatcnStreamSnapshot
  subscribe(listener: () => void): () => void
  readonly duration: number
}

const realClock: ChatcnClock = {
  now: () => globalThis.performance?.now() ?? Date.now(),
  setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
  clearTimeout: (h) => globalThis.clearTimeout(h as ReturnType<typeof setTimeout>),
}

type AwaitStep = Extract<ChatcnStreamStep, { op: "await" }>

const EPSILON = 1e-6

function replaceAt<T>(list: T[], index: number, item: T): T[] {
  const copy = list.slice()
  copy[index] = item
  return copy
}

function applyOp(
  messages: ChatcnMessage[],
  step: ChatcnStreamOp,
  resolved: Resolved,
): ChatcnMessage[] {
  if (step.op === "await") return messages
  if (step.op === "message") {
    const i = messages.findIndex((m) => m.id === step.message.id)
    return i < 0 ? [...messages, step.message] : replaceAt(messages, i, step.message)
  }
  const mi = messages.findIndex((m) => m.id === step.messageId)
  const msg = messages[mi]
  if (!msg) return messages
  let parts: ChatcnPart[]
  if (step.op === "part") parts = [...msg.parts, step.part]
  else {
    const pi =
      step.toolCallId === undefined
        ? (step.partIndex ?? msg.parts.length - 1)
        : msg.parts.findIndex((p) => (p as ChatcnToolPart).toolCallId === step.toolCallId)
    const part = msg.parts[pi] as Record<string, unknown> | undefined
    if (!part) return messages
    let next: Record<string, unknown>
    if (step.op === "text-delta") next = { ...part, text: `${part.text ?? ""}${step.delta}` }
    else if (step.op === "tool-input-delta") {
      const rawInput = `${part.rawInput ?? ""}${step.delta}`
      next = { ...part, rawInput, input: parsePartialJson(rawInput).value }
    } else {
      next = { ...part, ...(typeof step.patch === "function" ? step.patch(resolved) : step.patch) }
      for (const key in next) if (next[key] === undefined) delete next[key]
    }
    parts = replaceAt(msg.parts, pi, next as ChatcnPart)
  }
  return replaceAt(messages, mi, { ...msg, parts })
}

/** Create a player for a script. Nothing runs until `play()` or `seek()`. */
function createChatcnStream(
  script: ChatcnScript | ChatcnStreamStep[],
  options: ChatcnStreamOptions = {},
): ChatcnStream {
  const list = Array.isArray(script) ? script : script.steps
  const steps = [...list].sort((a, b) => a.at - b.at)
  const last = steps.at(-1)?.at ?? 0
  const duration = Array.isArray(script) ? last : Math.max(script.duration, last)
  const { speed = 1, loop = false, loopDelay = 1200, clock = realClock } = options
  const end = loop ? duration + loopDelay : duration
  const listeners = new Set<() => void>()

  let messages: ChatcnMessage[] = []
  let time = 0
  let cursor = 0
  let playing = false
  let awaiting: AwaitStep | null = null
  let resolved: Record<string, unknown> = {}
  let timer: unknown = null
  let anchorWall = 0
  let anchorTime = 0
  let snapshot = makeSnapshot()

  function status(): ChatcnStreamStatus {
    if (awaiting) return "awaiting"
    if (playing) return "playing"
    if (cursor >= steps.length && time >= duration) return "done"
    return cursor === 0 && time === 0 ? "idle" : "paused"
  }

  function makeSnapshot(): ChatcnStreamSnapshot {
    const parts = messages.flatMap((m) => m.parts)
    return { messages, parts, time, status: status(), awaiting: awaiting?.key ?? null }
  }

  function emit() {
    snapshot = makeSnapshot()
    for (const fn of listeners) fn()
  }

  const scriptNow = () => anchorTime + (clock.now() - anchorWall) * speed

  function anchor() {
    anchorWall = clock.now()
    anchorTime = time
  }

  function clear(keepResolved: boolean) {
    messages = []
    time = 0
    cursor = 0
    awaiting = null
    if (!keepResolved) resolved = {}
  }

  /** Apply steps due by `target`, stopping at an unresolved await. Seeking passes `auto` awaits. */
  function advance(target: number, seeking = false) {
    awaiting = null
    while (cursor < steps.length) {
      const step = steps[cursor] as ChatcnStreamStep
      if (step.at > target + EPSILON) break
      if (step.op === "await" && !(step.key in resolved)) {
        if (!seeking || !step.auto) {
          awaiting = step
          time = step.at
          return
        }
        resolved = { ...resolved, [step.key]: step.auto.value }
      }
      if (!step.when || step.when(resolved)) messages = applyOp(messages, step, resolved)
      cursor++
    }
    time = Math.min(target, duration)
  }

  /** Arm the timer for the next step, an `auto` await or the loop restart. */
  function schedule() {
    if (timer !== null) clock.clearTimeout(timer)
    timer = null
    if (!playing) return
    const blocker = awaiting
    if (blocker) {
      const auto = blocker.auto
      if (auto)
        timer = clock.setTimeout(() => api.resolve(blocker.key, auto.value), auto.after / speed)
      return
    }
    const next = steps[cursor]
    if (!next && !loop && time >= duration - EPSILON) {
      playing = false
      return
    }
    const target = next ? next.at : end
    timer = clock.setTimeout(tick, Math.max(0, (target - scriptNow()) / speed))
  }

  function tick() {
    timer = null
    const now = scriptNow()
    advance(now)
    if (loop && !awaiting && cursor >= steps.length && now >= end - EPSILON) {
      clear(false)
      advance(0)
    }
    anchor()
    schedule()
    emit()
  }

  const api: ChatcnStream = {
    duration,
    play() {
      if (playing) return
      if (status() === "done") clear(false)
      playing = true
      advance(time)
      anchor()
      schedule()
      emit()
    },
    pause() {
      if (!playing) return
      if (!awaiting) advance(scriptNow())
      playing = false
      schedule()
      emit()
    },
    reset() {
      playing = false
      clear(false)
      schedule()
      emit()
    },
    seek(ms) {
      clear(true)
      advance(Math.max(0, Math.min(ms, duration)), true)
      anchor()
      schedule()
      emit()
    },
    resolve(key, value = true) {
      resolved = { ...resolved, [key]: value }
      if (awaiting?.key !== key) return
      advance(time)
      anchor()
      schedule()
      emit()
    },
    getSnapshot: () => snapshot,
    subscribe(listener) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    },
  }
  return api
}

/**
 * Play a scripted chatcn stream in React. Pass a stable script (a module constant or memoized);
 * a new script, `loop` or `speed` value starts a fresh stream.
 */
function useChatcnStream(
  script: ChatcnScript | ChatcnStreamStep[],
  { autoplay = true, loop = false, speed = 1 }: UseChatcnStreamOptions = {},
): UseChatcnStreamResult {
  const stream = useMemo(() => createChatcnStream(script, { loop, speed }), [script, loop, speed])
  const snapshot = useSyncExternalStore(stream.subscribe, stream.getSnapshot, stream.getSnapshot)

  useEffect(() => {
    if (autoplay) stream.play()
    return () => stream.pause()
  }, [stream, autoplay])

  return {
    messages: snapshot.messages,
    parts: snapshot.parts,
    state: snapshot.status,
    time: snapshot.time,
    awaiting: snapshot.awaiting,
    play: stream.play,
    pause: stream.pause,
    reset: stream.reset,
    seek: stream.seek,
    resolve: stream.resolve,
  }
}

/** Reads the run out of the stream's parts. */
function useResearchRun(run: ReturnType<typeof buildResearchRun>, loop = true) {
  const { parts, time } = useChatcnStream(run, { loop })
  const part = parts.find((p) => p.type === "data-research") as
    | ChatcnDataPart<ResearchRunData>
    | undefined
  return { data: part?.data, time }
}

/** The kit's scripted run, a little quicker so a short preview sees every phase. */
const RUN = buildResearchRun({ pace: 0.5 })

/** Real research takes minutes. The demo starts its clock four minutes in. */
function useDemoClock(status: ResearchStatus | undefined) {
  const [startedAt, setStartedAt] = React.useState<number>()
  React.useEffect(() => {
    if (status === "running") setStartedAt((s) => s ?? Date.now() - 251_000)
    if (status === "queued") setStartedAt(undefined)
  }, [status])
  return startedAt
}

const FIT_CSS = "html,body{overflow:hidden}[data-fit-small] [data-fit-hide]{display:none!important}"

/**
 * 21st shows a demo in a small frame: about 674 by 421 on a component page, 358 by 224 on a
 * phone and 245 by 139 on a profile card. A demo taller than its frame gets scrollbars, which
 * render white on dark pages. FitFrame fills the frame exactly instead.
 *
 * - "scale": the content shrinks until it fits, centered. For visuals such as orbs.
 * - "crop": the content stays at full size from the top and fades out at the bottom. For text
 *   that streams in, where a changing scale would make the page breathe.
 *
 * `minWidth` lays out a wide demo (a table, a two column panel) at that width and scales it down
 * to the frame, instead of letting it collapse to a narrow layout. The frame centers it, so it
 * shrinks evenly on both sides.
 *
 * Anything marked `data-fit-hide` (state buttons, the chatcn.dev line) drops out in frames
 * shorter than `hideBelow`, so a profile card shows the visual alone.
 */
function FitFrame({
  children,
  mode = "scale",
  hideBelow = 360,
  minWidth,
}: {
  children: React.ReactNode
  mode?: "scale" | "crop"
  hideBelow?: number
  minWidth?: string
}) {
  const content = React.useRef<HTMLDivElement>(null)
  const [cropped, setCropped] = React.useState(false)

  React.useLayoutEffect(() => {
    const el = content.current
    if (!el) return
    const measure = () => {
      const vw = window.innerWidth
      const vh = window.innerHeight
      el.toggleAttribute("data-fit-small", vh < hideBelow)
      // Layout sizes ignore the transform, so measuring never feeds back.
      const w = Math.max(el.offsetWidth, el.scrollWidth)
      const h = Math.max(el.offsetHeight, el.scrollHeight)
      const scale = mode === "scale" ? Math.min(1, vw / w, vh / h) : 1
      el.style.transform = scale < 1 ? `scale(${scale})` : ""
      setCropped(mode === "crop" && h > vh + 1)
    }
    measure()
    const observer = new ResizeObserver(measure)
    observer.observe(el)
    // Content that changes inside a fixed width box (a headline that cycles words) never resizes
    // the box itself, so changes below it measure again on the next frame.
    let frame = 0
    const mutations = new MutationObserver(() => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(measure)
    })
    mutations.observe(el, { subtree: true, childList: true, characterData: true })
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      mutations.disconnect()
      cancelAnimationFrame(frame)
      window.removeEventListener("resize", measure)
    }
  }, [mode, hideBelow])

  const fade = "linear-gradient(to bottom, #000 calc(100% - 40px), transparent)"
  return (
    <div
      className="flex w-full justify-center overflow-hidden"
      style={{
        height: "100dvh",
        alignItems: mode === "crop" ? "flex-start" : "center",
        maskImage: cropped ? fade : undefined,
        WebkitMaskImage: cropped ? fade : undefined,
      }}
    >
      <style>{FIT_CSS}</style>
      <div
        ref={content}
        className="w-full shrink-0"
        style={{ transformOrigin: mode === "crop" ? "top center" : "center", minWidth }}
      >
        {children}
      </div>
    </div>
  )
}

/** The line under each 21st demo that points back to the kit. */
function MoreOn({ slug, children }: { slug: string; children: React.ReactNode }) {
  return (
    <p data-fit-hide className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-faint) text-center text-balance">
      {children}{" "}
      <a
        href={`https://chatcn.dev/docs/components/${slug}`}
        target="_blank"
        rel="noopener"
        className="text-(color:--chatcn-fg-subtle) hover:text-(color:--chatcn-fg) underline underline-offset-2"
      >
        chatcn.dev
      </a>
    </p>
  )
}

/**
 * A deep research run on line length: it plans four steps, sends searches, reads pages one at a
 * time while the counts climb, writes the report section by section, then offers the report.
 * Loops. Open the activity log to see every search and page.
 */
export default function ResearchProgressPreview() {
  const { data } = useResearchRun(RUN)
  const startedAt = useDemoClock(data?.status)
  return (
    <FitFrame minWidth="36rem">
      <div className="flex w-full flex-col items-center justify-center gap-4 p-6">
        <div className="flex h-[21.5rem] w-full max-w-xl items-start">
          {data ? (
            <ResearchProgress
              className="w-full"
              title="How long should a line of text be on screen?"
              status={data.status}
              phase={data.phase}
              plan={data.plan}
              sourcesRead={data.sourcesRead}
              sourcesFound={data.sourcesFound}
              current={data.current}
              activity={data.activity}
              startedAt={startedAt}
              durationMs={data.status === "complete" ? 401_000 : undefined}
              summary="Read 64 sources and cited 11. The report runs 1,180 words."
              onCancel={() => {}}
              onOpenReport={() => {}}
            />
          ) : null}
        </div>
        <MoreOn slug="research-progress">Phases, live counts and an activity log for deep research. On</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    11
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Waveform

    Waveform

    4
  • Computer View

    Computer View

    3
  • Chat Search

    Chat Search

    2
  • Stat Card

    Stat Card

    2

More in AI chats

View all
  • AI Actions

    AI Actions

    339
  • Illos Knowledge Sources

    Illos Knowledge Sources

    41
  • Aquarium Prompt

    Aquarium Prompt

    56
  • Attachments

    Attachments

    39
  • Commit

    Commit

    33
  • Color Picker

    Color Picker

    171
  • Chat Bubble

    Chat Bubble

    199
  • AI Suggestions

    AI Suggestions

    20

Explore more

Part of chatcn — browse the full library on 21st.dev.