diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49080042..13642928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,29 @@ jobs: mkdir -p "$RUNNER_TEMP/c" && cd "$RUNNER_TEMP/c" npm init -y >/dev/null && npm pkg set type=module >/dev/null npm install "$TARBALL" - node -e 'import("@corbits/react-ui").then((m) => { for (const n of ["Button", "ChatThread", "Badge"]) if (typeof m[n] !== "function") throw new Error("missing export: " + n); })' + # One runtime export from each module the chat family puts in + # src/index.ts, so a dropped `export *` line fails here and not in a + # consumer's build. lib/conversation.js is types only and has none. + # A forwardRef component is an object, so presence is the check. + node --input-type=module -e ' + const m = await import("@corbits/react-ui"); + const names = [ + "Button", "Badge", + "usePromptInput", "useResizable", "useTabs", "useTabDrag", "useCopy", + "useHistoryWindow", "useReadingPosition", "useAttachments", + "useFileDrop", "useMediaViewer", + "groupActivity", "mediaKindOf", "checkAttachments", "matchPromptTokens", + "Conversation", "ConversationDivider", "ConversationEarlier", + "ConversationMap", "ConversationThread", + "PromptInput", "PromptInputToolbar", "Panel", "PanelTabs", + "Tool", "ToolApproval", "ToolGroup", "Reasoning", "Activity", + "Message", "MessageFold", "MessageActions", "MessageReactions", + "MessageReplies", "Attachments", "AttachmentDropZone", + "GeneratedImages", "Sources", "MediaViewer", + ]; + const missing = names.filter((n) => m[n] === undefined); + if (missing.length) throw new Error("missing exports: " + missing.join(", ")); + ' policy-check: if: github.event_name == 'pull_request' diff --git a/README.md b/README.md index 561ddb0d..93fdf49e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # @corbits/react-ui -React components for agent products: chat transcripts with reasoning and tool calls, approval cards, run status, artifacts and charts, styled by one Tailwind v4 token theme. The UI package of Corbits: it renders data a host web app loads from Interchange, and works in any React 18 or 19 app without Interchange. +React components for agent products: conversations with reasoning, tool calls and approvals, run status, artifacts and charts, styled by one Tailwind v4 token theme. The UI package of Corbits: it renders data a host web app loads from Interchange, and works in any React 18 or 19 app without Interchange. ## Why @corbits/react-ui? 1. **Props in, markup out.** No component fetches, subscribes or holds a client. Your data layer stays yours, and every component renders the same in stories, tests and production. -2. **Agent-shaped building blocks.** `ChatThread` renders a message as ordered parts (text, reasoning, tool calls), and `ApprovalCard`, `GateBlock` and `StepList` cover a run that waits on a person. +2. **Agent-shaped building blocks.** `Message` holds a turn while `Tool`, `Reasoning` and `Activity` render its parts (text, reasoning, tool calls), `ToolApproval` asks a person before a call runs, and `GateBlock` and `StepList` cover a run that waits on a person. 3. **Pay for what you import.** One root entry, the package is side-effect free apart from its CSS, and heavy peers (Radix dialog, dropdown-menu, tooltip, `sonner`) are optional. 4. **Server Components ready.** Stateful modules ship `"use client"`, so a React Server Components app imports them directly. Outside RSC the directive is a no-op. @@ -33,44 +33,102 @@ Import the stylesheet once at the app root, before your own CSS, and mount `Them ```tsx import "@corbits/react-ui/styles.css"; -import { ChatThread, ThemeProvider, type ChatMessage } from "@corbits/react-ui"; - -const messages: ChatMessage[] = [ +import { + Activity, + Conversation, + ConversationContent, + ConversationItem, + ConversationScrollButton, + Message, + MessageContent, + MessageHeader, + ThemeProvider, + groupActivity, + type ConversationMessage, + type ConversationParticipant, +} from "@corbits/react-ui"; + +const you: ConversationParticipant = { id: "u1", kind: "person", name: "You" }; +const scout: ConversationParticipant = { + id: "a1", + kind: "agent", + name: "Scout", +}; + +const messages: ConversationMessage[] = [ { id: "m1", - role: "user", - createdAt: "2026-01-01T09:00:00Z", - parts: [{ type: "text", text: "What changed in the repo today?" }], + author: you, + at: "2026-01-01T09:00:00Z", + status: "sent", + parts: [{ kind: "text", text: "What changed in the repo today?" }], }, { id: "m2", - role: "agent", - createdAt: "2026-01-01T09:00:04Z", + author: scout, + at: "2026-01-01T09:00:04Z", + status: "sent", parts: [ - { type: "reasoning", text: "Check the commit log first." }, { - type: "tool", + kind: "reasoning", + text: "Check the commit log first.", + durationMs: 1200, + }, + { + kind: "tool-trace", toolCallId: "t1", - toolName: "git__log", - label: "Read today's commits", - state: "done", + name: "git__log", + status: "output-available", output: "3 commits", }, - { type: "text", text: "Three commits landed today, all in the docs." }, + { kind: "text", text: "Three commits landed today, all in the docs." }, ], }, ]; +function Turn({ message }: { message: ConversationMessage }) { + const own = message.author.id === you.id; + return ( + + {own ? null : ( + + )} + {groupActivity(message.parts).map((group) => { + if (group.kind === "activity") + return ; + if (group.part.kind === "text") + return ; + return null; + })} + + ); +} + export function App() { return ( - +
+ + + {messages.map((message) => ( + + + + ))} + + + +
); } ``` -This renders the user message, a collapsed reasoning block, a finished tool call and the agent's reply. +This renders the user message in a bubble, the agent's thinking and tool call folded into one activity line that opens onto both, and the agent's reply. Every other part kind (tool calls waiting on a person, images, sources, files) has its own component; see [Chat](#chat). `styles.css` includes Tailwind preflight and a base layer, so it restyles the page. If you already run Tailwind v4, import `@corbits/react-ui/theme.css` instead and let your build generate the utilities. Import one of the two, never both; without either, components render unstyled. @@ -119,31 +177,45 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", #### Chat -| Component | What it is | -| --------------------- | ------------------------------------------------------------------------ | -| `ChatThread` | Scrolling transcript of user and agent messages | -| `AgentTurn` | One agent reply: reasoning, tool calls, answer, in order | -| `MessageBubble` | One user or system message | -| `ChatInput` | Auto-growing message input with a send/stop button | -| `ChatComposer` | `ChatInput` with suggestion chips for an empty conversation | -| `ChatPanel` | Docked chat frame with header and footer | -| `PartsRenderer` | Renders a list of message parts | -| `ReasoningBlock` | Collapsible thinking text | -| `ToolBlock` | A tool call's lifecycle: running, done, failed | -| `ToolNarrative` | What the agent did, as one expandable line | -| `ToolPicker` | Browse, search and select tools | -| `QuickReplyChips` | Tappable suggested replies | -| `TypingIndicator` | Agent-is-typing dots | -| `ThinkingIndicator` | Animated Corbits mark with a status label | -| `ThinkingMark` | Decorative animated Corbits mark | -| `ThinkingLabel` | Rotating-verb status label | -| `ShimmerText` | Shimmer sweep over text | -| `MicButton` | Permission-aware microphone toggle | -| `VoiceWaveform` | Live audio level bars | -| `DictationStatusLine` | Composer dictation status row | -| `MicPermissionDialog` | Microphone permission prompt (optional peer, `ui/mic-permission-dialog`) | -| `Panel` | Side panel resized from its edge; hidden panes keep their state | -| `PanelTabs` | A panel's tab row: select, close, drag or keyboard to reorder; `useTabs` | +| Component | What it is | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `Conversation` | Scrolling conversation that settles or follows new messages; `useConversation` | +| `ConversationContent` | The scrolling log; a `ConversationItem` wraps each entry | +| `ConversationScrollButton` | Jump back to the latest message | +| `ConversationEmptyState` | No messages yet | +| `ConversationDivider` | A rule with an optional label, such as a day; `ConversationNotice` for a system line | +| `ConversationEarlier` | Loads older messages as the reader scrolls up; `useHistoryWindow` | +| `ConversationMap` | Rail of ticks, one per turn, to find your way around; `useReadingPosition` | +| `ConversationThread` | A reply thread headed by its parent message | +| `Message` | One message, built from `MessageAvatar`, `MessageHeader`, `MessageContent`, `MessageSendStatus` and `MessageFooter` | +| `MessageFold` | Folds a very long answer to its opening | +| `MessageActions` | Row of actions under a message: `MessageAction`, `MessageActionMenu` | +| `MessageReactions` | Emoji reactions with counts | +| `MessageReplies` | Reply count and time that opens the thread | +| `Activity` | A run of thinking and tool calls folded to one line; `groupActivity` | +| `Reasoning` | An agent's thinking, folded | +| `Tool` | One tool call that opens onto its input and output | +| `ToolGroup` | Tool calls that ran at the same time | +| `ToolApproval` | Approve, approve always or deny a tool call waiting on a person | +| `GeneratedImages` | Images an agent made, with placeholders while generating | +| `Sources` | Numbered citations for an answer | +| `MediaViewer` | Pictures, video, audio, PDFs and text full size; `useMediaViewer` | +| `Attachments` | Files as a stack, tiles or a list; `useAttachments` | +| `AttachmentDropZone` | Area that takes dropped files and folders; `useFileDrop` | +| `PromptInput` | The message box: `PromptInputTextarea`, `PromptInputSubmit`, `PromptInputSuggestions`; `usePromptInput` | +| `PromptInputToolbar` | Controls under the text: add menu, model and mode selects, notes | +| `ToolPicker` | Browse, search and select tools | +| `TypingIndicator` | Agent-is-typing dots | +| `ThinkingIndicator` | Animated Corbits mark with a status label | +| `ThinkingMark` | Decorative animated Corbits mark | +| `ThinkingLabel` | Rotating-verb status label | +| `ShimmerText` | Shimmer sweep over text | +| `MicButton` | Permission-aware microphone toggle | +| `VoiceWaveform` | Live audio level bars | +| `DictationStatusLine` | Composer dictation status row | +| `MicPermissionDialog` | Microphone permission prompt (optional peer, `ui/mic-permission-dialog`) | +| `Panel` | Side panel resized from its edge; hidden panes keep their state | +| `PanelTabs` | A panel's tab row: select, close, drag or keyboard to reorder; `useTabs` | #### Runs @@ -151,7 +223,6 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", | ------------------- | ------------------------------------------------- | | `LiveRunBanner` | Status of a run in progress | | `LiveStatusLine` | The single "what's happening now" line | -| `ApprovalCard` | Approve or reject a pending action | | `GateBlock` | Chrome for a run parked on a human decision | | `StepList` | Vertical list of run steps and their status | | `HorizontalStepper` | Horizontal step progress | @@ -253,17 +324,60 @@ Everything imports from `@corbits/react-ui`, except rows marked "optional peer", The host app owns the connection to the hub; components only take props. 1. Load run state with [`@intx/hub-client`](https://github.com/faremeter/interchange/tree/main/packages/hub-client): `createRunSession` polls a workflow run's event log and exposes a seq-ordered `WorkflowRunEvent` timeline. -2. Map that timeline to props: agent output to `ChatMessage` parts for `ChatThread`, step events to `StepList` items, and a run parked on a person to `GateBlock` or `ApprovalCard`. -3. Wire callbacks back to the hub. `ApprovalCard`'s `onApprove` and `onReject` call your hub route that resolves the awaited signal; the hub checks the caller's grant, not the component. +2. Map that timeline to props: agent output to `ConversationMessage` parts rendered by `Message` inside a `Conversation`, a tool call waiting on a person to a `PartTool` carrying its `ToolApprovalRecord` for `ToolApproval`, step events to `StepList` items, and a run parked on a person to `GateBlock`. +3. Wire callbacks back to the hub. `ToolApproval`'s `onDecide` calls your hub route that resolves the awaited approval with the decision's outcome and scope; the hub checks the caller's grant, not the component. The settled record comes back on the part, and `ToolApproval` shows what was decided. + +## Upgrading from 0.3 + +The old chat family is removed. The conversation family replaces it: a message is a `ConversationMessage` whose `parts` are a `ConversationPart[]` (discriminated by `kind`), and the components are built from parts you compose rather than one component per screen. + +- `Part`, `PartText`, `PartReasoning`, `PartToolTrace`, `PartBlock`, `PartFile`, `PartEvent` and `ToolTraceStatus` are unchanged and still import from the root. + +Removed exports and what replaces them: + +| Removed | Use instead | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ChatThread`, `ChatThreadProps` | `Conversation` with `ConversationContent`, a `ConversationItem` per message and `ConversationScrollButton` | +| `AgentTurn`, `AgentTurnProps` | `Message` with `from="agent"`, `MessageAvatar`, `MessageHeader` and `MessageContent`; thinking and tool calls through `groupActivity` and `Activity` | +| `MessageBubble`, `MessageBubbleProps` | `Message` with `MessageContent`; `from="own"` draws the bubble | +| `PartsRenderer`, `PartsRendererProps` | Map `groupActivity(message.parts)` to `Activity`, `MessageContent`, `ToolApproval`, `ToolGroup`, `GeneratedImages`, `Sources` and `Attachments`; `block` parts are the host's to render | +| `toolTraceToBlockState` | No replacement: `Tool` reads `ToolTraceStatus` and the approval record directly | +| `ReasoningBlock`, `ReasoningBlockProps` | `Reasoning` (`ReasoningTrigger`, `ReasoningContent`) on a `PartReasoning` | +| `ToolBlock`, `ToolBlockProps`, `ToolBlockState` | `Tool` (`ToolHeader`, `ToolInput`, `ToolOutput`) on a `PartTool`; the state is its `ToolTraceStatus` | +| `ToolNarrative`, `ToolNarrativeProps` | `Tool`, or `ToolGroup` for calls that ran together | +| `ApprovalCard`, `ApprovalCardProps` | `ToolApproval` on a `PartTool` with a `ToolApprovalRecord`; `onApprove`, `onReject` and `onAllowAlways` become `onDecide(ToolApprovalDecision)`, and a busy `state` becomes `disabled` | +| `ApprovalRequest`, `ApprovalDetail` | The `PartTool` itself: `name` and `input`, with the host's own account of the action as `ToolApproval`'s children | +| `ApprovalState` | `ToolApprovalRecord`'s `status` for the outcome, `ToolApproval`'s `disabled` while a decision is recorded | +| `ChatInput`, `ChatInputProps` | `PromptInput` with `PromptInputTextarea` and `PromptInputSubmit`; `PromptInputToolbar` for controls under the text | +| `ChatAttachment` | `AttachmentData` for `Attachments`, `AttachmentItem` from `useAttachments` | +| `ChatComposer`, `ChatComposerProps` | `PromptInput`, with `ConversationEmptyState` for an empty conversation | +| `QuickReplyChips`, `QuickReply` | No replacement: suggested replies are the host's own buttons, for example inside `ConversationEmptyState` | +| `ChatPanel`, `ChatPanelHeader`, `ChatPanelFooter` | `Panel`, `PanelHeader` and `PanelContent` for a side panel; put `Conversation` and `PromptInput` in your own frame | +| `ChatMessage` | `ConversationMessage`: `role` becomes `author` (`ConversationParticipant`), `createdAt` becomes `at`, `status` is required | +| `ChatRole` | `ConversationParticipant`'s `kind` (`person` or `agent`); a system line is a `PartEvent`, shown with `ConversationNotice` | +| `MessageStatus` | `ConversationMessageStatus`, which adds `streaming` | +| `MessagePart` | `ConversationPart`, discriminated by `kind` instead of `type` | +| `TextPart`, `ReasoningPart`, `FilePart` | `PartText`, `PartReasoning`, `PartFile` | +| `ToolPart` | `PartTool`: `toolName` becomes `name`, `state` becomes `status`, and `label` moves to `ToolHeader`'s `label` | +| `ToolPartState` | `ToolTraceStatus`: `done` becomes `output-available` | +| `AgentIdentity` | `ConversationParticipant` with `kind: "agent"`; `tagline` becomes `role`, and `MessageAvatar` takes `initials` | +| `agentInitials` | `MessageAvatar`, which derives initials from `name` | +| `SubagentRun`, `SubagentState` | No replacement: the conversation model has no subagent type; show a helper's calls as `PartTool`s or a `PartToolBatch` | +| `ChatThreadSummary` | No replacement: a list of conversations is the host's own data | +| `messageText`, `reasoningText`, `toolParts` | No replacement: filter `message.parts` by `kind` | +| `isTurnWorking` | `parts.some((p) => p.kind === "tool-trace" && p.status === "running")` (any tool still running; `message.status === "streaming"` is the whole message) | +| `toolLabel` | No replacement: `ToolHeader` shows the raw tool name, after the `label` you pass | +| `ToolProviderMark` | `ProviderMark` with the provider read off the tool id (`name.split("__")[0]`, for ids shaped `provider__operation`) | +| `useDelayedAutofocus` | No replacement: focus the element from an effect once its entrance has finished | ## Upgrading from 0.1 - Install from npm. Git installs (`github:corbitsdev/react-ui#`) no longer build `dist/`. - Per-module subpaths are gone: import from `@corbits/react-ui` (`import { Button } from "@corbits/react-ui"`). - `ui/dialog`, `ui/command-palette`, `ui/sidebar-search`, `ui/mic-permission-dialog`, `ui/menu`, `ui/tooltip` and `ui/toast` are subpath-only, and their peers are optional: `import { Toaster, toast } from "@corbits/react-ui/ui/toast"`. Install the peer for each subpath you keep. -- `lib/csv`, `lib/url`, `lib/utils`, `lib/workflow-registry` and `ui/chat-dock-timing` are internal. Replace `cn` with `twMerge(clsx(...))`. +- `lib/csv`, `lib/url`, `lib/utils`, `lib/workflow-registry` and `ui/chat-dock-timing` (removed in 0.4) are internal. Replace `cn` with `twMerge(clsx(...))`. - Stateful components ship `"use client"`. Client wrappers you wrote for them still work, and can go. -- 84 unused modules are removed, `ui/command` is removed, `ActivityBlock` is renamed `ReasoningBlock`, and `AuthLayout` no longer renders `DitherCanvas` by default. +- 84 unused modules are removed, `ui/command` is removed, `ActivityBlock` is renamed `ReasoningBlock` (removed in 0.4; use `Reasoning`, see [Upgrading from 0.3](#upgrading-from-03)), and `AuthLayout` no longer renders `DitherCanvas` by default. - With neither `.dark` nor `.light` on the root, the theme follows the OS. Hosts that toggle `.dark` must add `.light` for an explicit light choice, or mount `ThemeProvider`. ## License diff --git a/e2e/approval-card.spec.ts b/e2e/approval-card.spec.ts deleted file mode 100644 index e6eff737..00000000 --- a/e2e/approval-card.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("approve and reject each fire their callback once", async ({ page }) => { - await page.goto("/approval-card"); - await page.getByRole("button", { name: "Approve" }).click(); - await page.getByRole("button", { name: "Reject" }).click(); - await expect(page.getByRole("status", { name: "Events" })).toHaveText( - "approve,reject", - ); -}); - -test("a non-idle state disables both buttons", async ({ page }) => { - await page.goto("/approval-card-busy"); - const approve = page.getByRole("button", { name: "Approving…" }); - const reject = page.getByRole("button", { name: "Reject" }); - await expect(approve).toBeDisabled(); - await expect(reject).toBeDisabled(); - await approve.click({ force: true }); - await reject.click({ force: true }); - await expect(page.getByRole("status", { name: "Events" })).toHaveText(""); -}); diff --git a/e2e/chat-thread.spec.ts b/e2e/chat-thread.spec.ts deleted file mode 100644 index 508a5c20..00000000 --- a/e2e/chat-thread.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test } from "@playwright/test"; - -test("an agent turn renders its reasoning and tool parts, each expanding on click", async ({ - page, -}) => { - await page.goto("/chat-thread"); - const turn = page.getByRole("article", { name: "Scout replied" }); - await expect(turn.getByText("Three commits landed today.")).toBeVisible(); - - const reasoning = turn.locator("details"); - await expect(reasoning).not.toHaveAttribute("open"); - await reasoning.locator("summary").click(); - await expect(reasoning).toHaveAttribute("open"); - await expect( - reasoning.getByText("Check the commit log first."), - ).toBeVisible(); - - const tool = turn.getByRole("button", { name: /Read today's commits/ }); - await expect(tool).toHaveAttribute("aria-expanded", "false"); - await tool.click(); - await expect(tool).toHaveAttribute("aria-expanded", "true"); - await expect(turn.getByText("3 commits")).toBeVisible(); -}); diff --git a/e2e/fixtures.tsx b/e2e/fixtures.tsx index 7e41c2bd..9a909d10 100644 --- a/e2e/fixtures.tsx +++ b/e2e/fixtures.tsx @@ -2,10 +2,8 @@ import { useEffect, useLayoutEffect, useRef, useState, Profiler } from "react"; import type { ReactNode } from "react"; import { - ApprovalCard, AuthLayout, Button, - ChatThread, Conversation, ConversationContent, ConversationEmptyState, @@ -18,8 +16,6 @@ import { ThemeProvider, ThemeToggle, ThinkingMark, - type ApprovalRequest, - type ChatMessage, type ConversationScrollPolicy, useConversation, PromptInput, @@ -98,13 +94,6 @@ import { DialogTitle, } from "@corbits/react-ui/ui/dialog"; -const REQUEST: ApprovalRequest = { - id: "req-1", - headline: "Post to #sales (Slack)", - requestedBy: "Scout", - details: [{ label: "Message", value: "Q3 numbers are in." }], -}; - const GROUPS: CommandPaletteGroup[] = [ { id: "pages", @@ -126,21 +115,6 @@ function useEventLog() { }; } -function Approval({ state }: { state: "idle" | "approving" }) { - const { log, record } = useEventLog(); - return ( - <> - record("approve")} - onReject={() => record("reject")} - /> - {log} - - ); -} - function Palette() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); @@ -164,32 +138,6 @@ function Palette() { ); } -const CHAT: ChatMessage[] = [ - { - id: "m1", - role: "user", - createdAt: "2026-01-01T09:00:00Z", - parts: [{ type: "text", text: "What changed today?" }], - }, - { - id: "m2", - role: "agent", - createdAt: "2026-01-01T09:00:04Z", - parts: [ - { type: "reasoning", text: "Check the commit log first." }, - { - type: "tool", - toolCallId: "t1", - toolName: "git__log", - label: "Read today's commits", - state: "done", - output: "3 commits", - }, - { type: "text", text: "Three commits landed today." }, - ], - }, -]; - function SelectField() { const [value, setValue] = useState("a"); const { log, record } = useEventLog(); @@ -1692,14 +1640,7 @@ function ConversationThreadScenario() { export const scenarios: Record ReactNode> = { smoke: () => , - "approval-card": () => , - "approval-card-busy": () => , "command-palette": () => , - "chat-thread": () => ( -
- -
- ), select: () => , "filter-chip": () => , "dictation-denied": () => , diff --git a/src/hooks/use-attachments.test.ts b/src/hooks/use-attachments.test.ts index 15a9a47d..da58b3c0 100644 --- a/src/hooks/use-attachments.test.ts +++ b/src/hooks/use-attachments.test.ts @@ -11,7 +11,7 @@ import { } from "react"; import { createRoot } from "react-dom/client"; -import type { PartFile } from "../lib/chat-parts.js"; +import type { PartFile } from "../lib/conversation.js"; import type { AttachmentData } from "../ui/attachments.js"; import { useAttachments, diff --git a/src/hooks/use-controllable-state.ts b/src/hooks/use-controllable-state.ts index 8d033e88..c98cb36f 100644 --- a/src/hooks/use-controllable-state.ts +++ b/src/hooks/use-controllable-state.ts @@ -18,9 +18,8 @@ const isProduction = /** * The controlled-or-uncontrolled resolution every prop pair shaped like * `open`/`onOpenChange` needs: track state internally, but defer to `value` - * whenever the caller supplies one. `useDismissablePopover`, `ToolBlock` and - * `ToolNarrative` each want exactly this and previously each wrote their own - * ternary for it; this is the one place it lives now. + * whenever the caller supplies one. Every such pair resolves through here + * rather than writing its own ternary. * * The returned setter accepts a value or a React-style updater * (`setOpen((prev) => !prev)`), same as `useState`. diff --git a/src/hooks/use-delayed-autofocus.ts b/src/hooks/use-delayed-autofocus.ts deleted file mode 100644 index 3c333823..00000000 --- a/src/hooks/use-delayed-autofocus.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, type RefObject } from "react"; - -import { CHAT_DOCK_AUTOFOCUS_DELAY_MS } from "../ui/chat-dock-timing.js"; - -/** - * Focuses the given element shortly after `active` turns true — the composer - * wants to autofocus once the dock has finished popping open, not while it is - * still mid-animation, so the delay is deliberate rather than a magic number. - * `delayMs` defaults to `CHAT_DOCK_AUTOFOCUS_DELAY_MS`, which is pinned `>=` - * the dock's own entrance duration so focus never lands mid-animation. - */ -export function useDelayedAutofocus( - ref: RefObject, - active: boolean, - delayMs = CHAT_DOCK_AUTOFOCUS_DELAY_MS, -): void { - useEffect(() => { - if (!active) return; - const timer = window.setTimeout(() => ref.current?.focus(), delayMs); - return () => window.clearTimeout(timer); - // `ref` is intentionally excluded: it is a stable container, and including - // it would refocus on every render that happens to produce a new object. - }, [active, delayMs]); -} diff --git a/src/index.ts b/src/index.ts index e371e27d..85c981e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,6 @@ export * from "./blocks/login/auth-layout.js"; export * from "./blocks/login/login-form.js"; export * from "./hooks/use-command-palette-navigation.js"; export * from "./hooks/use-controllable-state.js"; -export * from "./hooks/use-delayed-autofocus.js"; export * from "./hooks/use-dismissable-popover.js"; export * from "./hooks/use-list-selection.js"; export * from "./hooks/use-prefers-reduced-motion.js"; @@ -32,8 +31,6 @@ export * from "./hooks/use-file-drop.js"; export * from "./hooks/use-media-viewer.js"; export * from "./lib/artifact.js"; export * from "./lib/chart-palette.js"; -export * from "./lib/chat-message.js"; -export * from "./lib/chat-parts.js"; export * from "./lib/metrics.js"; export * from "./lib/now-item.js"; export * from "./lib/relative-time.js"; @@ -47,9 +44,7 @@ export * from "./lib/conversation-activity.js"; export * from "./lib/media-kind.js"; export * from "./lib/attachment-limits.js"; export * from "./lib/prompt-tokens.js"; -export * from "./ui/agent-turn.js"; export * from "./ui/animated-number.js"; -export * from "./ui/approval-card.js"; export * from "./ui/artifact-body.js"; export * from "./ui/artifact-notice.js"; export * from "./ui/avatar.js"; @@ -62,10 +57,6 @@ export * from "./ui/button.js"; export * from "./ui/card.js"; export * from "./ui/category-bars.js"; export * from "./ui/chart-frame.js"; -export * from "./ui/chat-composer.js"; -export * from "./ui/chat-input.js"; -export * from "./ui/chat-panel.js"; -export * from "./ui/chat-thread.js"; export * from "./ui/checkbox.js"; export * from "./ui/compare-body.js"; export * from "./ui/confirm-button.js"; @@ -88,19 +79,15 @@ export * from "./ui/library-page-header.js"; export * from "./ui/list-detail.js"; export * from "./ui/live-run-banner.js"; export * from "./ui/live-status-line.js"; -export * from "./ui/message-bubble.js"; export * from "./ui/mic-button.js"; export * from "./ui/notifications-bell.js"; export * from "./ui/now-cards.js"; export * from "./ui/page-panel.js"; export * from "./ui/page-shell.js"; -export * from "./ui/parts-renderer.js"; export * from "./ui/profile-card.js"; export * from "./ui/progress-checklist.js"; export * from "./ui/provider-mark.js"; -export * from "./ui/quick-reply-chips.js"; export * from "./ui/quote-card.js"; -export * from "./ui/reasoning-block.js"; export * from "./ui/research-body.js"; export * from "./ui/rich-empty-state.js"; export * from "./ui/run-now-button.js"; @@ -129,8 +116,6 @@ export * from "./ui/thinking-label.js"; export * from "./ui/thinking-mark.js"; export * from "./ui/time-series-chart.js"; export * from "./ui/token-mosaic.js"; -export * from "./ui/tool-block.js"; -export * from "./ui/tool-narrative.js"; export * from "./ui/tool-picker.js"; export * from "./ui/top-bar.js"; export * from "./ui/trace-waterfall.js"; diff --git a/src/lib/chat-message.ts b/src/lib/chat-message.ts deleted file mode 100644 index 0e40c931..00000000 --- a/src/lib/chat-message.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * The conversation data model. Agent-agnostic on purpose: nothing here names a - * particular assistant, and the identity shown in the header is a prop. - * - * A message is an ordered list of parts rather than a string, because a single - * agent reply genuinely interleaves kinds — think, call a tool, think again, - * answer. Flattening that to `text` plus a `toolCalls` array loses the order, - * and the order is the only thing that makes a transcript readable. - */ - -export type ChatRole = "user" | "agent" | "system"; - -/** Delivery state of a message the user sent. Absent on received messages. */ -export type MessageStatus = "sending" | "sent" | "failed"; - -export type TextPart = { - readonly type: "text"; - readonly text: string; -}; - -/** Thinking text. One cumulative string per turn — there are no segment boundaries. */ -export type ReasoningPart = { - readonly type: "reasoning"; - readonly text: string; -}; - -/** - * Tool lifecycle, restricted to states a caller can actually distinguish: - * running, finished, or finished badly. There is no "input streaming" state - * because a surface cannot render one usefully. - */ -export type ToolPartState = "running" | "done" | "error"; - -export type ToolPart = { - readonly type: "tool"; - readonly toolCallId: string; - /** Raw tool identifier, e.g. `slack__post_message`. */ - readonly toolName: string; - readonly state: ToolPartState; - /** Human phrasing for what the tool did. Falls back to `toolName` when absent. */ - readonly label?: string; - readonly input?: Readonly>; - /** Result or failure text, already stringified by the host. */ - readonly output?: string; -}; - -export type FilePart = { - readonly type: "file"; - readonly name: string; - readonly mediaType: string; - /** Resolved by the host — this model never fetches bytes. */ - readonly url?: string; -}; - -export type MessagePart = TextPart | ReasoningPart | ToolPart | FilePart; - -export type ChatMessage = { - readonly id: string; - readonly role: ChatRole; - readonly parts: readonly MessagePart[]; - /** ISO timestamp. */ - readonly createdAt: string; - readonly status?: MessageStatus; -}; - -/** A tappable suggestion the agent offered. */ -export type QuickReply = { - readonly id: string; - readonly label: string; - /** Sent instead of the label when present. */ - readonly value?: string; -}; - -/** Who the user is talking to. The host names its own agent. */ -export type AgentIdentity = { - readonly name: string; - readonly tagline?: string; - /** Two or three characters for the avatar. Derived from `name` when absent. */ - readonly initials?: string; -}; - -/** - * Work the agent handed to a helper of its own. - * - * The same four states as a tool call plus `queued`, because a delegated task - * that has not started yet is a thing the user can see and a tool call is not. - * `tools` is the helper's own calls, so a subagent's detail is the same - * narrative the main transcript already renders rather than a second format. - */ -export type SubagentState = "queued" | "running" | "done" | "error"; - -export type SubagentRun = { - readonly id: string; - /** What this helper is, e.g. "Researcher". Named by the host, not the registry. */ - readonly name: string; - /** The one-line instruction it was given. */ - readonly task?: string; - readonly state: SubagentState; - /** ISO timestamp. Absent while queued. */ - readonly startedAt?: string; - readonly tools?: readonly ToolPart[]; - /** Its answer, once it has one. */ - readonly result?: string; -}; - -/** One conversation in the thread list. */ -export type ChatThreadSummary = { - readonly id: string; - readonly title: string; - /** ISO timestamp of the most recent message. */ - readonly updatedAt: string; - readonly preview?: string; -}; - -/** The answer text: every text part joined, thinking and tools excluded. */ -export function messageText(message: ChatMessage): string { - return message.parts - .filter((part): part is TextPart => part.type === "text") - .map((part) => part.text) - .join(""); -} - -/** The turn's thinking, if the agent exposed any. */ -export function reasoningText(message: ChatMessage): string { - return message.parts - .filter((part): part is ReasoningPart => part.type === "reasoning") - .map((part) => part.text) - .join(""); -} - -export function toolParts(message: ChatMessage): readonly ToolPart[] { - return message.parts.filter((part): part is ToolPart => part.type === "tool"); -} - -/** True while any tool in the turn is still running — drives the live marker. */ -export function isTurnWorking(message: ChatMessage): boolean { - return toolParts(message).some((part) => part.state === "running"); -} - -/** - * A readable name for a tool id. `slack__post_message` → "Post message - * (Slack)"; `mail_send` → "Mail send". Never shows a raw snake_case id, and - * never invents a label for a tool it does not recognise — it only reformats - * what it was given, so a new tool reads correctly with no table to update. - */ -export function toolLabel(part: ToolPart): string { - if (part.label !== undefined && part.label.length > 0) return part.label; - - const [provider, ...rest] = part.toolName.split("__"); - const humanise = (value: string) => { - const words = value.replace(/[_-]+/g, " ").trim(); - return words.charAt(0).toUpperCase() + words.slice(1); - }; - - if (rest.length > 0 && provider !== undefined) { - return `${humanise(rest.join("__"))} (${humanise(provider)})`; - } - return humanise(part.toolName); -} - -/** Avatar initials from a display name, capped at two characters. */ -export function agentInitials(identity: AgentIdentity): string { - if (identity.initials !== undefined) return identity.initials; - return ( - identity.name - .split(/\s+/) - .map((word) => word.charAt(0)) - .join("") - .slice(0, 2) - .toUpperCase() || "··" - ); -} diff --git a/src/lib/chat-parts.ts b/src/lib/chat-parts.ts deleted file mode 100644 index 4d72e9b1..00000000 --- a/src/lib/chat-parts.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * The structural shape of a message's content: an ordered list of `Part`s - * rather than a string, because a single turn genuinely interleaves kinds — - * think, call a tool, emit a generative block, answer. - * - * This mirrors the wire shape a host's own parsed/validated part type would - * take (text, reasoning, tool-trace, block, file, event) but carries no - * validation of its own — parsing untrusted data into this shape is the - * host's job, at its own trust boundary, with whatever schema library it - * already uses. This package only renders a `Part[]` it is handed. - * - * Members are named `Part*` rather than the bare `TextPart` / `FilePart` a - * host might reach for on its own, because `lib/chat-message.ts` already - * publishes those names for its own, simpler message model — the two are - * deliberately separate types. - */ - -export type PartText = { - readonly kind: "text"; - readonly text: string; -}; - -/** Thinking text, plus how long the agent spent producing it. */ -export type PartReasoning = { - readonly kind: "reasoning"; - readonly text: string; - /** Wall-clock milliseconds spent on this reasoning segment, once known. */ - readonly durationMs?: number; -}; - -/** - * A tool call's lifecycle. Six states, not three: a tool call is not only - * running-or-finished, it can wait on human approval and that approval can be - * refused — a state distinct from an execution error. See `ToolBlockState` - * (`ui/tool-block.tsx`) for the matching render-side union. - */ -export type ToolTraceStatus = - | "pending" - | "running" - | "output-available" - | "error" - | "approval-requested" - | "output-denied"; - -export type PartToolTrace = { - readonly kind: "tool-trace"; - readonly toolCallId: string; - readonly name: string; - readonly status: ToolTraceStatus; - readonly input?: unknown; - readonly output?: unknown; -}; - -/** A generative-UI block. `data` is opaque here — a host-specific registry parses it. */ -export type PartBlock = { - readonly kind: "block"; - readonly block: { - readonly type: string; - readonly data: unknown; - }; -}; - -export type PartFile = { - readonly kind: "file"; - readonly name: string; - readonly mediaType: string; - /** Resolved by the host — this model never fetches bytes. */ - readonly url?: string; -}; - -/** A system/lifecycle line, e.g. "agent joined". `data` carries whatever the event needs. */ -export type PartEvent = { - readonly kind: "event"; - readonly event: string; - readonly data?: unknown; -}; - -export type Part = - | PartText - | PartReasoning - | PartToolTrace - | PartBlock - | PartFile - | PartEvent; diff --git a/src/lib/conversation-activity.test.ts b/src/lib/conversation-activity.test.ts index ca3cb153..18d991b1 100644 --- a/src/lib/conversation-activity.test.ts +++ b/src/lib/conversation-activity.test.ts @@ -1,8 +1,9 @@ import { describe, expect, test } from "bun:test"; -import type { PartReasoning, PartText } from "./chat-parts.js"; import type { ConversationPart, + PartReasoning, + PartText, PartTool, PartToolBatch, ToolApprovalRecord, diff --git a/src/lib/conversation-activity.ts b/src/lib/conversation-activity.ts index a066d6ed..d63f8aa5 100644 --- a/src/lib/conversation-activity.ts +++ b/src/lib/conversation-activity.ts @@ -1,6 +1,6 @@ -import type { PartReasoning } from "./chat-parts.js"; import type { ConversationPart, + PartReasoning, PartTool, PartToolBatch, } from "./conversation.js"; diff --git a/src/lib/conversation.ts b/src/lib/conversation.ts index 08132f74..2165c98c 100644 --- a/src/lib/conversation.ts +++ b/src/lib/conversation.ts @@ -3,24 +3,83 @@ * message holds, and how a tool call that waited on a person records that * wait. * - * A message is an ordered list of parts. The text, reasoning, block, file and - * event members are the ones `lib/chat-parts.ts` already defines; this module - * adds what a conversation needs beyond them: a tool call carrying its - * approval record, a batch of calls that ran at the same time, generated - * images and sources. + * A message is an ordered list of parts rather than a string, because a + * single turn genuinely interleaves kinds: think, call a tool, emit a + * generative block, answer. Beyond text, reasoning, tool calls, blocks, files + * and events, a conversation part can be a tool call carrying its approval + * record, a batch of calls that ran at the same time, generated images or + * sources. * * Types only. Parsing untrusted data into this shape is the host's job, at its - * own trust boundary. + * own trust boundary, with whatever schema library it already uses. */ -import type { - PartBlock, - PartEvent, - PartFile, - PartReasoning, - PartText, - PartToolTrace, -} from "./chat-parts.js"; +export type PartText = { + readonly kind: "text"; + readonly text: string; +}; + +/** Thinking text, plus how long the agent spent producing it. */ +export type PartReasoning = { + readonly kind: "reasoning"; + readonly text: string; + /** Wall-clock milliseconds spent on this reasoning segment, once known. */ + readonly durationMs?: number; +}; + +/** + * A tool call's lifecycle. Six states, not three: a tool call is not only + * running-or-finished, it can wait on human approval and that approval can be + * refused — a state distinct from an execution error. + */ +export type ToolTraceStatus = + | "pending" + | "running" + | "output-available" + | "error" + | "approval-requested" + | "output-denied"; + +export type PartToolTrace = { + readonly kind: "tool-trace"; + readonly toolCallId: string; + readonly name: string; + readonly status: ToolTraceStatus; + readonly input?: unknown; + readonly output?: unknown; +}; + +/** A generative-UI block. `data` is opaque here — a host-specific registry parses it. */ +export type PartBlock = { + readonly kind: "block"; + readonly block: { + readonly type: string; + readonly data: unknown; + }; +}; + +export type PartFile = { + readonly kind: "file"; + readonly name: string; + readonly mediaType: string; + /** Resolved by the host — this model never fetches bytes. */ + readonly url?: string; +}; + +/** A system/lifecycle line, e.g. "agent joined". `data` carries whatever the event needs. */ +export type PartEvent = { + readonly kind: "event"; + readonly event: string; + readonly data?: unknown; +}; + +export type Part = + | PartText + | PartReasoning + | PartToolTrace + | PartBlock + | PartFile + | PartEvent; /** A person or an agent taking part in a conversation. */ export type ConversationParticipant = { diff --git a/src/lib/tool-display.ts b/src/lib/tool-display.ts index 164da3dd..bf9bf7a3 100644 --- a/src/lib/tool-display.ts +++ b/src/lib/tool-display.ts @@ -1,9 +1,8 @@ -import type { ToolTraceStatus } from "./chat-parts.js"; import { isAwaitingApproval, type ActivityStep, } from "./conversation-activity.js"; -import type { PartTool } from "./conversation.js"; +import type { PartTool, ToolTraceStatus } from "./conversation.js"; /** * The execution status a tool call shows, with its approval record taken diff --git a/src/theme.css b/src/theme.css index ebf0bebb..a1b9d7de 100644 --- a/src/theme.css +++ b/src/theme.css @@ -485,21 +485,6 @@ } } -/* The block swap: a small slide-and-fade for whichever block just became - active, including the empty-state's own transition into the first block. - Keyed by block id so React remounts a fresh node per swap and this plays - every time. Used by ui/parts-renderer and ui/tool-block. */ -@keyframes corbits-rail-block-in { - from { - opacity: 0; - transform: translateY(6px) scale(0.98); - } - to { - opacity: 1; - transform: translateY(0) scale(1); - } -} - /* The typing indicator's three dots. Each dot offsets its own animation-delay, so the bounce travels left to right. Used by ui/typing-indicator. */ diff --git a/src/ui/activity.test.tsx b/src/ui/activity.test.tsx index fd6e1c59..86f0ebd8 100644 --- a/src/ui/activity.test.tsx +++ b/src/ui/activity.test.tsx @@ -2,9 +2,8 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { describe, expect, test } from "bun:test"; -import type { PartReasoning } from "../lib/chat-parts.js"; import type { ActivityStep } from "../lib/conversation-activity.js"; -import type { PartTool } from "../lib/conversation.js"; +import type { PartReasoning, PartTool } from "../lib/conversation.js"; import { Activity, ActivityContent, ActivityTrigger } from "./activity.js"; import { Reasoning, ReasoningContent, ReasoningTrigger } from "./reasoning.js"; diff --git a/src/ui/agent-turn.tsx b/src/ui/agent-turn.tsx deleted file mode 100644 index 9d60cdf3..00000000 --- a/src/ui/agent-turn.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import type { ReactNode } from "react"; - -import { - agentInitials, - isTurnWorking, - messageText, - reasoningText, - toolParts, - type AgentIdentity, - type ChatMessage, -} from "../lib/chat-message.js"; -import { cn } from "../lib/utils.js"; -import { MessageBubble } from "./message-bubble.js"; -import { ReasoningBlock } from "./reasoning-block.js"; -import { ToolNarrative } from "./tool-narrative.js"; - -export type AgentTurnProps = { - readonly message: ChatMessage; - readonly identity: AgentIdentity; - /** Rich body for the answer — a markdown renderer. */ - readonly children?: ReactNode; - readonly now?: number | undefined; - /** e.g. "Thought for 4s". Shown on the reasoning block once it stops streaming. */ - readonly reasoningDurationLabel?: string; - readonly className?: string; -}; - -/** - * One agent reply, in the order it happened: what it thought, what it did, what - * it said. - * - * Parts are rendered in the order the model produced them rather than grouped - * by kind — a turn that thinks, calls a tool, thinks again and then answers - * reads as a sequence, and sorting it into "all thinking / all tools / answer" - * describes a turn that never happened. - * - * The avatar is `aria-hidden` and the turn is a labelled region instead: a - * screen reader should hear "Ada said …" once, not two initials followed by the - * name. - */ -export function AgentTurn({ - message, - identity, - children, - now, - reasoningDurationLabel, - className, -}: AgentTurnProps) { - const working = isTurnWorking(message); - const answer = messageText(message); - const thinking = reasoningText(message); - const tools = toolParts(message); - const hasWork = thinking.length > 0 || tools.length > 0; - - return ( -
- - {agentInitials(identity)} - - -
- {hasWork ? ( -
- - {tools.map((part) => ( - - ))} -
- ) : null} - - {answer.length > 0 || children !== undefined ? ( - - {children} - - ) : null} -
-
- ); -} diff --git a/src/ui/approval-card.tsx b/src/ui/approval-card.tsx deleted file mode 100644 index fd4ddf83..00000000 --- a/src/ui/approval-card.tsx +++ /dev/null @@ -1,163 +0,0 @@ -"use client"; - -import { useState } from "react"; -import type { ReactNode } from "react"; - -import { cn } from "../lib/utils.js"; -import { Badge } from "./badge.js"; -import { Button } from "./button.js"; - -/** One argument of the action being approved, already resolved for display. */ -export type ApprovalDetail = { - readonly label: string; - readonly value: string; -}; - -export type ApprovalRequest = { - readonly id: string; - /** What will happen if approved, in plain language: "Post to #sales (Slack)". */ - readonly headline: string; - /** Who or what is asking. */ - readonly requestedBy: string; - readonly details?: readonly ApprovalDetail[]; - /** - * A stable name for the *class* of action, so "always allow" can mean - * something. Without it the always-allow control is not offered at all. - */ - readonly actionKey?: string; -}; - -export type ApprovalState = - | "idle" - | "approving" - | "rejecting" - | "allowing-always"; - -export type ApprovalCardProps = { - readonly request: ApprovalRequest; - readonly onApprove: () => void; - readonly onReject: () => void; - /** Durably allow this action class. Omit to hide the control entirely. */ - readonly onAllowAlways?: (actionKey: string) => void; - readonly state?: ApprovalState; - readonly error?: string | null; - readonly className?: string; - readonly children?: ReactNode; -}; - -const LONG_VALUE = 140; - -/** - * The human decision surface: something is blocked until a person says yes or - * no, and this is where they say it. - * - * Approve is the primary and reject is an outline button — not because approval - * is the expected answer, but because a destructive-styled reject would read as - * the dangerous option when it is the *safe* one. Neither is pre-focused, and - * there is no default: a gate whose buttons can be dismissed by a stray Enter - * is not a gate. - * - * Every argument the action will run with is shown. A card that hides them - * behind "details" is asking for approval of something the user cannot see, so - * long values clamp rather than disappear. - */ -export function ApprovalCard({ - request, - onApprove, - onReject, - onAllowAlways, - state = "idle", - error = null, - className, - children, -}: ApprovalCardProps) { - const busy = state !== "idle"; - const canAllowAlways = - onAllowAlways !== undefined && request.actionKey !== undefined; - - return ( -
-
- Needs your approval - - Requested by {request.requestedBy} - -
- -

{request.headline}

- - {request.details === undefined || request.details.length === 0 ? null : ( -
- {request.details.map((detail) => ( - - ))} -
- )} - - {children} - - {error === null ? null : ( -

- {error} -

- )} - -
- - - {canAllowAlways ? ( - - ) : null} -
-
- ); -} - -function ApprovalDetailRow({ detail }: { detail: ApprovalDetail }) { - const [expanded, setExpanded] = useState(false); - const long = detail.value.length > LONG_VALUE; - - return ( -
-
{detail.label}
-
- - {detail.value} - - {long ? ( - - ) : null} -
-
- ); -} diff --git a/src/ui/chat-composer.tsx b/src/ui/chat-composer.tsx deleted file mode 100644 index fb278927..00000000 --- a/src/ui/chat-composer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { useRef } from "react"; - -import { useDelayedAutofocus } from "../hooks/use-delayed-autofocus.js"; -import type { QuickReply } from "../lib/chat-message.js"; -import { cn } from "../lib/utils.js"; -import { ChatInput, type ChatInputProps } from "./chat-input.js"; -import { QuickReplyChips } from "./quick-reply-chips.js"; - -export type ChatComposerProps = Omit< - ChatInputProps, - "working" | "disabled" | "textareaRef" -> & { - /** Suggestion chips shown above the input while the conversation is empty. */ - readonly suggestions?: readonly QuickReply[]; - readonly onSuggestionSelect?: (reply: QuickReply) => void; - /** True while a response is in flight — disables the input, same signal `ChatInput` calls `working`. */ - readonly busy?: boolean; - /** - * Flips true→false or false→true to (re)trigger the autofocus timer — pass - * the dock's `mode !== "closed"` so it fires once per open, after the - * panel's own entrance animation has settled (see `chat-dock-timing.ts`). - */ - readonly autoFocusOn?: boolean; - readonly className?: string; -}; - -/** - * The composer: suggestion chips above an empty conversation, otherwise just - * the input. Chips and input are mutually exclusive rather than stacked - * always-on, because a suggestion list next to ten prior turns is not a - * suggestion anymore — it is clutter competing with what the user already - * said. - */ -export function ChatComposer({ - suggestions = [], - onSuggestionSelect, - busy = false, - autoFocusOn = false, - className, - ...inputProps -}: ChatComposerProps) { - const textareaRef = useRef(null); - useDelayedAutofocus(textareaRef, autoFocusOn); - - const showSuggestions = - suggestions.length > 0 && onSuggestionSelect !== undefined; - - return ( -
- {showSuggestions ? ( - - ) : null} - -
- ); -} diff --git a/src/ui/chat-dock-timing.ts b/src/ui/chat-dock-timing.ts deleted file mode 100644 index 7abbe00a..00000000 --- a/src/ui/chat-dock-timing.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * The dock's one source of truth for motion timing. Every duration that has - * to line up with another — the entrance pop, the scrim fade, the autofocus - * delay — lives here so fixing one never means hunting the others down. - */ - -/** How long the closed→docked entrance pop takes, in milliseconds. */ -export const CHAT_DOCK_ENTRANCE_MS = 280; - -/** The scrim's own fade, coordinated to finish alongside the entrance. */ -export const CHAT_DOCK_SCRIM_MS = CHAT_DOCK_ENTRANCE_MS; - -/** - * The composer's autofocus delay. Must be `>=` the entrance duration — focus - * landing before the panel has finished animating steals the caret out from - * under a still-moving element. - */ -export const CHAT_DOCK_AUTOFOCUS_DELAY_MS = CHAT_DOCK_ENTRANCE_MS; diff --git a/src/ui/chat-input.tsx b/src/ui/chat-input.tsx deleted file mode 100644 index 0fad33f6..00000000 --- a/src/ui/chat-input.tsx +++ /dev/null @@ -1,181 +0,0 @@ -"use client"; - -import { ArrowUp, Paperclip, Square, X } from "lucide-react"; -import { useEffect, useRef, type RefObject } from "react"; - -import { cn } from "../lib/utils.js"; - -export type ChatAttachment = { - readonly id: string; - readonly name: string; -}; - -export type ChatInputProps = { - readonly value: string; - readonly onValueChange: (value: string) => void; - readonly onSend: () => void; - /** Shown while the agent is replying. Its presence turns send into stop. */ - readonly onStop?: () => void; - readonly working?: boolean; - readonly placeholder?: string; - readonly disabled?: boolean; - readonly attachments?: readonly ChatAttachment[]; - readonly onAttach?: (files: FileList) => void; - readonly onRemoveAttachment?: (attachment: ChatAttachment) => void; - /** Exposes the textarea node — a host that autofocuses on open needs something to focus. */ - readonly textareaRef?: RefObject; - readonly className?: string; -}; - -const MAX_ROWS_PX = 200; - -/** - * The composer. - * - * Enter sends and Shift+Enter breaks the line — the convention every chat app - * shares, and breaking it costs users a message. IME composition is excluded: - * mid-composition Enter commits a candidate in Japanese and Chinese input, and - * treating that as "send" fires a half-typed message. - * - * While the agent is replying, the send button becomes stop. Two buttons would - * mean one is always dead, and a stop control that appears somewhere else is one - * the user has to hunt for at the moment they most want it. - * - * 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. - */ -export function ChatInput({ - value, - onValueChange, - onSend, - onStop, - working = false, - placeholder = "Send a message…", - disabled = false, - attachments = [], - onAttach, - onRemoveAttachment, - textareaRef: externalTextareaRef, - 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 ( -
{ - event.preventDefault(); - submit(); - }} - > - {attachments.length === 0 ? null : ( -
    - {attachments.map((attachment) => ( -
  • - {attachment.name} - {onRemoveAttachment === undefined ? null : ( - - )} -
  • - ))} -
- )} - -
- {onAttach === undefined ? null : ( - <> - { - if (event.target.files !== null) onAttach(event.target.files); - event.target.value = ""; - }} - /> - - - )} - -