Agent 会话流
NewAI agent 会话流组件族:用户消息 pill、回合头、Markdown 排版容器、思考与工具调用状态行、审批请求卡、流式输出光标、带展开动画的文件与变更产物卡、命令执行行,以及悬停显现的回合操作栏。
"use client";
import {
ChevronDown,
Copy,
FileText,
Globe,
Info,
MessageCircleQuestion,
Plus,
Share2,
ShieldAlert,
SquarePen,
SquareTerminal,
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import {
Composer,
ComposerIconButton,
ComposerSendButton,
ComposerTextarea,
ComposerToolbar,
} from "@/components/motion/agent-composer";
import {
Thread,
ThreadActionBar,
ThreadActionButton,
ThreadApprovalCard,
ThreadBranchSwitcher,
ThreadCardButton,
ThreadCheckpoint,
ThreadCodeBlock,
ThreadCollapse,
ThreadCommandRow,
ThreadDiffCard,
ThreadDiffRow,
ThreadElicitation,
ThreadErrorState,
ThreadFileCard,
ThreadInlineCode,
ThreadItem,
ThreadMessage,
ThreadScrollPill,
ThreadSuggestions,
ThreadSystemBanner,
ThreadTask,
ThreadTaskList,
ThreadThinking,
ThreadToolCall,
ThreadTurnHeader,
ThreadUsage,
ThreadUserMessage,
} from "@/components/motion/agent-thread";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
/** Desktop-wallpaper backdrop behind the (transparent) thread + composer. */
function Backdrop() {
return (
<>
<div
className="absolute inset-0 dark:hidden"
style={{
background:
"radial-gradient(ellipse at 20% 20%, rgba(99, 102, 241, 0.25), transparent 55%), " +
"radial-gradient(ellipse at 80% 15%, rgba(236, 72, 153, 0.18), transparent 50%), " +
"radial-gradient(ellipse at 50% 100%, rgba(45, 212, 191, 0.2), transparent 55%), " +
"linear-gradient(180deg, #eef1f5, #e4e8ef)",
}}
/>
<div
className="absolute inset-0 hidden dark:block"
style={{
background:
"radial-gradient(ellipse at 20% 20%, rgba(99, 102, 241, 0.28), transparent 55%), " +
"radial-gradient(ellipse at 80% 15%, rgba(217, 70, 160, 0.22), transparent 50%), " +
"radial-gradient(ellipse at 50% 100%, rgba(20, 184, 166, 0.22), transparent 55%), " +
"linear-gradient(180deg, #17181d, #0e0f12)",
}}
/>
</>
);
}
/** Mid-turn: shimmering header + thinking, a live search, streaming prose and a running command. */
function StreamingScene() {
const [seconds, setSeconds] = useState(8);
useEffect(() => {
const id = setInterval(() => setSeconds((s) => s + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<Thread>
<ThreadItem>
<ThreadUserMessage>Read the handoff doc and prep the release.</ThreadUserMessage>
</ThreadItem>
<ThreadItem>
<div className="group/turn flex flex-col">
<ThreadTurnHeader working>{`Working… · ${seconds}s`}</ThreadTurnHeader>
<ThreadThinking thinking label="Thinking…" />
<ThreadTaskList title="Release prep" progress="1/4">
<ThreadTask status="done">Read handoff doc</ThreadTask>
<ThreadTask status="active">Draft launch steps</ThreadTask>
<ThreadTask status="pending">Verify build</ThreadTask>
<ThreadTask status="pending">Stage the diff</ThreadTask>
</ThreadTaskList>
<ThreadToolCall
status="running"
icon={<Globe className="h-4 w-4" />}
detail={<span className="font-mono text-[13px]">release checklist</span>}
>
Searching the web
</ThreadToolCall>
<ThreadToolCall status="done" icon={<FileText className="h-4 w-4" />}>
Read <span className="font-mono text-[13px]">backend/pom.xml</span>
</ThreadToolCall>
<ThreadMessage streaming className="py-1">
The smoke suite is green so far — pulling the checklist together and drafting the
launch steps
</ThreadMessage>
<ThreadCommandRow icon={<SquareTerminal className="h-4 w-4" />} running>
Running <span className="font-mono text-[13px]">./mvnw -q verify</span>
</ThreadCommandRow>
</div>
</ThreadItem>
</Thread>
);
}
/** Finished turns: thought summary, settled tool calls (including a failure), artifacts and actions. */
function DoneScene() {
const [turn1Open, setTurn1Open] = useState(true);
const [turn2Open, setTurn2Open] = useState(true);
return (
<Thread>
<ThreadItem>
<ThreadUserMessage>Read the handoff doc and prep the release.</ThreadUserMessage>
</ThreadItem>
<ThreadItem>
<div className="group/turn flex flex-col">
<ThreadTurnHeader open={turn1Open} onOpenChange={setTurn1Open}>
Worked for 2m 4s
</ThreadTurnHeader>
<ThreadCollapse open={turn1Open}>
<ThreadThinking label="Thought for 8s">
The release prep needs the smoke suite green before anything ships. Validating the
build first keeps the riskier deploy steps behind a checkpoint.
</ThreadThinking>
<ThreadToolCall icon={<Globe className="h-4 w-4" />}>
Searched the web · 2 searches
</ThreadToolCall>
<ThreadToolCall icon={<FileText className="h-4 w-4" />}>
Read <span className="font-mono text-[13px]">LAUNCH-STEPS.md</span>
</ThreadToolCall>
<ThreadToolCall status="error" icon={<SquareTerminal className="h-4 w-4" />}>
Command failed <span className="font-mono text-[13px]">./mvnw deploy</span>
</ThreadToolCall>
</ThreadCollapse>
<ThreadMessage>
<p>
Pulled the latest handoff notes at <ThreadInlineCode>efd14fb</ThreadInlineCode> and
walked through the release checklist before touching anything.
</p>
<ul>
<li>Confirmed the smoke-test suite is green on main</li>
<li>
Ran <ThreadInlineCode>./mvnw validate</ThreadInlineCode> to catch config drift early
</li>
<li>Drafted the launch steps doc for the on-call reviewer</li>
</ul>
<ThreadCodeBlock label="bash">./mvnw -q -Dtest=SmokeTest test</ThreadCodeBlock>
<p>
Everything passed, so I moved on to writing up the handoff and staging the diff
below.
</p>
</ThreadMessage>
<ThreadFileCard
icon={<FileText className="h-6 w-6" />}
title="LAUNCH-STEPS.md"
subtitle="Document · MD"
action={
<ThreadCardButton>
Open <ChevronDown className="h-3.5 w-3.5" />
</ThreadCardButton>
}
/>
<ThreadDiffCard
icon={<SquarePen className="h-5 w-5" />}
title="Edited 3 files"
added={29}
removed={5}
actions={
<>
<ThreadCardButton variant="ghost">Undo</ThreadCardButton>
<ThreadCardButton>Review</ThreadCardButton>
</>
}
moreLabel="Show 2 more files"
moreCount={2}
hiddenRows={
<>
<ThreadDiffRow
path="backend/src/main/resources/application.yml"
added={4}
removed={1}
/>
<ThreadDiffRow path="CHANGELOG.md" added={2} removed={0} />
</>
}
>
<ThreadDiffRow path="backend/pom.xml" added={23} removed={0} />
</ThreadDiffCard>
<ThreadCommandRow icon={<SquareTerminal className="h-4 w-4" />}>
Ran <span className="font-mono text-[13px]">./mvnw -q verify</span>
</ThreadCommandRow>
<ThreadActionBar timestamp="Thu 22:09">
<ThreadActionButton aria-label="Copy">
<Copy className="h-3.5 w-3.5" />
</ThreadActionButton>
<ThreadActionButton aria-label="Good response">
<ThumbsUp className="h-3.5 w-3.5" />
</ThreadActionButton>
<ThreadActionButton aria-label="Bad response">
<ThumbsDown className="h-3.5 w-3.5" />
</ThreadActionButton>
<ThreadActionButton aria-label="Share">
<Share2 className="h-3.5 w-3.5" />
</ThreadActionButton>
</ThreadActionBar>
<ThreadUsage
cost={0.024}
inputTokens={12300}
outputTokens={1200}
duration="8.4s"
cacheHitRate={0.78}
className="pb-1"
/>
</div>
</ThreadItem>
<ThreadCheckpoint timestamp="22:10" onRestore={() => {}} />
<ThreadItem>
<ThreadUserMessage>What branch are we on?</ThreadUserMessage>
</ThreadItem>
<ThreadItem>
<div className="group/turn flex flex-col">
<ThreadTurnHeader open={turn2Open} onOpenChange={setTurn2Open}>
Worked for 24s
</ThreadTurnHeader>
<ThreadCollapse open={turn2Open}>
<ThreadToolCall icon={<FileText className="h-4 w-4" />}>
Read <span className="font-mono text-[13px]">.git/HEAD</span>
</ThreadToolCall>
</ThreadCollapse>
<ThreadMessage>
<p>
You're on <ThreadInlineCode>main</ThreadInlineCode>, up to date with origin.
</p>
</ThreadMessage>
<ThreadActionBar timestamp="Thu 22:11">
<ThreadActionButton aria-label="Copy">
<Copy className="h-3.5 w-3.5" />
</ThreadActionButton>
</ThreadActionBar>
</div>
</ThreadItem>
</Thread>
);
}
/** Approval gate: the agent pauses on a sensitive command until the user approves or denies. */
function ApprovalScene() {
const [decision, setDecision] = useState<"pending" | "approved" | "denied">("pending");
return (
<Thread>
<ThreadItem>
<ThreadUserMessage>Clean install the dependencies.</ThreadUserMessage>
</ThreadItem>
<ThreadItem>
<div className="group/turn flex flex-col">
<ThreadTurnHeader working>Working… · 4s</ThreadTurnHeader>
<ThreadMessage>
<p>
A clean install means wiping the existing packages first — that step needs your
sign-off before I run it.
</p>
</ThreadMessage>
<ThreadApprovalCard
icon={<ShieldAlert className="h-5 w-5" />}
title="Approval required"
description="This command can modify files outside the workspace."
command="rm -rf node_modules && bun install"
status={decision}
resolution={
decision === "approved" ? "Approved" : decision === "denied" ? "Denied" : undefined
}
onApprove={() => setDecision("approved")}
onDeny={() => setDecision("denied")}
/>
{decision === "approved" ? (
<ThreadCommandRow icon={<SquareTerminal className="h-4 w-4" />} running>
Running{" "}
<span className="font-mono text-[13px]">{"rm -rf node_modules && bun install"}</span>
</ThreadCommandRow>
) : null}
</div>
</ThreadItem>
</Thread>
);
}
/** State-row gallery: branch switching, a system notice, a blocking
* clarification picker, an error/retry row and suggested follow-ups. */
function StatesScene() {
const [branchIndex, setBranchIndex] = useState(2);
const branchCount = 3;
const [envChoice, setEnvChoice] = useState<string | null>(null);
const envOptions = [
{ value: "staging", label: "Staging", description: "Safe to break, resets nightly" },
{ value: "production", label: "Production", description: "Live traffic — needs approval" },
{ value: "local", label: "Local", description: "Your machine only" },
];
const suggestions = [
{ value: "logs", label: "Show me the logs" },
{ value: "retry", label: "Retry the search" },
{ value: "skip", label: "Skip this step" },
];
return (
<Thread>
<ThreadItem>
<div className="flex flex-col items-end gap-1">
<ThreadUserMessage>Target the staging environment for this run.</ThreadUserMessage>
<ThreadBranchSwitcher
index={branchIndex}
count={branchCount}
onPrev={() => setBranchIndex((i) => Math.max(1, i - 1))}
onNext={() => setBranchIndex((i) => Math.min(branchCount, i + 1))}
/>
</div>
</ThreadItem>
<ThreadSystemBanner icon={<Info className="h-3 w-3" />}>
Model switched to 5.6
</ThreadSystemBanner>
<ThreadItem>
<ThreadElicitation
icon={<MessageCircleQuestion className="h-5 w-5" />}
prompt="Which environment should I target?"
options={envOptions}
value={envChoice}
onSelect={setEnvChoice}
/>
</ThreadItem>
<ThreadItem>
<ThreadErrorState message="Network error while calling the search tool." onRetry={() => {}} />
</ThreadItem>
<ThreadItem>
<ThreadSuggestions suggestions={suggestions} onSelect={() => {}} />
</ThreadItem>
</Thread>
);
}
type Scene = "streaming" | "done" | "approval" | "states";
const SCENES: Array<{ id: Scene; label: string }> = [
{ id: "streaming", label: "Streaming" },
{ id: "done", label: "Done" },
{ id: "approval", label: "Approval" },
{ id: "states", label: "States" },
];
export function AgentThreadPreview() {
const reduce = useReducedMotion() ?? false;
const [scene, setScene] = useState<Scene>("streaming");
const [reply, setReply] = useState("");
const [pillOpen, setPillOpen] = useState(false);
useEffect(() => {
if (scene !== "states") {
setPillOpen(false);
return;
}
const id = setTimeout(() => setPillOpen(true), 1200);
return () => clearTimeout(id);
}, [scene]);
const sceneMotion = {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
animate: reduce ? { opacity: 1 } : { opacity: 1, y: 0 },
exit: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
transition: { duration: 0.15, ease: EASE_OUT },
} as const;
return (
<div className="relative h-[560px] w-full overflow-hidden rounded-xl border border-border">
<Backdrop />
<div className="absolute top-3 right-3 z-10 flex gap-1 rounded-full border border-border bg-white/80 p-1 backdrop-blur dark:bg-neutral-900/80">
{SCENES.map((s) => (
<button
key={s.id}
type="button"
aria-pressed={scene === s.id}
onClick={() => setScene(s.id)}
className={cn(
"h-6 rounded-full px-2.5 text-xs transition-colors",
scene === s.id
? "bg-foreground text-background"
: "text-muted-foreground hover:text-foreground",
)}
>
{s.label}
</button>
))}
</div>
<div className="relative flex h-full flex-col">
<div className="relative flex-1 overflow-y-auto py-6">
<AnimatePresence mode="wait" initial={false}>
<motion.div key={scene} {...sceneMotion}>
{scene === "streaming" ? (
<StreamingScene />
) : scene === "done" ? (
<DoneScene />
) : scene === "approval" ? (
<ApprovalScene />
) : (
<StatesScene />
)}
</motion.div>
</AnimatePresence>
<ThreadScrollPill open={pillOpen} count={2} onClick={() => setPillOpen(false)} />
</div>
<div className="px-6 pb-6">
<Composer>
<ComposerTextarea
value={reply}
onChange={setReply}
placeholder="Reply…"
aria-label="Reply"
/>
<ComposerToolbar>
<ComposerIconButton aria-label="Add">
<Plus className="h-4 w-4" />
</ComposerIconButton>
<div className="ml-auto" />
<ComposerSendButton disabled={reply.trim().length === 0} />
</ComposerToolbar>
</Composer>
</div>
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-thread
import { ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { ThreadShimmerText } from "./cards";
import { ThreadStreamingCaret } from "./status";
export {
ThreadCard,
ThreadCardButton,
ThreadCommandRow,
ThreadDiffCard,
ThreadDiffRow,
ThreadFileCard,
ThreadShimmerText,
} from "./cards";
export {
ThreadApprovalCard,
ThreadBranchSwitcher,
ThreadCheckpoint,
ThreadElicitation,
ThreadErrorState,
ThreadScrollPill,
ThreadStreamingCaret,
ThreadSuggestions,
ThreadSystemBanner,
ThreadTask,
ThreadTaskList,
ThreadThinking,
ThreadToolCall,
ThreadUsage,
} from "./status";
export interface ThreadProps {
className?: string;
children?: ReactNode;
}
/**
* Conversation column — centers the message stream at a fixed reading
* width. Renders `children` directly; spacing between items (user messages,
* turns) comes from each item's own margins rather than a gap here, so a
* lone item still looks right regardless of what precedes it.
*/
export function Thread({ className, children }: ThreadProps) {
return (
<div className={cn("relative mx-auto flex w-full max-w-3xl flex-col px-4", className)}>
{children}
</div>
);
}
export interface ThreadItemProps {
className?: string;
children?: ReactNode;
}
/**
* Entrance wrapper for a stream item (a user message or an agent turn) as it
* streams in — a short opacity + upward slide, reduced to an opacity-only
* fade under `useReducedMotion()`. Purely presentational: callers decide
* whether to wrap a given item at all (e.g. history rendered on first paint
* may skip it to avoid replaying the entrance).
*/
export function ThreadItem({ className, children }: ThreadItemProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={className}
>
{children}
</motion.div>
);
}
export interface ThreadUserMessageProps {
className?: string;
children?: ReactNode;
}
/** Right-aligned user message — a rounded gray pill capped at 77% of the column width. */
export function ThreadUserMessage({ className, children }: ThreadUserMessageProps) {
return (
<div className={cn("group flex w-full flex-col items-end gap-1 py-3", className)}>
<div className="max-w-[77%] overflow-hidden break-words rounded-2xl bg-[var(--wb-inset)] px-3 py-2 text-sm leading-[22px]">
{children}
</div>
</div>
);
}
export interface ThreadTurnHeaderProps {
/** Controlled open state — purely a chevron-rotation signal (see below). */
open?: boolean;
onOpenChange?: (next: boolean) => void;
/** While the turn is still executing: shimmers the label. The header stays fully interactive — a running turn's live work log can be expanded and collapsed too. */
working?: boolean;
className?: string;
children?: ReactNode;
}
/**
* Turn-header button, e.g. "Worked for 2m 4s ›". Renders only the header
* itself — label plus a chevron that rotates 90° to signal open/closed —
* and does not fold or animate any content region below it: pair it with
* `ThreadCollapse` (wired to the same `open`) to collapse the turn's work
* log, or render your own region. Works controlled (`open`/`onOpenChange`)
* or uncontrolled. `working` (the turn is still executing, e.g. a live
* elapsed-seconds label) only swaps the label into `ThreadShimmerText`;
* chevron and toggling stay live, since a running turn's work log can be
* inspected mid-flight.
*/
export function ThreadTurnHeader({
open: openProp,
onOpenChange,
working = false,
className,
children,
}: ThreadTurnHeaderProps) {
const reduce = useReducedMotion() ?? false;
const [openState, setOpenState] = useState(false);
const open = openProp ?? openState;
const toggle = () => {
const next = !open;
setOpenState(next);
onOpenChange?.(next);
};
return (
<button
type="button"
aria-expanded={open}
onClick={toggle}
className={cn(
"-mx-1 my-3 inline-flex items-center gap-1 self-start rounded-lg px-1 text-sm text-muted-foreground transition-colors hover:bg-[var(--wb-hover)]",
className,
)}
>
{working ? <ThreadShimmerText>{children}</ThreadShimmerText> : children}
<motion.span
aria-hidden
className="flex text-muted-foreground/60"
animate={reduce ? undefined : { rotate: open ? 90 : 0 }}
style={reduce ? { transform: open ? "rotate(90deg)" : "rotate(0deg)" } : undefined}
transition={reduce ? undefined : { duration: 0.2, ease: EASE_OUT }}
>
<ChevronRight className="h-3.5 w-3.5" />
</motion.span>
</button>
);
}
export interface ThreadCollapseProps {
open: boolean;
className?: string;
children?: ReactNode;
}
/**
* Measured-height collapse region — the pairing for `ThreadTurnHeader`: put
* the turn's work log (thinking row, tool calls, interim notes) inside and
* wire `open` to the header's state so clicking the header collapses and
* expands it. Height comes from a `ResizeObserver`, so content that changes
* while open (e.g. rows streaming in) is tracked; animated 0 ↔ measured
* with `EASE_OUT` (0.25s), switched instantly under `useReducedMotion()`.
*/
export function ThreadCollapse({ open, className, children }: ThreadCollapseProps) {
const reduce = useReducedMotion() ?? false;
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const update = () => setContentHeight(node.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<motion.div
initial={false}
animate={reduce ? undefined : { height: open ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (open ? "h-auto" : "h-0"), className)}
>
<div ref={contentRef} className="flex flex-col">
{children}
</div>
</motion.div>
);
}
export interface ThreadMessageProps {
/** Appends a blinking `ThreadStreamingCaret` after `children` while the response is still streaming in. */
streaming?: boolean;
className?: string;
children?: ReactNode;
}
/**
* Typography container for agent-authored prose. A pure styling shell — the
* Markdown → JSX rendering engine is left to the caller (this component
* doesn't depend on any particular one); descendant selectors give
* paragraphs, lists and inline marks consistent spacing regardless of which
* renderer produced them. `streaming` appends a `ThreadStreamingCaret` at
* the container tail — after a block-level last paragraph that lands on its
* own line; to embed the caret inside the last line of text, place
* `ThreadStreamingCaret` directly in your own JSX instead.
*/
export function ThreadMessage({ streaming = false, className, children }: ThreadMessageProps) {
return (
<div
className={cn(
"text-sm leading-[22px] text-foreground",
"[&_p]:mb-[11px] [&_p:last-child]:mb-0 [&_ul]:mb-[11px] [&_ul]:list-disc [&_ul]:pl-[21px] [&_ol]:mb-[11px] [&_ol]:list-decimal [&_ol]:pl-[21px] [&_li]:pl-0.5 [&_a]:underline [&_a]:underline-offset-2 [&_strong]:font-semibold",
className,
)}
>
{children}
{streaming ? <ThreadStreamingCaret /> : null}
</div>
);
}
export interface ThreadInlineCodeProps {
className?: string;
children?: ReactNode;
}
/** Inline code chip for use inside `ThreadMessage` prose. */
export function ThreadInlineCode({ className, children }: ThreadInlineCodeProps) {
return (
<span
className={cn(
"rounded-[6px] bg-[var(--wb-code-inline)] px-1.5 py-px font-mono text-[0.92em]",
className,
)}
>
{children}
</span>
);
}
export interface ThreadCodeBlockProps {
/** Small label in the top-right corner, e.g. a language name like "bash". */
label?: ReactNode;
className?: string;
children?: ReactNode;
}
/** Fenced code block for `ThreadMessage` prose — a `pre > code` structure with an optional corner label. */
export function ThreadCodeBlock({ label, className, children }: ThreadCodeBlockProps) {
return (
<div className={cn("relative mb-[11px]", className)}>
{label ? (
<span className="absolute top-2 right-3 text-muted-foreground text-xs">{label}</span>
) : null}
<pre className="overflow-x-auto whitespace-pre rounded-xl bg-[var(--wb-code-block)] p-3 font-mono text-sm leading-[22px]">
<code>{children}</code>
</pre>
</div>
);
}
export interface ThreadActionBarProps {
/** Rendered after the buttons, e.g. a relative send time. */
timestamp?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Turn-tail action row — hidden until the turn is hovered or a child gains
* focus. Pair it with a parent that carries the `group/turn` class (see the
* preview) so `group-hover/turn:opacity-100` has something to key off; a
* bare hover on the bar itself would only reveal it once the pointer is
* already over these 20px-tall icons.
*/
export function ThreadActionBar({ timestamp, className, children }: ThreadActionBarProps) {
return (
<div
className={cn(
"flex h-5 items-center gap-0.5 text-muted-foreground opacity-0 transition-opacity focus-within:opacity-100 group-hover/turn:opacity-100",
className,
)}
>
{children}
{timestamp ? <span className="ml-1.5 text-muted-foreground/80 text-xs">{timestamp}</span> : null}
</div>
);
}
export interface ThreadActionButtonProps {
"aria-label": string;
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** One icon button inside a `ThreadActionBar` (copy, react, share, ...). */
export function ThreadActionButton({
"aria-label": ariaLabel,
onClick,
children,
className,
}: ThreadActionButtonProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-thread
import { ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { ThreadShimmerText } from "./cards";
import { ThreadStreamingCaret } from "./status";
export {
ThreadCard,
ThreadCardButton,
ThreadCommandRow,
ThreadDiffCard,
ThreadDiffRow,
ThreadFileCard,
ThreadShimmerText,
} from "./cards";
export {
ThreadApprovalCard,
ThreadBranchSwitcher,
ThreadCheckpoint,
ThreadElicitation,
ThreadErrorState,
ThreadScrollPill,
ThreadStreamingCaret,
ThreadSuggestions,
ThreadSystemBanner,
ThreadTask,
ThreadTaskList,
ThreadThinking,
ThreadToolCall,
ThreadUsage,
} from "./status";
export interface ThreadProps {
className?: string;
children?: ReactNode;
}
/**
* Conversation column — centers the message stream at a fixed reading
* width. Renders `children` directly; spacing between items (user messages,
* turns) comes from each item's own margins rather than a gap here, so a
* lone item still looks right regardless of what precedes it.
*/
export function Thread({ className, children }: ThreadProps) {
return (
<div className={cn("relative mx-auto flex w-full max-w-3xl flex-col px-4", className)}>
{children}
</div>
);
}
export interface ThreadItemProps {
className?: string;
children?: ReactNode;
}
/**
* Entrance wrapper for a stream item (a user message or an agent turn) as it
* streams in — a short opacity + upward slide, reduced to an opacity-only
* fade under `useReducedMotion()`. Purely presentational: callers decide
* whether to wrap a given item at all (e.g. history rendered on first paint
* may skip it to avoid replaying the entrance).
*/
export function ThreadItem({ className, children }: ThreadItemProps) {
const reduce = useReducedMotion() ?? false;
return (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0.15 : 0.25, ease: EASE_OUT }}
className={className}
>
{children}
</motion.div>
);
}
export interface ThreadUserMessageProps {
className?: string;
children?: ReactNode;
}
/** Right-aligned user message — a rounded gray pill capped at 77% of the column width. */
export function ThreadUserMessage({ className, children }: ThreadUserMessageProps) {
return (
<div className={cn("group flex w-full flex-col items-end gap-1 py-3", className)}>
<div className="max-w-[77%] overflow-hidden break-words rounded-2xl bg-[var(--wb-inset)] px-3 py-2 text-sm leading-[22px]">
{children}
</div>
</div>
);
}
export interface ThreadTurnHeaderProps {
/** Controlled open state — purely a chevron-rotation signal (see below). */
open?: boolean;
onOpenChange?: (next: boolean) => void;
/** While the turn is still executing: shimmers the label. The header stays fully interactive — a running turn's live work log can be expanded and collapsed too. */
working?: boolean;
className?: string;
children?: ReactNode;
}
/**
* Turn-header button, e.g. "Worked for 2m 4s ›". Renders only the header
* itself — label plus a chevron that rotates 90° to signal open/closed —
* and does not fold or animate any content region below it: pair it with
* `ThreadCollapse` (wired to the same `open`) to collapse the turn's work
* log, or render your own region. Works controlled (`open`/`onOpenChange`)
* or uncontrolled. `working` (the turn is still executing, e.g. a live
* elapsed-seconds label) only swaps the label into `ThreadShimmerText`;
* chevron and toggling stay live, since a running turn's work log can be
* inspected mid-flight.
*/
export function ThreadTurnHeader({
open: openProp,
onOpenChange,
working = false,
className,
children,
}: ThreadTurnHeaderProps) {
const reduce = useReducedMotion() ?? false;
const [openState, setOpenState] = useState(false);
const open = openProp ?? openState;
const toggle = () => {
const next = !open;
setOpenState(next);
onOpenChange?.(next);
};
return (
<button
type="button"
aria-expanded={open}
onClick={toggle}
className={cn(
"-mx-1 my-3 inline-flex items-center gap-1 self-start rounded-lg px-1 text-sm text-muted-foreground transition-colors hover:bg-[var(--wb-hover)]",
className,
)}
>
{working ? <ThreadShimmerText>{children}</ThreadShimmerText> : children}
<motion.span
aria-hidden
className="flex text-muted-foreground/60"
animate={reduce ? undefined : { rotate: open ? 90 : 0 }}
style={reduce ? { transform: open ? "rotate(90deg)" : "rotate(0deg)" } : undefined}
transition={reduce ? undefined : { duration: 0.2, ease: EASE_OUT }}
>
<ChevronRight className="h-3.5 w-3.5" />
</motion.span>
</button>
);
}
export interface ThreadCollapseProps {
open: boolean;
className?: string;
children?: ReactNode;
}
/**
* Measured-height collapse region — the pairing for `ThreadTurnHeader`: put
* the turn's work log (thinking row, tool calls, interim notes) inside and
* wire `open` to the header's state so clicking the header collapses and
* expands it. Height comes from a `ResizeObserver`, so content that changes
* while open (e.g. rows streaming in) is tracked; animated 0 ↔ measured
* with `EASE_OUT` (0.25s), switched instantly under `useReducedMotion()`.
*/
export function ThreadCollapse({ open, className, children }: ThreadCollapseProps) {
const reduce = useReducedMotion() ?? false;
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const update = () => setContentHeight(node.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
return (
<motion.div
initial={false}
animate={reduce ? undefined : { height: open ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (open ? "h-auto" : "h-0"), className)}
>
<div ref={contentRef} className="flex flex-col">
{children}
</div>
</motion.div>
);
}
export interface ThreadMessageProps {
/** Appends a blinking `ThreadStreamingCaret` after `children` while the response is still streaming in. */
streaming?: boolean;
className?: string;
children?: ReactNode;
}
/**
* Typography container for agent-authored prose. A pure styling shell — the
* Markdown → JSX rendering engine is left to the caller (this component
* doesn't depend on any particular one); descendant selectors give
* paragraphs, lists and inline marks consistent spacing regardless of which
* renderer produced them. `streaming` appends a `ThreadStreamingCaret` at
* the container tail — after a block-level last paragraph that lands on its
* own line; to embed the caret inside the last line of text, place
* `ThreadStreamingCaret` directly in your own JSX instead.
*/
export function ThreadMessage({ streaming = false, className, children }: ThreadMessageProps) {
return (
<div
className={cn(
"text-sm leading-[22px] text-foreground",
"[&_p]:mb-[11px] [&_p:last-child]:mb-0 [&_ul]:mb-[11px] [&_ul]:list-disc [&_ul]:pl-[21px] [&_ol]:mb-[11px] [&_ol]:list-decimal [&_ol]:pl-[21px] [&_li]:pl-0.5 [&_a]:underline [&_a]:underline-offset-2 [&_strong]:font-semibold",
className,
)}
>
{children}
{streaming ? <ThreadStreamingCaret /> : null}
</div>
);
}
export interface ThreadInlineCodeProps {
className?: string;
children?: ReactNode;
}
/** Inline code chip for use inside `ThreadMessage` prose. */
export function ThreadInlineCode({ className, children }: ThreadInlineCodeProps) {
return (
<span
className={cn(
"rounded-[6px] bg-[var(--wb-code-inline)] px-1.5 py-px font-mono text-[0.92em]",
className,
)}
>
{children}
</span>
);
}
export interface ThreadCodeBlockProps {
/** Small label in the top-right corner, e.g. a language name like "bash". */
label?: ReactNode;
className?: string;
children?: ReactNode;
}
/** Fenced code block for `ThreadMessage` prose — a `pre > code` structure with an optional corner label. */
export function ThreadCodeBlock({ label, className, children }: ThreadCodeBlockProps) {
return (
<div className={cn("relative mb-[11px]", className)}>
{label ? (
<span className="absolute top-2 right-3 text-muted-foreground text-xs">{label}</span>
) : null}
<pre className="overflow-x-auto whitespace-pre rounded-xl bg-[var(--wb-code-block)] p-3 font-mono text-sm leading-[22px]">
<code>{children}</code>
</pre>
</div>
);
}
export interface ThreadActionBarProps {
/** Rendered after the buttons, e.g. a relative send time. */
timestamp?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Turn-tail action row — hidden until the turn is hovered or a child gains
* focus. Pair it with a parent that carries the `group/turn` class (see the
* preview) so `group-hover/turn:opacity-100` has something to key off; a
* bare hover on the bar itself would only reveal it once the pointer is
* already over these 20px-tall icons.
*/
export function ThreadActionBar({ timestamp, className, children }: ThreadActionBarProps) {
return (
<div
className={cn(
"flex h-5 items-center gap-0.5 text-muted-foreground opacity-0 transition-opacity focus-within:opacity-100 group-hover/turn:opacity-100",
className,
)}
>
{children}
{timestamp ? <span className="ml-1.5 text-muted-foreground/80 text-xs">{timestamp}</span> : null}
</div>
);
}
export interface ThreadActionButtonProps {
"aria-label": string;
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** One icon button inside a `ThreadActionBar` (copy, react, share, ...). */
export function ThreadActionButton({
"aria-label": ariaLabel,
onClick,
children,
className,
}: ThreadActionButtonProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex h-6 w-6 items-center justify-center rounded-md transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-thread
import { ChevronDown } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ThreadShimmerTextProps {
className?: string;
children?: ReactNode;
}
/**
* Loading-state shimmer text — a dim → bright → dim mask window sweeping
* across the label, shared by working turn headers, thinking labels and
* running tool/command rows. The sweep is cadenced — a 1s sweep followed by
* a 2s rest — rather than a continuous loop, so it reads as a periodic
* pulse of activity instead of a spinner. Deliberately self-contained (no
* dependency on the standalone text-shimmer component) so the thread block
* distributes as one unit. Under `useReducedMotion()` it renders a plain
* muted span with no sweep.
*/
export function ThreadShimmerText({ className, children }: ThreadShimmerTextProps) {
const reduce = useReducedMotion() ?? false;
if (reduce) {
return <span className={cn("text-muted-foreground", className)}>{children}</span>;
}
return (
<motion.span
style={{
backgroundImage:
"linear-gradient(90deg, var(--wb-shimmer-dim) 0%, var(--wb-shimmer-dim) 40%, var(--wb-shimmer-bright) 50%, var(--wb-shimmer-dim) 60%, var(--wb-shimmer-dim) 100%)",
backgroundSize: "200% 100%",
}}
animate={{ backgroundPosition: ["100% 0%", "-100% 0%"] }}
transition={{ duration: 1, repeat: Infinity, repeatDelay: 2, ease: "linear" }}
className={cn(
"bg-clip-text text-transparent",
className,
)}
>
{children}
</motion.span>
);
}
export interface ThreadCardProps {
className?: string;
children?: ReactNode;
}
/**
* Shared card shell for file and diff artifacts — a hairline-ringed surface
* using the same CSS-variable box-shadow technique as `WorkbenchSummaryCard`
* / `Composer`, so the right hairline shade is picked per color scheme.
* `my-1` gives two adjacent cards an 8px gap (4px contributed by each).
*/
export function ThreadCard({ className, children }: ThreadCardProps) {
return (
<div
style={{ boxShadow: "0 0 0 0.5px var(--wb-hairline)" }}
className={cn(
"my-1 flex max-w-full flex-col overflow-hidden rounded-xl bg-[var(--wb-card)]",
className,
)}
>
{children}
</div>
);
}
export interface ThreadCardButtonProps {
/** "outline" (default) draws a hairline border; "ghost" is borderless — for a de-emphasized action beside it (e.g. "Undo" next to "Review"); "primary" is a filled emphasis button (e.g. "Approve"). */
variant?: "outline" | "ghost" | "primary";
onClick?: () => void;
"aria-label"?: string;
children?: ReactNode;
className?: string;
}
/** Small action button used inside file/diff/approval card headers. */
export function ThreadCardButton({
variant = "outline",
onClick,
"aria-label": ariaLabel,
children,
className,
}: ThreadCardButtonProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex h-7 items-center gap-1 rounded-lg text-sm transition-colors",
variant === "outline" &&
"border border-[var(--wb-border)] px-2 hover:bg-[var(--wb-hover)]",
variant === "ghost" &&
"px-2 text-muted-foreground hover:bg-[var(--wb-hover)] hover:text-foreground",
variant === "primary" &&
"bg-foreground px-3 text-background transition-opacity hover:opacity-85",
className,
)}
>
{children}
</button>
);
}
export interface ThreadFileCardProps {
/** 24px icon rendered in a 40px rounded slot, e.g. `<FileText className="h-6 w-6" />`. */
icon?: ReactNode;
title: ReactNode;
subtitle?: ReactNode;
/** Trailing slot for the caller's own controls, e.g. an "Open" `ThreadCardButton`. */
action?: ReactNode;
className?: string;
/** Optional content appended below the header row, extending the card body. */
children?: ReactNode;
}
/** File-artifact card — icon, title/subtitle, and an optional trailing action. */
export function ThreadFileCard({
icon,
title,
subtitle,
action,
className,
children,
}: ThreadFileCardProps) {
return (
<ThreadCard className={className}>
<div className="flex items-center gap-3 px-3 py-2.5">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--wb-inset-strong)]">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">{title}</div>
{subtitle ? <div className="text-[13px] text-muted-foreground">{subtitle}</div> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</ThreadCard>
);
}
export interface ThreadDiffRowProps {
/** Split at the last `/` into a muted directory prefix and a bright filename. Ignored when `children` is passed. */
path?: string;
added?: number;
removed?: number;
className?: string;
children?: ReactNode;
}
/** One changed-file row inside a `ThreadDiffCard`. */
export function ThreadDiffRow({ path, added, removed, className, children }: ThreadDiffRowProps) {
let content = children;
if (content === undefined && path !== undefined) {
const lastSlash = path.lastIndexOf("/");
const prefix = lastSlash >= 0 ? path.slice(0, lastSlash + 1) : "";
const name = lastSlash >= 0 ? path.slice(lastSlash + 1) : path;
content = (
<>
<span className="text-muted-foreground">{prefix}</span>
<span className="text-foreground">{name}</span>
</>
);
}
return (
<div
className={cn(
"flex h-9 items-center justify-between gap-3 border-[var(--wb-divider)] border-t-[0.5px] px-3 text-sm",
className,
)}
>
<div className="min-w-0 truncate">{content}</div>
{added !== undefined || removed !== undefined ? (
<div className="flex shrink-0 gap-1.5 text-[13px]">
{added !== undefined ? (
<span className="text-[var(--wb-success)]">+{added}</span>
) : null}
{removed !== undefined ? (
<span className="text-[var(--wb-danger)]">−{removed}</span>
) : null}
</div>
) : null}
</div>
);
}
export interface ThreadDiffCardProps {
/** 20px icon rendered in a 40px rounded slot, e.g. `<SquarePen className="h-5 w-5" />`. */
icon?: ReactNode;
title: ReactNode;
added?: number;
removed?: number;
/** Trailing slot for the caller's own controls, e.g. an "Undo" ghost button plus a "Review" outline button. */
actions?: ReactNode;
/** Visible `ThreadDiffRow`s. */
children?: ReactNode;
/** Extra `ThreadDiffRow`s revealed by the "show more" row below `children`. */
hiddenRows?: ReactNode;
/** Label for the "show more" row, e.g. "Show 2 more files". */
moreLabel?: ReactNode;
/** Row count represented by `hiddenRows` — the "show more" row only renders when this is greater than 0. */
moreCount?: number;
className?: string;
}
/**
* Diff/change-summary card — a header (icon, title, +added/−removed counts,
* trailing actions) followed by a row region for `ThreadDiffRow` children.
* When `moreCount` is positive, a "show more" row is appended after
* `children`; clicking it reveals `hiddenRows` with a measured height 0 →
* auto tween (`EASE_OUT`, 0.25s) and hides the row itself.
* `useReducedMotion()` swaps the tween for an instant show, matching the
* measure-with-`ResizeObserver` idiom used by `BouncyAccordion`.
*/
export function ThreadDiffCard({
icon,
title,
added,
removed,
actions,
children,
hiddenRows,
moreLabel,
moreCount = 0,
className,
}: ThreadDiffCardProps) {
const reduce = useReducedMotion() ?? false;
const [expanded, setExpanded] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const update = () => setContentHeight(node.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, []);
const showMoreRow = moreCount > 0 && !expanded;
return (
<ThreadCard className={className}>
<div className="flex items-center gap-3 px-3 py-2.5">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--wb-inset-strong)]">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm">{title}</div>
{added !== undefined || removed !== undefined ? (
<div className="flex gap-1.5 text-[13px]">
{added !== undefined ? (
<span className="text-[var(--wb-success)]">+{added}</span>
) : null}
{removed !== undefined ? (
<span className="text-[var(--wb-danger)]">−{removed}</span>
) : null}
</div>
) : null}
</div>
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
</div>
{children}
{moreCount > 0 ? (
<>
<motion.div
initial={false}
animate={reduce ? undefined : { height: expanded ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (expanded ? "h-auto" : "h-0"))}
>
<div ref={contentRef}>{hiddenRows}</div>
</motion.div>
{showMoreRow ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="flex w-full items-center gap-1 px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-[var(--wb-hover)]"
>
{moreLabel}
<ChevronDown className="h-3.5 w-3.5" />
</button>
) : null}
</>
) : null}
</ThreadCard>
);
}
export interface ThreadCommandRowProps {
/** 16px leading icon, e.g. `<SquareTerminal className="h-4 w-4" />`. */
icon?: ReactNode;
/** Pulses the icon's opacity while the command is executing. */
running?: boolean;
className?: string;
children?: ReactNode;
}
/** Single command-execution line — a leading icon (pulsing while `running`, with the text shimmering) followed by the command text. */
export function ThreadCommandRow({ icon, running, className, children }: ThreadCommandRowProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("flex items-center gap-2 py-1 text-sm text-muted-foreground", className)}>
{icon ? (
running ? (
<motion.span
className="flex h-4 w-4 shrink-0 items-center justify-center"
animate={reduce ? { opacity: 1 } : { opacity: [0.4, 1, 0.4] }}
transition={reduce ? undefined : { duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
>
{icon}
</motion.span>
) : (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">{icon}</span>
)
) : null}
{running ? <ThreadShimmerText>{children}</ThreadShimmerText> : children}
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-thread
import { ArrowDown, Check, ChevronLeft, ChevronRight, CircleAlert, History } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Fragment, type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { ThreadCard, ThreadCardButton, ThreadShimmerText } from "./cards";
export type ThreadToolCallStatus = "running" | "done" | "error" | "stopped";
export interface ThreadToolCallProps {
/** 16px leading icon, e.g. `<Globe className="h-4 w-4" />`. */
icon?: ReactNode;
status?: ThreadToolCallStatus;
/** Supplement after the label — e.g. a mono query or path; wrapping (such as `font-mono text-[13px]`) is the caller's choice. */
detail?: ReactNode;
/** Trailing elapsed-time readout. */
elapsed?: ReactNode;
className?: string;
/** The label, verb-tensed by the caller: "Searching the web" while running, "Searched the web" when done. */
children?: ReactNode;
}
/**
* Generic tool-invocation row — web searches, file reads, directory
* listings, MCP tools and anything else the agent runs mid-turn. The
* general-purpose sibling of `ThreadCommandRow`, which stays around as the
* command-scenario interface; both share the same row layout. `running`
* pulses the icon and shimmers the label (`detail` stays static); `done` is
* fully static and muted; `error` paints icon and label red while `detail`
* stays muted; `stopped` stays muted with the caller wording the label
* (e.g. "Stopped command").
*/
export function ThreadToolCall({
icon,
status = "done",
detail,
elapsed,
className,
children,
}: ThreadToolCallProps) {
const reduce = useReducedMotion() ?? false;
const running = status === "running";
const error = status === "error";
return (
<div className={cn("flex items-center gap-2 py-1 text-sm text-muted-foreground", className)}>
{icon ? (
running ? (
<motion.span
className="flex h-4 w-4 shrink-0 items-center justify-center"
animate={reduce ? { opacity: 1 } : { opacity: [0.4, 1, 0.4] }}
transition={reduce ? undefined : { duration: 1.2, repeat: Infinity, ease: "easeInOut" }}
>
{icon}
</motion.span>
) : (
<span
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center",
error && "text-[var(--wb-danger)]",
)}
>
{icon}
</span>
)
) : null}
{running ? (
<ThreadShimmerText>{children}</ThreadShimmerText>
) : (
<span className={cn(error && "text-[var(--wb-danger)]")}>{children}</span>
)}
{detail !== undefined && detail !== null ? (
<span className="min-w-0 truncate">{detail}</span>
) : null}
{elapsed !== undefined && elapsed !== null ? (
<span className="text-[13px] text-muted-foreground/70 tabular-nums">{elapsed}</span>
) : null}
</div>
);
}
export interface ThreadThinkingProps {
/** Still reasoning: the label shimmers, the chevron is hidden and expansion is disabled (there is no summary to show yet). */
thinking?: boolean;
/** Controlled open state for the summary region. */
open?: boolean;
onOpenChange?: (next: boolean) => void;
className?: string;
/** "Thinking…" while `thinking`, then e.g. "Thought for 8s". */
label: ReactNode;
/** Optional reasoning summary revealed below the header once `thinking` is over. */
children?: ReactNode;
}
/**
* Reasoning-state row. Visually matches `ThreadTurnHeader` (deliberately an
* independent implementation to avoid coupling): while `thinking` the label
* shimmers with no chevron; once done, pass a summary as `children` to get
* an expandable region — measured with a `ResizeObserver` and animated
* height 0 ↔ measured (`EASE_OUT`, 0.25s), shown instantly under
* `useReducedMotion()` — rendered as a left-ruled quote block. Works
* controlled (`open`/`onOpenChange`) or uncontrolled.
*/
export function ThreadThinking({
thinking = false,
open: openProp,
onOpenChange,
className,
label,
children,
}: ThreadThinkingProps) {
const reduce = useReducedMotion() ?? false;
const [openState, setOpenState] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState(0);
const expandable = !thinking && children !== undefined && children !== null;
const open = expandable && (openProp ?? openState);
const toggle = () => {
const next = !open;
setOpenState(next);
onOpenChange?.(next);
};
// biome-ignore lint/correctness/useExhaustiveDependencies: `expandable` is the trigger — the summary region only mounts once thinking ends, so the observer must re-attach to the node the effect reads from the DOM at that point.
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
const update = () => setContentHeight(node.offsetHeight);
update();
const observer = new ResizeObserver(update);
observer.observe(node);
return () => observer.disconnect();
}, [expandable]);
return (
<div className={cn("flex flex-col", className)}>
<button
type="button"
disabled={!expandable}
aria-expanded={expandable ? open : undefined}
onClick={toggle}
className="-mx-1 my-1 inline-flex items-center gap-1 self-start rounded-lg px-1 text-sm text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] disabled:pointer-events-none"
>
{thinking ? <ThreadShimmerText>{label}</ThreadShimmerText> : label}
{expandable ? (
<motion.span
aria-hidden
className="flex text-muted-foreground/60"
animate={reduce ? undefined : { rotate: open ? 90 : 0 }}
style={reduce ? { transform: open ? "rotate(90deg)" : "rotate(0deg)" } : undefined}
transition={reduce ? undefined : { duration: 0.2, ease: EASE_OUT }}
>
<ChevronRight className="h-3.5 w-3.5" />
</motion.span>
) : null}
</button>
{expandable ? (
<motion.div
initial={false}
animate={reduce ? undefined : { height: open ? contentHeight : 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn("overflow-hidden", reduce && (open ? "h-auto" : "h-0"))}
>
<div
ref={contentRef}
className="border-[var(--wb-border)] border-l-2 py-1 pl-3 text-[13px] text-muted-foreground leading-[20px]"
>
{children}
</div>
</motion.div>
) : null}
</div>
);
}
export interface ThreadStreamingCaretProps {
className?: string;
}
/**
* Blinking block caret appended to text that is still streaming in. Inline —
* drop it right after the last streamed character. Static at half opacity
* under `useReducedMotion()`.
*/
export function ThreadStreamingCaret({ className }: ThreadStreamingCaretProps) {
const reduce = useReducedMotion() ?? false;
const base = "ml-0.5 inline-block h-3.5 w-[7px] translate-y-[2px] rounded-[2px] bg-foreground/70";
if (reduce) {
return <span aria-hidden className={cn(base, "opacity-50", className)} />;
}
return (
<motion.span
aria-hidden
className={cn(base, className)}
animate={{ opacity: [1, 0.15, 1] }}
transition={{ duration: 1, repeat: Infinity, ease: "easeInOut" }}
/>
);
}
export type ThreadApprovalStatus = "pending" | "approved" | "denied";
export interface ThreadApprovalCardProps {
/** 20px icon rendered in a 40px rounded slot, e.g. `<ShieldAlert className="h-5 w-5" />`. */
icon?: ReactNode;
title: ReactNode;
/** Sub-line under the title — wraps, never truncated. */
description?: ReactNode;
/** Optional mono one-liner of what will run. */
command?: ReactNode;
status?: ThreadApprovalStatus;
/** Shown in place of the buttons once `status` is no longer "pending". */
resolution?: ReactNode;
onApprove?: () => void;
onDeny?: () => void;
approveLabel?: ReactNode;
denyLabel?: ReactNode;
className?: string;
}
/**
* Approval-gate card — the agent pauses and asks before running something
* sensitive. While `pending`, a ghost Deny and a primary Approve button sit
* at the trailing edge; once resolved, `resolution` replaces them (green
* when `approved`, muted when `denied`). Built on `ThreadCard`, so it shares
* the hairline surface with the file and diff cards.
*/
export function ThreadApprovalCard({
icon,
title,
description,
command,
status = "pending",
resolution,
onApprove,
onDeny,
approveLabel = "Approve",
denyLabel = "Deny",
className,
}: ThreadApprovalCardProps) {
return (
<ThreadCard className={className}>
<div className="flex items-center gap-3 px-3 py-2.5">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--wb-inset-strong)]">
{icon}
</span>
<div className="min-w-0 flex-1">
<div className="font-medium text-sm">{title}</div>
{description ? <div className="text-[13px] text-muted-foreground">{description}</div> : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{status === "pending" ? (
<>
<ThreadCardButton variant="ghost" onClick={onDeny}>
{denyLabel}
</ThreadCardButton>
<ThreadCardButton variant="primary" onClick={onApprove}>
{approveLabel}
</ThreadCardButton>
</>
) : resolution ? (
<span
className={cn(
"text-[13px]",
status === "approved"
? "text-[var(--wb-success)]"
: "text-muted-foreground",
)}
>
{resolution}
</span>
) : null}
</div>
</div>
{command ? (
<div className="mx-3 mb-3 rounded-lg bg-[var(--wb-code-block)] px-3 py-2 font-mono text-[13px]">
{command}
</div>
) : null}
</ThreadCard>
);
}
export interface ThreadElicitationProps {
/** 20px icon rendered in a 40px rounded slot, e.g. `<MessageCircleQuestion className="h-5 w-5" />`. */
icon?: ReactNode;
prompt: ReactNode;
options: { value: string; label: ReactNode; description?: ReactNode }[];
/** Selected option value; `null`/`undefined` means still awaiting an answer. */
value?: string | null;
onSelect?: (value: string) => void;
className?: string;
}
/**
* Blocking clarification picker — the agent pauses on an ambiguous request
* and offers a fixed set of answers instead of free text. Built on
* `ThreadCard`, matching `ThreadApprovalCard`'s header row (icon slot plus a
* medium-weight prompt). Once `value` is set the whole list locks: the
* matching option gets a primary ring and a trailing check, the rest dim —
* the same pending → resolved shape as `ThreadApprovalCard`, but for an
* N-way choice instead of approve/deny.
*/
export function ThreadElicitation({
icon,
prompt,
options,
value = null,
onSelect,
className,
}: ThreadElicitationProps) {
return (
<ThreadCard className={className}>
<div className="flex items-center gap-3 px-3 py-2.5">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--wb-inset-strong)]">
{icon}
</span>
<div className="min-w-0 flex-1 font-medium text-sm">{prompt}</div>
</div>
<div className="flex flex-col gap-1 px-3 pb-3">
{options.map((option) => {
const selected = value === option.value;
const locked = value !== null;
return (
<button
key={option.value}
type="button"
disabled={locked}
onClick={() => onSelect?.(option.value)}
className={cn(
"flex w-full items-start gap-2 rounded-lg border border-[var(--wb-border)] px-3 py-2 text-left text-sm transition-colors",
"hover:bg-[var(--wb-hover-subtle)] disabled:pointer-events-none",
selected && "border-[var(--wb-accent)] ring-1 ring-[var(--wb-accent)]/30",
locked && !selected && "opacity-50",
)}
>
<span className="min-w-0 flex-1">
<span className="block">{option.label}</span>
{option.description ? (
<span className="block text-[13px] text-muted-foreground">
{option.description}
</span>
) : null}
</span>
{selected ? (
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--wb-accent)]" />
) : null}
</button>
);
})}
</div>
</ThreadCard>
);
}
export interface ThreadErrorStateProps {
message: ReactNode;
detail?: ReactNode;
onRetry?: () => void;
retryLabel?: ReactNode;
className?: string;
}
/**
* Inline error row — a failed tool call, request or turn surfaced as a
* red-tinted banner with a retry action. Not built on `ThreadCard`: the
* failure isn't a browsable artifact, just a transient state to recover
* from. `onRetry` renders a `ThreadCardButton`, so retrying matches every
* other card's action styling.
*/
export function ThreadErrorState({
message,
detail,
onRetry,
retryLabel = "Retry",
className,
}: ThreadErrorStateProps) {
return (
<div
className={cn(
"flex items-start gap-2.5 rounded-xl border border-[var(--wb-danger-surface)]/25 bg-[var(--wb-danger-surface)]/[0.06] px-3 py-2.5 text-sm",
className,
)}
>
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-[var(--wb-danger)]" />
<div className="min-w-0 flex-1">
<div className="text-foreground">{message}</div>
{detail ? <div className="text-[13px] text-muted-foreground">{detail}</div> : null}
</div>
{onRetry ? (
<ThreadCardButton onClick={onRetry} className="shrink-0">
{retryLabel}
</ThreadCardButton>
) : null}
</div>
);
}
export interface ThreadSystemBannerProps {
icon?: ReactNode;
children?: ReactNode;
className?: string;
}
/** Centered pill for low-emphasis system notices — e.g. "Model switched to
* 5.6" — dropped inline in the stream without a `ThreadItem` entrance
* wrapper (it's a passive notice, not a message). */
export function ThreadSystemBanner({ icon, children, className }: ThreadSystemBannerProps) {
return (
<div
className={cn(
"mx-auto my-2 flex w-fit items-center gap-1.5 rounded-full bg-[var(--wb-inset)] px-3 py-1 text-muted-foreground text-xs",
className,
)}
>
{icon ? (
<span className="flex h-3 w-3 shrink-0 items-center justify-center">{icon}</span>
) : null}
{children}
</div>
);
}
export interface ThreadBranchSwitcherProps {
/** 1-based position of the branch currently shown. */
index: number;
count: number;
onPrev?: () => void;
onNext?: () => void;
className?: string;
}
/**
* Prev/next control for switching between sibling response branches (e.g.
* regenerated replies) — small chevron buttons flanking a tabular-nums
* "2/3" readout, disabled past either end. The readout crossfades with a
* short y-shift on change (`AnimatePresence mode="popLayout"`), reduced to
* an instant swap under `useReducedMotion()`.
*/
export function ThreadBranchSwitcher({
index,
count,
onPrev,
onNext,
className,
}: ThreadBranchSwitcherProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("flex items-center gap-0.5 text-muted-foreground text-xs", className)}>
<button
type="button"
aria-label="Previous branch"
disabled={index <= 1}
onClick={onPrev}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-[var(--wb-hover)] disabled:pointer-events-none disabled:opacity-30"
>
<ChevronLeft className="h-3 w-3" />
</button>
<span className="relative inline-flex h-4 min-w-[2.5ch] items-center justify-center overflow-hidden tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={`${index}/${count}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="absolute inset-0 flex items-center justify-center"
>
{index}/{count}
</motion.span>
</AnimatePresence>
</span>
<button
type="button"
aria-label="Next branch"
disabled={index >= count}
onClick={onNext}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-[var(--wb-hover)] disabled:pointer-events-none disabled:opacity-30"
>
<ChevronRight className="h-3 w-3" />
</button>
</div>
);
}
export interface ThreadSuggestionsProps {
suggestions: { value: string; label: ReactNode }[];
onSelect?: (value: string) => void;
className?: string;
}
/**
* Row of tappable follow-up prompts offered after an agent turn. Each chip
* stagger-fades in (opacity plus a 4px rise, staggered 0.05s per index) so
* the row reads as offered rather than dumped in all at once, reduced to an
* instant render under `useReducedMotion()`.
*/
export function ThreadSuggestions({ suggestions, onSelect, className }: ThreadSuggestionsProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("flex flex-wrap gap-1.5 py-2", className)}>
{suggestions.map((suggestion, i) => (
<motion.span
key={suggestion.value}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: EASE_OUT, delay: reduce ? 0 : i * 0.05 }}
>
<button
type="button"
onClick={() => onSelect?.(suggestion.value)}
className="h-7 rounded-full border border-[var(--wb-border)] px-3 text-[13px] text-muted-foreground transition-colors hover:border-[var(--wb-border-emphasis)] hover:text-foreground"
>
{suggestion.label}
</button>
</motion.span>
))}
</div>
);
}
export type ThreadTaskStatus = "pending" | "active" | "done";
export interface ThreadTaskListProps {
title?: ReactNode;
/** Short counter, e.g. "2/5". Swaps with a `popLayout` crossfade when it changes. */
progress?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Checklist card for a long-running agent task, built on `ThreadCard` so it
* shares the hairline ring and tint with the file/diff/approval cards. The
* optional title row pairs a medium-weight label with a tabular-nums
* progress readout on the trailing edge; the readout crossfades with a
* short y-shift on change (`AnimatePresence mode="popLayout"`, mirroring
* `ThreadBranchSwitcher`'s counter), reduced to an opacity-only swap under
* `useReducedMotion()`. Compose `ThreadTask` rows as `children`.
*/
export function ThreadTaskList({ title, progress, className, children }: ThreadTaskListProps) {
const reduce = useReducedMotion() ?? false;
return (
<ThreadCard className={cn("px-3 py-2", className)}>
{title !== undefined ? (
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-[13px]">{title}</span>
{progress !== undefined ? (
<span className="relative inline-flex h-4 min-w-[2.5ch] items-center justify-center overflow-hidden text-muted-foreground text-xs tabular-nums">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={String(progress)}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="absolute inset-0 flex items-center justify-center"
>
{progress}
</motion.span>
</AnimatePresence>
</span>
) : null}
</div>
) : null}
<div className="flex flex-col">{children}</div>
</ThreadCard>
);
}
export interface ThreadTaskProps {
status?: ThreadTaskStatus;
className?: string;
children?: ReactNode;
}
/**
* Single row inside a `ThreadTaskList` — a 16px status slot followed by the
* task's label. `pending` is a hollow ring, `active` is a solid blue dot
* wrapped in a pulsing ring (frozen, not removed, under
* `useReducedMotion()`) and its label runs through `ThreadShimmerText`,
* `done` is a check mark. The icon swap itself is a springy scale pop
* (`SPRING_PANEL`, `AnimatePresence mode="wait"`), reduced to a plain
* opacity cut. `done` text stays muted rather than a celebratory color —
* finishing a step should read as quiet progress, not an event.
*/
export function ThreadTask({ status = "pending", className, children }: ThreadTaskProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("flex items-center gap-2 py-1 text-sm", className)}>
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={status}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
className="flex items-center justify-center"
>
{status === "done" ? (
<Check className="h-3.5 w-3.5 text-[var(--wb-success)]" />
) : status === "active" ? (
<span className="relative flex h-2 w-2 items-center justify-center">
{reduce ? (
<span
aria-hidden
className="absolute inset-0 rounded-full bg-[var(--wb-accent)] opacity-25"
/>
) : (
<motion.span
aria-hidden
className="absolute inset-0 rounded-full bg-[var(--wb-accent)]"
animate={{ scale: [1, 1.8], opacity: [0.5, 0] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeOut" }}
/>
)}
<span className="h-2 w-2 rounded-full bg-[var(--wb-accent)]" />
</span>
) : (
<span className="h-3.5 w-3.5 rounded-full border-[1.5px] border-[var(--wb-border-emphasis)]" />
)}
</motion.span>
</AnimatePresence>
</span>
{status === "active" ? (
<ThreadShimmerText>{children}</ThreadShimmerText>
) : (
<span className="text-muted-foreground">{children}</span>
)}
</div>
);
}
export interface ThreadScrollPillProps {
open: boolean;
count?: number;
onClick?: () => void;
className?: string;
}
/**
* "New messages" pill for auto-scrolling thread containers. Needs a
* `relative` ancestor to anchor against — toggle `open` when the user has
* scrolled away from the bottom while new content streams in below (see the
* preview). Springs up from the bottom edge (`SPRING_PANEL`), reduced to an
* opacity-only fade under `useReducedMotion()`.
*/
export function ThreadScrollPill({ open, count, onClick, className }: ThreadScrollPillProps) {
const reduce = useReducedMotion() ?? false;
return (
<AnimatePresence>
{open ? (
<motion.button
type="button"
onClick={onClick}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
className={cn(
"absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 items-center gap-1 rounded-full bg-[var(--wb-inverse)] px-3 py-1.5 text-[var(--wb-inverse-fg)] text-xs shadow-lg",
className,
)}
>
<ArrowDown className="h-3 w-3" />
{count !== undefined ? `${count} new messages` : null}
</motion.button>
) : null}
</AnimatePresence>
);
}
export interface ThreadCheckpointProps {
/** @default "Checkpoint" */
label?: ReactNode;
timestamp?: ReactNode;
onRestore?: () => void;
/** @default "Restore" */
restoreLabel?: ReactNode;
className?: string;
}
/**
* Rollback marker dividing the stream at a point the user can restore to —
* a hairline rule on either side of a pill carrying a history icon, the
* checkpoint label and an optional timestamp. When `onRestore` is passed, a
* trailing text button ("Restore") is appended inside the same pill.
*/
export function ThreadCheckpoint({
label = "Checkpoint",
timestamp,
onRestore,
restoreLabel = "Restore",
className,
}: ThreadCheckpointProps) {
return (
<div className={cn("relative flex items-center gap-3 py-2", className)}>
<span className="h-px flex-1 bg-[var(--wb-divider)]" />
<span className="flex h-6 items-center gap-1.5 rounded-full border border-[var(--wb-border)] px-2.5 text-xs text-muted-foreground">
<History className="h-3 w-3" />
{label}
{timestamp !== undefined && timestamp !== null ? (
<span className="text-muted-foreground/70">{timestamp}</span>
) : null}
{onRestore ? (
<button
type="button"
onClick={onRestore}
className="text-xs transition-colors hover:text-foreground"
>
{restoreLabel}
</button>
) : null}
</span>
<span className="h-px flex-1 bg-[var(--wb-divider)]" />
</div>
);
}
/** <0.01 keeps 4 decimal places, otherwise 3 — trailing zeros are trimmed either way (e.g. 0.003 → "$0.003", not "$0.0030"). */
function formatUsageCost(cost: number): string {
const decimals = cost < 0.01 ? 4 : 3;
const trimmed = cost.toFixed(decimals).replace(/0+$/, "").replace(/\.$/, "");
return `$${trimmed}`;
}
/** K/M abbreviation for a raw token count; values under 1000 render as-is. */
function formatUsageTokenCount(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
return `${count}`;
}
export interface ThreadUsageProps {
cost?: number;
inputTokens?: number;
outputTokens?: number;
duration?: ReactNode;
cacheHitRate?: number;
className?: string;
children?: ReactNode;
}
/**
* Per-message usage/cost line — cost, input/output token counts, duration
* and cache-hit rate, each shown only when its prop is present and joined
* by a muted middle dot. Meant to live alongside `ThreadActionBar` in the
* same hover group; the component itself renders unconditionally, so pass
* e.g. `"opacity-0 group-hover/turn:opacity-100"` in `className` if you want
* it to reveal on turn hover the way the action bar does.
*/
export function ThreadUsage({
cost,
inputTokens,
outputTokens,
duration,
cacheHitRate,
className,
children,
}: ThreadUsageProps) {
const fragments: { key: string; node: ReactNode }[] = [];
if (cost !== undefined) {
fragments.push({ key: "cost", node: formatUsageCost(cost) });
}
if (inputTokens !== undefined || outputTokens !== undefined) {
const inStr = inputTokens !== undefined ? `${formatUsageTokenCount(inputTokens)} in` : null;
const outStr = outputTokens !== undefined ? `${formatUsageTokenCount(outputTokens)} out` : null;
fragments.push({
key: "tokens",
node: inStr && outStr ? `${inStr} / ${outStr}` : (inStr ?? outStr),
});
}
if (duration !== undefined && duration !== null) {
fragments.push({ key: "duration", node: duration });
}
if (cacheHitRate !== undefined) {
fragments.push({ key: "cache", node: `cache ${Math.round(cacheHitRate * 100)}%` });
}
if (children !== undefined && children !== null) {
fragments.push({ key: "children", node: children });
}
if (fragments.length === 0) return null;
return (
<div
className={cn(
"flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground/80 tabular-nums",
className,
)}
>
{fragments.map((fragment, i) => (
<Fragment key={fragment.key}>
{i > 0 ? <span className="text-muted-foreground/40">·</span> : null}
<span>{fragment.node}</span>
</Fragment>
))}
</div>
);
}
"use client";
import { ArrowUp, ChevronDown, X } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export { ComposerAutonomyDial } from "./autonomy-dial";
export { ComposerEffortSlider } from "./effort-slider";
export interface ComposerProps {
className?: string;
children?: ReactNode;
}
/**
* Composer shell — a translucent, rounded card that hosts a textarea and a
* toolbar row. Renders `children` directly (auto-height textarea and toolbar
* are stacked by the caller); the hairline shadow trio and glass surface use
* the same `--composer-hairline` CSS-variable technique as
* `WorkbenchSummaryCard`, so the right shade is picked per color scheme.
* Sits at `z-10` so it stacks above a sibling `ComposerContextBar`.
*/
export function Composer({ className, children }: ComposerProps) {
return (
<div
style={{
boxShadow:
"0 0 0 0.5px var(--wb-hairline-soft), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)",
}}
className={cn(
"relative z-10 flex flex-col rounded-[25px] bg-[var(--wb-surface-composer)] backdrop-blur-lg",
className,
)}
>
{children}
</div>
);
}
export interface ComposerContextBarProps {
className?: string;
children?: ReactNode;
}
/**
* Optional context bar meant to sit as a sibling right above `<Composer>` in
* markup order. Its bottom padding is taller than its visible height and
* pulled up with a negative margin, so `Composer` (rendered after it, at
* `z-10`) sits on top and hides the lower half — the bar reads as tucked in
* "behind" the composer shell.
*/
export function ComposerContextBar({ className, children }: ComposerContextBarProps) {
return (
<div
className={cn(
"-mb-5 flex items-center gap-4 rounded-t-2xl bg-[var(--wb-inset)] px-2 pt-2 pb-7",
className,
)}
>
{children}
</div>
);
}
export interface ComposerChipProps {
icon?: ReactNode;
/** Swapped in for `icon` on hover/focus (opacity crossfade, no layout shift).
* Only meaningful on interactive chips — pair it with `onClick`. */
hoverIcon?: ReactNode;
/** Makes the chip a `<button>` with a hover pill surface. */
onClick?: () => void;
/** Native tooltip, e.g. "Change project". */
title?: string;
children?: ReactNode;
className?: string;
}
const CHIP_BASE = "flex h-7 items-center gap-1.5 rounded-full px-2 text-[13px] text-muted-foreground";
/**
* A single label inside `ComposerContextBar` — an optional 16px icon plus
* text. With `onClick` it becomes a button whose hover state paints a pill
* surface and (when `hoverIcon` is set) crossfades the icon — e.g. a project
* chip whose folder icon turns into a clear-mark.
*/
export function ComposerChip({
icon,
hoverIcon,
onClick,
title,
children,
className,
}: ComposerChipProps) {
const iconSlot = icon ? (
hoverIcon ? (
<span className="relative flex h-4 w-4 shrink-0 items-center justify-center">
<span className="absolute inset-0 flex items-center justify-center opacity-100 transition-opacity group-focus-visible:opacity-0 group-hover:opacity-0">
{icon}
</span>
<span className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-focus-visible:opacity-100 group-hover:opacity-100">
{hoverIcon}
</span>
</span>
) : (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">{icon}</span>
)
) : null;
if (onClick) {
return (
<button
type="button"
onClick={onClick}
title={title}
className={cn(
CHIP_BASE,
"group transition-colors hover:bg-[var(--wb-hover-strong)] hover:text-foreground",
className,
)}
>
{iconSlot}
{children}
</button>
);
}
return (
<span title={title} className={cn(CHIP_BASE, className)}>
{iconSlot}
{children}
</span>
);
}
export interface ComposerTextareaProps {
value: string;
onChange: (next: string) => void;
placeholder?: string;
/** Fires when Enter is pressed without Shift — the caller owns what "submit" means. */
onSubmit?: () => void;
"aria-label"?: string;
className?: string;
}
/**
* Auto-growing message field. A real `<textarea rows={1}>` — height tracks
* content via `scrollHeight`, up to the scrollable container's
* `max-h-[25dvh]`. The measure runs in a layout effect keyed on `value` (not
* an input handler), so programmatic updates — e.g. the caller clearing the
* draft after a send — collapse the field just like keystrokes do. Enter
* submits (and is prevented from inserting a newline); Shift+Enter inserts a
* newline as usual.
*/
export function ComposerTextarea({
value,
onChange,
placeholder,
onSubmit,
"aria-label": ariaLabel,
className,
}: ComposerTextareaProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: `value` is the trigger — the height depends on the rendered content, which this effect reads from the DOM rather than from the prop.
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value]);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSubmit?.();
}
},
[onSubmit],
);
return (
<div className="max-h-[25dvh] overflow-y-auto px-4 pt-3.5 pb-1">
<textarea
ref={textareaRef}
rows={1}
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
aria-label={ariaLabel}
className={cn(
"min-h-[44px] w-full resize-none border-none bg-transparent text-sm leading-5 outline-none",
"placeholder:text-muted-foreground",
className,
)}
/>
</div>
);
}
export interface ComposerToolbarProps {
className?: string;
children?: ReactNode;
}
/**
* Bottom action row. Just a flex container — order children left to right
* and drop in a `<div className="ml-auto" />` spacer to split left/right
* clusters (see the preview for the exact arrangement).
*/
export function ComposerToolbar({ className, children }: ComposerToolbarProps) {
return <div className={cn("flex items-center gap-1 px-2 pb-2", className)}>{children}</div>;
}
export interface ComposerIconButtonProps {
"aria-label": string;
onClick?: () => void;
children?: ReactNode;
className?: string;
disabled?: boolean;
}
/** Circular 28px icon button for the toolbar (add attachment, dictate, ...). */
export function ComposerIconButton({
"aria-label": ariaLabel,
onClick,
children,
className,
disabled,
}: ComposerIconButtonProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors",
"hover:bg-[var(--wb-hover)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
export interface ComposerAccessChipProps {
icon?: ReactNode;
/** "warning" (default) reads as an orange access-level alert; "default" is muted. */
tone?: "warning" | "default";
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** Pill button surfacing the current permission level (e.g. "Full access"). */
export function ComposerAccessChip({
icon,
tone = "warning",
onClick,
children,
className,
}: ComposerAccessChipProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex h-7 items-center gap-1 rounded-full px-2 text-[13px] transition-colors",
tone === "warning"
? "text-[var(--wb-warning-text)] hover:bg-[var(--wb-warning-hover)]/10"
: "text-muted-foreground hover:bg-[var(--wb-hover)]",
className,
)}
>
{icon ? <span className="flex h-4 w-4 shrink-0 items-center justify-center">{icon}</span> : null}
{children}
</button>
);
}
/**
* Shared open state + dismiss behavior for the popover triggers below.
* Controlled (`openProp`/`onOpenChange`) or uncontrolled; while open, Escape
* or a pointerdown outside `rootRef` closes.
*/
function usePopover(openProp: boolean | undefined, onOpenChange?: (open: boolean) => void) {
const [openState, setOpenState] = useState(false);
const open = openProp ?? openState;
const rootRef = useRef<HTMLDivElement>(null);
const setOpen = useCallback(
(next: boolean) => {
setOpenState(next);
onOpenChange?.(next);
},
[onOpenChange],
);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
const onPointerDown = (event: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
};
window.addEventListener("keydown", onKeyDown);
window.addEventListener("pointerdown", onPointerDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("pointerdown", onPointerDown);
};
}, [open, setOpen]);
return { open, setOpen, rootRef };
}
interface PopoverPanelProps {
open: boolean;
/** Horizontal anchor against the trigger: "start" = left edges flush, "end" = right edges flush. */
align: "start" | "end";
className?: string;
children?: ReactNode;
}
/**
* The floating panel shell shared by `ComposerModelPicker` and
* `ComposerMenuButton` — glass surface, hairline shadow trio, and the
* scale/y/opacity + `SPRING_PANEL` entrance from `WorkbenchSummaryCard`,
* reduced to an opacity-only fade under `useReducedMotion()`. Anchored above
* the trigger; `align` picks the flush edge and the transform origin.
*/
function PopoverPanel({ open, align, className, children }: PopoverPanelProps) {
const reduce = useReducedMotion() ?? false;
return (
<AnimatePresence>
{open ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: 4 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
style={{
transformOrigin: align === "end" ? "bottom right" : "bottom left",
boxShadow:
"0 0 0 0.5px var(--wb-hairline-soft), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)",
}}
className={cn(
"absolute bottom-[calc(100%+8px)] z-20 min-w-[224px] rounded-2xl bg-[var(--wb-surface-raised)] p-1 backdrop-blur-xl",
align === "end" ? "right-0" : "left-0",
className,
)}
>
{children}
</motion.div>
) : null}
</AnimatePresence>
);
}
export interface ComposerModelPickerProps {
/** Trigger content, e.g. `"5.6 · High"` — a chevron is appended automatically. */
label: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: ReactNode;
className?: string;
}
/**
* Trigger plus a popover that springs open above and right-aligned to it —
* for model/effort pickers. Works controlled (`open`/`onOpenChange`) or
* uncontrolled; dismiss and entrance behavior come from `usePopover` /
* `PopoverPanel`.
*/
export function ComposerModelPicker({
label,
open: openProp,
onOpenChange,
children,
className,
}: ComposerModelPickerProps) {
const { open, setOpen, rootRef } = usePopover(openProp, onOpenChange);
return (
<div ref={rootRef} className="relative">
<button
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={cn(
"flex h-7 items-center gap-1 rounded-full px-2 text-[13px] text-muted-foreground",
"hover:bg-[var(--wb-hover)]",
className,
)}
>
{label}
<ChevronDown className="h-3.5 w-3.5" />
</button>
<PopoverPanel open={open} align="end">
{children}
</PopoverPanel>
</div>
);
}
export interface ComposerMenuButtonProps {
/** 16px icon; alone it renders the circular icon-button look. */
icon?: ReactNode;
/** Optional text label; with it the trigger becomes a pill like the model-picker trigger (no chevron). */
label?: ReactNode;
"aria-label": string;
/** Panel anchor edge — defaults to "start" (left-aligned above the trigger). */
align?: "start" | "end";
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: ReactNode;
className?: string;
}
/**
* Trigger plus a popover menu (compose `ComposerMenuSection` /
* `ComposerMenuItem` as children) — e.g. the "+" add-attachments menu.
* Controlled or uncontrolled; same panel shell and dismiss behavior as
* `ComposerModelPicker`.
*/
export function ComposerMenuButton({
icon,
label,
"aria-label": ariaLabel,
align = "start",
open: openProp,
onOpenChange,
children,
className,
}: ComposerMenuButtonProps) {
const { open, setOpen, rootRef } = usePopover(openProp, onOpenChange);
return (
<div ref={rootRef} className="relative">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={cn(
label
? "flex h-7 items-center gap-1 rounded-full px-2 text-[13px] text-muted-foreground hover:bg-[var(--wb-hover)]"
: "flex h-7 w-7 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground",
className,
)}
>
{icon}
{label}
</button>
<PopoverPanel open={open} align={align} className="min-w-[260px] max-w-[320px]">
{children}
</PopoverPanel>
</div>
);
}
export interface ComposerMenuSectionProps {
title?: ReactNode;
children?: ReactNode;
className?: string;
}
/** A titled group of rows inside a `ComposerMenuButton` panel. */
export function ComposerMenuSection({ title, children, className }: ComposerMenuSectionProps) {
return (
<div className={cn("flex flex-col", className)}>
{title ? <div className="px-2 pt-1.5 pb-1 text-muted-foreground text-xs">{title}</div> : null}
{children}
</div>
);
}
export interface ComposerMenuItemProps {
icon?: ReactNode;
/** Muted one-liner rendered inline after the name, truncated when tight. */
description?: ReactNode;
onSelect?: () => void;
children?: ReactNode;
className?: string;
}
/** One selectable row in a `ComposerMenuButton` panel. */
export function ComposerMenuItem({
icon,
description,
onSelect,
children,
className,
}: ComposerMenuItemProps) {
return (
<button
type="button"
onClick={onSelect}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition-colors",
"hover:bg-[var(--wb-hover)]",
className,
)}
>
{icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="whitespace-nowrap">{children}</span>
{description ? <span className="min-w-0 truncate text-muted-foreground">{description}</span> : null}
</button>
);
}
export interface ComposerSendButtonProps {
running?: boolean;
disabled?: boolean;
onClick?: () => void;
"aria-label"?: string;
className?: string;
}
/**
* Morphing send/stop control. `running` swaps the arrow glyph for a solid
* square via `AnimatePresence mode="wait"` — a scale+opacity springy pop
* (`SPRING_PANEL`), reduced to a plain opacity cut under
* `useReducedMotion()`.
*/
export function ComposerSendButton({
running = false,
disabled,
onClick,
"aria-label": ariaLabel,
className,
}: ComposerSendButtonProps) {
const reduce = useReducedMotion() ?? false;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel ?? (running ? "Stop" : "Send")}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full bg-foreground text-background",
"disabled:pointer-events-none disabled:opacity-40",
className,
)}
>
<AnimatePresence mode="wait" initial={false}>
{running ? (
<motion.span
key="stop"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
className="block h-2.5 w-2.5 rounded-[2px] bg-current"
/>
) : (
<motion.span
key="send"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
className="flex items-center justify-center"
>
<ArrowUp className="h-4 w-4" />
</motion.span>
)}
</AnimatePresence>
</button>
);
}
/** 3px dash / 4px gap horizontal dash pattern, colored by `currentColor`. */
const DICTATION_DASHES = "repeating-linear-gradient(90deg, currentColor 0 3px, transparent 3px 7px)";
export interface ComposerDictationProps {
/** Elapsed recording time, owned by the caller; formatted as m:ss. */
seconds: number;
onStop?: () => void;
className?: string;
/** Label for the stop button — defaults to "Stop dictation". */
"aria-label"?: string;
}
/**
* Voice-dictation state for the toolbar's middle stretch: a dashed sound
* track whose brighter right-end segment slowly flows leftward while
* listening, an m:ss timer, and a white stop button (white in both themes).
* The flow loops over exactly one dash period so it reads as continuous; a
* static track is shown under `useReducedMotion()`.
*/
export function ComposerDictation({
seconds,
onStop,
className,
"aria-label": ariaLabel,
}: ComposerDictationProps) {
const reduce = useReducedMotion() ?? false;
const minutes = Math.floor(seconds / 60);
const remainder = `${Math.floor(seconds % 60)}`.padStart(2, "0");
return (
<div className={cn("flex items-center gap-3", className)}>
<div
aria-hidden
className="relative h-px flex-1 overflow-hidden text-muted-foreground/50"
style={{ backgroundImage: DICTATION_DASHES }}
>
<motion.div
className="absolute inset-y-0 right-0 w-[72px] text-foreground/80"
style={{ backgroundImage: DICTATION_DASHES }}
animate={reduce ? undefined : { backgroundPositionX: ["0px", "-7px"] }}
transition={reduce ? undefined : { duration: 0.6, repeat: Infinity, ease: "linear" }}
/>
</div>
<span className="text-[13px] text-muted-foreground tabular-nums">
{minutes}:{remainder}
</span>
<button
type="button"
aria-label={ariaLabel ?? "Stop dictation"}
onClick={onStop}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--wb-accent-fg)] text-[var(--wb-solid-control-fg)] shadow-[0_0_0_0.5px_rgba(0,0,0,0.08),0_1px_4px_rgba(0,0,0,0.2)]"
>
<span className="block h-2.5 w-2.5 rounded-[2px] bg-current" />
</button>
</div>
);
}
export interface ComposerAttachmentsProps {
className?: string;
children?: ReactNode;
}
/**
* Row of attachment chips shown above `ComposerTextarea` — a plain
* flex-wrap container. Wrap the mapped `ComposerAttachmentChip` list in the
* caller's own `AnimatePresence` to animate removal (see the preview); this
* component only supplies the layout.
*/
export function ComposerAttachments({ className, children }: ComposerAttachmentsProps) {
return <div className={cn("flex flex-wrap gap-1.5 px-4 pt-3", className)}>{children}</div>;
}
export interface ComposerAttachmentChipProps {
/** 14px icon, e.g. `<FileText className="h-3.5 w-3.5" />`. */
icon?: ReactNode;
name: ReactNode;
/** Muted trailing detail, e.g. a file size. */
meta?: ReactNode;
onRemove?: () => void;
className?: string;
}
/**
* One attached file/image chip inside `ComposerAttachments`. Carries
* `layout` so sibling chips reflow smoothly when one is added or removed;
* the removal transition itself is the caller's responsibility (wrap the
* list in `AnimatePresence`).
*/
export function ComposerAttachmentChip({
icon,
name,
meta,
onRemove,
className,
}: ComposerAttachmentChipProps) {
return (
<motion.div
layout
className={cn(
"group/att flex items-center gap-1.5 rounded-lg border border-[var(--wb-border)] bg-[var(--wb-inset-subtle)] py-1 pr-1 pl-2 text-[13px]",
className,
)}
>
{icon ? (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<span className="max-w-40 truncate text-foreground">{name}</span>
{meta ? <span className="text-muted-foreground text-xs">{meta}</span> : null}
{onRemove ? (
<button
type="button"
aria-label={typeof name === "string" ? `Remove ${name}` : "Remove attachment"}
onClick={onRemove}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-[var(--wb-hover-strong)]"
>
<X className="h-3 w-3" />
</button>
) : null}
</motion.div>
);
}
export interface ComposerContextGaugeProps {
used: number;
limit: number;
/** Defaults to a K-abbreviated "32K / 200K" readout. */
formatLabel?: (used: number, limit: number) => ReactNode;
className?: string;
}
function formatContextK(n: number) {
return n >= 1000 ? `${Math.round(n / 1000)}K` : `${n}`;
}
function defaultContextLabel(used: number, limit: number) {
return `${formatContextK(used)} / ${formatContextK(limit)}`;
}
/**
* Token-budget gauge for the toolbar or a `ComposerContextBar` — a thin
* track filled to `used / limit`, colored blue under 80%, orange from
* 80–95%, red past that. The fill width animates with `SPRING_PANEL`,
* reduced to an instant cut under `useReducedMotion()`.
*/
export function ComposerContextGauge({
used,
limit,
formatLabel = defaultContextLabel,
className,
}: ComposerContextGaugeProps) {
const reduce = useReducedMotion() ?? false;
const ratio = limit > 0 ? Math.min(Math.max(used / limit, 0), 1) : 0;
const tone = ratio < 0.8 ? "safe" : ratio < 0.95 ? "warn" : "crit";
return (
<div className={cn("flex items-center gap-1.5", className)}>
<div className="h-1 w-16 overflow-hidden rounded-full bg-[var(--wb-border)]">
<motion.div
initial={false}
animate={{ width: `${ratio * 100}%` }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
className={cn(
"h-full rounded-full",
tone === "safe" && "bg-[var(--wb-accent)]",
tone === "warn" && "bg-[var(--wb-warning)]",
tone === "crit" && "bg-[var(--wb-danger)]",
)}
/>
</div>
<span className="text-muted-foreground text-xs tabular-nums">
{formatLabel(used, limit)}
</span>
</div>
);
}
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
const DEFAULT_LABELS = ["Suggest only", "Ask first", "Scoped auto", "Full auto"];
/** Tick centers are inset this many px from either end of the track so the
* end dots clear the rounded caps. */
const TRACK_INSET = 12;
export interface ComposerAutonomyDialProps {
value: number;
onChange: (next: number) => void;
/** Step labels — length sets the number of tiers (defaults to 4). */
labels?: string[];
"aria-label"?: string;
className?: string;
disabled?: boolean;
}
/** Blue for the low tiers, amber for the one below max, red at max — the
* fill climbs like risk does as the dial approaches unsupervised action. */
function fillColorFor(index: number, maxIndex: number) {
if (maxIndex <= 0 || index >= maxIndex) return "var(--wb-danger-strong)";
if (index === maxIndex - 1) return "var(--wb-warning)";
return "var(--wb-accent)";
}
/**
* Segmented autonomy dial — how free the agent is to act without checking
* in, from suggestion-only up to fully unsupervised. Built on the same
* measured-track / pointer-capture / keyboard skeleton as
* `ComposerEffortSlider` (a "risk-tiered autonomy" scale: the further the
* dial travels, the less the agent asks first), but themed for risk instead
* of compute: the fill color climbs from blue through amber to red as the
* tier rises, and the top tier breathes a red ring around the thumb instead
* of rippling — that ripple is the effort slider's own signature, kept
* unique to it. Snaps to one of `labels.length` evenly spaced steps via drag
* or the keyboard (ArrowLeft/Right, Home/End); the color glide and thumb
* glide both collapse to instant cuts under `useReducedMotion()`, while the
* warning ring stays visible but stops animating rather than disappearing.
*/
export function ComposerAutonomyDial({
value,
onChange,
labels = DEFAULT_LABELS,
"aria-label": ariaLabel,
className,
disabled = false,
}: ComposerAutonomyDialProps) {
const reduce = useReducedMotion() ?? false;
const trackRef = useRef<HTMLDivElement>(null);
const [trackWidth, setTrackWidth] = useState(0);
// False until the frame carrying the first real measurement has painted —
// fill/thumb transitions run at duration 0 while false, so mounting inside
// a popover lands them in place instead of sweeping in from the left edge.
const [ready, setReady] = useState(false);
const [dragging, setDragging] = useState(false);
const maxIndex = Math.max(0, labels.length - 1);
const clampedValue = Math.min(maxIndex, Math.max(0, value));
const isMax = maxIndex > 0 && clampedValue === maxIndex;
// Synchronous first measure so the initial thumb position is correct
// before paint, then keep it correct if the track is ever resized (e.g. a
// wider className override). `offsetWidth` (layout width) rather than a
// bounding rect: a host popover's scale entrance would otherwise skew the
// measure and leave the geometry permanently off by the entrance scale.
// `ready` flips only after a double rAF — i.e. after the browser has
// painted the correctly-placed first frame — so the springs can't animate
// the 0-width → measured-width jump.
useLayoutEffect(() => {
const el = trackRef.current;
if (!el) return;
setTrackWidth(el.offsetWidth);
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setReady(true));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, []);
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setTrackWidth(el.offsetWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
const centerFor = useCallback(
(index: number) => {
const usable = Math.max(0, trackWidth - TRACK_INSET * 2);
const step = maxIndex > 0 ? usable / maxIndex : 0;
return TRACK_INSET + step * index;
},
[trackWidth, maxIndex],
);
const indexFromClientX = useCallback(
(clientX: number) => {
const el = trackRef.current;
if (!el || maxIndex === 0) return 0;
const rect = el.getBoundingClientRect();
const usable = Math.max(1, rect.width - TRACK_INSET * 2);
const ratio = (clientX - rect.left - TRACK_INSET) / usable;
return Math.round(Math.min(1, Math.max(0, ratio)) * maxIndex);
},
[maxIndex],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
onChange(indexFromClientX(event.clientX));
},
[disabled, indexFromClientX, onChange],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!dragging || disabled) return;
onChange(indexFromClientX(event.clientX));
},
[dragging, disabled, indexFromClientX, onChange],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId);
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowLeft: clampedValue - 1,
ArrowRight: clampedValue + 1,
Home: 0,
End: maxIndex,
};
const next = map[event.key];
if (next !== undefined) {
event.preventDefault();
onChange(Math.min(maxIndex, Math.max(0, next)));
}
},
[clampedValue, disabled, maxIndex, onChange],
);
const thumbCenter = centerFor(clampedValue);
const thumbScale = reduce ? 1 : dragging ? 1.08 : 1;
const fillColor = fillColorFor(clampedValue, maxIndex);
// Instant placement until the first measured frame has painted, and always
// under reduced motion; spring glide otherwise.
const glideTransition = !ready || reduce ? { duration: 0 } : SPRING_PANEL;
const colorTransition = !ready || reduce ? { duration: 0 } : { duration: 0.25, ease: EASE_OUT };
return (
<div className={cn("inline-flex flex-col items-center", className)}>
<div
ref={trackRef}
role="slider"
aria-valuemin={0}
aria-valuemax={maxIndex}
aria-valuenow={clampedValue}
aria-valuetext={labels[clampedValue]}
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
tabIndex={disabled ? -1 : 0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
className={cn(
"relative h-6 w-[200px] touch-none select-none overflow-visible rounded-full bg-[var(--wb-inset-strong)] outline-none",
"shadow-[inset_0_0_0_0.5px_var(--wb-control-hairline)]",
"focus-visible:ring-2 focus-visible:ring-[var(--wb-accent)]/50",
disabled && "pointer-events-none opacity-50",
)}
>
{/* fill — left edge to the thumb center */}
<motion.div
aria-hidden
className="absolute inset-y-0 left-0 rounded-full"
initial={false}
animate={{ width: thumbCenter, backgroundColor: fillColor }}
transition={{ width: glideTransition, backgroundColor: colorTransition }}
/>
{/* tick dots */}
{labels.map((label, index) => (
<span
key={label}
aria-hidden
className={cn(
"-translate-x-1/2 -translate-y-1/2 absolute top-1/2 h-1 w-1 rounded-full",
index <= clampedValue
? "bg-[var(--wb-accent-fg)]/50"
: "bg-[var(--wb-control-tick)]",
)}
style={{ left: centerFor(index) }}
/>
))}
{/* thumb */}
<motion.div
aria-hidden
className="absolute top-0 h-7 w-7 rounded-full bg-[var(--wb-accent-fg)]"
style={{
y: "-2px",
boxShadow: "0 0 0 0.5px rgba(0,0,0,0.08), 0 1px 4px rgba(0,0,0,0.24)",
}}
animate={{ left: thumbCenter, x: "-50%", scale: thumbScale }}
transition={{
left: glideTransition,
x: { duration: 0 },
scale: SPRING_PANEL,
}}
>
{isMax ? (
reduce ? (
<span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
boxShadow:
"0 0 0 3px color-mix(in srgb, var(--wb-danger-strong) 50%, transparent)",
}}
/>
) : (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full"
style={{
boxShadow:
"0 0 0 3px color-mix(in srgb, var(--wb-danger-strong) 25%, transparent)",
}}
animate={{ opacity: [0.4, 0.8, 0.4] }}
transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
/>
)
) : null}
</motion.div>
</div>
{/* current tier label */}
<div className="mt-1.5 text-xs text-muted-foreground">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={clampedValue}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: EASE_OUT }}
className={cn("inline-block", isMax && "text-[var(--wb-danger-strong)]")}
>
{labels[clampedValue]}
</motion.span>
</AnimatePresence>
</div>
</div>
);
}
"use client";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
const DEFAULT_LABELS = ["Minimal", "Low", "Standard", "High", "Max"];
/** Tick centers are inset this many px from either end of the track so the
* end dots clear the rounded caps. */
const TRACK_INSET = 12;
/** How long the one-shot thumb bounce plays after landing on the top tier. */
const MAX_BOUNCE_MS = 400;
export interface ComposerEffortSliderProps {
value: number;
onChange: (next: number) => void;
/** Step labels — length sets the number of segments (defaults to 5). */
labels?: string[];
"aria-label"?: string;
className?: string;
disabled?: boolean;
}
/**
* Segmented reasoning-effort slider. Snaps to one of `labels.length` evenly
* spaced steps via drag (pointer capture) or the keyboard (ArrowLeft/Right,
* Home/End). Reaching the last step bounces the thumb once and sends out a
* pair of accent-token ripple rings — both skipped under
* `useReducedMotion()`, which also drops the spring glide in favor of an
* instant snap.
*/
export function ComposerEffortSlider({
value,
onChange,
labels = DEFAULT_LABELS,
"aria-label": ariaLabel,
className,
disabled = false,
}: ComposerEffortSliderProps) {
const reduce = useReducedMotion() ?? false;
const trackRef = useRef<HTMLDivElement>(null);
const [trackWidth, setTrackWidth] = useState(0);
// False until the frame carrying the first real measurement has painted —
// fill/thumb transitions run at duration 0 while false, so mounting inside
// a popover lands them in place instead of sweeping in from the left edge.
const [ready, setReady] = useState(false);
const [dragging, setDragging] = useState(false);
const [justMaxed, setJustMaxed] = useState(false);
const maxIndex = Math.max(0, labels.length - 1);
const clampedValue = Math.min(maxIndex, Math.max(0, value));
const isMax = maxIndex > 0 && clampedValue === maxIndex;
// Fires the one-shot bounce only on the transition into the top tier, not
// on every render while already there.
const wasMaxRef = useRef(isMax);
useEffect(() => {
const wasMax = wasMaxRef.current;
wasMaxRef.current = isMax;
if (isMax && !wasMax && !reduce) {
setJustMaxed(true);
const timer = setTimeout(() => setJustMaxed(false), MAX_BOUNCE_MS);
return () => clearTimeout(timer);
}
}, [isMax, reduce]);
// Synchronous first measure so the initial thumb position is correct
// before paint, then keep it correct if the track is ever resized (e.g. a
// wider className override). `offsetWidth` (layout width) rather than a
// bounding rect: a host popover's scale entrance would otherwise skew the
// measure and leave the geometry permanently off by the entrance scale.
// `ready` flips only after a double rAF — i.e. after the browser has
// painted the correctly-placed first frame — so the springs can't animate
// the 0-width → measured-width jump.
useLayoutEffect(() => {
const el = trackRef.current;
if (!el) return;
setTrackWidth(el.offsetWidth);
let raf2 = 0;
const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setReady(true));
});
return () => {
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
};
}, []);
useEffect(() => {
const el = trackRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setTrackWidth(el.offsetWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
const centerFor = useCallback(
(index: number) => {
const usable = Math.max(0, trackWidth - TRACK_INSET * 2);
const step = maxIndex > 0 ? usable / maxIndex : 0;
return TRACK_INSET + step * index;
},
[trackWidth, maxIndex],
);
const indexFromClientX = useCallback(
(clientX: number) => {
const el = trackRef.current;
if (!el || maxIndex === 0) return 0;
const rect = el.getBoundingClientRect();
const usable = Math.max(1, rect.width - TRACK_INSET * 2);
const ratio = (clientX - rect.left - TRACK_INSET) / usable;
return Math.round(Math.min(1, Math.max(0, ratio)) * maxIndex);
},
[maxIndex],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
onChange(indexFromClientX(event.clientX));
},
[disabled, indexFromClientX, onChange],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!dragging || disabled) return;
onChange(indexFromClientX(event.clientX));
},
[dragging, disabled, indexFromClientX, onChange],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId);
setDragging(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowLeft: clampedValue - 1,
ArrowRight: clampedValue + 1,
Home: 0,
End: maxIndex,
};
const next = map[event.key];
if (next !== undefined) {
event.preventDefault();
onChange(Math.min(maxIndex, Math.max(0, next)));
}
},
[clampedValue, disabled, maxIndex, onChange],
);
const thumbCenter = centerFor(clampedValue);
const thumbScale = reduce ? 1 : justMaxed ? [1, 1.15, 1] : dragging ? 1.08 : 1;
// Instant placement until the first measured frame has painted, and always
// under reduced motion; spring glide otherwise.
const glideTransition = !ready || reduce ? { duration: 0 } : SPRING_PANEL;
return (
<div
ref={trackRef}
role="slider"
aria-valuemin={0}
aria-valuemax={maxIndex}
aria-valuenow={clampedValue}
aria-valuetext={labels[clampedValue]}
aria-label={ariaLabel}
aria-disabled={disabled || undefined}
tabIndex={disabled ? -1 : 0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
className={cn(
"relative h-6 w-[200px] touch-none select-none overflow-visible rounded-full bg-[var(--wb-inset-strong)] outline-none",
"shadow-[inset_0_0_0_0.5px_var(--wb-control-hairline)]",
"focus-visible:ring-2 focus-visible:ring-[var(--wb-accent)]/50",
disabled && "pointer-events-none opacity-50",
className,
)}
>
{/* fill — left edge to the thumb center */}
<motion.div
aria-hidden
className="absolute inset-y-0 left-0 rounded-full"
style={{ backgroundColor: "var(--wb-accent)" }}
animate={{ width: thumbCenter }}
transition={glideTransition}
/>
{/* tick dots */}
{labels.map((label, index) => (
<span
key={label}
aria-hidden
className={cn(
"-translate-x-1/2 -translate-y-1/2 absolute top-1/2 h-1 w-1 rounded-full",
index <= clampedValue
? "bg-[var(--wb-accent-fg)]/50"
: "bg-[var(--wb-control-tick)]",
)}
style={{ left: centerFor(index) }}
/>
))}
{/* thumb */}
<motion.div
aria-hidden
className="absolute top-0 h-7 w-7 rounded-full bg-[var(--wb-accent-fg)]"
style={{
y: "-2px",
boxShadow:
"0 0 0 0.5px var(--wb-control-hairline), 0 1px 4px color-mix(in srgb, var(--wb-inverse) 24%, transparent)",
}}
animate={{ left: thumbCenter, x: "-50%", scale: thumbScale }}
transition={{
left: glideTransition,
x: { duration: 0 },
scale: justMaxed ? { duration: MAX_BOUNCE_MS / 1000, ease: EASE_OUT } : SPRING_PANEL,
}}
>
{isMax && !reduce ? (
<>
<RippleRing delay={0} />
<RippleRing delay={0.9} />
</>
) : null}
</motion.div>
</div>
);
}
/** One expanding ring in the "reached max effort" ripple pair. */
function RippleRing({ delay }: { delay: number }) {
return (
<motion.span
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full border-2"
style={{ borderColor: "color-mix(in srgb, var(--wb-accent) 60%, transparent)" }}
initial={{ scale: 1, opacity: 0.6 }}
animate={{ scale: 2.4, opacity: 0 }}
transition={{ duration: 1.8, repeat: Infinity, ease: "easeOut", delay }}
/>
);
}
API 参考
Thread
className?string—ThreadItem
className?string—ThreadUserMessage
className?string—ThreadTurnHeader
open?booleanControlled open state — purely a chevron-rotation signal (see below).
—onOpenChange?((next: boolean) => void)—working?booleanWhile the turn is still executing: shimmers the label. The header stays fully interactive — a running turn's live work log can be expanded and collapsed too.
falseclassName?string—ThreadCollapse
openboolean—className?string—ThreadMessage
streaming?booleanAppends a blinking `ThreadStreamingCaret` after `children` while the response is still streaming in.
falseclassName?string—ThreadInlineCode
className?string—ThreadCodeBlock
label?ReactNodeSmall label in the top-right corner, e.g. a language name like "bash".
—className?string—ThreadActionBar
timestamp?ReactNodeRendered after the buttons, e.g. a relative send time.
—className?string—ThreadActionButton
aria-labelstring—onClick?(() => void)—className?string—ThreadCard
className?string—ThreadCardButton
variant?"primary" | "ghost" | "outline""outline" (default) draws a hairline border; "ghost" is borderless — for a de-emphasized action beside it (e.g. "Undo" next to "Review"); "primary" is a filled emphasis button (e.g. "Approve").
outlineonClick?(() => void)—aria-label?string—className?string—ThreadCommandRow
icon?ReactNode16px leading icon, e.g. `<SquareTerminal className="h-4 w-4" />`.
—running?booleanPulses the icon's opacity while the command is executing.
—className?string—ThreadDiffCard
icon?ReactNode20px icon rendered in a 40px rounded slot, e.g. `<SquarePen className="h-5 w-5" />`.
—titleReactNode—added?number—removed?number—actions?ReactNodeTrailing slot for the caller's own controls, e.g. an "Undo" ghost button plus a "Review" outline button.
—children?ReactNodeVisible `ThreadDiffRow`s.
—hiddenRows?ReactNodeExtra `ThreadDiffRow`s revealed by the "show more" row below `children`.
—moreLabel?ReactNodeLabel for the "show more" row, e.g. "Show 2 more files".
—moreCount?numberRow count represented by `hiddenRows` — the "show more" row only renders when this is greater than 0.
0className?string—ThreadDiffRow
path?stringSplit at the last `/` into a muted directory prefix and a bright filename. Ignored when `children` is passed.
—added?number—removed?number—className?string—ThreadFileCard
icon?ReactNode24px icon rendered in a 40px rounded slot, e.g. `<FileText className="h-6 w-6" />`.
—titleReactNode—subtitle?ReactNode—action?ReactNodeTrailing slot for the caller's own controls, e.g. an "Open" `ThreadCardButton`.
—className?string—children?ReactNodeOptional content appended below the header row, extending the card body.
—ThreadShimmerText
className?string—ThreadApprovalCard
icon?ReactNode20px icon rendered in a 40px rounded slot, e.g. `<ShieldAlert className="h-5 w-5" />`.
—titleReactNode—description?ReactNodeSub-line under the title — wraps, never truncated.
—command?ReactNodeOptional mono one-liner of what will run.
—status?"pending" | "approved" | "denied"pendingresolution?ReactNodeShown in place of the buttons once `status` is no longer "pending".
—onApprove?(() => void)—onDeny?(() => void)—approveLabel?ReactNodeApprovedenyLabel?ReactNodeDenyclassName?string—ThreadBranchSwitcher
indexnumber1-based position of the branch currently shown.
—countnumber—onPrev?(() => void)—onNext?(() => void)—className?string—ThreadCheckpoint
label?ReactNodeCheckpointtimestamp?ReactNode—onRestore?(() => void)—restoreLabel?ReactNodeRestoreclassName?string—ThreadElicitation
icon?ReactNode20px icon rendered in a 40px rounded slot, e.g. `<MessageCircleQuestion className="h-5 w-5" />`.
—promptReactNode—options{ value: string; label: ReactNode; description?: ReactNode; }[]—value?string | nullSelected option value; `null`/`undefined` means still awaiting an answer.
nullonSelect?((value: string) => void)—className?string—ThreadErrorState
messageReactNode—detail?ReactNode—onRetry?(() => void)—retryLabel?ReactNodeRetryclassName?string—ThreadScrollPill
openboolean—count?number—onClick?(() => void)—className?string—ThreadStreamingCaret
className?string—ThreadSuggestions
suggestions{ value: string; label: ReactNode; }[]—onSelect?((value: string) => void)—className?string—ThreadSystemBanner
icon?ReactNode—className?string—ThreadTask
status?"done" | "pending" | "active"pendingclassName?string—ThreadTaskList
title?ReactNode—progress?ReactNodeShort counter, e.g. "2/5". Swaps with a `popLayout` crossfade when it changes.
—className?string—ThreadThinking
thinking?booleanStill reasoning: the label shimmers, the chevron is hidden and expansion is disabled (there is no summary to show yet).
falseopen?booleanControlled open state for the summary region.
—onOpenChange?((next: boolean) => void)—className?string—labelReactNode"Thinking…" while `thinking`, then e.g. "Thought for 8s".
—children?ReactNodeOptional reasoning summary revealed below the header once `thinking` is over.
—ThreadToolCall
icon?ReactNode16px leading icon, e.g. `<Globe className="h-4 w-4" />`.
—status?"done" | "error" | "running" | "stopped"donedetail?ReactNodeSupplement after the label — e.g. a mono query or path; wrapping (such as `font-mono text-[13px]`) is the caller's choice.
—elapsed?ReactNodeTrailing elapsed-time readout.
—className?string—children?ReactNodeThe label, verb-tensed by the caller: "Searching the web" while running, "Searched the web" when done.
—ThreadUsage
cost?number—inputTokens?number—outputTokens?number—duration?ReactNode—cacheHitRate?number—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.