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>
)
}
















