From c853bbbdae1e73f62c4045638abb12d0270054e1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 21:55:00 -0700 Subject: [PATCH 1/7] feat(ui): add SegmentedControl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small set of mutually exclusive choices as joined segments — theme, protocol, density. role=group with aria-pressed toggles rather than a radiogroup because the choice takes effect immediately. Stateless: the host owns the value since it usually outlives the component. --- package.json | 4 ++ src/ui/segmented-control.test.tsx | 61 +++++++++++++++++++++++++++++++ src/ui/segmented-control.tsx | 58 +++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 src/ui/segmented-control.test.tsx create mode 100644 src/ui/segmented-control.tsx diff --git a/package.json b/package.json index 878ce814..991e2daf 100644 --- a/package.json +++ b/package.json @@ -443,6 +443,10 @@ "types": "./dist/ui/section.d.ts", "default": "./dist/ui/section.js" }, + "./ui/segmented-control": { + "types": "./dist/ui/segmented-control.d.ts", + "default": "./dist/ui/segmented-control.js" + }, "./ui/select": { "types": "./dist/ui/select.d.ts", "default": "./dist/ui/select.js" diff --git a/src/ui/segmented-control.test.tsx b/src/ui/segmented-control.test.tsx new file mode 100644 index 00000000..24ee4c96 --- /dev/null +++ b/src/ui/segmented-control.test.tsx @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import type { ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; + +import { SegmentedControl } from "./segmented-control.js"; + +type Id = "light" | "system" | "dark"; +type Props = ComponentProps>; + +const OPTIONS: Props["options"] = [ + { id: "light", label: "Light" }, + { id: "system", label: "System" }, + { id: "dark", label: "Dark" }, +]; + +function mount(props: Partial = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const full: Props = { options: OPTIONS, value: "system", onValueChange: () => {}, label: "Theme", ...props }; + act(() => { + root.render(createElement(SegmentedControl, full)); + }); + return { + group: () => container.querySelector("[role=group]") as HTMLElement, + buttons: () => Array.from(container.querySelectorAll("button")), + container, + unmount: () => root.unmount(), + }; +} + +describe("SegmentedControl", () => { + test("names the set and marks the current segment pressed", () => { + const { group, buttons, unmount } = mount(); + expect(group().getAttribute("aria-label")).toBe("Theme"); + const pressed = buttons().map((b) => b.getAttribute("aria-pressed")); + expect(pressed).toEqual(["false", "true", "false"]); + unmount(); + }); + + test("pressing a segment reports its id", () => { + let received: Id | undefined; + const { buttons, unmount } = mount({ onValueChange: (id) => { received = id; } }); + act(() => { + buttons()[2]?.click(); + }); + expect(received).toBe("dark"); + unmount(); + }); + + test("pressing the current segment still reports — the host decides idempotence", () => { + let received: Id | undefined; + const { buttons, unmount } = mount({ onValueChange: (id) => { received = id; } }); + act(() => { + buttons()[1]?.click(); + }); + expect(received).toBe("system"); + unmount(); + }); +}); diff --git a/src/ui/segmented-control.tsx b/src/ui/segmented-control.tsx new file mode 100644 index 00000000..f89bde93 --- /dev/null +++ b/src/ui/segmented-control.tsx @@ -0,0 +1,58 @@ +import { cn } from "../lib/utils.js"; + +export type SegmentedControlOption = { + readonly id: Id; + readonly label: string; +}; + +export type SegmentedControlProps = { + readonly options: readonly SegmentedControlOption[]; + readonly value: Id; + readonly onValueChange: (value: Id) => void; + /** Names the set for assistive tech: "Theme". */ + readonly label: string; + readonly className?: string; +}; + +/** + * A small set of mutually exclusive choices as joined segments — theme, + * protocol, density. `role="group"` with `aria-pressed` toggles rather than a + * radiogroup: these change something immediately (same reasoning as + * ViewToggle), and radios promise form semantics that do not apply. + * + * Stateless — the host owns the value, because the choice usually outlives the + * component and gets persisted. + */ +export function SegmentedControl({ + options, + value, + onValueChange, + label, + className, +}: SegmentedControlProps) { + return ( +
+ {options.map((option) => { + const active = option.id === value; + return ( + + ); + })} +
+ ); +} From c6523d805c9dfe36e7d3a44d62578473971d69e6 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 21:55:00 -0700 Subject: [PATCH 2/7] feat(ui): icon/marker slots and a scrollable variant on Tabs Artifact-style strips outgrow their pane; wrapping pushes content down. The scrollable tablist fades whichever edge still hides tabs, driven by scroll position and a ResizeObserver on the pane itself. icon carries the thing's kind, marker a trailing state (live dot, dirty flag). --- src/ui/tabs.test.tsx | 82 ++++++++++++++++++++++++++++++++++++++++++++ src/ui/tabs.tsx | 58 +++++++++++++++++++++++++++++-- 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 src/ui/tabs.test.tsx diff --git a/src/ui/tabs.test.tsx b/src/ui/tabs.test.tsx new file mode 100644 index 00000000..5743e559 --- /dev/null +++ b/src/ui/tabs.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import type { ComponentProps } from "react"; +import { createRoot } from "react-dom/client"; + +import { Tabs } from "./tabs.js"; + +type Id = "brief" | "design" | "packet"; +type Props = ComponentProps>; + +const BASE: Pick = { + tabs: [ + { id: "brief", label: "Brief" }, + { id: "design", label: "Design" }, + { id: "packet", label: "Packet" }, + ], + active: "brief", + onChange: () => {}, + label: "Artifacts", +}; + +function mount(overrides: Partial = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const props: Props = { ...BASE, ...overrides, children: () => null }; + act(() => { + root.render(createElement(Tabs, props)); + }); + return { + tablist: () => container.querySelector("[role=tablist]") as HTMLElement, + tabs: () => Array.from(container.querySelectorAll("[role=tab]")), + container, + unmount: () => root.unmount(), + }; +} + +describe("Tabs", () => { + test("icon slot renders a glyph hidden from assistive tech", () => { + const { tabs, unmount } = mount({ + tabs: [ + { id: "brief", label: "Brief", icon: createElement("svg", { "data-kind": "doc" }) }, + { id: "design", label: "Design" }, + { id: "packet", label: "Packet" }, + ], + }); + const wrapper = tabs()[0]?.querySelector("[aria-hidden]"); + expect(wrapper?.querySelector("svg[data-kind=doc]")).not.toBeNull(); + expect(tabs()[1]?.querySelector("svg")).toBeNull(); + unmount(); + }); + + test("marker slot renders after the label — a live dot is the caller's to style", () => { + const { tabs, unmount } = mount({ + tabs: [ + { id: "brief", label: "Brief", marker: createElement("span", { className: "dot" }) }, + { id: "design", label: "Design" }, + { id: "packet", label: "Packet" }, + ], + }); + expect(tabs()[0]?.querySelector(".dot")).not.toBeNull(); + unmount(); + }); + + test("scrollable swaps wrapping for a horizontal scroller and keeps selection", () => { + let received: Id | undefined; + const { tablist, tabs, unmount } = mount({ + scrollable: true, + onChange: (id) => { + received = id; + }, + }); + expect(tablist().className).toContain("overflow-x-auto"); + expect(tablist().className).toContain("flex-nowrap"); + expect(tablist().className).not.toContain("flex-wrap"); + act(() => { + tabs()[2]?.click(); + }); + expect(received).toBe("packet"); + unmount(); + }); +}); diff --git a/src/ui/tabs.tsx b/src/ui/tabs.tsx index df4ce414..fca3f99b 100644 --- a/src/ui/tabs.tsx +++ b/src/ui/tabs.tsx @@ -1,4 +1,4 @@ -import { useId, useRef } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import type { ReactNode } from "react"; import { cn } from "../lib/utils.js"; @@ -8,6 +8,10 @@ export type TabDescriptor = { readonly label: string; /** Shown beside the label — a count of what is inside. */ readonly count?: number; + /** A glyph before the label — the thing's kind, not decoration. */ + readonly icon?: ReactNode; + /** A trailing mark — a live dot, a dirty flag — rendered after the label. */ + readonly marker?: ReactNode; }; export type TabsProps = { @@ -20,9 +24,19 @@ export type TabsProps = { readonly children: (active: Id) => ReactNode; /** `underline` for page-level navigation, `enclosed` for tabs inside a card. */ readonly variant?: "underline" | "enclosed"; + /** + * Scrolls the tab list horizontally instead of wrapping, with edge fades on + * whichever side hides tabs. For strips that can outgrow their pane — a + * growing artifact set — where wrapping would push content down. + */ + readonly scrollable?: boolean; readonly className?: string; }; +const FADE_L = "[mask-image:linear-gradient(to_right,transparent,black_32px)]"; +const FADE_R = "[mask-image:linear-gradient(to_left,transparent,black_32px)]"; +const FADE_BOTH = "[mask-image:linear-gradient(to_right,transparent,black_32px,black_calc(100%-32px),transparent)]"; + /** * Tabs that behave the way the tab pattern promises. * @@ -54,6 +68,7 @@ export function Tabs({ label, children, variant = "underline", + scrollable = false, className, }: TabsProps) { const base = useId(); @@ -70,6 +85,32 @@ export function Tabs({ buttons.current.get(id)?.focus(); } + // Scrollable strips fade whichever edge still hides tabs. Both directions are + // tracked separately — mid-scroll shows both — and a ResizeObserver catches + // the pane itself changing width, not just the list being scrolled. + const listRef = useRef(null); + const [fades, setFades] = useState({ left: false, right: false }); + useEffect(() => { + if (!scrollable) return; + const el = listRef.current; + if (!el) return; + const update = () => { + const next = { + left: el.scrollLeft > 1, + right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1, + }; + setFades((prev) => (prev.left === next.left && prev.right === next.right ? prev : next)); + }; + update(); + el.addEventListener("scroll", update, { passive: true }); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => { + el.removeEventListener("scroll", update); + observer.disconnect(); + }; + }, [scrollable, tabs.length]); + function move(delta: number) { const index = tabs.findIndex((tab) => tab.id === active); const next = tabs[(index + delta + tabs.length) % tabs.length]; @@ -79,6 +120,7 @@ export function Tabs({ return (
{ @@ -98,7 +140,13 @@ export function Tabs({ } }} className={cn( - "flex flex-wrap items-center", + "flex items-center", + scrollable + ? cn( + "flex-nowrap gap-0.5 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + fades.left && fades.right ? FADE_BOTH : fades.left ? FADE_L : fades.right ? FADE_R : "", + ) + : "flex-wrap", variant === "underline" ? "gap-1 border-b border-border" : "gap-1 rounded-md border border-border p-1", )} > @@ -131,10 +179,16 @@ export function Tabs({ : "rounded-sm text-muted-foreground hover:text-foreground", )} > + {tab.icon === undefined ? null : ( + + {tab.icon} + + )} {tab.label} {tab.count === undefined ? null : ( {tab.count} )} + {tab.marker} ); })} From 86b172b61a9bdc0f7d47d6c4dbcfdafbb9308b88 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 21:55:01 -0700 Subject: [PATCH 3/7] feat(ui): dot marker on NotificationsBell, segments variant on HorizontalStepper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queue that is either empty or not makes the count noise; marker=dot keeps 'something is waiting' without it. The segments stepper is the whisper track — hairline segments, current stretched in accent — for dense chrome where the step name is carried by the surface beneath. --- src/index.ts | 1 + src/ui/horizontal-stepper.tsx | 40 ++++++++++++++++++++++++++++++++++- src/ui/notifications-bell.tsx | 23 ++++++++++++++------ src/ui/tabs.test.tsx | 2 +- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1f4a3e8e..b4f4a395 100644 --- a/src/index.ts +++ b/src/index.ts @@ -96,6 +96,7 @@ export * from "./ui/research-body.js"; export * from "./ui/rich-empty-state.js"; export * from "./ui/run-now-button.js"; export * from "./ui/section.js"; +export * from "./ui/segmented-control.js"; export * from "./ui/select.js"; export * from "./ui/selection-checkbox.js"; export * from "./ui/settings-panel.js"; diff --git a/src/ui/horizontal-stepper.tsx b/src/ui/horizontal-stepper.tsx index 7a73732f..ac80339e 100644 --- a/src/ui/horizontal-stepper.tsx +++ b/src/ui/horizontal-stepper.tsx @@ -18,6 +18,13 @@ function pillClass(status: WorkflowStepStatus): string { export type HorizontalStepperProps = { readonly steps: readonly WorkflowStep[]; + /** + * `pills` is the default rail: numbered pills with connecting fills and + * labels. `segments` is the whisper track — hairline segments, done in ink, + * current in accent and stretched — for dense chrome like a top bar, where + * the step's name is already carried by the surface beneath it. + */ + readonly variant?: "pills" | "segments"; readonly className?: string; }; @@ -28,11 +35,42 @@ export type HorizontalStepperProps = { * change, so a run with more steps than fit on screen never leaves the * active one off to the side unannounced. */ -export function HorizontalStepper({ steps, className }: HorizontalStepperProps) { +export function HorizontalStepper({ steps, variant = "pills", className }: HorizontalStepperProps) { const currentStep = steps.find((step) => step.status === "current"); const currentRef = useScrollCurrentIntoView(currentStep?.number ?? -1); const compress = steps.length > LABEL_VISIBLE_STEP_THRESHOLD; + if (variant === "segments") { + return ( +
    + {steps.map((step) => ( +
  1. + {step.label} +
  2. + ))} +
+ ); + } + return (
    void; @@ -36,6 +42,7 @@ export function NotificationsBell({ count, children, maxCount = 99, + marker = "count", open: openProp, onOpenChange, className, @@ -68,12 +75,16 @@ export function NotificationsBell({ > {count > 0 ? ( - - {count > maxCount ? `${maxCount}+` : count} - + marker === "dot" ? ( + + ) : ( + + {count > maxCount ? `${maxCount}+` : count} + + ) ) : null} diff --git a/src/ui/tabs.test.tsx b/src/ui/tabs.test.tsx index 5743e559..dccaecf3 100644 --- a/src/ui/tabs.test.tsx +++ b/src/ui/tabs.test.tsx @@ -74,7 +74,7 @@ describe("Tabs", () => { expect(tablist().className).toContain("flex-nowrap"); expect(tablist().className).not.toContain("flex-wrap"); act(() => { - tabs()[2]?.click(); + (tabs()[2] as HTMLElement | undefined)?.click(); }); expect(received).toBe("packet"); unmount(); From 90bf97bd9a01974aad94162c113c10ac476a0ed1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 21 Sep 2026 22:36:26 -0700 Subject: [PATCH 4/7] feat(ui): hold gesture on ChatInput's send button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onSendHold fires on a >=450ms press instead of sending — the heavier alternative (send back, send-and-…) that shares the button because it is a send, not a separate action. The click ending a hold is swallowed so it never sends on release, and a leaked hold cannot eat a later send. --- src/ui/chat-input.tsx | 49 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/ui/chat-input.tsx b/src/ui/chat-input.tsx index 0d222208..ac940a85 100644 --- a/src/ui/chat-input.tsx +++ b/src/ui/chat-input.tsx @@ -14,6 +14,16 @@ export type ChatInputProps = { readonly onSend: () => void; /** Shown while the agent is replying. Its presence turns send into stop. */ readonly onStop?: () => void; + /** + * Holding the send button fires this instead of sending — the heavier + * alternative gesture (send back, send-and-…) that shares the button + * because it is a send, not a separate action. The click that ends the + * hold is swallowed so it never sends on release. There is no keyboard + * hold, so whatever this opens must be reachable another way too. + */ + readonly onSendHold?: () => void; + /** How long a press counts as a hold. Defaults to 450ms. */ + readonly sendHoldMs?: number; readonly working?: boolean; readonly placeholder?: string; readonly disabled?: boolean; @@ -48,6 +58,8 @@ export function ChatInput({ onValueChange, onSend, onStop, + onSendHold, + sendHoldMs = 450, working = false, placeholder = "Send a message…", disabled = false, @@ -68,14 +80,30 @@ export function ChatInput({ node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`; }, [value]); + useEffect(() => releaseHold, []); + const canSend = value.trim().length > 0 && !disabled; const showStop = working && onStop !== undefined; + const holdTimer = useRef | null>(null); + // Set when a hold fires so the click that ends the press does not send. + const heldRef = useRef(false); const submit = () => { + if (heldRef.current) { + heldRef.current = false; + return; + } if (!canSend) return; onSend(); }; + const releaseHold = () => { + if (holdTimer.current !== null) { + clearTimeout(holdTimer.current); + holdTimer.current = null; + } + }; + return (
    { + releaseHold(); + // A hold released off the button never produces the + // click that consumes this — reset it per press so it + // cannot swallow a later normal send. + heldRef.current = false; + holdTimer.current = setTimeout(() => { + heldRef.current = true; + holdTimer.current = null; + onSendHold(); + }, sendHoldMs); + }, + onPointerUp: releaseHold, + onPointerLeave: releaseHold, + onPointerCancel: releaseHold, + })} className="grid size-9 shrink-0 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary-active disabled:opacity-40" > From 17e407238596ae26142e684fa4926caea8b17e4f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 22 Sep 2026 00:06:32 -0700 Subject: [PATCH 5/7] feat(ui): completed HorizontalStepper segments can be clicked --- src/ui/horizontal-stepper.tsx | 49 +++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/src/ui/horizontal-stepper.tsx b/src/ui/horizontal-stepper.tsx index ac80339e..4bba9c67 100644 --- a/src/ui/horizontal-stepper.tsx +++ b/src/ui/horizontal-stepper.tsx @@ -26,6 +26,12 @@ export type HorizontalStepperProps = { */ readonly variant?: "pills" | "segments"; readonly className?: string; + /** + * Makes completed steps clickable — a finished step is a place you can go + * back to, not just a mark of progress. Pending and current steps stay + * inert: there is nothing behind them to open. + */ + readonly onStepClick?: (step: WorkflowStep) => void; }; /** @@ -35,12 +41,24 @@ export type HorizontalStepperProps = { * change, so a run with more steps than fit on screen never leaves the * active one off to the side unannounced. */ -export function HorizontalStepper({ steps, variant = "pills", className }: HorizontalStepperProps) { +export function HorizontalStepper({ steps, variant = "pills", className, onStepClick }: HorizontalStepperProps) { const currentStep = steps.find((step) => step.status === "current"); const currentRef = useScrollCurrentIntoView(currentStep?.number ?? -1); const compress = steps.length > LABEL_VISIBLE_STEP_THRESHOLD; if (variant === "segments") { + const segmentClass = (step: WorkflowStep) => + cn( + "h-0.5 rounded-full transition-[width,background-color] duration-300", + step.status === "current" ? "w-6" : "w-4", + step.status === "completed" + ? "bg-foreground" + : step.status === "failed" + ? "bg-destructive" + : step.status === "current" + ? "bg-primary" + : "bg-border", + ); return (
      - {step.label} + {step.status === "completed" && onStepClick ? ( + + ) : ( + + )} ))}
    From 1d24ea49b41043be9cd0e95efc298937cdf0dbf1 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 22 Sep 2026 09:02:48 -0700 Subject: [PATCH 6/7] feat(chat): optional attachIcon and sendIcon on ChatInput --- CHANGELOG.md | 4 ++ src/ui/chat-input.test.tsx | 81 ++++++++++++++++++++++++++++++++++++++ src/ui/chat-input.tsx | 15 +++++-- 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 src/ui/chat-input.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f26899c4..3fd255c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,10 @@ contract — modules that are not exported are internal and may change in any re ### Added +- **`ChatInput` icon slots** — optional `attachIcon` and `sendIcon` (`ReactNode`) + replace the default Paperclip and ArrowUp glyphs. Omit them and the composer + looks as it did; the stop control is unchanged. + - **`ui/shimmer-text` and `ui/thinking-label`** — a token-pure shimmer sweep over text, and a rotating-verb status label built on it. Verb rotation is width-stable (stacked grid, crossfade) and stops under reduced motion via diff --git a/src/ui/chat-input.test.tsx b/src/ui/chat-input.test.tsx new file mode 100644 index 00000000..26a646d8 --- /dev/null +++ b/src/ui/chat-input.test.tsx @@ -0,0 +1,81 @@ +import { describe, expect, mock, test } from "bun:test"; +import { act, createElement, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; + +import { ChatInput, type ChatInputProps } from "./chat-input.js"; + +function mount(overrides: Partial = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const props: ChatInputProps = { + value: "", + onValueChange: mock(), + onSend: mock(), + ...overrides, + }; + act(() => { + root.render(createElement(ChatInput, props)); + }); + return { + container, + attach: () => container.querySelector('button[aria-label="Attach files"]') as HTMLButtonElement | null, + send: () => container.querySelector('button[aria-label="Send message"]') as HTMLButtonElement | null, + stop: () => container.querySelector('button[aria-label="Stop generating"]') as HTMLButtonElement | null, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("ChatInput icon slots", () => { + test("send defaults to the ArrowUp lucide icon", () => { + const { send, unmount } = mount(); + const svg = send()?.querySelector("svg"); + expect(svg).not.toBeNull(); + expect(svg?.classList.contains("size-4")).toBe(true); + expect(svg?.getAttribute("class") ?? "").toMatch(/lucide-arrow-up|lucide-arrowup/i); + unmount(); + }); + + test("sendIcon replaces the default send glyph", () => { + const sendIcon: ReactNode = createElement("span", { "data-slot": "custom-send" }, "go"); + const { send, unmount } = mount({ sendIcon }); + expect(send()?.querySelector('[data-slot="custom-send"]')?.textContent).toBe("go"); + expect(send()?.querySelector("svg")).toBeNull(); + unmount(); + }); + + test("attach defaults to the Paperclip lucide icon when onAttach is set", () => { + const { attach, unmount } = mount({ onAttach: mock() }); + const svg = attach()?.querySelector("svg"); + expect(attach()).not.toBeNull(); + expect(svg).not.toBeNull(); + expect(svg?.classList.contains("size-4")).toBe(true); + expect(svg?.getAttribute("class") ?? "").toMatch(/lucide-paperclip/i); + unmount(); + }); + + test("attachIcon replaces the default attach glyph", () => { + const attachIcon: ReactNode = createElement("span", { "data-slot": "custom-attach" }, "clip"); + const { attach, unmount } = mount({ onAttach: mock(), attachIcon }); + expect(attach()?.querySelector('[data-slot="custom-attach"]')?.textContent).toBe("clip"); + expect(attach()?.querySelector("svg")).toBeNull(); + unmount(); + }); + + test("sendIcon does not replace the stop control", () => { + const sendIcon: ReactNode = createElement("span", { "data-slot": "custom-send" }, "go"); + const { send, stop, unmount } = mount({ + working: true, + onStop: mock(), + sendIcon, + }); + expect(send()).toBeNull(); + expect(stop()).not.toBeNull(); + expect(stop()?.querySelector('[data-slot="custom-send"]')).toBeNull(); + expect(stop()?.querySelector("svg")?.classList.contains("size-3.5")).toBe(true); + unmount(); + }); +}); diff --git a/src/ui/chat-input.tsx b/src/ui/chat-input.tsx index 0d222208..5249893d 100644 --- a/src/ui/chat-input.tsx +++ b/src/ui/chat-input.tsx @@ -1,5 +1,5 @@ import { ArrowUp, Paperclip, Square, X } from "lucide-react"; -import { useEffect, useRef, type RefObject } from "react"; +import { useEffect, useRef, type ReactNode, type RefObject } from "react"; import { cn } from "../lib/utils.js"; @@ -22,11 +22,18 @@ export type ChatInputProps = { readonly onRemoveAttachment?: (attachment: ChatAttachment) => void; /** Exposes the textarea node — a host that autofocuses on open needs something to focus. */ readonly textareaRef?: RefObject; + /** Replaces the default paperclip on the attach control. Ignored when `onAttach` is omitted. */ + readonly attachIcon?: ReactNode; + /** Replaces the default arrow on the send control. The stop control is unchanged. */ + readonly sendIcon?: ReactNode; readonly className?: string; }; const MAX_ROWS_PX = 200; +const defaultAttachIcon = ; +const defaultSendIcon = ; + /** * The composer. * @@ -55,6 +62,8 @@ export function ChatInput({ onAttach, onRemoveAttachment, textareaRef: externalTextareaRef, + attachIcon = defaultAttachIcon, + sendIcon = defaultSendIcon, className, }: ChatInputProps) { const internalTextareaRef = useRef(null); @@ -127,7 +136,7 @@ export function ChatInput({ aria-label="Attach files" className="grid size-9 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50" > - + {attachIcon} )} @@ -167,7 +176,7 @@ export function ChatInput({ aria-label="Send message" className="grid size-9 shrink-0 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary-active disabled:opacity-40" > - + {sendIcon} )}
From 36b803afdb9f1afdd2e4d871a10e189462e9e291 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 22 Sep 2026 09:11:07 -0700 Subject: [PATCH 7/7] feat(chat): compound ChatInput slots and leading/trailing tools Named Root/Header/Body/Textarea/Footer/Tools/Submit/Button/Attach slots compose a Vercel-style prompt. The all-in-one ChatInput still works; leadingTools and trailingTools land extra footer controls. --- CHANGELOG.md | 8 + src/ui/chat-input.test.tsx | 124 +++++++++++- src/ui/chat-input.tsx | 399 ++++++++++++++++++++++++++++--------- 3 files changed, 432 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd255c5..0087fe53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,14 @@ contract — modules that are not exported are internal and may change in any re ### Added +- **`ChatInput` compound slots** — `ChatInputRoot`, `ChatInputHeader`, + `ChatInputBody`, `ChatInputTextarea`, `ChatInputFooter`, `ChatInputTools`, + `ChatInputSubmit`, `ChatInputButton`, and `ChatInputAttach` compose a + Vercel-style prompt. The existing all-in-one `ChatInput` still works; + optional `leadingTools` and `trailingTools` land extra footer controls + (dictate, model picker, …) without overlaying the composer. `attachIcon` + and `sendIcon` are unchanged. + - **`ChatInput` icon slots** — optional `attachIcon` and `sendIcon` (`ReactNode`) replace the default Paperclip and ArrowUp glyphs. Omit them and the composer looks as it did; the stop control is unchanged. diff --git a/src/ui/chat-input.test.tsx b/src/ui/chat-input.test.tsx index 26a646d8..19623253 100644 --- a/src/ui/chat-input.test.tsx +++ b/src/ui/chat-input.test.tsx @@ -2,7 +2,19 @@ import { describe, expect, mock, test } from "bun:test"; import { act, createElement, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; -import { ChatInput, type ChatInputProps } from "./chat-input.js"; +import { + ChatInput, + ChatInputAttach, + ChatInputBody, + ChatInputButton, + ChatInputFooter, + ChatInputHeader, + ChatInputRoot, + ChatInputSubmit, + ChatInputTextarea, + ChatInputTools, + type ChatInputProps, +} from "./chat-input.js"; function mount(overrides: Partial = {}) { const container = document.createElement("div"); @@ -22,6 +34,23 @@ function mount(overrides: Partial = {}) { attach: () => container.querySelector('button[aria-label="Attach files"]') as HTMLButtonElement | null, send: () => container.querySelector('button[aria-label="Send message"]') as HTMLButtonElement | null, stop: () => container.querySelector('button[aria-label="Stop generating"]') as HTMLButtonElement | null, + textarea: () => container.querySelector("textarea") as HTMLTextAreaElement, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function mountTree(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render(node); + }); + return { + container, unmount: () => { act(() => root.unmount()); container.remove(); @@ -79,3 +108,96 @@ describe("ChatInput icon slots", () => { unmount(); }); }); + +describe("ChatInput tool slots", () => { + test("leadingTools appears in the footer tools row", () => { + const leadingTools = createElement( + "button", + { type: "button", "data-slot": "dictate" }, + "mic", + ); + const { container, unmount } = mount({ onAttach: mock(), leadingTools }); + const tools = container.querySelector('[data-slot="chat-input-tools"]'); + expect(tools?.querySelector('[data-slot="dictate"]')?.textContent).toBe("mic"); + expect(tools?.querySelector('button[aria-label="Attach files"]')).not.toBeNull(); + unmount(); + }); + + test("trailingTools appears before send", () => { + const trailingTools = createElement("span", { "data-slot": "model-picker" }, "gpt"); + const { container, send, unmount } = mount({ trailingTools }); + const footer = container.querySelector('[data-slot="chat-input-footer"]'); + const picker = footer?.querySelector('[data-slot="model-picker"]'); + const sendButton = send(); + expect(picker?.textContent).toBe("gpt"); + expect(picker).not.toBeNull(); + expect(sendButton).not.toBeNull(); + if (picker !== null && picker !== undefined && sendButton !== null) { + expect(picker.compareDocumentPosition(sendButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + } + unmount(); + }); +}); + +describe("ChatInput send", () => { + test("Enter still sends", () => { + const onSend = mock(); + const { textarea, unmount } = mount({ value: "hello", onSend }); + act(() => { + textarea().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + }); + expect(onSend).toHaveBeenCalledTimes(1); + unmount(); + }); + + test("Shift+Enter does not send", () => { + const onSend = mock(); + const { textarea, unmount } = mount({ value: "hello", onSend }); + act(() => { + textarea().dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true }), + ); + }); + expect(onSend).not.toHaveBeenCalled(); + unmount(); + }); + + test("empty value does not send on Enter", () => { + const onSend = mock(); + const { textarea, unmount } = mount({ value: " ", onSend }); + act(() => { + textarea().dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })); + }); + expect(onSend).not.toHaveBeenCalled(); + unmount(); + }); +}); + +describe("ChatInput compound slots", () => { + test("compound footer renders a custom tool beside attach and send", () => { + const onAttach = mock(); + const { container, unmount } = mountTree( + + + files + + + + + + + mic + + + + + , + ); + expect(container.querySelector('[data-slot="chat-input-footer"]')).not.toBeNull(); + expect(container.querySelector('[data-slot="extra-header"]')?.textContent).toBe("files"); + expect(container.querySelector('[aria-label="Dictate"]')?.textContent).toBe("mic"); + expect(container.querySelector('[aria-label="Attach files"]')).not.toBeNull(); + expect(container.querySelector('[aria-label="Send message"]')).not.toBeNull(); + unmount(); + }); +}); diff --git a/src/ui/chat-input.tsx b/src/ui/chat-input.tsx index 5249893d..3183651f 100644 --- a/src/ui/chat-input.tsx +++ b/src/ui/chat-input.tsx @@ -1,5 +1,11 @@ import { ArrowUp, Paperclip, Square, X } from "lucide-react"; -import { useEffect, useRef, type ReactNode, type RefObject } from "react"; +import { + useEffect, + useRef, + type ComponentProps, + type ReactNode, + type RefObject, +} from "react"; import { cn } from "../lib/utils.js"; @@ -26,14 +32,55 @@ export type ChatInputProps = { readonly attachIcon?: ReactNode; /** Replaces the default arrow on the send control. The stop control is unchanged. */ readonly sendIcon?: ReactNode; + /** + * Extra controls in the footer tools row, after attach. A host that needs a + * dictate button (or any other leading action) passes it here instead of + * overlaying the composer with CSS. + */ + readonly leadingTools?: ReactNode; + /** Extra controls in the footer, immediately before send/stop. */ + readonly trailingTools?: ReactNode; readonly className?: string; }; +export type ChatInputRootProps = ComponentProps<"form">; +export type ChatInputHeaderProps = ComponentProps<"div">; +export type ChatInputBodyProps = ComponentProps<"div">; +export type ChatInputFooterProps = ComponentProps<"div">; +export type ChatInputToolsProps = ComponentProps<"div">; +export type ChatInputButtonProps = ComponentProps<"button">; + +export type ChatInputTextareaProps = Omit, "ref"> & { + readonly onValueChange?: (value: string) => void; + readonly onSend?: () => void; + readonly textareaRef?: RefObject; +}; + +export type ChatInputSubmitProps = Omit, "type" | "children"> & { + readonly onStop?: () => void; + readonly working?: boolean; + readonly sendIcon?: ReactNode; + /** When false, send is disabled. Omit to leave enablement to `disabled`. */ + readonly canSend?: boolean; +}; + +export type ChatInputAttachProps = { + readonly onAttach: (files: FileList) => void; + readonly icon?: ReactNode; + readonly disabled?: boolean; + readonly className?: string; + readonly accept?: string; + readonly multiple?: boolean; +}; + const MAX_ROWS_PX = 200; const defaultAttachIcon = ; const defaultSendIcon = ; +const toolButtonClass = + "inline-flex size-9 shrink-0 items-center justify-center gap-1.5 rounded-md text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"; + /** * The composer. * @@ -49,6 +96,10 @@ const defaultSendIcon = ; * The textarea grows with its content up to a cap, then scrolls. It is measured * rather than counted in rows, because a wrapped long line takes more height * than its newline count implies. + * + * Callers that need extra footer controls (dictate, model picker, …) pass + * `leadingTools` / `trailingTools`, or compose the named slots instead of + * this all-in-one. Omit the extras and the composer looks as it did. */ export function ChatInput({ value, @@ -64,122 +115,274 @@ export function ChatInput({ textareaRef: externalTextareaRef, attachIcon = defaultAttachIcon, sendIcon = defaultSendIcon, + leadingTools, + trailingTools, className, }: ChatInputProps) { - const internalTextareaRef = useRef(null); - const textareaRef = externalTextareaRef ?? internalTextareaRef; - const fileRef = useRef(null); - - useEffect(() => { - const node = textareaRef.current; - if (node === null) return; - node.style.height = "auto"; - node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`; - }, [value]); - const canSend = value.trim().length > 0 && !disabled; - const showStop = working && onStop !== undefined; const submit = () => { if (!canSend) return; onSend(); }; + return ( + + {attachments.length === 0 ? null : ( + +
    + {attachments.map((attachment) => ( +
  • + {attachment.name} + {onRemoveAttachment === undefined ? null : ( + + )} +
  • + ))} +
+
+ )} + + + + + + + + {onAttach === undefined ? null : ( + + )} + {leadingTools} + + {trailingTools} + + +
+ ); +} + +/** Form shell. `className` lands on the bordered box. Submit is always `preventDefault`'d. */ +export function ChatInputRoot({ className, onSubmit, ...props }: ChatInputRootProps) { return ( { event.preventDefault(); - submit(); + onSubmit?.(event); }} - > - {attachments.length === 0 ? null : ( -
    - {attachments.map((attachment) => ( -
  • - {attachment.name} - {onRemoveAttachment === undefined ? null : ( - - )} -
  • - ))} -
+ /> + ); +} + +/** Attachments row, or any extra header above the textarea. */ +export function ChatInputHeader({ className, ...props }: ChatInputHeaderProps) { + return
; +} + +/** Textarea region. */ +export function ChatInputBody({ className, ...props }: ChatInputBodyProps) { + return
; +} + +/** + * Auto-resizing message field. Enter sends, Shift+Enter breaks the line, IME + * composition is ignored. Pass `onSend` or let Enter `requestSubmit` the form. + */ +export function ChatInputTextarea({ + value, + onValueChange, + onSend, + onChange, + onKeyDown, + disabled = false, + placeholder = "Send a message…", + textareaRef: externalTextareaRef, + className, + rows = 1, + ...props +}: ChatInputTextareaProps) { + const internalTextareaRef = useRef(null); + const textareaRef = externalTextareaRef ?? internalTextareaRef; + + useEffect(() => { + const node = textareaRef.current; + if (node === null) return; + node.style.height = "auto"; + node.style.height = `${Math.min(node.scrollHeight, MAX_ROWS_PX)}px`; + }, [value]); + + return ( +