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
















