Aadi

Tool Browser

AI chats

About

A browsing agent in the thread: each step it takes, open, click, scroll, appears beside a live view of the page with its cursor, the current URL and a step count, so people can follow and stop it.

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

Code

How Tool Browser 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 { cn } from "@/lib/utils"
import { BrowserStep, ChatcnToolPart, ToolBrowser, asRecord, parsePartialJson, toolBasePropsFromPart } from "@/components/ui/tool-browser"

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

/** 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
}

/** 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
}

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

/** 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
}

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
}

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

/** 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()
}

/** 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,
  }
}

/*
 * A browser agent fetches the latest invoice from a preview deploy. The plan streams in as the
 * tool input, then progress arrives as preliminary output: which step runs, which page is on
 * screen. The example maps the screen key to a picture; a real tool sends a screenshot URL.
 */

const HOST = "https://preview-482.palletapp.dev"

/** Where the Download link of the first invoice sits when the page is scrolled. */
const FIRST_DOWNLOAD = { x: 0.915, y: (14 + 7 + 4.25) / 100 }

const STEPS: BrowserStep[] = [
  { kind: "navigate", url: `${HOST}/harbor-goods` },
  { kind: "click", target: "Settings", x: 0.09, y: 0.49 },
  { kind: "click", target: "Billing", x: 0.455, y: 0.207 },
  { kind: "scroll", target: "Invoices", x: 0.6, y: 0.64 },
  { kind: "click", target: "Download on Oct 2026", ...FIRST_DOWNLOAD },
]

/** Progress per step: the page on screen while that step runs. */
const PROGRESS: [screen: string, path: string, ms: number][] = [
  ["blank", "/harbor-goods", 1100],
  ["dashboard", "/harbor-goods", 1500],
  ["settings", "/harbor-goods/settings", 1500],
  ["billing", "/harbor-goods/settings/billing", 1500],
  ["invoices", "/harbor-goods/settings/billing#invoices", 1500],
]

const ref = { messageId: "a1", toolCallId: "browse" }

function build() {
  const s = script()
    .assistant({ id: "a1" })
    .wait(500)
    .toolCall({ name: "browser", toolCallId: "browse" })
    .streamInput({ steps: STEPS }, { chunkMs: 18, chunkSize: 10 })
    .wait(300)
  for (const [i, [screen, path, ms]] of PROGRESS.entries()) {
    s.step({
      op: "patch",
      ...ref,
      patch: {
        state: "output-available",
        preliminary: true,
        output: { step: i, screen, url: `${HOST}${path}` },
      },
    }).wait(ms)
  }
  return s
    .step({
      op: "patch",
      ...ref,
      patch: {
        state: "output-available",
        preliminary: undefined,
        output: {
          step: STEPS.length,
          screen: "downloaded",
          url: `${HOST}/harbor-goods/settings/billing#invoices`,
          title: "Billing",
          file: "INV-2026-10.pdf",
        },
      },
    })
    .wait(3200)
    .build()
}

const session = build()

/** True for tool parts, static or dynamic. */
function isToolPart(part: { type: string }): part is ChatcnToolPart {
  return part.type === "dynamic-tool" || part.type.startsWith("tool-")
}

function Page({ children, className }: { children: React.ReactNode; className?: string }) {
  return (
    <div
      className={cn("bg-(color:--chatcn-bg) text-(color:--chatcn-fg) absolute inset-0 overflow-hidden", className)}
      style={{ fontSize: "1.5cqw", lineHeight: 1.4 }}
    >
      {children}
    </div>
  )
}

/*
 * Monochrome placeholder screens for the computer view examples. Everything is sized in `em` off a
 * root font size of 1.5cqw, so one screen renders crisply at full size and as a thumbnail.
 * Targets sit at fixed percentages so the agent's cursor lands exactly on them.
 */

function At({
  x,
  y,
  w,
  h,
  className,
  style,
  children,
}: {
  x: number
  y: number
  w?: number
  h?: number
  className?: string
  style?: React.CSSProperties
  children?: React.ReactNode
}) {
  return (
    <div
      className={cn("absolute", className)}
      style={{
        left: `${x}%`,
        top: `${y}%`,
        width: w === undefined ? undefined : `${w}%`,
        height: h === undefined ? undefined : `${h}%`,
        ...style,
      }}
    >
      {children}
    </div>
  )
}

/* -------------------------------------------------------------------------------------------------
 * Screens
 * -----------------------------------------------------------------------------------------------*/

function BlankScreen() {
  return (
    <Page className="bg-(color:--chatcn-bg-subtle)">
      <At x={0} y={0} w={34} h={0.6} className="bg-(color:--chatcn-fg)" />
      <At
        x={42}
        y={46}
        w={16}
        h={2.4}
        className="bg-(color:--chatcn-fill)"
        style={{ borderRadius: "0.4em" }}
      />
      <At
        x={45}
        y={52}
        w={10}
        h={2.4}
        className="bg-(color:--chatcn-fill)"
        style={{ borderRadius: "0.4em" }}
      />
    </Page>
  )
}

function Logo({ x, y }: { x: number; y: number }) {
  return (
    <At x={x} y={y} className="flex items-center" style={{ gap: "0.5em" }}>
      <span
        className="bg-(color:--chatcn-fg) block"
        style={{ width: "1.2em", height: "1.2em", borderRadius: "0.3em" }}
      />
      <span className="font-medium" style={{ fontSize: "1.05em" }}>
        Pallet
      </span>
    </At>
  )
}

const NAV = ["Overview", "Deployments", "Analytics", "Logs", "Settings"]

function Shell({ active, children }: { active: string; children: React.ReactNode }) {
  return (
    <Page>
      <At
        x={0}
        y={0}
        w={18}
        h={100}
        className="bg-(color:--chatcn-bg-subtle)"
        style={{ boxShadow: "inset -0.08em 0 0 var(--chatcn-border)" }}
      />
      <Logo x={2.5} y={5} />
      {NAV.map((item, i) => (
        <At
          key={item}
          x={1.5}
          y={14 + i * 6.5 + (item === "Settings" ? 6.5 : 0)}
          w={15}
          h={5.2}
          className={cn(
            "flex items-center",
            item === active ? "bg-(color:--chatcn-fill) text-(color:--chatcn-fg) font-medium" : "text-(color:--chatcn-fg-muted)",
          )}
          style={{ borderRadius: "0.45em", padding: "0 0.75em", fontSize: "0.95em" }}
        >
          {item}
        </At>
      ))}
      <At
        x={2.5}
        y={90}
        className="text-(color:--chatcn-fg-subtle) flex items-center"
        style={{ gap: "0.5em", fontSize: "0.85em" }}
      >
        <span
          className="bg-(color:--chatcn-fill) block rounded-full"
          style={{ width: "1.4em", height: "1.4em" }}
        />
        dev@harborgoods.co
      </At>
      {children}
    </Page>
  )
}

function DashboardScreen() {
  const rows = [
    ["web", "Ready", "2 m ago"],
    ["docs", "Ready", "1 h ago"],
    ["api", "Building", "now"],
    ["marketing", "Ready", "3 d ago"],
  ]
  return (
    <Shell active="Overview">
      <At x={22} y={6} className="font-medium" style={{ fontSize: "1.45em" }}>
        Projects
      </At>
      <At
        x={84}
        y={5.5}
        w={12}
        h={5.5}
        className="bg-(color:--chatcn-fg) text-(color:--chatcn-bg) flex items-center justify-center font-medium"
        style={{ borderRadius: "0.45em", fontSize: "0.9em" }}
      >
        New project
      </At>
      {rows.map(([name, status, when], i) => (
        <At
          key={name}
          x={22}
          y={18 + i * 12}
          w={74}
          h={10}
          className="flex items-center"
          style={{
            borderRadius: "0.6em",
            boxShadow: "inset 0 0 0 0.08em var(--chatcn-border)",
            padding: "0 1.2em",
            gap: "1em",
          }}
        >
          <span
            className="bg-(color:--chatcn-fill) block"
            style={{ width: "2em", height: "2em", borderRadius: "0.4em" }}
          />
          <span className="flex-1 font-medium">{name}</span>
          <span
            className="text-(color:--chatcn-fg-muted) flex items-center"
            style={{ gap: "0.4em", fontSize: "0.9em" }}
          >
            <span
              className={cn(
                "block rounded-full",
                status === "Ready" ? "bg-(color:--chatcn-fg)" : "bg-(color:--chatcn-fg-faint)",
              )}
              style={{ width: "0.5em", height: "0.5em" }}
            />
            {status}
          </span>
          <span
            className="text-(color:--chatcn-fg-subtle)"
            style={{ fontSize: "0.9em", width: "5em", textAlign: "right" }}
          >
            {when}
          </span>
        </At>
      ))}
    </Shell>
  )
}

const TABS = ["General", "Members", "Billing", "Security"]

function SettingsTabs({ active }: { active: string }) {
  return (
    <>
      <At x={22} y={6} className="font-medium" style={{ fontSize: "1.45em" }}>
        Settings
      </At>
      <At x={22} y={24.5} w={74} h={0.2} className="bg-(color:--chatcn-border)" />
      {TABS.map((tab, i) => (
        <At
          key={tab}
          x={22 + i * 9.5}
          y={17}
          w={9}
          h={7.5}
          className={cn(
            "flex items-center justify-center",
            tab === active ? "text-(color:--chatcn-fg) font-medium" : "text-(color:--chatcn-fg-muted)",
          )}
          style={{
            fontSize: "0.95em",
            boxShadow: tab === active ? "inset 0 -0.15em 0 var(--chatcn-fg)" : undefined,
          }}
        >
          {tab}
        </At>
      ))}
    </>
  )
}

function SettingsScreen() {
  return (
    <Shell active="Settings">
      <SettingsTabs active="General" />
      {[
        ["Team name", "Harbor Goods"],
        ["Team URL", "palletapp.dev/team/harbor-goods"],
        ["Default region", "Frankfurt, Germany"],
      ].map(([label, value], i) => (
        <At key={label} x={22} y={31 + i * 15} w={50}>
          <div
            className="text-(color:--chatcn-fg-muted) font-medium"
            style={{ fontSize: "0.85em", marginBottom: "0.5em" }}
          >
            {label}
          </div>
          <div
            className="flex items-center"
            style={{
              height: "2.6em",
              borderRadius: "0.45em",
              padding: "0 0.8em",
              boxShadow: "inset 0 0 0 0.08em var(--chatcn-border-strong)",
            }}
          >
            {value}
          </div>
        </At>
      ))}
    </Shell>
  )
}

const INVOICES = [
  ["Oct 2026", "INV-2026-10", "$240.00"],
  ["Sep 2026", "INV-2026-09", "$240.00"],
  ["Aug 2026", "INV-2026-08", "$220.00"],
  ["Jul 2026", "INV-2026-07", "$220.00"],
  ["Jun 2026", "INV-2026-06", "$200.00"],
  ["May 2026", "INV-2026-05", "$200.00"],
]

function BillingScreen({
  scrolled,
  downloaded,
}: {
  scrolled?: boolean
  downloaded?: boolean
}) {
  const top = scrolled ? 14 : 58
  return (
    <Shell active="Settings">
      {scrolled ? null : (
        <>
          <SettingsTabs active="Billing" />
          <At
            x={22}
            y={30}
            w={74}
            h={21}
            className="flex items-center justify-between"
            style={{
              borderRadius: "0.7em",
              boxShadow: "inset 0 0 0 0.08em var(--chatcn-border)",
              padding: "0 1.6em",
            }}
          >
            <div>
              <div className="text-(color:--chatcn-fg-muted)" style={{ fontSize: "0.85em" }}>
                Current plan
              </div>
              <div className="font-medium" style={{ fontSize: "1.5em" }}>
                Pro, 12 seats
              </div>
              <div className="text-(color:--chatcn-fg-subtle)" style={{ fontSize: "0.85em" }}>
                Renews on Nov 1, 2026
              </div>
            </div>
            <div className="font-medium" style={{ fontSize: "1.3em" }}>
              $240 / month
            </div>
          </At>
        </>
      )}
      <At x={22} y={top} className="font-medium" style={{ fontSize: "1.1em" }}>
        Invoices
      </At>
      {INVOICES.map(([month, id, amount], i) => (
        <At
          key={id}
          x={22}
          y={top + 7 + i * 8.5}
          w={74}
          h={8.5}
          className="text-(color:--chatcn-fg) flex items-center"
          style={{
            boxShadow: "inset 0 -0.08em 0 var(--chatcn-border)",
            padding: "0 0.4em",
            gap: "1em",
            fontSize: "0.95em",
          }}
        >
          <span style={{ width: "7em" }}>{month}</span>
          <span className="text-(color:--chatcn-fg-muted) flex-1 font-mono" style={{ fontSize: "0.9em" }}>
            {id}
          </span>
          <span style={{ width: "6em" }}>{amount}</span>
          <span className="text-(color:--chatcn-fg-muted)" style={{ width: "4em" }}>
            Paid
          </span>
          <span className="font-medium underline" style={{ width: "5.5em", textAlign: "right" }}>
            Download
          </span>
        </At>
      ))}
      {downloaded ? (
        <At
          x={70}
          y={84}
          w={27}
          h={9}
          className="bg-(color:--chatcn-fg) text-(color:--chatcn-bg) flex items-center"
          style={{ borderRadius: "0.6em", padding: "0 1em", gap: "0.6em", fontSize: "0.9em" }}
        >
          <span className="font-medium">Downloaded</span>
          <span className="font-mono opacity-70" style={{ fontSize: "0.9em" }}>
            INV-2026-10.pdf
          </span>
        </At>
      ) : null}
    </Shell>
  )
}

const SCREENS: Record<string, React.ReactNode> = {
  blank: <BlankScreen />,
  dashboard: <DashboardScreen />,
  settings: <SettingsScreen />,
  billing: <BillingScreen />,
  invoices: <BillingScreen scrolled />,
  downloaded: <BillingScreen scrolled downloaded />,
}

function ToolBrowserDemo() {
  const { parts, resolve } = useChatcnStream(session, { loop: true })
  const part = parts.find(isToolPart)
  if (!part) return <div className="h-12 w-full max-w-2xl" />

  const output = asRecord(part.output)
  const planned = (asRecord(part.input).steps as Partial<BrowserStep>[] | undefined) ?? []
  // While the plan streams, the last step may be half written: show only complete ones.
  const complete = part.state === "input-streaming" ? planned.slice(0, -1) : planned
  const at = typeof output.step === "number" ? output.step : -1
  const steps = complete
    .filter((step): step is BrowserStep => typeof step?.kind === "string")
    .map<BrowserStep>((step, i) => ({
      ...step,
      status: at < 0 ? "pending" : i < at ? "done" : i === at ? "running" : "pending",
    }))

  return (
    <div className="w-full max-w-2xl">
      <ToolBrowser
        {...toolBasePropsFromPart({
          part,
          onApprove: (id, scope) => resolve(id, { approved: true, scope }),
          onDeny: (id, reason) => resolve(id, { approved: false, reason }),
        })}
        steps={steps}
        url={typeof output.url === "string" ? output.url : undefined}
        pageTitle={typeof output.title === "string" ? output.title : undefined}
        screenshot={typeof output.screen === "string" ? SCREENS[output.screen] : undefined}
        screenshotAlt="Pallet dashboard, billing settings"
        defaultOpen
      />
    </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>
  )
}

/** The kit's own example, framed for the 21st preview. */
export default function ToolBrowserPreview() {
  return (
    <FitFrame minWidth="40rem">
      <div className="flex w-full flex-col items-center justify-center gap-4 p-6">
        <div className="flex w-full max-w-xl justify-center">
          <ToolBrowserDemo />
        </div>
        <MoreOn slug="tool-browser">A browsing agent's page, actions and steps, live. On</MoreOn>
      </div>
    </FitFrame>
  )
}

Dependencies

More by Aadi

View all
  • Dither Orb

    Dither Orb

    12
  • Audio Visualizer

    Audio Visualizer

    9
  • Voice Orb

    Voice Orb

    8
  • Agent Lanes

    Agent Lanes

    6
  • Computer View

    Computer View

    3
  • Waveform

    Waveform

    2
  • Reasoning

    Reasoning

    2
  • Voice Session

    Voice Session

    1

More in AI chats

View all
  • AI Actions

    AI Actions

    338
  • Illos Knowledge Sources

    Illos Knowledge Sources

    40
  • Aquarium Prompt

    Aquarium Prompt

    48
  • Attachments

    Attachments

    38
  • Chat Search

    Chat Search

    1
  • Color Picker

    Color Picker

    171
  • Commit

    Commit

    32
  • Chat Bubble

    Chat Bubble

    199

Explore more

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