About
Messages people send while an agent is still working: a queue above the composer, one marked as steering for the next step, edit, delete and reorder, and Clear for the rest.
Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Queued Messages to your project and fits it to your theme.
Code
How Queued Messages 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 { cn } from "@/lib/utils"
import { Collapsible } from "@base-ui/react/collapsible"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { Button, Icon, IconNode, QueuedMessage, QueuedMessages, RotateCcwIcon, Spinner, createIcon } from "@/components/ui/queued-messages"
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>
)
}
const SEED: QueuedMessage[] = [
{ id: "q1", text: "Also add a test for the empty runs list.", mode: "after-run" },
{
id: "q2",
text: "Use the existing Skeleton component from components/ui instead of a new one.",
mode: "steer",
},
{ id: "q3", text: "Then open a draft PR against main.", mode: "after-run" },
]
const ACTIVITY = [
"Reading src/app/runs/layout.tsx",
"Editing src/app/runs/layout.tsx",
"Creating src/app/runs/header-skeleton.tsx",
"Editing src/app/runs/page.tsx",
"Running pnpm test --filter web",
"Running pnpm typecheck --filter web",
]
let counter = 0
type SubmitOn = "enter" | "mod-enter" | "auto"
interface UseChatcnImeSubmitOptions {
/** Called on Enter (or Cmd/Ctrl+Enter). Never called while an IME is composing. */
onSubmit: () => void
/** Called on Escape while `streaming` is true. */
onStop?: () => void
streaming?: boolean
/**
* Which key sends.
* - `enter`: Enter sends, Shift+Enter inserts a newline. Cmd/Ctrl+Enter also sends.
* - `mod-enter`: Enter inserts a newline, Cmd/Ctrl+Enter sends.
* - `auto` (default): `enter` on fine pointers, `mod-enter` on touch screens, where the
* on-screen keyboard has no Shift+Enter and the send button is right there.
*/
submitOn?: SubmitOn
/** Swallow Enter without sending. */
disabled?: boolean
}
function isCoarsePointer() {
return typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches === true
}
/**
* Keyboard handling for chat inputs that is safe with input method editors (Japanese, Chinese,
* Korean and others). Enter that confirms a composition never sends.
*
* Safari fires `compositionend` before the confirming `keydown`, so `isComposing` is already
* false there. The hook also checks `keyCode === 229` and holds a flag until the next task.
*
* Spread the returned handlers on a textarea or input.
*/
function useChatcnImeSubmit(options: UseChatcnImeSubmitOptions) {
const optionsRef = React.useRef(options)
optionsRef.current = options
const composing = React.useRef(false)
const onCompositionStart = React.useCallback(() => {
composing.current = true
}, [])
const onCompositionEnd = React.useCallback(() => {
window.setTimeout(() => {
composing.current = false
}, 0)
}, [])
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>) => {
if (event.defaultPrevented) return
const native = event.nativeEvent
// biome-ignore lint/suspicious/noExplicitAny: keyCode 229 is the only Safari IME signal
if (native.isComposing || composing.current || (native as any).keyCode === 229) return
const { onSubmit, onStop, streaming, submitOn = "auto", disabled } = optionsRef.current
if (event.key === "Escape" && streaming && onStop) {
event.preventDefault()
onStop()
return
}
if (event.key !== "Enter" || event.shiftKey || event.altKey) return
const mod = event.metaKey || event.ctrlKey
const mode = submitOn === "auto" ? (isCoarsePointer() ? "mod-enter" : "enter") : submitOn
if (mode === "mod-enter" && !mod) return
event.preventDefault()
if (disabled) return
onSubmit()
},
[],
)
return { onKeyDown, onCompositionStart, onCompositionEnd }
}
/**
* Wrap the app once. After the first tooltip opens, the next ones open instantly, the way
* native toolbars behave.
*/
function TooltipProvider({
delay = 500,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return <TooltipPrimitive.Provider delay={delay} closeDelay={0} {...props} />
}
/* -------------------------------------------------------------------------------------------------
* Types
* -----------------------------------------------------------------------------------------------*/
type SubagentStatus = "spawning" | "running" | "waiting" | "done" | "failed" | "cancelled"
/* -------------------------------------------------------------------------------------------------
* Status
*
* One vocabulary for plan steps, todos and tasks. It maps one to one onto Claude Code TodoWrite
* (`pending`, `in_progress`, `completed`) and adds the states a real run also hits.
* -----------------------------------------------------------------------------------------------*/
type TaskStatus =
| "pending"
| "in-progress"
| "done"
| "blocked"
| "skipped"
| "failed"
| "cancelled"
interface SubagentStep {
id: string
/** What the step did: "Read", "Searched". */
label: string
/** Mono context: a path, a query, a command. */
detail?: string
status: TaskStatus
}
interface SubagentProps extends Omit<React.ComponentProps<"div">, "title"> {
/** The subagent's name: "Explore agent". */
name: React.ReactNode
status: SubagentStatus
/** What it was asked to do. Shown at the top of the expanded trace. */
task?: React.ReactNode
/**
* What it is doing right now, one line: "Reading src/app/runs/page.tsx". Changes crossfade in
* place. While done, the first line of `summary` takes this spot.
*/
activity?: string
/** The result it handed back. Shown when done or failed. */
summary?: React.ReactNode
/** One-line text for the folded row once done. Defaults to `summary` when it is a string. */
summaryLine?: string
/** Its steps, rendered as a compact trace. Pass `children` instead for a full custom trace. */
steps?: SubagentStep[]
/** Tool calls so far, shown as "8 tools" next to the timer. */
toolCount?: number
durationMs?: number
startedAt?: number
/** Replaces the glyph at rest (done). Defaults to the subagent icon. */
icon?: React.ReactNode
/** Retry a failed subagent. Shows Retry under its error, in place. */
onRetry?: () => void
/** The expanded trace. Nest `Subagent`s inside to show agents that spawn agents. */
children?: React.ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
function firstLine(node: React.ReactNode) {
return typeof node === "string" ? (node.split("\n")[0] ?? "") : undefined
}
interface StepTimerProps extends Omit<React.ComponentProps<"span">, "children" | "prefix"> {
/** True while the step runs. The counter ticks once per second, in whole seconds. */
running?: boolean
/** Epoch ms when the step started. Measured from the first running render when omitted. */
startedAt?: number
/** Final duration. Shown once `running` is false. */
durationMs?: number
/**
* Hide the live counter until this much time has passed, so quick steps never flash a timer.
* Defaults to 1000 ms.
*/
showAfter?: number
/** Shown while running before `showAfter`, for example "Running". Nothing by default. */
placeholder?: React.ReactNode
/** Format for the final duration: `precise` keeps decimals under 10 s. Defaults to true. */
precise?: boolean
/** Text before the time, for example "Thought for". */
prefix?: React.ReactNode
}
interface UseChatcnElapsedOptions {
/** True while the timed work is happening. */
running: boolean
/** Epoch ms when the work started. Measured from the first `running` render when omitted. */
startedAt?: number
/** Final duration from the runtime. Wins over the measured one once `running` is false. */
durationMs?: number
}
interface UseChatcnElapsedResult {
/** Live elapsed ms while running, the final duration afterwards, `undefined` when unknown. */
ms: number | undefined
running: boolean
}
/**
* Elapsed time for a step, a run or a reasoning block.
*
* Ticks once per second, aligned to the start time so the counter turns over exactly at 1 s,
* 2 s and so on, never at random offsets. A counter that changes faster only adds noise.
*/
function useChatcnElapsed({
running,
startedAt,
durationMs,
}: UseChatcnElapsedOptions): UseChatcnElapsedResult {
const [autoStart, setAutoStart] = React.useState<number>()
const [measured, setMeasured] = React.useState<number>()
// Starts empty and reads the clock after mount: a render must not read it (Next.js prerender).
const [now, setNow] = React.useState<number | null>(null)
const start = startedAt ?? autoStart
// Record our own start when the caller gives none. A new run (running again) starts over.
React.useEffect(() => {
if (!running) return
if (startedAt === undefined) setAutoStart((prev) => prev ?? Date.now())
setMeasured(undefined)
setNow(Date.now())
}, [running, startedAt])
// Freeze the measured duration when the work stops.
const wasRunning = React.useRef(running)
React.useEffect(() => {
if (wasRunning.current && !running && start !== undefined) {
setMeasured(Date.now() - start)
setAutoStart(undefined)
}
wasRunning.current = running
}, [running, start])
React.useEffect(() => {
if (!running || start === undefined) return
let id: number
const schedule = () => {
const elapsed = Date.now() - start
// Next whole-second boundary, plus a few ms so rounding never shows the old second.
const wait = 1000 - (((elapsed % 1000) + 1000) % 1000) + 8
id = window.setTimeout(() => {
setNow(Date.now())
schedule()
}, wait)
}
schedule()
return () => window.clearTimeout(id)
}, [running, start])
if (running)
return { ms: start === undefined ? undefined : Math.max(0, (now ?? start) - start), running }
return { ms: durationMs ?? measured, running }
}
const NBSP = " "
/**
* Formats a duration the chatcn way: numerals, a non-breaking space before the unit.
* `precise` (the default) keeps a decimal under 10 s and milliseconds under 1 s, for finished
* steps. Turn it off for live counters and "Thought for 12 s", which read in whole seconds.
*/
function formatChatcnDuration(ms: number, { precise = true }: { precise?: boolean } = {}) {
const safe = Math.max(0, ms)
if (precise && safe < 1000) return `${Math.max(1, Math.round(safe))}${NBSP}ms`
const s = safe / 1000
if (precise && s < 10) return `${s.toFixed(1)}${NBSP}s`
if (s < 60) return `${precise ? Math.round(s) : Math.floor(s)}${NBSP}s`
const totalS = Math.floor(s)
const h = Math.floor(totalS / 3600)
const m = Math.floor((totalS % 3600) / 60)
const rest = totalS % 60
if (h > 0) return `${h}${NBSP}h ${m}${NBSP}m`
return `${m}${NBSP}m ${rest}${NBSP}s`
}
function isoDuration(ms: number) {
return `PT${Math.max(0, Math.round(ms / 100) / 10)}S`
}
/**
* Elapsed and final duration for a step, set in tabular figures so the width never jitters.
* Not a live region: a timer that speaks every second is noise. Announce completion elsewhere.
*/
function StepTimer({
running = false,
startedAt,
durationMs,
showAfter = 1000,
placeholder = null,
precise = true,
prefix,
className,
...props
}: StepTimerProps) {
const { ms } = useChatcnElapsed({ running, startedAt, durationMs })
let time: string | null = null
if (running && ms !== undefined && ms >= showAfter)
time = formatChatcnDuration(ms, { precise: false })
else if (!running && ms !== undefined) time = formatChatcnDuration(ms, { precise })
const content = time ?? (running ? placeholder : null)
if (content === null || content === undefined) return null
return (
<span
data-slot="step-timer"
data-running={running ? "" : undefined}
role={running ? "timer" : undefined}
className={cn("text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) tabular-nums", className)}
{...props}
>
{/* The placeholder stands alone; a prefix only reads well in front of a time. */}
{prefix && time !== null ? <>{prefix} </> : null}
{time !== null && ms !== undefined ? <time dateTime={isoDuration(ms)}>{time}</time> : content}
</span>
)
}
const STATUS_TEXT: Record<SubagentStatus, string> = {
spawning: "Starting",
running: "Running",
waiting: "Waiting for input",
done: "Done",
failed: "Failed",
cancelled: "Cancelled",
}
const iconNode: IconNode = [
["path", {"d":"M5 3h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2"}],
["path", {"d":"M6.5 11.5v4.25a2.5 2.5 0 0 0 2.5 2.5h3.75"}],
["path", {"d":"M6.5 12h6.25"}],
["path", {"d":"M15.75 9.75h2A1.25 1.25 0 0 1 19 11v2a1.25 1.25 0 0 1-1.25 1.25h-2A1.25 1.25 0 0 1 14.5 13v-2a1.25 1.25 0 0 1 1.25-1.25"}],
["path", {"d":"M15.75 16h2A1.25 1.25 0 0 1 19 17.25v2a1.25 1.25 0 0 1-1.25 1.25h-2a1.25 1.25 0 0 1-1.25-1.25v-2A1.25 1.25 0 0 1 15.75 16"}],
]
const SubagentIcon: Icon = /* @__PURE__ */ createIcon("subagent", iconNode)
const iconNode2: IconNode = [
["path", {"d":"M6.25 14.5V8.25A1.75 1.75 0 0 1 8 6.5a1.75 1.75 0 0 1 1.75 1.75v3.25"}],
["path", {"d":"M9.75 11V6a1.75 1.75 0 0 1 1.75-1.75A1.75 1.75 0 0 1 13.25 6v5"}],
["path", {"d":"M13.25 11V7A1.75 1.75 0 0 1 15 5.25 1.75 1.75 0 0 1 16.75 7v4.5"}],
["path", {"d":"M16.75 12v-2a1.75 1.75 0 0 1 1.75-1.75A1.75 1.75 0 0 1 20.25 10v6.75a4.5 4.5 0 0 1-4.5 4.5H10.1a3 3 0 0 1-2.25-1.01l-4.32-4.89a1.25 1.25 0 0 1-.2-1.33l.02-.06a1.25 1.25 0 0 1 1.91-.48l.99.77"}],
]
const HandIcon: Icon = /* @__PURE__ */ createIcon("hand", iconNode2)
const iconNode3: IconNode = [
["path", {"d":"M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"}],
["path", {"d":"m9 9 6 6"}],
["path", {"d":"m15 9-6 6"}],
]
const CircleXIcon: Icon = /* @__PURE__ */ createIcon("circle-x", iconNode3)
const iconNode4: IconNode = [
["path", {"d":"M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"}],
["path", {"d":"M10.75 8.75h2.5a2 2 0 0 1 2 2v2.5a2 2 0 0 1-2 2h-2.5a2 2 0 0 1-2-2v-2.5a2 2 0 0 1 2-2"}],
]
const CircleStopIcon: Icon = /* @__PURE__ */ createIcon("circle-stop", iconNode4)
/* -------------------------------------------------------------------------------------------------
* Parts
* -----------------------------------------------------------------------------------------------*/
function SubagentGlyph({ status, icon }: { status: SubagentStatus; icon?: React.ReactNode }) {
let glyph: React.ReactNode = icon ?? <SubagentIcon />
let tone = "text-(color:--chatcn-fg-subtle)"
if (status === "running" || status === "spawning") {
glyph = <Spinner />
tone = "text-(color:--chatcn-blue)"
} else if (status === "waiting") {
glyph = <HandIcon />
tone = "text-(color:--chatcn-amber)"
} else if (status === "failed") {
glyph = <CircleXIcon />
tone = "text-(color:--chatcn-red)"
} else if (status === "cancelled") {
glyph = <CircleStopIcon />
}
const key = status === "spawning" ? "running" : status
return (
<span
aria-hidden="true"
data-slot="subagent-status"
className={cn("grid size-4 shrink-0 place-items-center [&_svg]:size-4", tone)}
>
<span
key={key}
className="grid place-items-center motion-safe:animate-[chatcn-swap_150ms_var(--chatcn-ease-out)]"
>
{glyph}
</span>
</span>
)
}
/**
* The only shimmer in chatcn: a status label such as "Thinking". Static under reduced motion.
* Pass `active={false}` to settle it into plain subtle text.
*/
function ShimmerText({
active = true,
className,
...props
}: React.ComponentProps<"span"> & { active?: boolean }) {
return (
<span
data-slot="shimmer-text"
className={cn("font-medium", active ? "chatcn-shimmer" : "text-(color:--chatcn-fg-subtle)", className)}
{...props}
/>
)
}
/* -------------------------------------------------------------------------------------------------
* Crossfading text
* -----------------------------------------------------------------------------------------------*/
interface SwapItem {
key: number
text: string
leaving: boolean
}
/**
* One line of text that crossfades when it changes: the old line fades out over 110 ms while the
* new one fades in over 150 ms in the same spot. No slide, no ticker, no layout shift.
*/
function CrossfadeText({ text, className }: { text: string; className?: string }) {
const [items, setItems] = React.useState<SwapItem[]>(() => [{ key: 0, text, leaving: false }])
const counter = React.useRef(0)
React.useEffect(() => {
setItems((prev) => {
const current = prev[prev.length - 1]
if (current && current.text === text && !current.leaving) return prev
counter.current += 1
return [
...prev.filter((i) => !i.leaving).map((i) => ({ ...i, leaving: true })),
{ key: counter.current, text, leaving: false },
]
})
}, [text])
const hasLeaving = items.some((i) => i.leaving)
React.useEffect(() => {
if (!hasLeaving) return
const id = window.setTimeout(() => setItems((prev) => prev.filter((i) => !i.leaving)), 160)
return () => window.clearTimeout(id)
}, [hasLeaving])
return (
<span data-slot="crossfade-text" className={cn("grid min-w-0", className)}>
{items.map((item) => (
<span
key={item.key}
aria-hidden={item.leaving || undefined}
className={cn(
"col-start-1 row-start-1 min-w-0 truncate",
"ease-(--chatcn-ease-out) transition-opacity",
item.leaving ? "opacity-0 duration-110" : "opacity-100 duration-150",
item.key > 0 && "starting:opacity-0",
)}
>
{item.text}
</span>
))}
</span>
)
}
const iconNode5: IconNode = [
["path", {"d":"m6 9.25 4.23 4.23a2.5 2.5 0 0 0 3.54 0L18 9.25"}],
]
const ChevronDownIcon: Icon = /* @__PURE__ */ createIcon("chevron-down", iconNode5)
interface TaskStatusGlyphProps {
status: TaskStatus
className?: string
}
/**
* Remembers whether a value has changed since mount, so a list of 40 finished todos mounts still
* and only a todo that finishes while you watch plays its check.
*/
function useChangedSinceMount<T>(value: T) {
const first = React.useRef(value)
const changed = React.useRef(false)
if (!Object.is(value, first.current)) changed.current = true
return changed.current
}
function prefersReducedMotion() {
return (
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
)
}
const iconNode6: IconNode = [
["path", {"d":"m4.5 12.75 3.23 3.23a2.5 2.5 0 0 0 3.54 0l8.48-8.48"}],
]
const CheckIcon: Icon = /* @__PURE__ */ createIcon("check", iconNode6)
/**
* The check inside the done disc. When it appears after a real change it draws itself with a
* 180 ms stroke, the one moment of reward in a plan. Static under reduced motion.
*/
function DrawnCheck({ draw }: { draw: boolean }) {
const ref = React.useRef<SVGSVGElement>(null)
React.useLayoutEffect(() => {
if (!draw || prefersReducedMotion()) return
const path = ref.current?.querySelector("path")
if (!path || typeof path.animate !== "function") return
const length = path.getTotalLength()
const animation = path.animate(
[
{ strokeDasharray: `${length}`, strokeDashoffset: `${length}` },
{ strokeDasharray: `${length}`, strokeDashoffset: "0" },
],
{ duration: 180, delay: 40, easing: "cubic-bezier(0.23, 1, 0.32, 1)", fill: "backwards" },
)
return () => animation.cancel()
}, [draw])
return <CheckIcon ref={ref} absoluteStrokeWidth strokeWidth={1.75} className="size-2.5" />
}
const iconNode7: IconNode = [
["path", {"d":"M3 12a9 9 0 1 0 18 0 9 9 0 1 0-18 0"}],
["path", {"d":"M8 12h8"}],
]
const CircleMinusIcon: Icon = /* @__PURE__ */ createIcon("circle-minus", iconNode7)
const iconNode8: IconNode = [
["path", {"d":"M20.85 13.64a9 9 0 0 1-1.43 3.46"}],
["path", {"d":"M17.1 19.42a9 9 0 0 1-3.46 1.43"}],
["path", {"d":"M10.36 20.85a9 9 0 0 1-3.46-1.43"}],
["path", {"d":"M4.58 17.1a9 9 0 0 1-1.43-3.46"}],
["path", {"d":"M3.15 10.36A9 9 0 0 1 4.58 6.9"}],
["path", {"d":"M6.9 4.58a9 9 0 0 1 3.46-1.43"}],
["path", {"d":"M13.64 3.15a9 9 0 0 1 3.46 1.43"}],
["path", {"d":"M19.42 6.9a9 9 0 0 1 1.43 3.46"}],
]
const CircleDashedIcon: Icon = /* @__PURE__ */ createIcon("circle-dashed", iconNode8)
/**
* A 16 px status glyph built on one 14 px circle: a ring waiting, a spinner working, an ink disc
* with a check when done. Blocked, skipped, failed and cancelled use circled icons of the same
* size, so a column of mixed states stays perfectly aligned.
*/
function TaskStatusGlyph({ status, className }: TaskStatusGlyphProps) {
const changed = useChangedSinceMount(status)
let glyph: React.ReactNode
let tone = "text-(color:--chatcn-fg-subtle)"
switch (status) {
case "in-progress":
glyph = <Spinner className="size-3.5" />
tone = "text-(color:--chatcn-blue)"
break
case "done":
glyph = (
<span className="bg-(color:--chatcn-green) text-(color:--chatcn-bg) grid size-3.5 place-items-center rounded-full">
<DrawnCheck draw={changed} />
</span>
)
break
case "blocked":
glyph = <CircleMinusIcon className="size-4" />
tone = "text-(color:--chatcn-amber)"
break
case "skipped":
glyph = <CircleDashedIcon className="size-4" />
tone = "text-(color:--chatcn-fg-faint)"
break
case "failed":
glyph = <CircleXIcon className="size-4" />
tone = "text-(color:--chatcn-red)"
break
case "cancelled":
glyph = <CircleStopIcon className="size-4" />
break
default:
glyph = (
<span className="size-3.5 rounded-full shadow-[inset_0_0_0_1.5px_var(--chatcn-border-strong)]" />
)
}
return (
<span
aria-hidden="true"
data-slot="task-status"
data-task-status={status}
className={cn("relative grid size-4 shrink-0 place-items-center", tone, className)}
>
<span
key={status}
className={cn(
"grid place-items-center",
changed && "motion-safe:animate-[chatcn-swap_150ms_var(--chatcn-ease-out)]",
)}
>
{glyph}
</span>
</span>
)
}
/** A compact trace for `steps`: 28 px rows, status glyph, label, mono detail. */
function SubagentSteps({ steps }: { steps: SubagentStep[] }) {
return (
<ol data-slot="subagent-steps" className="flex flex-col">
{steps.map((step) => (
<li key={step.id} className="flex h-7 min-w-0 items-center gap-2.5">
<TaskStatusGlyph status={step.status} />
<span
className={cn(
"text-[0.8125rem]/[1.25rem] shrink-0",
step.status === "pending" ? "text-(color:--chatcn-fg-subtle)" : "text-(color:--chatcn-fg)",
)}
>
{step.label}
</span>
{step.detail ? (
<span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) min-w-0 truncate font-mono">
{step.detail}
</span>
) : null}
</li>
))}
</ol>
)
}
/* -------------------------------------------------------------------------------------------------
* Subagent
* -----------------------------------------------------------------------------------------------*/
/**
* A subagent, folded to one line that says what it is doing right now. Expand it to see its own
* trace on a rail, and its result once it is done. Nest them for agents that spawn agents.
*/
function Subagent({
name,
status,
task,
activity,
summary,
summaryLine,
steps,
toolCount,
durationMs,
startedAt,
icon,
onRetry,
children,
open,
defaultOpen,
onOpenChange,
className,
...props
}: SubagentProps) {
const working = status === "running" || status === "spawning"
// Opened by the status it mounts with (a failure opens it); later status changes do not fight
// an uncontrolled panel.
const [initialOpen] = React.useState(
() => defaultOpen ?? (status === "failed" || status === "waiting"),
)
const settled = status === "done" || status === "failed" || status === "cancelled"
const line =
status === "spawning"
? undefined
: status === "waiting"
? (activity ?? "Waiting for input")
: settled
? (summaryLine ?? firstLine(summary) ?? activity)
: activity
const hasTrace =
(children !== undefined && children !== null && children !== false) ||
(steps !== undefined && steps.length > 0) ||
task !== undefined ||
summary !== undefined ||
(status === "failed" && onRetry !== undefined)
let tail: React.ReactNode = null
if (working || status === "done") {
tail = (
<StepTimer
running={working}
startedAt={startedAt}
durationMs={durationMs}
className="text-inherit"
/>
)
} else if (status === "failed") tail = <span className="text-(color:--chatcn-red)">Failed</span>
else if (status === "cancelled" || status === "waiting") tail = <span>{STATUS_TEXT[status]}</span>
const meta = (
<span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) flex shrink-0 items-center gap-1.5 tabular-nums">
{toolCount !== undefined ? (
<span>
{toolCount} {toolCount === 1 ? "tool" : "tools"}
</span>
) : null}
{toolCount !== undefined && tail ? (
// Hidden until something follows it: a timer renders nothing in its first second.
<span aria-hidden="true" className="text-(color:--chatcn-fg-faint) hidden [&:has(+*)]:inline">
·
</span>
) : null}
{tail}
</span>
)
return (
<Collapsible.Root
open={open}
defaultOpen={initialOpen}
onOpenChange={onOpenChange}
render={<div />}
data-slot="subagent"
data-subagent-status={status}
data-subagent-expandable={hasTrace ? "" : undefined}
className={cn("group/subagent text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg) min-w-0", className)}
{...props}
>
<Collapsible.Trigger
disabled={!hasTrace}
className={cn(
"rounded-(--chatcn-radius-md) flex h-8 w-full min-w-0 items-center gap-2.5 px-2 text-start outline-none select-none",
"ease-(--chatcn-ease-standard) transition-colors duration-150",
"enabled:hover:bg-(color:--chatcn-fill-hover) focus-visible:outline-(color:--chatcn-accent) focus-visible:outline-2 focus-visible:-outline-offset-2",
"pointer-coarse:h-11",
// Beside agents that expand, a static row keeps the chevron's slot so meta lines up.
!hasTrace && "[:has(>[data-subagent-expandable])>*>&]:pe-[2.125rem]",
)}
>
<SubagentGlyph status={status} icon={icon} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="shrink-0 font-medium">{name}</span>
<span className="sr-only">, {STATUS_TEXT[status]}</span>
{status === "spawning" ? (
<ShimmerText className="text-[0.8125rem]/[1.25rem]">Starting</ShimmerText>
) : line ? (
<CrossfadeText
text={line}
className={cn(
"text-[0.8125rem]/[1.25rem] flex-1",
working ? "text-(color:--chatcn-fg-muted)" : "text-(color:--chatcn-fg-subtle)",
)}
/>
) : null}
</span>
{meta}
{hasTrace ? (
<ChevronDownIcon
aria-hidden="true"
className={cn(
"text-(color:--chatcn-fg-subtle) ease-(--chatcn-ease-out) size-4 shrink-0 transition-transform duration-200",
"motion-reduce:transition-none [[data-panel-open]>&]:rotate-180",
)}
/>
) : null}
</Collapsible.Trigger>
{hasTrace ? (
<Collapsible.Panel
className={cn(
"h-(--collapsible-panel-height) overflow-hidden",
"ease-(--chatcn-ease-out) transition-[height,opacity] duration-200 motion-reduce:transition-none",
"data-[ending-style]:h-0 data-[ending-style]:opacity-0 data-[ending-style]:duration-150",
"data-[starting-style]:h-0 data-[starting-style]:opacity-0",
)}
>
{/* The rail lines up with the glyph above, so nested agents read as one tree. */}
<div
data-slot="subagent-trace"
className="border-(color:--chatcn-border) ms-[15.5px] mt-0.5 mb-1.5 flex flex-col gap-2 border-s ps-[17.5px] pt-1 pb-1"
>
{task ? (
<div data-slot="subagent-task" className="flex flex-col gap-1">
<span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) font-medium">Task</span>
<p className="text-[0.8125rem]/[1.25rem] text-(color:--chatcn-fg-muted) text-pretty">{task}</p>
</div>
) : null}
{children ?? (steps && steps.length > 0 ? <SubagentSteps steps={steps} /> : null)}
{settled && summary ? (
<div data-slot="subagent-result" className="flex flex-col gap-1">
<span className="text-[0.75rem]/[1rem] text-(color:--chatcn-fg-subtle) font-medium">
{status === "failed" ? "Error" : "Result"}
</span>
<div
className={cn(
"text-[0.8125rem]/[1.25rem] text-pretty",
status === "failed" ? "text-(color:--chatcn-fg)" : "text-(color:--chatcn-fg-muted)",
)}
>
{summary}
</div>
</div>
) : null}
{status === "failed" && onRetry ? (
<div className="-ms-2 flex">
<Button
size="xs"
onClick={onRetry}
className="relative pointer-coarse:after:absolute pointer-coarse:after:-inset-2.5 pointer-coarse:after:content-['']"
>
<RotateCcwIcon aria-hidden="true" />
Retry
</Button>
</div>
) : null}
</div>
</Collapsible.Panel>
) : null}
<span aria-live="polite" className="sr-only">
{settled
? `${typeof name === "string" ? name : "Subagent"} ${STATUS_TEXT[status].toLowerCase()}`
: ""}
</span>
</Collapsible.Root>
)
}
const iconNode9: IconNode = [
["path", {"d":"m18.5 11.5-4.73-4.73a2.5 2.5 0 0 0-3.54 0L5.5 11.5"}],
["path", {"d":"M12 19v-9"}],
]
const ArrowUpIcon: Icon = /* @__PURE__ */ createIcon("arrow-up", iconNode9)
/**
* The agent works in steps. Steering messages go in at the next step; the rest wait for the run
* to finish. Type below to add to the queue while it works.
*/
function QueuedMessagesDemo() {
const [queue, setQueue] = React.useState(SEED)
const [step, setStep] = React.useState(0)
const [delivered, setDelivered] = React.useState<string[]>([])
const [draft, setDraft] = React.useState("")
const running = step < ACTIVITY.length
const queueRef = React.useRef(queue)
queueRef.current = queue
// Each step boundary delivers steering messages; the end of the run delivers the rest.
React.useEffect(() => {
const id = window.setTimeout(
() => {
const boundary = running
const due = queueRef.current.filter(
(m) => (boundary ? m.mode === "steer" : true) && m.status !== "sending",
)
if (due.length > 0) {
const ids = new Set(due.map((m) => m.id))
setQueue((q) => q.map((m) => (ids.has(m.id) ? { ...m, status: "sending" } : m)))
window.setTimeout(() => {
setQueue((q) => q.filter((m) => !ids.has(m.id)))
setDelivered((d) => [...d, ...due.map((m) => m.text)].slice(-3))
}, 700)
}
setStep((s) => (s >= ACTIVITY.length + 2 ? 0 : s + 1))
},
running ? 3200 : 2400,
)
return () => window.clearTimeout(id)
}, [step, running])
const submit = () => {
const text = draft.trim()
if (!text) return
counter += 1
setQueue((q) => [...q, { id: `new-${counter}`, text, mode: "after-run" }])
setDraft("")
}
const keys = useChatcnImeSubmit({ onSubmit: submit, submitOn: "enter" })
return (
<TooltipProvider>
<div className="flex w-full max-w-2xl flex-col gap-3">
<div className="flex min-h-24 flex-col justify-end gap-2">
{delivered.map((text, i) => (
<p
key={`${i}-${text}`}
className="bg-(color:--chatcn-fill) text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg) rounded-(--chatcn-radius-lg) ms-auto max-w-[80%] px-3.5 py-2 text-pretty transition-[opacity,translate] duration-200 starting:translate-y-1 starting:opacity-0 motion-reduce:starting:translate-y-0"
>
{text}
</p>
))}
<Subagent
name="Coding agent"
status={running ? "running" : "done"}
activity={ACTIVITY[Math.min(step, ACTIVITY.length - 1)]}
summaryLine="Run finished. Delivering queued messages."
toolCount={Math.min(step + 1, ACTIVITY.length)}
/>
</div>
<QueuedMessages items={queue} onItemsChange={setQueue} />
<div className="rounded-(--chatcn-radius-lg) bg-(color:--chatcn-card) shadow-(--chatcn-shadow-xs) focus-within:shadow-(--chatcn-shadow-sm) flex items-center gap-2 py-2 ps-4 pe-2">
<textarea
value={draft}
rows={1}
onChange={(e) => setDraft(e.target.value)}
{...keys}
placeholder="Add to the queue while the agent works…"
aria-label="Message"
className="text-[0.875rem]/[1.25rem] tracking-[-0.003em] text-(color:--chatcn-fg) placeholder:text-(color:--chatcn-fg-subtle) field-sizing-content max-h-32 min-w-0 flex-1 resize-none bg-transparent py-1.5 outline-none"
/>
<button
type="button"
aria-label="Queue message"
onClick={submit}
disabled={!draft.trim()}
className="chatcn-focus chatcn-press bg-(color:--chatcn-fg) text-(color:--chatcn-bg) rounded-(--chatcn-radius-md) grid size-8 shrink-0 place-items-center disabled:opacity-30"
>
<ArrowUpIcon className="size-4" />
</button>
</div>
</div>
</TooltipProvider>
)
}
/** 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 QueuedMessagesPreview() {
return (
<FitFrame minWidth="34rem">
<div className="flex w-full flex-col items-center justify-center gap-4 p-6">
<div className="flex w-full max-w-2xl justify-center">
<QueuedMessagesDemo />
</div>
<MoreOn slug="queued-messages">Messages queued while the agent is still working. On</MoreOn>
</div>
</FitFrame>
)
}








