制品画布面板
NewAI 产物的画布侧栏:发丝面板壳、带操作簇的头部、交叉淡切的预览/代码双视图切换,以及带恢复按钮的版本导航。
Ship your product, faster.
Everything you need to launch, in one kit.
"use client";
import { Copy, Download, FileCode2 } from "lucide-react";
import { useState } from "react";
import { ThreadMessage, ThreadUserMessage } from "@/components/motion/agent-thread";
import {
ArtifactAction,
ArtifactContent,
ArtifactHeader,
ArtifactPanel,
ArtifactVersionNav,
ArtifactViewToggle,
type ArtifactView,
} from "@/components/motion/artifact-panel";
import { cn } from "@/lib/utils";
type Version = 1 | 2 | 3;
const CODE_BY_VERSION: Record<Version, string> = {
1: `export function LandingHero() {
return (
<section className="py-24 text-center">
<h1 className="text-4xl font-semibold">
Ship your product, faster.
</h1>
<p className="mt-3 text-muted-foreground">
A starter hero — no call to action yet.
</p>
</section>
);
}`,
2: `export function LandingHero() {
return (
<section className="py-24 text-center">
<h1 className="text-4xl font-semibold">
Ship your product, faster.
</h1>
<p className="mt-3 text-muted-foreground">
Everything you need to launch, in one kit.
</p>
<button className="mt-6 rounded-full bg-foreground px-5 py-2 text-background">
Get started
</button>
</section>
);
}`,
3: `export function LandingHero() {
return (
<section
className={cn(
"relative py-24 text-center",
"bg-gradient-to-b from-[#339CFF]/10 to-transparent",
)}
>
<h1 className="text-4xl font-semibold">
Ship your product, faster.
</h1>
<p className="mt-3 text-muted-foreground">
Everything you need to launch, in one kit.
</p>
<div className="mt-6 flex justify-center gap-2">
<button className="rounded-full bg-foreground px-5 py-2 text-background">
Get started
</button>
<button className="rounded-full border border-black/10 px-5 py-2">
View docs
</button>
</div>
</section>
);
}`,
};
/** Version-dependent hero mock — stands in for a live-rendered artifact preview. */
function HeroPreview({ version }: { version: Version }) {
return (
<div
className={cn(
"flex h-full flex-col items-center justify-center gap-3 px-8 text-center",
version === 3 && "bg-gradient-to-b from-[#339CFF]/10 to-transparent dark:from-[#339CFF]/15",
)}
>
<h2 className="font-semibold text-2xl">Ship your product, faster.</h2>
<p className="max-w-[280px] text-muted-foreground text-sm">
{version === 1
? "A starter hero — no call to action yet."
: "Everything you need to launch, in one kit."}
</p>
{version >= 2 ? (
<div className="mt-2 flex gap-2">
<span className="rounded-full bg-foreground px-4 py-1.5 text-background text-sm">
Get started
</span>
{version === 3 ? (
<span className="rounded-full border border-black/10 px-4 py-1.5 text-sm dark:border-white/10">
View docs
</span>
) : null}
</div>
) : null}
</div>
);
}
export function ArtifactPanelPreview() {
const [view, setView] = useState<ArtifactView>("preview");
const [version, setVersion] = useState<Version>(3);
return (
<div className="relative h-[560px] w-full overflow-hidden rounded-xl border border-border bg-neutral-100 p-4 dark:bg-neutral-950">
<div className="flex h-full gap-4">
<div className="flex w-[220px] shrink-0 flex-col gap-1">
<ThreadUserMessage>Build a hero section for the landing page.</ThreadUserMessage>
<ThreadMessage>Drafted the landing hero — see the artifact.</ThreadMessage>
</div>
<ArtifactPanel className="flex-1">
<ArtifactHeader
icon={<FileCode2 className="h-4 w-4" />}
title="landing-hero.tsx"
subtitle="React component"
actions={
<>
<ArtifactViewToggle value={view} onChange={setView} />
<ArtifactAction aria-label="Copy code">
<Copy className="h-3.5 w-3.5" />
</ArtifactAction>
<ArtifactAction aria-label="Download file">
<Download className="h-3.5 w-3.5" />
</ArtifactAction>
</>
}
/>
<ArtifactContent view={`${view}-${version}`}>
{view === "preview" ? (
<HeroPreview version={version} />
) : (
<pre className="m-0 h-full overflow-auto whitespace-pre p-4 font-mono text-[13px] leading-[20px]">
<code>{CODE_BY_VERSION[version]}</code>
</pre>
)}
</ArtifactContent>
<div className="flex h-10 shrink-0 items-center justify-between border-black/5 border-t-[0.5px] px-3 dark:border-white/[0.06]">
<ArtifactVersionNav
index={version}
count={3}
onPrev={() => setVersion((v) => (v > 1 ? ((v - 1) as Version) : v))}
onNext={() => setVersion((v) => (v < 3 ? ((v + 1) as Version) : v))}
onRestore={version < 3 ? () => setVersion(3) : undefined}
/>
<span className="text-muted-foreground text-xs">Updated just now</span>
</div>
</ArtifactPanel>
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/artifact-panel
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ArtifactPanelProps {
className?: string;
children?: ReactNode;
}
/**
* Panel shell for a live artifact/canvas surface next to a conversation — the
* hairline-ringed card that hosts a header, a scrollable content region and
* (optionally) a version-nav footer. Uses the same CSS-variable box-shadow
* hairline technique as `ThreadCard` / `WorkbenchSummaryCard`, tuned to a
* full opaque surface rather than a tinted one since this panel is the
* primary content, not an inline card in a message stream.
*/
export function ArtifactPanel({ className, children }: ArtifactPanelProps) {
return (
<div
style={{ boxShadow: "0 0 0 0.5px var(--wb-hairline)" }}
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-[var(--wb-surface)]",
className,
)}
>
{children}
</div>
);
}
export interface ArtifactHeaderProps {
/** 16px leading icon, e.g. `<FileCode2 className="h-4 w-4" />`. */
icon?: ReactNode;
title: ReactNode;
/** Rendered beside the title on the same line, truncating independently. */
subtitle?: ReactNode;
/** Trailing slot for the caller's own controls — copy/download buttons, an `ArtifactViewToggle`. */
actions?: ReactNode;
className?: string;
}
/** Fixed 48px header row: icon, truncating title/subtitle, and a trailing action cluster. */
export function ArtifactHeader({ icon, title, subtitle, actions, className }: ArtifactHeaderProps) {
return (
<div
className={cn(
"flex h-12 shrink-0 items-center gap-2.5 border-[var(--wb-divider)] border-b-[0.5px] px-3",
className,
)}
>
{icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-sm font-medium">{title}</span>
{subtitle ? <span className="truncate text-muted-foreground text-xs">{subtitle}</span> : null}
</div>
{actions ? <div className="ml-auto flex shrink-0 items-center gap-0.5">{actions}</div> : null}
</div>
);
}
export interface ArtifactActionProps {
"aria-label": string;
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** One icon button inside an `ArtifactHeader`'s actions slot (copy, download, ...) — same visual as `ThreadActionButton`. */
export function ArtifactAction({
"aria-label": ariaLabel,
onClick,
children,
className,
}: ArtifactActionProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
export type ArtifactView = "preview" | "code";
export interface ArtifactViewToggleProps {
value: ArtifactView;
onChange: (next: ArtifactView) => void;
previewLabel?: ReactNode;
codeLabel?: ReactNode;
className?: string;
}
/** Two-segment pill for switching between a rendered "Preview" and raw "Code" view — same visual language as the preview's scene-switcher pill. */
export function ArtifactViewToggle({
value,
onChange,
previewLabel = "Preview",
codeLabel = "Code",
className,
}: ArtifactViewToggleProps) {
return (
<div
className={cn(
"flex items-center gap-0.5 rounded-full border border-[var(--wb-border)] p-0.5",
className,
)}
>
{(["preview", "code"] as const).map((view) => (
<button
key={view}
type="button"
aria-pressed={value === view}
onClick={() => onChange(view)}
className={cn(
"h-6 rounded-full px-2.5 text-xs transition-colors",
value === view
? "bg-foreground text-background"
: "text-muted-foreground hover:text-foreground",
)}
>
{view === "preview" ? previewLabel : codeLabel}
</button>
))}
</div>
);
}
export interface ArtifactContentProps {
/** When passed, `children` cross-fades keyed on this value (e.g. the active `ArtifactView`, or a composite key that also folds in a version so version switches transition too). Omit for static content that never swaps. */
view?: string | number;
className?: string;
children?: ReactNode;
}
/**
* Scrollable body region of an `ArtifactPanel`. When `view` is passed, the
* previous and next `children` cross-fade (opacity + a 4px rise, 0.15s
* `EASE_OUT`, `AnimatePresence mode="wait"`) instead of swapping instantly —
* reduced to a plain opacity fade under `useReducedMotion()`. Without `view`,
* `children` render directly with no transition wrapper.
*/
export function ArtifactContent({ view, className, children }: ArtifactContentProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("relative min-h-0 flex-1 overflow-auto", className)}>
{view !== undefined ? (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={view}
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 }}
>
{children}
</motion.div>
</AnimatePresence>
) : (
children
)}
</div>
);
}
export interface ArtifactVersionNavProps {
/** 1-based position of the version currently shown. */
index: number;
count: number;
onPrev?: () => void;
onNext?: () => void;
/** Renders a trailing ghost "Restore" button when passed — the caller decides when restoring makes sense (e.g. only on non-latest versions). */
onRestore?: () => void;
restoreLabel?: ReactNode;
className?: string;
}
/**
* Prev/next control for stepping through an artifact's version history — the
* same shape as `ThreadBranchSwitcher`: chevron buttons flanking a
* tabular-nums "v2 / 3" readout that crossfades with a short y-shift on
* change (`AnimatePresence mode="popLayout"`), reduced to an instant swap
* under `useReducedMotion()`. An optional trailing ghost "Restore" button
* appears whenever `onRestore` is passed.
*/
export function ArtifactVersionNav({
index,
count,
onPrev,
onNext,
onRestore,
restoreLabel = "Restore",
className,
}: ArtifactVersionNavProps) {
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 version"
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-[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"
>
v{index} / {count}
</motion.span>
</AnimatePresence>
</span>
<button
type="button"
aria-label="Next version"
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>
{onRestore ? (
<button
type="button"
onClick={onRestore}
className="ml-1 rounded px-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
>
{restoreLabel}
</button>
) : null}
</div>
);
}
安装
用 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/artifact-panel
import { ChevronLeft, ChevronRight } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface ArtifactPanelProps {
className?: string;
children?: ReactNode;
}
/**
* Panel shell for a live artifact/canvas surface next to a conversation — the
* hairline-ringed card that hosts a header, a scrollable content region and
* (optionally) a version-nav footer. Uses the same CSS-variable box-shadow
* hairline technique as `ThreadCard` / `WorkbenchSummaryCard`, tuned to a
* full opaque surface rather than a tinted one since this panel is the
* primary content, not an inline card in a message stream.
*/
export function ArtifactPanel({ className, children }: ArtifactPanelProps) {
return (
<div
style={{ boxShadow: "0 0 0 0.5px var(--wb-hairline)" }}
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-[var(--wb-surface)]",
className,
)}
>
{children}
</div>
);
}
export interface ArtifactHeaderProps {
/** 16px leading icon, e.g. `<FileCode2 className="h-4 w-4" />`. */
icon?: ReactNode;
title: ReactNode;
/** Rendered beside the title on the same line, truncating independently. */
subtitle?: ReactNode;
/** Trailing slot for the caller's own controls — copy/download buttons, an `ArtifactViewToggle`. */
actions?: ReactNode;
className?: string;
}
/** Fixed 48px header row: icon, truncating title/subtitle, and a trailing action cluster. */
export function ArtifactHeader({ icon, title, subtitle, actions, className }: ArtifactHeaderProps) {
return (
<div
className={cn(
"flex h-12 shrink-0 items-center gap-2.5 border-[var(--wb-divider)] border-b-[0.5px] px-3",
className,
)}
>
{icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{icon}
</span>
) : null}
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-sm font-medium">{title}</span>
{subtitle ? <span className="truncate text-muted-foreground text-xs">{subtitle}</span> : null}
</div>
{actions ? <div className="ml-auto flex shrink-0 items-center gap-0.5">{actions}</div> : null}
</div>
);
}
export interface ArtifactActionProps {
"aria-label": string;
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** One icon button inside an `ArtifactHeader`'s actions slot (copy, download, ...) — same visual as `ThreadActionButton`. */
export function ArtifactAction({
"aria-label": ariaLabel,
onClick,
children,
className,
}: ArtifactActionProps) {
return (
<button
type="button"
aria-label={ariaLabel}
onClick={onClick}
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[var(--wb-hover)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
export type ArtifactView = "preview" | "code";
export interface ArtifactViewToggleProps {
value: ArtifactView;
onChange: (next: ArtifactView) => void;
previewLabel?: ReactNode;
codeLabel?: ReactNode;
className?: string;
}
/** Two-segment pill for switching between a rendered "Preview" and raw "Code" view — same visual language as the preview's scene-switcher pill. */
export function ArtifactViewToggle({
value,
onChange,
previewLabel = "Preview",
codeLabel = "Code",
className,
}: ArtifactViewToggleProps) {
return (
<div
className={cn(
"flex items-center gap-0.5 rounded-full border border-[var(--wb-border)] p-0.5",
className,
)}
>
{(["preview", "code"] as const).map((view) => (
<button
key={view}
type="button"
aria-pressed={value === view}
onClick={() => onChange(view)}
className={cn(
"h-6 rounded-full px-2.5 text-xs transition-colors",
value === view
? "bg-foreground text-background"
: "text-muted-foreground hover:text-foreground",
)}
>
{view === "preview" ? previewLabel : codeLabel}
</button>
))}
</div>
);
}
export interface ArtifactContentProps {
/** When passed, `children` cross-fades keyed on this value (e.g. the active `ArtifactView`, or a composite key that also folds in a version so version switches transition too). Omit for static content that never swaps. */
view?: string | number;
className?: string;
children?: ReactNode;
}
/**
* Scrollable body region of an `ArtifactPanel`. When `view` is passed, the
* previous and next `children` cross-fade (opacity + a 4px rise, 0.15s
* `EASE_OUT`, `AnimatePresence mode="wait"`) instead of swapping instantly —
* reduced to a plain opacity fade under `useReducedMotion()`. Without `view`,
* `children` render directly with no transition wrapper.
*/
export function ArtifactContent({ view, className, children }: ArtifactContentProps) {
const reduce = useReducedMotion() ?? false;
return (
<div className={cn("relative min-h-0 flex-1 overflow-auto", className)}>
{view !== undefined ? (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={view}
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 }}
>
{children}
</motion.div>
</AnimatePresence>
) : (
children
)}
</div>
);
}
export interface ArtifactVersionNavProps {
/** 1-based position of the version currently shown. */
index: number;
count: number;
onPrev?: () => void;
onNext?: () => void;
/** Renders a trailing ghost "Restore" button when passed — the caller decides when restoring makes sense (e.g. only on non-latest versions). */
onRestore?: () => void;
restoreLabel?: ReactNode;
className?: string;
}
/**
* Prev/next control for stepping through an artifact's version history — the
* same shape as `ThreadBranchSwitcher`: chevron buttons flanking a
* tabular-nums "v2 / 3" readout that crossfades with a short y-shift on
* change (`AnimatePresence mode="popLayout"`), reduced to an instant swap
* under `useReducedMotion()`. An optional trailing ghost "Restore" button
* appears whenever `onRestore` is passed.
*/
export function ArtifactVersionNav({
index,
count,
onPrev,
onNext,
onRestore,
restoreLabel = "Restore",
className,
}: ArtifactVersionNavProps) {
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 version"
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-[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"
>
v{index} / {count}
</motion.span>
</AnimatePresence>
</span>
<button
type="button"
aria-label="Next version"
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>
{onRestore ? (
<button
type="button"
onClick={onRestore}
className="ml-1 rounded px-1 text-muted-foreground text-xs transition-colors hover:text-foreground"
>
{restoreLabel}
</button>
) : null}
</div>
);
}
"use client";
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";
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";
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>
);
}
API 参考
ArtifactPanel
className?string—ArtifactHeader
icon?ReactNode16px leading icon, e.g. `<FileCode2 className="h-4 w-4" />`.
—titleReactNode—subtitle?ReactNodeRendered beside the title on the same line, truncating independently.
—actions?ReactNodeTrailing slot for the caller's own controls — copy/download buttons, an `ArtifactViewToggle`.
—className?string—ArtifactAction
aria-labelstring—onClick?(() => void)—className?string—ArtifactViewToggle
value"code" | "preview"—onChange(next: ArtifactView) => void—previewLabel?ReactNodePreviewcodeLabel?ReactNodeCodeclassName?string—ArtifactContent
view?string | numberWhen passed, `children` cross-fades keyed on this value (e.g. the active `ArtifactView`, or a composite key that also folds in a version so version switches transition too). Omit for static content that never swaps.
—className?string—ArtifactVersionNav
indexnumber1-based position of the version currently shown.
—countnumber—onPrev?(() => void)—onNext?(() => void)—onRestore?(() => void)Renders a trailing ghost "Restore" button when passed — the caller decides when restoring makes sense (e.g. only on non-latest versions).
—restoreLabel?ReactNodeRestoreclassName?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.