diff --git a/CHANGELOG.md b/CHANGELOG.md index f26899c4..0087fe53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,18 @@ 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. + - **`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/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/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/chat-input.test.tsx b/src/ui/chat-input.test.tsx new file mode 100644 index 00000000..307232a4 --- /dev/null +++ b/src/ui/chat-input.test.tsx @@ -0,0 +1,224 @@ +import { describe, expect, mock, test } from "bun:test"; +import { act, createElement, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; + +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"); + 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, + 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(); + }, + }; +} + +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(); + }); +}); + +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(); + }); + + test("holding send fires onSendHold and swallows the trailing click", async () => { + const onSend = mock(); + const onSendHold = mock(); + const { send, unmount } = mount({ value: "hello", onSend, onSendHold, sendHoldMs: 20 }); + const button = send(); + if (button === null) throw new Error("send button missing"); + act(() => { + button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + }); + expect(onSendHold).toHaveBeenCalledTimes(1); + act(() => { + button.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); + button.click(); + }); + 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 0d222208..b2bfe3e5 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 RefObject } from "react"; +import { + useEffect, + useRef, + type ComponentProps, + type ReactNode, + type RefObject, +} from "react"; import { cn } from "../lib/utils.js"; @@ -14,6 +20,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; @@ -22,11 +38,62 @@ 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; + /** + * 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; + readonly onSendHold?: () => void; + /** How long a press counts as a hold. Defaults to 450ms. */ + readonly sendHoldMs?: number; +}; + +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. * @@ -42,12 +109,21 @@ const MAX_ROWS_PX = 200; * 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. + * + * Holding send fires `onSendHold` instead of sending. The click that ends the + * hold is swallowed so it never sends on release. */ export function ChatInput({ value, onValueChange, onSend, onStop, + onSendHold, + sendHoldMs = 450, working = false, placeholder = "Send a message…", disabled = false, @@ -55,122 +131,322 @@ export function ChatInput({ onAttach, onRemoveAttachment, 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; + // 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(); }; + return ( + + {attachments.length === 0 ? null : ( + +
    + {attachments.map((attachment) => ( +
  • + {attachment.name} + {onRemoveAttachment === undefined ? null : ( + + )} +
  • + ))} +
+
+ )} + + + + + + + + {onAttach === undefined ? null : ( + + )} + {leadingTools} + + {trailingTools} + { + heldRef.current = true; + onSendHold(); + }, + })} + /> + +
+ ); +} + +/** 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 ( +