From 72f67e6e18f4d39e5890658f0e2a877fdd581dd7 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 17:36:32 +0530 Subject: [PATCH 1/6] feat(chat)!: remove the old chat components There is now one way to build chat UI: the Conversation family. Remove ChatThread, ChatComposer, ChatInput, ChatPanel, MessageBubble, ApprovalCard, ToolBlock, ToolNarrative, ReasoningBlock and the modules built only on them, with their stories, tests and browser specs. The part types move into the conversation module unchanged, so their names stay stable. ToolProviderMark takes a tool name instead of a part. README gains "Upgrading from 0.3", which maps every removed export to what replaced it. --- .github/workflows/ci.yml | 2 +- README.md | 206 ++++++++++--- e2e/approval-card.spec.ts | 21 -- e2e/chat-thread.spec.ts | 23 -- e2e/fixtures.tsx | 59 ---- src/hooks/use-attachments.test.ts | 2 +- src/hooks/use-controllable-state.ts | 5 +- src/hooks/use-delayed-autofocus.ts | 17 +- src/index.ts | 14 - src/lib/chat-message.ts | 172 ----------- src/lib/chat-parts.ts | 84 ----- src/lib/conversation-activity.test.ts | 3 +- src/lib/conversation-activity.ts | 2 +- src/lib/conversation.ts | 87 +++++- src/lib/tool-display.ts | 3 +- src/theme.css | 15 - src/ui/activity.test.tsx | 3 +- src/ui/agent-turn.tsx | 90 ------ src/ui/approval-card.tsx | 163 ---------- src/ui/chat-composer.tsx | 63 ---- src/ui/chat-dock-timing.ts | 18 -- src/ui/chat-input.tsx | 181 ----------- src/ui/chat-panel.tsx | 84 ----- src/ui/chat-thread.tsx | 111 ------- src/ui/message-bubble.tsx | 100 ------ src/ui/parts-renderer.test.tsx | 175 ----------- src/ui/parts-renderer.tsx | 288 ------------------ src/ui/provider-mark.tsx | 23 +- src/ui/quick-reply-chips.tsx | 43 --- src/ui/reasoning-block.test.tsx | 177 ----------- src/ui/reasoning-block.tsx | 110 ------- src/ui/reasoning.tsx | 2 +- src/ui/textarea.tsx | 4 +- src/ui/tool-block.test.tsx | 169 ---------- src/ui/tool-block.tsx | 235 -------------- src/ui/tool-narrative.tsx | 113 ------- src/ui/tool.test.tsx | 7 +- src/ui/tool.tsx | 3 +- stories/chat/activity.stories.tsx | 2 +- stories/chat/attachments.stories.tsx | 2 +- stories/chat/full-chat.stories.tsx | 2 +- stories/chat/parts-renderer.stories.tsx | 145 --------- stories/chat/tool-block.stories.tsx | 87 ------ stories/chat/tool.stories.tsx | 2 +- .../primitives/reasoning-block.stories.tsx | 19 -- 45 files changed, 277 insertions(+), 2859 deletions(-) delete mode 100644 e2e/approval-card.spec.ts delete mode 100644 e2e/chat-thread.spec.ts delete mode 100644 src/lib/chat-message.ts delete mode 100644 src/lib/chat-parts.ts delete mode 100644 src/ui/agent-turn.tsx delete mode 100644 src/ui/approval-card.tsx delete mode 100644 src/ui/chat-composer.tsx delete mode 100644 src/ui/chat-dock-timing.ts delete mode 100644 src/ui/chat-input.tsx delete mode 100644 src/ui/chat-panel.tsx delete mode 100644 src/ui/chat-thread.tsx delete mode 100644 src/ui/message-bubble.tsx delete mode 100644 src/ui/parts-renderer.test.tsx delete mode 100644 src/ui/parts-renderer.tsx delete mode 100644 src/ui/quick-reply-chips.tsx delete mode 100644 src/ui/reasoning-block.test.tsx delete mode 100644 src/ui/reasoning-block.tsx delete mode 100644 src/ui/tool-block.test.tsx delete mode 100644 src/ui/tool-block.tsx delete mode 100644 src/ui/tool-narrative.tsx delete mode 100644 stories/chat/parts-renderer.stories.tsx delete mode 100644 stories/chat/tool-block.stories.tsx delete mode 100644 stories/primitives/reasoning-block.stories.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49080042..7e1a225f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,7 @@ 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); })' + node -e 'import("@corbits/react-ui").then((m) => { for (const n of ["Button", "Conversation", "Badge"]) if (typeof m[n] !== "function") throw new Error("missing export: " + n); })' policy-check: if: github.event_name == 'pull_request' diff --git a/README.md b/README.md index 561ddb0d..c6b4c857 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,8 +324,51 @@ 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. +- `ToolProviderMark` takes the raw tool id as `toolName` instead of a `part` (``); its props are `ToolProviderMarkProps`. +- `useDelayedAutofocus` is unchanged. + +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 | ## Upgrading from 0.1 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 index 3c333823..c373a0e4 100644 --- a/src/hooks/use-delayed-autofocus.ts +++ b/src/hooks/use-delayed-autofocus.ts @@ -1,18 +1,21 @@ import { useEffect, type RefObject } from "react"; -import { CHAT_DOCK_AUTOFOCUS_DELAY_MS } from "../ui/chat-dock-timing.js"; +/** + * Roughly a typical panel entrance (a few hundred milliseconds), so focus + * never lands on an element that is still moving. + */ +const DEFAULT_DELAY_MS = 280; /** - * 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. + * Focuses the given element shortly after `active` turns true: an element + * that animates in takes focus once it has settled, not while it is still + * mid-animation. Pass a `delayMs` at least as long as the entrance it waits + * for. */ export function useDelayedAutofocus( ref: RefObject, active: boolean, - delayMs = CHAT_DOCK_AUTOFOCUS_DELAY_MS, + delayMs = DEFAULT_DELAY_MS, ): void { useEffect(() => { if (!active) return; diff --git a/src/index.ts b/src/index.ts index e371e27d..6ecf490a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,8 +32,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 +45,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 +58,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 +80,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 +117,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 = ""; - }} - /> - - - )} - -