Agent 输入台
NewAI agent 聊天输入台组件族:上下文 chips、权限 chip、内置分段推理力度滑杆的模型选择器,以及可变形的发送/停止按钮,并带添加菜单与语音听写态。
"use client";
import {
CircleAlert,
CircleX,
FileText,
FolderGit2,
GitBranch,
Globe,
Image as ImageIcon,
Laptop,
ListTodo,
Mic,
Paperclip,
Plus,
ShieldCheck,
Target,
Zap,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import {
Composer,
ComposerAccessChip,
ComposerAttachmentChip,
ComposerAttachments,
ComposerAutonomyDial,
ComposerChip,
ComposerContextBar,
ComposerContextGauge,
ComposerDictation,
ComposerEffortSlider,
ComposerIconButton,
ComposerMenuButton,
ComposerMenuItem,
ComposerMenuSection,
ComposerModelPicker,
ComposerSendButton,
ComposerTextarea,
ComposerToolbar,
} from "@/components/motion/agent-composer";
import { EASE_OUT } from "@/lib/ease";
const EFFORT_LABELS = ["Minimal", "Low", "Standard", "High", "Max"];
const AUTONOMY_LABELS = ["Suggest only", "Ask first", "Scoped auto", "Full auto"];
const RUN_DURATION_MS = 3000;
/** Desktop-wallpaper backdrop behind the (transparent) composer 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 AgentComposerPreview() {
const reduce = useReducedMotion() ?? false;
const [value, setValue] = useState("");
const [running, setRunning] = useState(false);
const [effort, setEffort] = useState(3); // "High"
const [autonomy, setAutonomy] = useState(1); // "Ask first"
const [pickerOpen, setPickerOpen] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [recording, setRecording] = useState(false);
const [seconds, setSeconds] = useState(0);
const [attachments, setAttachments] = useState([
{ id: "spec", name: "spec.md", meta: "12 KB", icon: <FileText className="h-3.5 w-3.5" /> },
{ id: "mockup", name: "mockup.png", meta: "1.2 MB", icon: <ImageIcon className="h-3.5 w-3.5" /> },
]);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
const startRecording = () => {
setRecording(true);
setSeconds(0);
intervalRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
};
const stopRecording = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = null;
setRecording(false);
setSeconds(0);
};
// Demo send/stop state machine: clicking Send while idle with text runs
// for RUN_DURATION_MS then resets and clears the draft (simulating the
// agent having received it); clicking Stop mid-run just resets, keeping
// the draft so the user can retry. Sending also exits dictation.
const handleSend = () => {
if (recording) stopRecording();
if (running) {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = null;
setRunning(false);
return;
}
if (!value.trim()) return;
setRunning(true);
timerRef.current = setTimeout(() => {
setRunning(false);
setValue("");
timerRef.current = null;
}, RUN_DURATION_MS);
};
const closeAddMenu = () => setAddOpen(false);
// Rendered in both toolbar layouts — only one mounts at a time, so the
// controlled open state carries over cleanly.
const addMenu = (
<ComposerMenuButton
icon={<Plus className="h-4 w-4" />}
aria-label="Add files and more"
align="start"
open={addOpen}
onOpenChange={setAddOpen}
>
<ComposerMenuSection title="Add">
<ComposerMenuItem icon={<Paperclip className="h-4 w-4" />} onSelect={closeAddMenu}>
Files & folders
</ComposerMenuItem>
<ComposerMenuItem icon={<Globe className="h-4 w-4" />} onSelect={closeAddMenu}>
Attach browser
</ComposerMenuItem>
<ComposerMenuItem
icon={<Target className="h-4 w-4" />}
description="Set a goal to keep pursuing"
onSelect={closeAddMenu}
>
Goal
</ComposerMenuItem>
<ComposerMenuItem
icon={<ListTodo className="h-4 w-4" />}
description="Draft a plan before acting"
onSelect={closeAddMenu}
>
Plan mode
</ComposerMenuItem>
</ComposerMenuSection>
<ComposerMenuSection title="Agents">
<ComposerMenuItem
description="Researches a decision and returns a comparison"
onSelect={closeAddMenu}
>
Research Assistant
</ComposerMenuItem>
<ComposerMenuItem
description="Reviews changes for bugs and risky patterns"
onSelect={closeAddMenu}
>
Code Reviewer
</ComposerMenuItem>
<ComposerMenuItem description="Drafts docs from the current diff" onSelect={closeAddMenu}>
Doc Writer
</ComposerMenuItem>
</ComposerMenuSection>
</ComposerMenuButton>
);
const sendButton = (
<ComposerSendButton
running={running}
disabled={!running && !recording && value.trim().length === 0}
onClick={handleSend}
/>
);
const rowMotion = {
initial: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98 },
animate: reduce ? { opacity: 1 } : { opacity: 1, scale: 1 },
exit: reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98 },
transition: { duration: 0.15, ease: EASE_OUT },
} as const;
return (
<div className="relative h-[420px] w-full overflow-hidden rounded-xl border border-border">
<Backdrop />
<div className="relative flex h-full w-full flex-col items-center justify-end px-4 pb-12">
<div className="w-full max-w-xl">
<ComposerContextBar>
<ComposerChip
icon={<FolderGit2 className="h-4 w-4" />}
hoverIcon={<CircleX className="h-4 w-4" />}
title="Change project"
onClick={() => {}}
>
ui-lab
</ComposerChip>
<ComposerChip icon={<Laptop className="h-4 w-4" />} onClick={() => {}}>
Local
</ComposerChip>
<ComposerChip icon={<GitBranch className="h-4 w-4" />} onClick={() => {}}>
main
</ComposerChip>
</ComposerContextBar>
<Composer>
<ComposerAttachments>
<AnimatePresence>
{attachments.map((file) => (
<ComposerAttachmentChip
key={file.id}
icon={file.icon}
name={file.name}
meta={file.meta}
onRemove={() => setAttachments((prev) => prev.filter((f) => f.id !== file.id))}
/>
))}
</AnimatePresence>
</ComposerAttachments>
<ComposerTextarea
value={value}
onChange={setValue}
onSubmit={handleSend}
placeholder="Describe your next change…"
aria-label="Message"
/>
<ComposerToolbar>
<AnimatePresence mode="wait" initial={false}>
{recording ? (
<motion.div
key="dictation"
className="flex min-w-0 flex-1 items-center gap-1"
{...rowMotion}
>
{addMenu}
<ComposerDictation
seconds={seconds}
onStop={stopRecording}
className="min-w-0 flex-1 px-1"
/>
{sendButton}
</motion.div>
) : (
<motion.div
key="tools"
className="flex min-w-0 flex-1 items-center gap-1"
{...rowMotion}
>
{addMenu}
<ComposerAccessChip
icon={<CircleAlert className="h-4 w-4" />}
tone={autonomy === AUTONOMY_LABELS.length - 1 ? "warning" : "default"}
>
{AUTONOMY_LABELS[autonomy]}
</ComposerAccessChip>
<div className="ml-auto" />
<ComposerContextGauge used={64000} limit={200000} />
<ComposerModelPicker
label={`5.6 · ${EFFORT_LABELS[effort]}`}
open={pickerOpen}
onOpenChange={setPickerOpen}
>
<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>
<div className="mt-1 border-t-[0.5px] border-black/5 pt-1 dark:border-white/[0.06]">
<div className="flex items-center justify-between px-2 text-muted-foreground text-xs">
<span>Autonomy</span>
<ShieldCheck className="h-3.5 w-3.5" />
</div>
<div className="px-2 pb-2">
<ComposerAutonomyDial
value={autonomy}
onChange={setAutonomy}
labels={AUTONOMY_LABELS}
aria-label="Autonomy"
/>
</div>
</div>
</ComposerModelPicker>
<ComposerIconButton aria-label="Dictate" onClick={startRecording}>
<Mic className="h-4 w-4" />
</ComposerIconButton>
{sendButton}
</motion.div>
)}
</AnimatePresence>
</ComposerToolbar>
</Composer>
</div>
</div>
</div>
);
}
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-composer
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>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// ui-lab-ten.vercel.app/components/blocks/agent-composer
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";
// ui-lab-ten.vercel.app/components/blocks/agent-composer
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";
// ui-lab-ten.vercel.app/components/blocks/agent-composer
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>
);
}
API 参考
Composer
className?string—ComposerContextBar
className?string—ComposerChip
icon?ReactNode—hoverIcon?ReactNodeSwapped in for `icon` on hover/focus (opacity crossfade, no layout shift). Only meaningful on interactive chips — pair it with `onClick`.
—onClick?(() => void)Makes the chip a `<button>` with a hover pill surface.
—title?stringNative tooltip, e.g. "Change project".
—className?string—ComposerTextarea
valuestring—onChange(next: string) => void—placeholder?string—onSubmit?(() => void)Fires when Enter is pressed without Shift — the caller owns what "submit" means.
—aria-label?string—className?string—ComposerToolbar
className?string—ComposerIconButton
aria-labelstring—onClick?(() => void)—className?string—disabled?boolean—ComposerAccessChip
icon?ReactNode—tone?"default" | "warning""warning" (default) reads as an orange access-level alert; "default" is muted.
warningonClick?(() => void)—className?string—ComposerModelPicker
labelReactNodeTrigger content, e.g. `"5.6 · High"` — a chevron is appended automatically.
—open?boolean—onOpenChange?((open: boolean) => void)—className?string—ComposerMenuButton
icon?ReactNode16px icon; alone it renders the circular icon-button look.
—label?ReactNodeOptional text label; with it the trigger becomes a pill like the model-picker trigger (no chevron).
—aria-labelstring—align?"end" | "start"Panel anchor edge — defaults to "start" (left-aligned above the trigger).
startopen?boolean—onOpenChange?((open: boolean) => void)—className?string—ComposerMenuSection
title?ReactNode—className?string—ComposerMenuItem
icon?ReactNode—description?ReactNodeMuted one-liner rendered inline after the name, truncated when tight.
—onSelect?(() => void)—className?string—ComposerSendButton
running?booleanfalsedisabled?boolean—onClick?(() => void)—aria-label?string—className?string—ComposerDictation
secondsnumberElapsed recording time, owned by the caller; formatted as m:ss.
—onStop?(() => void)—className?string—aria-label?stringLabel for the stop button — defaults to "Stop dictation".
—ComposerAttachments
className?string—ComposerAttachmentChip
icon?ReactNode14px icon, e.g. `<FileText className="h-3.5 w-3.5" />`.
—nameReactNode—meta?ReactNodeMuted trailing detail, e.g. a file size.
—onRemove?(() => void)—className?string—ComposerContextGauge
usednumber—limitnumber—formatLabel?((used: number, limit: number) => ReactNode)Defaults to a K-abbreviated "32K / 200K" readout.
—className?string—ComposerAutonomyDial
valuenumber—onChange(next: number) => void—labels?string[]Step labels — length sets the number of tiers (defaults to 4).
["Suggest only", "Ask first", "Scoped auto", "Full auto"]aria-label?string—className?string—disabled?booleanfalseComposerEffortSlider
valuenumber—onChange(next: number) => void—labels?string[]Step labels — length sets the number of segments (defaults to 5).
["Minimal", "Low", "Standard", "High", "Max"]aria-label?string—className?string—disabled?booleanfalseKeep 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.