Agent 工作台
New响应式 Agent 应用外壳:桌面端为可拖宽三栏布局,平板端将侧区切换为覆盖层,移动端一次只展示一个任务表面;支持弹簧过渡与横跨全宽的 46px 顶部工具栏,并内置置顶摘要浮层卡片。
Pulled the handoff notes at efd14fb and staged the release diff below.
"use client";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
CircleAlert,
Clock,
Copy,
Diff,
FileText,
Folder,
GitBranch,
GitCommitHorizontal,
GitPullRequest,
Globe,
Image,
Laptop,
ListChecks,
ListTodo,
MessageCircle,
Mic,
PanelLeft,
PanelRight,
Plus,
Search,
SquarePen,
SquareTerminal,
Zap,
} from "lucide-react";
import { type ComponentType, useState } from "react";
import {
Composer,
ComposerAccessChip,
ComposerEffortSlider,
ComposerIconButton,
ComposerModelPicker,
ComposerSendButton,
ComposerTextarea,
ComposerToolbar,
} from "@/components/motion/agent-composer";
import {
Thread,
ThreadActionBar,
ThreadActionButton,
ThreadCardButton,
ThreadCollapse,
ThreadCommandRow,
ThreadDiffCard,
ThreadDiffRow,
ThreadInlineCode,
ThreadMessage,
ThreadThinking,
ThreadToolCall,
ThreadTurnHeader,
ThreadUserMessage,
} from "@/components/motion/agent-thread";
import {
useWorkbench,
Workbench,
WorkbenchHeader,
WorkbenchMain,
WorkbenchPanel,
WorkbenchSidebar,
WorkbenchSummaryCard,
WorkbenchSummarySection,
} from "@/components/motion/agent-workbench";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ icon: SquarePen, label: "New task" },
{ icon: Clock, label: "Scheduled" },
{ icon: GitPullRequest, label: "Pull requests" },
{ icon: MessageCircle, label: "Chats" },
];
const PINNED_TASKS = [
"Refactor auth middleware",
"Fix flaky CI on macOS runners",
"Update onboarding docs",
"Investigate memory leak in worker pool",
];
const PROJECTS = [
{ name: "web-app", children: ["main", "feature/cache-keys"] },
{ name: "infra-scripts", children: ["deploy.sh"] },
];
const TOOL_ROWS: { icon: ComponentType<{ className?: string }>; label: string; shortcut?: string }[] = [
{ icon: ListTodo, label: "Side tasks", shortcut: "⌥⌘S" },
{ icon: Globe, label: "Browser", shortcut: "⌘T" },
{ icon: SquareTerminal, label: "Terminal" },
];
const SUGGESTED_FILES: { icon: ComponentType<{ className?: string }>; name: string }[] = [
{ icon: FileText, name: "useCacheKey.ts" },
{ icon: FileText, name: "webpack.config.js" },
{ icon: FileText, name: "ci.yml" },
{ icon: Image, name: "screenshot.png" },
{ icon: FileText, name: "README.md" },
];
const ICON_BUTTON =
"flex h-7 w-7 items-center justify-center rounded-lg text-foreground/70 hover:bg-black/5 dark:hover:bg-white/10";
interface ToolbarProps {
onToggleSummary: () => void;
}
function Toolbar({ onToggleSummary }: ToolbarProps) {
const { toggleSidebar, togglePanel } = useWorkbench();
return (
<WorkbenchHeader
leading={
<div className="flex items-center gap-1.5 pl-3">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-red-500/80" />
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500/80" />
<span className="h-2.5 w-2.5 rounded-full bg-green-500/80" />
</span>
<button
type="button"
onClick={toggleSidebar}
aria-label="Toggle sidebar"
className={cn(ICON_BUTTON, "ml-1")}
>
<PanelLeft className="h-4 w-4" />
</button>
<ChevronLeft className="h-4 w-4 text-muted-foreground" />
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</div>
}
trailing={
<div className="flex items-center gap-0.5 pr-3">
<button
type="button"
onClick={onToggleSummary}
aria-label="Toggle summary"
className={ICON_BUTTON}
>
<ListChecks className="h-4 w-4" />
</button>
<button type="button" onClick={togglePanel} aria-label="Toggle panel" className={ICON_BUTTON}>
<PanelRight className="h-4 w-4" />
</button>
</div>
}
>
<div className="flex h-full items-center gap-1.5 pl-3 text-xs text-muted-foreground">
<span>Investigate build cache misses</span>
<span>···</span>
</div>
</WorkbenchHeader>
);
}
function SidebarContent() {
return (
<div className="flex flex-col gap-5 px-3 pb-4">
<div className="flex items-center justify-between px-1 pt-1">
<span className="text-sm font-bold text-foreground">Workbench</span>
<Search className="h-4 w-4 text-muted-foreground" />
</div>
<nav className="flex flex-col gap-0.5">
{NAV_ITEMS.map(({ icon: Icon, label }) => (
<div
key={label}
className="flex h-[30px] items-center gap-2 rounded-full px-2.5 text-sm text-foreground/80 hover:bg-black/5 dark:hover:bg-white/10"
>
<Icon className="h-4 w-4" />
<span>{label}</span>
</div>
))}
</nav>
<div className="flex flex-col gap-0.5">
<span className="px-2.5 text-[11px] font-medium text-muted-foreground">Pinned</span>
{PINNED_TASKS.map((title) => (
<div
key={title}
className="truncate rounded-lg px-2.5 py-1.5 text-sm text-foreground/70 hover:bg-black/5 dark:hover:bg-white/10"
>
{title}
</div>
))}
</div>
<div className="flex flex-col gap-0.5">
<span className="px-2.5 text-[11px] font-medium text-muted-foreground">Projects</span>
{PROJECTS.map((project) => (
<div key={project.name}>
<div className="flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm text-foreground/80 hover:bg-black/5 dark:hover:bg-white/10">
<Folder className="h-4 w-4" />
<span>{project.name}</span>
</div>
<div className="flex flex-col gap-0.5 pl-8">
{project.children.map((child) => (
<span key={child} className="truncate py-0.5 text-xs text-muted-foreground">
{child}
</span>
))}
</div>
</div>
))}
</div>
<div className="mt-2 flex items-center gap-2 px-2.5 py-1.5">
<span className="h-6 w-6 shrink-0 rounded-full bg-gradient-to-br from-violet to-accent" />
<span className="text-sm text-foreground/80">Acme Workspace</span>
</div>
</div>
);
}
const EFFORT_LABELS = ["Minimal", "Low", "Standard", "High", "Max"];
/** Main column composed from the agent-thread and agent-composer blocks — the full app shape. */
function MainContent() {
const [draft, setDraft] = useState("");
const [effort, setEffort] = useState(3); // "High"
const [logOpen, setLogOpen] = useState(true);
return (
<>
<div className="flex-1 overflow-y-auto pt-[54px] pb-4">
<Thread>
<ThreadUserMessage>Read the handoff doc and prep the release.</ThreadUserMessage>
<div className="group/turn flex flex-col">
<ThreadTurnHeader open={logOpen} onOpenChange={setLogOpen}>
Worked for 2m 4s
</ThreadTurnHeader>
<ThreadCollapse open={logOpen}>
<ThreadThinking label="Thought for 8s">
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>
</ThreadCollapse>
<ThreadMessage>
<p>
Pulled the handoff notes at <ThreadInlineCode>efd14fb</ThreadInlineCode> and staged
the release diff below.
</p>
</ThreadMessage>
<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>
</>
}
>
<ThreadDiffRow path="backend/pom.xml" added={23} removed={0} />
<ThreadDiffRow path="CHANGELOG.md" added={2} removed={0} />
</ThreadDiffCard>
<ThreadCommandRow icon={<SquareTerminal className="h-4 w-4" />} running>
Running <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>
</ThreadActionBar>
</div>
</Thread>
</div>
<div className="px-4 pb-4">
<Composer>
<ComposerTextarea
value={draft}
onChange={setDraft}
placeholder="Describe your next change…"
aria-label="Message"
/>
<ComposerToolbar>
<ComposerIconButton aria-label="Add files and more">
<Plus className="h-4 w-4" />
</ComposerIconButton>
<ComposerAccessChip icon={<CircleAlert className="h-4 w-4" />}>
Full access
</ComposerAccessChip>
<div className="ml-auto" />
<ComposerModelPicker label={`5.6 · ${EFFORT_LABELS[effort]}`}>
<div className="flex items-center justify-between px-2 pt-1 text-muted-foreground text-xs">
<span>Reasoning effort</span>
<Zap className="h-3.5 w-3.5" />
</div>
<div className="px-2 pt-1 pb-2">
<ComposerEffortSlider
value={effort}
onChange={setEffort}
labels={EFFORT_LABELS}
aria-label="Reasoning effort"
/>
</div>
</ComposerModelPicker>
<ComposerIconButton aria-label="Dictate">
<Mic className="h-4 w-4" />
</ComposerIconButton>
<ComposerSendButton disabled={draft.trim().length === 0} />
</ComposerToolbar>
</Composer>
</div>
</>
);
}
function PanelContent() {
return (
<div className="flex flex-col gap-4 px-3 py-3">
<div className="flex flex-col gap-0.5">
{TOOL_ROWS.map(({ icon: Icon, label, shortcut }) => (
<div
key={label}
className="flex h-9 items-center justify-between rounded-lg px-2.5 hover:bg-black/5 dark:hover:bg-white/10"
>
<div className="flex items-center gap-2 text-sm text-foreground/80">
<Icon className="h-4 w-4" />
<span>{label}</span>
</div>
{shortcut ? (
<kbd className="rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
{shortcut}
</kbd>
) : null}
</div>
))}
</div>
<div className="flex flex-col">
<span className="px-2.5 pb-1 text-[11px] font-medium text-muted-foreground">Suggested</span>
{SUGGESTED_FILES.map(({ icon: Icon, name }, index) => (
<div
key={name}
className={cn(
"flex items-center gap-2 border-black/5 px-2.5 py-2 text-sm text-foreground/80 dark:border-white/10",
index < SUGGESTED_FILES.length - 1 && "border-b",
)}
>
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="truncate font-mono text-xs">{name}</span>
</div>
))}
</div>
</div>
);
}
const SUMMARY_ROW_BASE = "flex h-7 w-full items-center gap-2 rounded-lg px-1.5 text-sm";
const SUMMARY_ROW = cn(SUMMARY_ROW_BASE, "hover:bg-black/5 dark:hover:bg-white/10");
function SummaryContent() {
return (
<WorkbenchSummarySection
title="Environment"
action={
<button
type="button"
aria-label="Add"
className="flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10"
>
<Plus className="h-3.5 w-3.5" />
</button>
}
>
<button type="button" className={SUMMARY_ROW}>
<Diff className="h-4 w-4 text-muted-foreground" />
<span>Changes</span>
<span className="ml-auto rounded-full bg-black/5 px-1.5 text-[10px] dark:bg-white/10">2</span>
</button>
<button type="button" className={SUMMARY_ROW}>
<Laptop className="h-4 w-4 text-muted-foreground" />
<span>Local</span>
<ChevronDown className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</button>
<button type="button" className={SUMMARY_ROW}>
<GitBranch className="h-4 w-4 text-muted-foreground" />
<span>main</span>
<ChevronDown className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</button>
<button type="button" className={SUMMARY_ROW}>
<GitCommitHorizontal className="h-4 w-4 text-muted-foreground" />
<span>Commit or push</span>
</button>
<div aria-disabled className={cn(SUMMARY_ROW_BASE, "text-muted-foreground/60")}>
<GitPullRequest className="h-4 w-4 text-muted-foreground" />
<span>Can't fetch pull request status</span>
</div>
</WorkbenchSummarySection>
);
}
/** Desktop-wallpaper backdrop behind the (transparent) workbench shell. */
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)",
}}
/>
</>
);
}
export function AgentWorkbenchPreview() {
const [summaryOpen, setSummaryOpen] = useState(true);
return (
<div className="relative h-[560px] w-full overflow-hidden rounded-xl border border-border">
<Backdrop />
<div className="relative h-full w-full">
<Workbench defaultPanelOpen className="h-full">
<Toolbar onToggleSummary={() => setSummaryOpen((open) => !open)} />
<WorkbenchSidebar>
<SidebarContent />
</WorkbenchSidebar>
<WorkbenchMain>
<MainContent />
<WorkbenchSummaryCard open={summaryOpen}>
<SummaryContent />
</WorkbenchSummaryCard>
</WorkbenchMain>
<WorkbenchPanel>
<PanelContent />
</WorkbenchPanel>
</Workbench>
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-workbench
import { motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { ResizeHandle } from "./resize-handle";
/** Height of the overlay toolbar (`WorkbenchHeader`), in pixels. */
export const HEADER_HEIGHT = 46;
/** Sidebar width when `defaultSidebarWidth` is unset, and its reset target. */
export const SIDEBAR_DEFAULT_WIDTH = 275;
export const SIDEBAR_MIN_WIDTH = 240;
export const SIDEBAR_MAX_WIDTH = 520;
/** Panel width when `defaultPanelWidth` is unset, and its reset target. */
export const PANEL_DEFAULT_WIDTH = 384;
export const PANEL_MIN_WIDTH = 320;
/** Floor kept for the main column — sidebar/panel can never squeeze past it. */
export const MAIN_MIN_WIDTH = 320;
// Static ceiling used for the panel's aria-valuemax before the container has
// been measured (first paint only — react-use-measure reports 0 until its
// ResizeObserver fires).
const PANEL_MAX_FALLBACK = 960;
export type WorkbenchLayoutMode = "desktop" | "tablet" | "mobile";
// Drag (or arrow-key nudge) a pane this far past its own minimum and it
// collapses instead of clamping at the minimum — the "drag past the edge to
// close" gesture native three-pane app shells use.
const COLLAPSE_MARGIN = 40;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
interface WorkbenchContextValue {
layoutMode: WorkbenchLayoutMode;
sidebarOpen: boolean;
panelOpen: boolean;
toggleSidebar: () => void;
togglePanel: () => void;
setSidebarOpen: (open: boolean) => void;
setPanelOpen: (open: boolean) => void;
sidebarWidth: number;
panelWidth: number;
// Internal wiring for WorkbenchSidebar / WorkbenchPanel / WorkbenchHeader.
// Not part of the public useWorkbench() contract.
containerWidth: number;
sidebarDefault: number;
panelDefault: number;
sidebarDragging: boolean;
panelDragging: boolean;
setSidebarDragging: (dragging: boolean) => void;
setPanelDragging: (dragging: boolean) => void;
resizeSidebar: (rawWidth: number) => void;
resizePanel: (rawWidth: number) => void;
resetSidebarWidth: () => void;
resetPanelWidth: () => void;
}
const WorkbenchContext = createContext<WorkbenchContextValue | null>(null);
function useWorkbenchInternal(component: string): WorkbenchContextValue {
const ctx = useContext(WorkbenchContext);
if (!ctx) throw new Error(`${component} must be used inside <Workbench>`);
return ctx;
}
/** Reads the workbench's open/close and width state. Must be used inside `<Workbench>`. */
export function useWorkbench() {
const {
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
} = useWorkbenchInternal("useWorkbench");
return {
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
};
}
export interface WorkbenchProps {
sidebarOpen?: boolean;
defaultSidebarOpen?: boolean;
onSidebarOpenChange?: (open: boolean) => void;
panelOpen?: boolean;
defaultPanelOpen?: boolean;
onPanelOpenChange?: (open: boolean) => void;
/** Initial sidebar width, and its double-click/Enter reset target. Defaults to `SIDEBAR_DEFAULT_WIDTH`. */
defaultSidebarWidth?: number;
/** Initial panel width, and its double-click/Enter reset target. Defaults to `PANEL_DEFAULT_WIDTH`. */
defaultPanelWidth?: number;
/** Width at which the inline three-column layout begins. Defaults to 1200px. */
desktopBreakpoint?: number;
/** Width below which the workbench shows one task surface at a time. Defaults to 720px. */
tabletBreakpoint?: number;
className?: string;
children?: ReactNode;
}
/**
* Three-pane app shell root: a resizable sidebar, a flexible main column and
* a resizable utility panel, laid out as a single flex row. Compose it with
* `WorkbenchHeader` (an absolutely-positioned overlay), `WorkbenchSidebar`,
* `WorkbenchMain` and `WorkbenchPanel` as children, in whatever DOM order —
* only the header's position is order-independent by design.
*/
export function Workbench({
sidebarOpen: sidebarOpenProp,
defaultSidebarOpen = true,
onSidebarOpenChange,
panelOpen: panelOpenProp,
defaultPanelOpen = false,
onPanelOpenChange,
defaultSidebarWidth,
defaultPanelWidth,
desktopBreakpoint = 1200,
tabletBreakpoint = 720,
className,
children,
}: WorkbenchProps) {
const [containerRef, bounds] = useMeasure();
const containerWidth = bounds.width;
const layoutMode: WorkbenchLayoutMode =
containerWidth > 0 && containerWidth < tabletBreakpoint
? "mobile"
: containerWidth > 0 && containerWidth < desktopBreakpoint
? "tablet"
: "desktop";
const sidebarDefault = defaultSidebarWidth ?? SIDEBAR_DEFAULT_WIDTH;
const panelDefault = defaultPanelWidth ?? PANEL_DEFAULT_WIDTH;
const [internalSidebarOpen, setInternalSidebarOpen] = useState(defaultSidebarOpen);
const [internalPanelOpen, setInternalPanelOpen] = useState(defaultPanelOpen);
const sidebarControlled = sidebarOpenProp !== undefined;
const panelControlled = panelOpenProp !== undefined;
const sidebarOpen = sidebarControlled ? sidebarOpenProp : internalSidebarOpen;
const panelOpen = panelControlled ? panelOpenProp : internalPanelOpen;
// Which side pane the user opened last — the other one yields when the
// desktop container can't fit both (see the auto-yield effect below).
const lastOpenedRef = useRef<"sidebar" | "panel">("sidebar");
const setSidebarOpen = useCallback(
(open: boolean) => {
if (open) lastOpenedRef.current = "sidebar";
if (open && layoutMode !== "desktop") {
if (!panelControlled) setInternalPanelOpen(false);
onPanelOpenChange?.(false);
}
if (!sidebarControlled) setInternalSidebarOpen(open);
onSidebarOpenChange?.(open);
},
[
layoutMode,
panelControlled,
onPanelOpenChange,
sidebarControlled,
onSidebarOpenChange,
],
);
const setPanelOpen = useCallback(
(open: boolean) => {
if (open) lastOpenedRef.current = "panel";
if (open && layoutMode !== "desktop") {
if (!sidebarControlled) setInternalSidebarOpen(false);
onSidebarOpenChange?.(false);
}
if (!panelControlled) setInternalPanelOpen(open);
onPanelOpenChange?.(open);
},
[
layoutMode,
sidebarControlled,
onSidebarOpenChange,
panelControlled,
onPanelOpenChange,
],
);
const toggleSidebar = useCallback(
() => setSidebarOpen(!sidebarOpen),
[setSidebarOpen, sidebarOpen],
);
const togglePanel = useCallback(() => setPanelOpen(!panelOpen), [setPanelOpen, panelOpen]);
const [sidebarWidth, setSidebarWidth] = useState(sidebarDefault);
const [panelWidth, setPanelWidth] = useState(panelDefault);
const [sidebarDragging, setSidebarDragging] = useState(false);
const [panelDragging, setPanelDragging] = useState(false);
const resizeSidebar = useCallback(
(rawWidth: number) => {
if (rawWidth < SIDEBAR_MIN_WIDTH - COLLAPSE_MARGIN) {
setSidebarOpen(false);
return;
}
const spaceLimit =
containerWidth > 0 ? containerWidth - MAIN_MIN_WIDTH : SIDEBAR_MAX_WIDTH;
const max = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, spaceLimit));
setSidebarWidth(clamp(rawWidth, SIDEBAR_MIN_WIDTH, max));
},
[setSidebarOpen, containerWidth],
);
const resizePanel = useCallback(
(rawWidth: number) => {
if (rawWidth < PANEL_MIN_WIDTH - COLLAPSE_MARGIN) {
setPanelOpen(false);
return;
}
const reserved = sidebarOpen ? sidebarWidth : 0;
const max =
containerWidth > 0
? Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH)
: PANEL_MAX_FALLBACK;
setPanelWidth(clamp(rawWidth, PANEL_MIN_WIDTH, max));
},
[setPanelOpen, sidebarOpen, sidebarWidth, containerWidth],
);
const resetSidebarWidth = useCallback(() => setSidebarWidth(sidebarDefault), [sidebarDefault]);
const resetPanelWidth = useCallback(() => setPanelWidth(panelDefault), [panelDefault]);
const previousLayoutModeRef = useRef<WorkbenchLayoutMode>(layoutMode);
// A narrow workbench starts with its navigation out of the way. This is an
// intentional mode transition rather than a width-fitting fallback: the
// panel remains open when an application controls it, and can be shown as an
// overlay without squeezing the task surface.
useEffect(() => {
const previous = previousLayoutModeRef.current;
previousLayoutModeRef.current = layoutMode;
if (layoutMode === "desktop" || previous === layoutMode) return;
setSidebarOpen(false);
}, [layoutMode, setSidebarOpen]);
// Re-clamp (never collapse) whenever the measured container shrinks, so a
// narrower window can't leave the sidebar/panel wider than there's room for.
useEffect(() => {
if (containerWidth <= 0 || layoutMode !== "desktop") return;
setSidebarWidth((w) =>
clamp(w, SIDEBAR_MIN_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, containerWidth - MAIN_MIN_WIDTH)),
);
}, [containerWidth, layoutMode]);
useEffect(() => {
if (containerWidth <= 0 || layoutMode !== "desktop") return;
const reserved = sidebarOpen ? sidebarWidth : 0;
setPanelWidth((w) =>
clamp(w, PANEL_MIN_WIDTH, Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH)),
);
}, [containerWidth, layoutMode, sidebarOpen, sidebarWidth]);
// Both side panes plus the main floor can exceed a narrow container even at
// their minimum widths. When they do, the pane opened less recently yields —
// the narrow-window rule desktop three-pane shells use — so opening one side
// swaps the other out instead of crushing the main column.
useEffect(() => {
if (layoutMode !== "desktop" || containerWidth <= 0 || !sidebarOpen || !panelOpen) return;
if (sidebarWidth + panelWidth + MAIN_MIN_WIDTH <= containerWidth) return;
if (lastOpenedRef.current === "panel") setSidebarOpen(false);
else setPanelOpen(false);
}, [layoutMode, containerWidth, sidebarOpen, panelOpen, sidebarWidth, panelWidth, setSidebarOpen, setPanelOpen]);
const value = useMemo<WorkbenchContextValue>(
() => ({
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
containerWidth,
sidebarDefault,
panelDefault,
sidebarDragging,
panelDragging,
setSidebarDragging,
setPanelDragging,
resizeSidebar,
resizePanel,
resetSidebarWidth,
resetPanelWidth,
}),
[
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
containerWidth,
sidebarDefault,
panelDefault,
sidebarDragging,
panelDragging,
resizeSidebar,
resizePanel,
resetSidebarWidth,
resetPanelWidth,
],
);
return (
<WorkbenchContext.Provider value={value}>
<div
ref={containerRef}
data-layout-mode={layoutMode}
className={cn("relative isolate flex h-full min-h-0 w-full overflow-hidden", className)}
>
{children}
</div>
</WorkbenchContext.Provider>
);
}
export interface WorkbenchSidebarProps {
className?: string;
children?: ReactNode;
}
/** Translucent, resizable left pane. Drag its right-edge handle past the
* minimum to collapse it; double-click the handle to reset its width. */
export function WorkbenchSidebar({ className, children }: WorkbenchSidebarProps) {
const {
layoutMode,
sidebarOpen,
sidebarWidth,
sidebarDragging,
containerWidth,
resizeSidebar,
resetSidebarWidth,
setSidebarDragging,
setSidebarOpen,
} = useWorkbenchInternal("WorkbenchSidebar");
const reduce = useReducedMotion() ?? false;
const ariaMax =
containerWidth > 0
? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, containerWidth - MAIN_MIN_WIDTH))
: SIDEBAR_MAX_WIDTH;
if (layoutMode !== "desktop") {
const mobile = layoutMode === "mobile";
return (
<>
{sidebarOpen ? (
<button
type="button"
aria-label="Close sidebar overlay"
className="absolute inset-x-0 bottom-0 top-[46px] z-10 cursor-default bg-[var(--wb-overlay-scrim)]"
onClick={() => setSidebarOpen(false)}
/>
) : null}
<motion.aside
inert={!sidebarOpen}
aria-label="Navigation overlay"
initial={false}
animate={{ x: sidebarOpen ? "0%" : "-100%", opacity: sidebarOpen ? 1 : 0 }}
transition={{ x: reduce ? { duration: 0 } : SPRING_PANEL, opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL }}
className={cn(
"absolute inset-y-0 left-0 z-20 overflow-hidden bg-[var(--wb-surface-translucent)] backdrop-blur-xl",
mobile ? "w-full" : "max-w-[min(86vw,520px)]",
className,
)}
style={mobile ? undefined : { width: sidebarWidth }}
>
<div className="h-full overflow-y-auto pt-[46px]">{children}</div>
</motion.aside>
</>
);
}
return (
<motion.aside
inert={!sidebarOpen}
initial={false}
animate={{ width: sidebarOpen ? sidebarWidth : 0, opacity: sidebarOpen ? 1 : 0 }}
transition={{
width: sidebarDragging || reduce ? { duration: 0 } : SPRING_PANEL,
opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL,
}}
className={cn(
// overflow stays visible so the resize handle can straddle the edge
// (z-10 keeps that overhang hit-testable above the main column);
// clipping happens one div down.
"relative isolate z-10 shrink-0 overflow-visible bg-[var(--wb-surface-translucent)] backdrop-blur-xl",
className,
)}
>
<div className="absolute inset-0 overflow-hidden">
{/* Content is laid out at the pane's resting width so open/close only
clips it instead of re-wrapping every line mid-animation. */}
<div
className="h-full overflow-y-auto pt-[46px] [mask-image:linear-gradient(to_bottom,transparent_0,black_16px,black_calc(100%-24px),transparent_100%)]"
style={{ width: sidebarWidth }}
>
{children}
</div>
</div>
{sidebarOpen ? (
<ResizeHandle
edge="right"
value={sidebarWidth}
min={SIDEBAR_MIN_WIDTH}
max={ariaMax}
onResize={resizeSidebar}
onReset={resetSidebarWidth}
onDraggingChange={setSidebarDragging}
aria-label="Resize sidebar"
/>
) : null}
</motion.aside>
);
}
export interface WorkbenchMainProps {
className?: string;
children?: ReactNode;
}
/** Opaque, flexible main column — takes up whatever width the sidebar/panel leave behind. */
export function WorkbenchMain({ className, children }: WorkbenchMainProps) {
const { layoutMode, sidebarOpen, panelOpen } = useWorkbenchInternal("WorkbenchMain");
return (
<main
inert={layoutMode !== "desktop" && (sidebarOpen || panelOpen)}
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col bg-[var(--wb-surface)]",
className,
)}
>
<div className="flex min-h-0 flex-1 flex-col pt-[46px]">{children}</div>
</main>
);
}
export interface WorkbenchPanelProps {
className?: string;
children?: ReactNode;
}
/** Opaque, resizable right pane, squeezed in-flow (not floated). Drag its
* left-edge handle past the minimum to collapse it; double-click to reset. */
export function WorkbenchPanel({ className, children }: WorkbenchPanelProps) {
const {
layoutMode,
panelOpen,
panelWidth,
panelDragging,
sidebarOpen,
sidebarWidth,
containerWidth,
resizePanel,
resetPanelWidth,
setPanelDragging,
setPanelOpen,
} = useWorkbenchInternal("WorkbenchPanel");
const reduce = useReducedMotion() ?? false;
const reserved = sidebarOpen ? sidebarWidth : 0;
const ariaMax =
containerWidth > 0
? Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH)
: PANEL_MAX_FALLBACK;
if (layoutMode !== "desktop") {
const mobile = layoutMode === "mobile";
return (
<>
{panelOpen ? (
<button
type="button"
aria-label="Close artifact overlay"
className="absolute inset-x-0 bottom-0 top-[46px] z-10 cursor-default bg-[var(--wb-overlay-scrim)]"
onClick={() => setPanelOpen(false)}
/>
) : null}
<motion.aside
inert={!panelOpen}
aria-label="Artifact overlay"
initial={false}
animate={{ x: panelOpen ? "0%" : "100%", opacity: panelOpen ? 1 : 0 }}
transition={{ x: reduce ? { duration: 0 } : SPRING_PANEL, opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL }}
className={cn(
"absolute inset-y-0 right-0 z-20 overflow-hidden border-[var(--wb-border-subtle)] border-l bg-[var(--wb-surface)]",
mobile ? "w-full" : "max-w-[min(86vw,560px)]",
className,
)}
style={mobile ? undefined : { width: panelWidth }}
>
<div className="h-full overflow-y-auto pt-[46px]">{children}</div>
</motion.aside>
</>
);
}
return (
<motion.aside
inert={!panelOpen}
initial={false}
animate={{ width: panelOpen ? panelWidth : 0, opacity: panelOpen ? 1 : 0 }}
transition={{
width: panelDragging || reduce ? { duration: 0 } : SPRING_PANEL,
opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL,
}}
className={cn(
"relative isolate z-10 shrink-0 overflow-visible border-[var(--wb-border-subtle)] border-l bg-[var(--wb-surface)]",
className,
)}
>
<div className="absolute inset-0 overflow-hidden">
<div className="h-full overflow-y-auto pt-[46px]" style={{ width: panelWidth }}>
{children}
</div>
</div>
{panelOpen ? (
<ResizeHandle
edge="left"
value={panelWidth}
min={PANEL_MIN_WIDTH}
max={ariaMax}
onResize={resizePanel}
onReset={resetPanelWidth}
onDraggingChange={setPanelDragging}
aria-label="Resize panel"
/>
) : null}
</motion.aside>
);
}
export interface WorkbenchHeaderProps {
/** Rendered above the sidebar; its wrapper width tracks `sidebarWidth` while open. */
leading?: ReactNode;
trailing?: ReactNode;
children?: ReactNode;
className?: string;
}
/** Full-width, 46px overlay toolbar. Absolutely positioned so it sits above
* the sidebar/main/panel row regardless of where it's placed in the DOM. */
export function WorkbenchHeader({ leading, trailing, children, className }: WorkbenchHeaderProps) {
const { layoutMode, sidebarOpen, sidebarWidth, sidebarDragging } = useWorkbenchInternal("WorkbenchHeader");
const reduce = useReducedMotion() ?? false;
return (
<header
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-30 flex h-[46px] items-center",
className,
)}
>
<div
className="pointer-events-auto flex h-full shrink-0 items-center overflow-hidden"
style={{
width: layoutMode === "desktop" && sidebarOpen ? sidebarWidth : "auto",
transitionProperty: "width",
transitionDuration: sidebarDragging || reduce ? "0ms" : "300ms",
transitionTimingFunction: EASE_OUT_CSS,
}}
>
{leading}
</div>
<div className="pointer-events-auto min-w-0 flex-1">{children}</div>
<div className="pointer-events-auto flex shrink-0 items-center">{trailing}</div>
</header>
);
}
export { WorkbenchSummaryCard, WorkbenchSummarySection } from "./summary-card";
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion react-use-measure 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-workbench
import { motion, useReducedMotion } from "motion/react";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import useMeasure from "react-use-measure";
import { EASE_OUT, EASE_OUT_CSS, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
import { ResizeHandle } from "./resize-handle";
/** Height of the overlay toolbar (`WorkbenchHeader`), in pixels. */
export const HEADER_HEIGHT = 46;
/** Sidebar width when `defaultSidebarWidth` is unset, and its reset target. */
export const SIDEBAR_DEFAULT_WIDTH = 275;
export const SIDEBAR_MIN_WIDTH = 240;
export const SIDEBAR_MAX_WIDTH = 520;
/** Panel width when `defaultPanelWidth` is unset, and its reset target. */
export const PANEL_DEFAULT_WIDTH = 384;
export const PANEL_MIN_WIDTH = 320;
/** Floor kept for the main column — sidebar/panel can never squeeze past it. */
export const MAIN_MIN_WIDTH = 320;
// Static ceiling used for the panel's aria-valuemax before the container has
// been measured (first paint only — react-use-measure reports 0 until its
// ResizeObserver fires).
const PANEL_MAX_FALLBACK = 960;
export type WorkbenchLayoutMode = "desktop" | "tablet" | "mobile";
// Drag (or arrow-key nudge) a pane this far past its own minimum and it
// collapses instead of clamping at the minimum — the "drag past the edge to
// close" gesture native three-pane app shells use.
const COLLAPSE_MARGIN = 40;
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
interface WorkbenchContextValue {
layoutMode: WorkbenchLayoutMode;
sidebarOpen: boolean;
panelOpen: boolean;
toggleSidebar: () => void;
togglePanel: () => void;
setSidebarOpen: (open: boolean) => void;
setPanelOpen: (open: boolean) => void;
sidebarWidth: number;
panelWidth: number;
// Internal wiring for WorkbenchSidebar / WorkbenchPanel / WorkbenchHeader.
// Not part of the public useWorkbench() contract.
containerWidth: number;
sidebarDefault: number;
panelDefault: number;
sidebarDragging: boolean;
panelDragging: boolean;
setSidebarDragging: (dragging: boolean) => void;
setPanelDragging: (dragging: boolean) => void;
resizeSidebar: (rawWidth: number) => void;
resizePanel: (rawWidth: number) => void;
resetSidebarWidth: () => void;
resetPanelWidth: () => void;
}
const WorkbenchContext = createContext<WorkbenchContextValue | null>(null);
function useWorkbenchInternal(component: string): WorkbenchContextValue {
const ctx = useContext(WorkbenchContext);
if (!ctx) throw new Error(`${component} must be used inside <Workbench>`);
return ctx;
}
/** Reads the workbench's open/close and width state. Must be used inside `<Workbench>`. */
export function useWorkbench() {
const {
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
} = useWorkbenchInternal("useWorkbench");
return {
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
};
}
export interface WorkbenchProps {
sidebarOpen?: boolean;
defaultSidebarOpen?: boolean;
onSidebarOpenChange?: (open: boolean) => void;
panelOpen?: boolean;
defaultPanelOpen?: boolean;
onPanelOpenChange?: (open: boolean) => void;
/** Initial sidebar width, and its double-click/Enter reset target. Defaults to `SIDEBAR_DEFAULT_WIDTH`. */
defaultSidebarWidth?: number;
/** Initial panel width, and its double-click/Enter reset target. Defaults to `PANEL_DEFAULT_WIDTH`. */
defaultPanelWidth?: number;
/** Width at which the inline three-column layout begins. Defaults to 1200px. */
desktopBreakpoint?: number;
/** Width below which the workbench shows one task surface at a time. Defaults to 720px. */
tabletBreakpoint?: number;
className?: string;
children?: ReactNode;
}
/**
* Three-pane app shell root: a resizable sidebar, a flexible main column and
* a resizable utility panel, laid out as a single flex row. Compose it with
* `WorkbenchHeader` (an absolutely-positioned overlay), `WorkbenchSidebar`,
* `WorkbenchMain` and `WorkbenchPanel` as children, in whatever DOM order —
* only the header's position is order-independent by design.
*/
export function Workbench({
sidebarOpen: sidebarOpenProp,
defaultSidebarOpen = true,
onSidebarOpenChange,
panelOpen: panelOpenProp,
defaultPanelOpen = false,
onPanelOpenChange,
defaultSidebarWidth,
defaultPanelWidth,
desktopBreakpoint = 1200,
tabletBreakpoint = 720,
className,
children,
}: WorkbenchProps) {
const [containerRef, bounds] = useMeasure();
const containerWidth = bounds.width;
const layoutMode: WorkbenchLayoutMode =
containerWidth > 0 && containerWidth < tabletBreakpoint
? "mobile"
: containerWidth > 0 && containerWidth < desktopBreakpoint
? "tablet"
: "desktop";
const sidebarDefault = defaultSidebarWidth ?? SIDEBAR_DEFAULT_WIDTH;
const panelDefault = defaultPanelWidth ?? PANEL_DEFAULT_WIDTH;
const [internalSidebarOpen, setInternalSidebarOpen] = useState(defaultSidebarOpen);
const [internalPanelOpen, setInternalPanelOpen] = useState(defaultPanelOpen);
const sidebarControlled = sidebarOpenProp !== undefined;
const panelControlled = panelOpenProp !== undefined;
const sidebarOpen = sidebarControlled ? sidebarOpenProp : internalSidebarOpen;
const panelOpen = panelControlled ? panelOpenProp : internalPanelOpen;
// Which side pane the user opened last — the other one yields when the
// desktop container can't fit both (see the auto-yield effect below).
const lastOpenedRef = useRef<"sidebar" | "panel">("sidebar");
const setSidebarOpen = useCallback(
(open: boolean) => {
if (open) lastOpenedRef.current = "sidebar";
if (open && layoutMode !== "desktop") {
if (!panelControlled) setInternalPanelOpen(false);
onPanelOpenChange?.(false);
}
if (!sidebarControlled) setInternalSidebarOpen(open);
onSidebarOpenChange?.(open);
},
[
layoutMode,
panelControlled,
onPanelOpenChange,
sidebarControlled,
onSidebarOpenChange,
],
);
const setPanelOpen = useCallback(
(open: boolean) => {
if (open) lastOpenedRef.current = "panel";
if (open && layoutMode !== "desktop") {
if (!sidebarControlled) setInternalSidebarOpen(false);
onSidebarOpenChange?.(false);
}
if (!panelControlled) setInternalPanelOpen(open);
onPanelOpenChange?.(open);
},
[
layoutMode,
sidebarControlled,
onSidebarOpenChange,
panelControlled,
onPanelOpenChange,
],
);
const toggleSidebar = useCallback(
() => setSidebarOpen(!sidebarOpen),
[setSidebarOpen, sidebarOpen],
);
const togglePanel = useCallback(() => setPanelOpen(!panelOpen), [setPanelOpen, panelOpen]);
const [sidebarWidth, setSidebarWidth] = useState(sidebarDefault);
const [panelWidth, setPanelWidth] = useState(panelDefault);
const [sidebarDragging, setSidebarDragging] = useState(false);
const [panelDragging, setPanelDragging] = useState(false);
const resizeSidebar = useCallback(
(rawWidth: number) => {
if (rawWidth < SIDEBAR_MIN_WIDTH - COLLAPSE_MARGIN) {
setSidebarOpen(false);
return;
}
const spaceLimit =
containerWidth > 0 ? containerWidth - MAIN_MIN_WIDTH : SIDEBAR_MAX_WIDTH;
const max = Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, spaceLimit));
setSidebarWidth(clamp(rawWidth, SIDEBAR_MIN_WIDTH, max));
},
[setSidebarOpen, containerWidth],
);
const resizePanel = useCallback(
(rawWidth: number) => {
if (rawWidth < PANEL_MIN_WIDTH - COLLAPSE_MARGIN) {
setPanelOpen(false);
return;
}
const reserved = sidebarOpen ? sidebarWidth : 0;
const max =
containerWidth > 0
? Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH)
: PANEL_MAX_FALLBACK;
setPanelWidth(clamp(rawWidth, PANEL_MIN_WIDTH, max));
},
[setPanelOpen, sidebarOpen, sidebarWidth, containerWidth],
);
const resetSidebarWidth = useCallback(() => setSidebarWidth(sidebarDefault), [sidebarDefault]);
const resetPanelWidth = useCallback(() => setPanelWidth(panelDefault), [panelDefault]);
const previousLayoutModeRef = useRef<WorkbenchLayoutMode>(layoutMode);
// A narrow workbench starts with its navigation out of the way. This is an
// intentional mode transition rather than a width-fitting fallback: the
// panel remains open when an application controls it, and can be shown as an
// overlay without squeezing the task surface.
useEffect(() => {
const previous = previousLayoutModeRef.current;
previousLayoutModeRef.current = layoutMode;
if (layoutMode === "desktop" || previous === layoutMode) return;
setSidebarOpen(false);
}, [layoutMode, setSidebarOpen]);
// Re-clamp (never collapse) whenever the measured container shrinks, so a
// narrower window can't leave the sidebar/panel wider than there's room for.
useEffect(() => {
if (containerWidth <= 0 || layoutMode !== "desktop") return;
setSidebarWidth((w) =>
clamp(w, SIDEBAR_MIN_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, containerWidth - MAIN_MIN_WIDTH)),
);
}, [containerWidth, layoutMode]);
useEffect(() => {
if (containerWidth <= 0 || layoutMode !== "desktop") return;
const reserved = sidebarOpen ? sidebarWidth : 0;
setPanelWidth((w) =>
clamp(w, PANEL_MIN_WIDTH, Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH)),
);
}, [containerWidth, layoutMode, sidebarOpen, sidebarWidth]);
// Both side panes plus the main floor can exceed a narrow container even at
// their minimum widths. When they do, the pane opened less recently yields —
// the narrow-window rule desktop three-pane shells use — so opening one side
// swaps the other out instead of crushing the main column.
useEffect(() => {
if (layoutMode !== "desktop" || containerWidth <= 0 || !sidebarOpen || !panelOpen) return;
if (sidebarWidth + panelWidth + MAIN_MIN_WIDTH <= containerWidth) return;
if (lastOpenedRef.current === "panel") setSidebarOpen(false);
else setPanelOpen(false);
}, [layoutMode, containerWidth, sidebarOpen, panelOpen, sidebarWidth, panelWidth, setSidebarOpen, setPanelOpen]);
const value = useMemo<WorkbenchContextValue>(
() => ({
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
containerWidth,
sidebarDefault,
panelDefault,
sidebarDragging,
panelDragging,
setSidebarDragging,
setPanelDragging,
resizeSidebar,
resizePanel,
resetSidebarWidth,
resetPanelWidth,
}),
[
layoutMode,
sidebarOpen,
panelOpen,
toggleSidebar,
togglePanel,
setSidebarOpen,
setPanelOpen,
sidebarWidth,
panelWidth,
containerWidth,
sidebarDefault,
panelDefault,
sidebarDragging,
panelDragging,
resizeSidebar,
resizePanel,
resetSidebarWidth,
resetPanelWidth,
],
);
return (
<WorkbenchContext.Provider value={value}>
<div
ref={containerRef}
data-layout-mode={layoutMode}
className={cn("relative isolate flex h-full min-h-0 w-full overflow-hidden", className)}
>
{children}
</div>
</WorkbenchContext.Provider>
);
}
export interface WorkbenchSidebarProps {
className?: string;
children?: ReactNode;
}
/** Translucent, resizable left pane. Drag its right-edge handle past the
* minimum to collapse it; double-click the handle to reset its width. */
export function WorkbenchSidebar({ className, children }: WorkbenchSidebarProps) {
const {
layoutMode,
sidebarOpen,
sidebarWidth,
sidebarDragging,
containerWidth,
resizeSidebar,
resetSidebarWidth,
setSidebarDragging,
setSidebarOpen,
} = useWorkbenchInternal("WorkbenchSidebar");
const reduce = useReducedMotion() ?? false;
const ariaMax =
containerWidth > 0
? Math.max(SIDEBAR_MIN_WIDTH, Math.min(SIDEBAR_MAX_WIDTH, containerWidth - MAIN_MIN_WIDTH))
: SIDEBAR_MAX_WIDTH;
if (layoutMode !== "desktop") {
const mobile = layoutMode === "mobile";
return (
<>
{sidebarOpen ? (
<button
type="button"
aria-label="Close sidebar overlay"
className="absolute inset-x-0 bottom-0 top-[46px] z-10 cursor-default bg-[var(--wb-overlay-scrim)]"
onClick={() => setSidebarOpen(false)}
/>
) : null}
<motion.aside
inert={!sidebarOpen}
aria-label="Navigation overlay"
initial={false}
animate={{ x: sidebarOpen ? "0%" : "-100%", opacity: sidebarOpen ? 1 : 0 }}
transition={{ x: reduce ? { duration: 0 } : SPRING_PANEL, opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL }}
className={cn(
"absolute inset-y-0 left-0 z-20 overflow-hidden bg-[var(--wb-surface-translucent)] backdrop-blur-xl",
mobile ? "w-full" : "max-w-[min(86vw,520px)]",
className,
)}
style={mobile ? undefined : { width: sidebarWidth }}
>
<div className="h-full overflow-y-auto pt-[46px]">{children}</div>
</motion.aside>
</>
);
}
return (
<motion.aside
inert={!sidebarOpen}
initial={false}
animate={{ width: sidebarOpen ? sidebarWidth : 0, opacity: sidebarOpen ? 1 : 0 }}
transition={{
width: sidebarDragging || reduce ? { duration: 0 } : SPRING_PANEL,
opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL,
}}
className={cn(
// overflow stays visible so the resize handle can straddle the edge
// (z-10 keeps that overhang hit-testable above the main column);
// clipping happens one div down.
"relative isolate z-10 shrink-0 overflow-visible bg-[var(--wb-surface-translucent)] backdrop-blur-xl",
className,
)}
>
<div className="absolute inset-0 overflow-hidden">
{/* Content is laid out at the pane's resting width so open/close only
clips it instead of re-wrapping every line mid-animation. */}
<div
className="h-full overflow-y-auto pt-[46px] [mask-image:linear-gradient(to_bottom,transparent_0,black_16px,black_calc(100%-24px),transparent_100%)]"
style={{ width: sidebarWidth }}
>
{children}
</div>
</div>
{sidebarOpen ? (
<ResizeHandle
edge="right"
value={sidebarWidth}
min={SIDEBAR_MIN_WIDTH}
max={ariaMax}
onResize={resizeSidebar}
onReset={resetSidebarWidth}
onDraggingChange={setSidebarDragging}
aria-label="Resize sidebar"
/>
) : null}
</motion.aside>
);
}
export interface WorkbenchMainProps {
className?: string;
children?: ReactNode;
}
/** Opaque, flexible main column — takes up whatever width the sidebar/panel leave behind. */
export function WorkbenchMain({ className, children }: WorkbenchMainProps) {
const { layoutMode, sidebarOpen, panelOpen } = useWorkbenchInternal("WorkbenchMain");
return (
<main
inert={layoutMode !== "desktop" && (sidebarOpen || panelOpen)}
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col bg-[var(--wb-surface)]",
className,
)}
>
<div className="flex min-h-0 flex-1 flex-col pt-[46px]">{children}</div>
</main>
);
}
export interface WorkbenchPanelProps {
className?: string;
children?: ReactNode;
}
/** Opaque, resizable right pane, squeezed in-flow (not floated). Drag its
* left-edge handle past the minimum to collapse it; double-click to reset. */
export function WorkbenchPanel({ className, children }: WorkbenchPanelProps) {
const {
layoutMode,
panelOpen,
panelWidth,
panelDragging,
sidebarOpen,
sidebarWidth,
containerWidth,
resizePanel,
resetPanelWidth,
setPanelDragging,
setPanelOpen,
} = useWorkbenchInternal("WorkbenchPanel");
const reduce = useReducedMotion() ?? false;
const reserved = sidebarOpen ? sidebarWidth : 0;
const ariaMax =
containerWidth > 0
? Math.max(PANEL_MIN_WIDTH, containerWidth - reserved - MAIN_MIN_WIDTH)
: PANEL_MAX_FALLBACK;
if (layoutMode !== "desktop") {
const mobile = layoutMode === "mobile";
return (
<>
{panelOpen ? (
<button
type="button"
aria-label="Close artifact overlay"
className="absolute inset-x-0 bottom-0 top-[46px] z-10 cursor-default bg-[var(--wb-overlay-scrim)]"
onClick={() => setPanelOpen(false)}
/>
) : null}
<motion.aside
inert={!panelOpen}
aria-label="Artifact overlay"
initial={false}
animate={{ x: panelOpen ? "0%" : "100%", opacity: panelOpen ? 1 : 0 }}
transition={{ x: reduce ? { duration: 0 } : SPRING_PANEL, opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL }}
className={cn(
"absolute inset-y-0 right-0 z-20 overflow-hidden border-[var(--wb-border-subtle)] border-l bg-[var(--wb-surface)]",
mobile ? "w-full" : "max-w-[min(86vw,560px)]",
className,
)}
style={mobile ? undefined : { width: panelWidth }}
>
<div className="h-full overflow-y-auto pt-[46px]">{children}</div>
</motion.aside>
</>
);
}
return (
<motion.aside
inert={!panelOpen}
initial={false}
animate={{ width: panelOpen ? panelWidth : 0, opacity: panelOpen ? 1 : 0 }}
transition={{
width: panelDragging || reduce ? { duration: 0 } : SPRING_PANEL,
opacity: reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL,
}}
className={cn(
"relative isolate z-10 shrink-0 overflow-visible border-[var(--wb-border-subtle)] border-l bg-[var(--wb-surface)]",
className,
)}
>
<div className="absolute inset-0 overflow-hidden">
<div className="h-full overflow-y-auto pt-[46px]" style={{ width: panelWidth }}>
{children}
</div>
</div>
{panelOpen ? (
<ResizeHandle
edge="left"
value={panelWidth}
min={PANEL_MIN_WIDTH}
max={ariaMax}
onResize={resizePanel}
onReset={resetPanelWidth}
onDraggingChange={setPanelDragging}
aria-label="Resize panel"
/>
) : null}
</motion.aside>
);
}
export interface WorkbenchHeaderProps {
/** Rendered above the sidebar; its wrapper width tracks `sidebarWidth` while open. */
leading?: ReactNode;
trailing?: ReactNode;
children?: ReactNode;
className?: string;
}
/** Full-width, 46px overlay toolbar. Absolutely positioned so it sits above
* the sidebar/main/panel row regardless of where it's placed in the DOM. */
export function WorkbenchHeader({ leading, trailing, children, className }: WorkbenchHeaderProps) {
const { layoutMode, sidebarOpen, sidebarWidth, sidebarDragging } = useWorkbenchInternal("WorkbenchHeader");
const reduce = useReducedMotion() ?? false;
return (
<header
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-30 flex h-[46px] items-center",
className,
)}
>
<div
className="pointer-events-auto flex h-full shrink-0 items-center overflow-hidden"
style={{
width: layoutMode === "desktop" && sidebarOpen ? sidebarWidth : "auto",
transitionProperty: "width",
transitionDuration: sidebarDragging || reduce ? "0ms" : "300ms",
transitionTimingFunction: EASE_OUT_CSS,
}}
>
{leading}
</div>
<div className="pointer-events-auto min-w-0 flex-1">{children}</div>
<div className="pointer-events-auto flex shrink-0 items-center">{trailing}</div>
</header>
);
}
export { WorkbenchSummaryCard, WorkbenchSummarySection } from "./summary-card";
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-workbench
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
export interface ResizeHandleProps {
/** Which edge of the pane this handle sits on — sets both the hit-area
* position and the sign of the pointer delta (dragging toward the pane
* grows it, away from it shrinks it). */
edge: "left" | "right";
/** Current pane width, used for keyboard math and the aria value. */
value: number;
min: number;
max: number;
/** Raw (unclamped) next width — the caller owns clamping/collapse. */
onResize: (nextValue: number) => void;
/** Called on double-click or Enter — the caller owns what "default" means. */
onReset: () => void;
onDraggingChange?: (dragging: boolean) => void;
className?: string;
"aria-label"?: string;
}
const KEY_STEP = 16;
export function ResizeHandle({
edge,
value,
min,
max,
onResize,
onReset,
onDraggingChange,
className,
"aria-label": ariaLabel,
}: ResizeHandleProps) {
const [dragging, setDragging] = useState(false);
// Captured at drag start so pointer math is relative to where the drag
// began, not the (already-updating) live value.
const dragStartRef = useRef({ pointerX: 0, width: value });
const valueRef = useRef(value);
valueRef.current = value;
const setDraggingState = useCallback(
(next: boolean) => {
setDragging(next);
onDraggingChange?.(next);
},
[onDraggingChange],
);
// A drag past the collapse threshold unmounts this handle mid-gesture (the
// pane conditionally renders it), so no pointerup ever fires — clear the
// parent's dragging flag on unmount or every later open/close animation
// would keep the zeroed drag transition.
const onDraggingChangeRef = useRef(onDraggingChange);
onDraggingChangeRef.current = onDraggingChange;
useEffect(() => () => onDraggingChangeRef.current?.(false), []);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.setPointerCapture(event.pointerId);
dragStartRef.current = { pointerX: event.clientX, width: valueRef.current };
setDraggingState(true);
},
[setDraggingState],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!dragging) return;
const delta = event.clientX - dragStartRef.current.pointerX;
const signedDelta = edge === "right" ? delta : -delta;
onResize(dragStartRef.current.width + signedDelta);
},
[dragging, edge, onResize],
);
const endDrag = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId);
setDraggingState(false);
},
[setDraggingState],
);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter") {
event.preventDefault();
onReset();
return;
}
// Arrows move the divider, not the width: on a left-edge handle the
// divider sits left of the pane, so ArrowRight shrinks the pane.
const dir = edge === "right" ? 1 : -1;
const map: Record<string, number> = {
ArrowLeft: value - KEY_STEP * dir,
ArrowRight: value + KEY_STEP * dir,
Home: min,
End: max,
};
const next = map[event.key];
if (next !== undefined) {
event.preventDefault();
onResize(next);
}
},
[edge, value, min, max, onResize, onReset],
);
return (
// biome-ignore lint/a11y/useSemanticElements: WAI-ARIA "window splitter" pattern — a focusable, draggable separator; <hr> can't take the child gradient line or own drag/keyboard handlers.
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={Math.round(value)}
aria-valuemin={min}
aria-valuemax={max}
aria-label={ariaLabel}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onKeyDown={onKeyDown}
onDoubleClick={onReset}
className={cn(
"group absolute inset-y-0 z-10 w-4 touch-none cursor-col-resize select-none outline-none",
edge === "right" ? "right-0 translate-x-2" : "left-0 -translate-x-2",
className,
)}
>
<div
className={cn(
"mx-auto h-full w-px bg-gradient-to-b from-transparent via-[var(--wb-resize-handle)] to-transparent opacity-0 transition-opacity",
"group-hover:opacity-100 group-focus-visible:opacity-100",
dragging && "opacity-100",
)}
/>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-workbench
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface WorkbenchSummaryCardProps {
/** Controlled open state — mounts/unmounts through `AnimatePresence`. */
open: boolean;
className?: string;
children?: ReactNode;
}
/**
* Pinned summary card overlay — a floating panel anchored to the top-right
* corner, meant for at-a-glance session/environment info that stays visible
* above the thread. Must be rendered inside `<WorkbenchMain>` (its `relative`
* ancestor); it does not read from `useWorkbench()` and is unaware of
* sidebar/panel state.
*
* `top-[54px]` = the 46px `HEADER_HEIGHT` toolbar overlay plus an 8px gap.
*/
export function WorkbenchSummaryCard({ open, className, children }: WorkbenchSummaryCardProps) {
const reduce = useReducedMotion() ?? false;
return (
<AnimatePresence>
{open ? (
<motion.div
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, x: 8 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, x: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, x: 8 }}
transition={reduce ? { duration: 0.15, ease: EASE_OUT } : SPRING_PANEL}
style={{
transformOrigin: "top right",
boxShadow:
"0 0 0 0.5px var(--wb-hairline-subtle), 0 3px 7.5px rgba(0,0,0,0.04), 0 0 20px rgba(0,0,0,0.05)",
}}
className={cn(
"absolute right-4 top-[54px] z-20 w-[260px] overflow-hidden rounded-3xl bg-[var(--wb-surface-raised)] pt-2.5 backdrop-blur-xl",
className,
)}
>
<div className="flex max-h-[420px] flex-col gap-3 overflow-y-auto pb-1.5">{children}</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
export interface WorkbenchSummarySectionProps {
title?: ReactNode;
/** Rendered at the right end of the title row — e.g. a small icon button. */
action?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* A titled group of rows inside `WorkbenchSummaryCard`. Only lays out the
* title row and a trailing hairline divider — row styling (hover surface,
* icon slot, trailing badge) is left to the caller to compose.
*/
export function WorkbenchSummarySection({
title,
action,
className,
children,
}: WorkbenchSummarySectionProps) {
return (
<section
className={cn(
"flex flex-col border-[var(--wb-border)] border-b-[0.5px] px-2 pb-3 last:border-0",
className,
)}
>
{title ? (
<div className="flex items-center justify-between px-1.5 pb-1">
<span className="text-muted-foreground text-xs">{title}</span>
{action}
</div>
) : null}
{children}
</section>
);
}
"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 { 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 { 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 }}
/>
);
}
"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 参考
Workbench
sidebarOpen?boolean—defaultSidebarOpen?booleantrueonSidebarOpenChange?((open: boolean) => void)—panelOpen?boolean—defaultPanelOpen?booleanfalseonPanelOpenChange?((open: boolean) => void)—defaultSidebarWidth?numberInitial sidebar width, and its double-click/Enter reset target. Defaults to `SIDEBAR_DEFAULT_WIDTH`.
—defaultPanelWidth?numberInitial panel width, and its double-click/Enter reset target. Defaults to `PANEL_DEFAULT_WIDTH`.
—desktopBreakpoint?numberWidth at which the inline three-column layout begins. Defaults to 1200px.
1200tabletBreakpoint?numberWidth below which the workbench shows one task surface at a time. Defaults to 720px.
720className?string—WorkbenchSidebar
className?string—WorkbenchMain
className?string—WorkbenchPanel
className?string—WorkbenchHeader
leading?ReactNodeRendered above the sidebar; its wrapper width tracks `sidebarWidth` while open.
—trailing?ReactNode—className?string—WorkbenchSummaryCard
openbooleanControlled open state — mounts/unmounts through `AnimatePresence`.
—className?string—WorkbenchSummarySection
title?ReactNode—action?ReactNodeRendered at the right end of the title row — e.g. a small icon button.
—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.