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 (
-
+
);
}
```
-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)}
-
-
-
-
- );
-}
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}
-
-
- );
-}
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 (
-
- );
-}
diff --git a/src/ui/chat-panel.tsx b/src/ui/chat-panel.tsx
deleted file mode 100644
index 1cd63c0a..00000000
--- a/src/ui/chat-panel.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import type { ReactNode } from "react";
-
-import { agentInitials, type AgentIdentity } from "../lib/chat-message.js";
-import { cn } from "../lib/utils.js";
-
-/**
- * The chat surface's frame: who you are talking to, the transcript, and the
- * composer.
- *
- * Parts rather than a `messages`/`onSend` mega-component, because the same
- * frame carries a full-page conversation, a docked bar and a floating panel,
- * and each wants something different in the header — a thread switcher here, a
- * close button there. Composition is what makes one frame serve three shells.
- */
-export function ChatPanel({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
-
- );
-}
-
-export function ChatPanelHeader({
- identity,
- children,
- className,
- ...props
-}: React.ComponentProps<"header"> & { identity: AgentIdentity }) {
- return (
-
-
- {agentInitials(identity)}
-
-
-
{identity.name}
- {identity.tagline === undefined ? null : (
-
- {identity.tagline}
-
- )}
-
-
{children}
-
- );
-}
-
-/** The composer area: quick replies, the input, anything else pinned to the bottom. */
-export function ChatPanelFooter({
- className,
- children,
-}: {
- className?: string;
- children: ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
diff --git a/src/ui/chat-thread.tsx b/src/ui/chat-thread.tsx
deleted file mode 100644
index 27e45f4d..00000000
--- a/src/ui/chat-thread.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-"use client";
-
-import { useEffect, useRef } from "react";
-import type { ReactNode } from "react";
-
-import type { AgentIdentity, ChatMessage } from "../lib/chat-message.js";
-import { cn } from "../lib/utils.js";
-import { AgentTurn } from "./agent-turn.js";
-import { MessageBubble } from "./message-bubble.js";
-
-export type ChatThreadProps = {
- readonly messages: readonly ChatMessage[];
- readonly identity: AgentIdentity;
- /** Rich body for one message — a markdown renderer, if the host has one. */
- readonly renderBody?: (message: ChatMessage) => ReactNode;
- readonly onRetry?: (message: ChatMessage) => void;
- /** Shown when there are no messages: a greeting, suggestions, anything. */
- readonly empty?: ReactNode;
- readonly now?: number;
- readonly className?: string;
-};
-
-/**
- * The transcript.
- *
- * Messages are a prop, not a `DataPort` collection, and that is the one place
- * this registry departs from its own rule. A transcript is live, host-owned,
- * append-and-mutate state — the last message changes on every token — whereas a
- * `DataPort` collection is a cached, paginated, refetchable thing. The *thread
- * list* is a real collection and does use the port; a streaming conversation is
- * not one. The ticket asks for message data as props for the same reason.
- *
- * It pins to the bottom only when the user is already there. Yanking someone
- * back down while they are reading earlier context is the single most common
- * way chat scrolling goes wrong.
- */
-export function ChatThread({
- messages,
- identity,
- renderBody,
- onRetry,
- empty,
- now,
- className,
-}: ChatThreadProps) {
- const scrollRef = useRef(null);
- const pinnedRef = useRef(true);
-
- useEffect(() => {
- const node = scrollRef.current;
- if (node === null || !pinnedRef.current) return;
- node.scrollTop = node.scrollHeight;
- }, [messages]);
-
- if (messages.length === 0 && empty !== undefined) {
- return (
-
- {empty}
-
- );
- }
-
- return (
-
{
- const node = event.currentTarget;
- // A few pixels of slack: sub-pixel layout means an exact comparison is
- // false on plenty of screens that are, visually, at the bottom.
- pinnedRef.current =
- node.scrollHeight - node.scrollTop - node.clientHeight < 32;
- }}
- // `log` + polite: new turns are announced without interrupting, which is
- // what an arriving reply is.
- role="log"
- aria-live="polite"
- aria-label="Conversation"
- className={cn(
- "flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4",
- className,
- )}
- >
- {messages.map((message) =>
- message.role === "agent" ? (
-
- {renderBody?.(message)}
-
- ) : (
- onRetry(message)}
- >
- {renderBody?.(message)}
-
- ),
- )}
-
- );
-}
diff --git a/src/ui/message-bubble.tsx b/src/ui/message-bubble.tsx
deleted file mode 100644
index 11f9995e..00000000
--- a/src/ui/message-bubble.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { ReactNode } from "react";
-
-import type { ChatMessage } from "../lib/chat-message.js";
-import { messageText } from "../lib/chat-message.js";
-import { formatRelativeTime } from "../lib/relative-time.js";
-import { cn } from "../lib/utils.js";
-
-export type MessageBubbleProps = {
- readonly message: ChatMessage;
- /** Retry affordance for a failed send. Omit and a failure is only reported. */
- readonly onRetry?: (() => void) | undefined;
- readonly now?: number | undefined;
- /** Rich body — a markdown renderer. Defaults to the message's plain text. */
- readonly children?: ReactNode;
- readonly className?: string;
-};
-
-/**
- * One message, as said by whoever said it.
- *
- * Only the user's messages get a filled bubble. An agent reply is set on the
- * page ground with no container: agent turns are long, and a 600-word tinted
- * block is harder to read than plain text, while the alternating alignment that
- * makes chat apps legible comes from the user side alone.
- *
- * Text is rendered as text. Passing agent output through a markdown or HTML
- * renderer is the host's decision to make, with the host's sanitiser — pass one
- * as `children`.
- */
-export function MessageBubble({
- message,
- onRetry,
- now,
- children,
- className,
-}: MessageBubbleProps) {
- const isUser = message.role === "user";
- const isSystem = message.role === "system";
- const failed = message.status === "failed";
-
- if (isSystem) {
- return (
-
- {children ?? messageText(message)}
-
- );
- }
-
- return (
-
-
- {children ?? messageText(message)}
-
-
-
-
- {message.status === "sending" ? Sending… : null}
- {failed ? (
- <>
- {/* role=alert, not a quiet grey note: a message the user believes
- they sent and did not is worth interrupting for. */}
-
- Not sent
-
- {onRetry === undefined ? null : (
-
- )}
- >
- ) : null}
-
-
- );
-}
diff --git a/src/ui/parts-renderer.test.tsx b/src/ui/parts-renderer.test.tsx
deleted file mode 100644
index b3a69017..00000000
--- a/src/ui/parts-renderer.test.tsx
+++ /dev/null
@@ -1,175 +0,0 @@
-import { act } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { describe, expect, test } from "bun:test";
-
-import type { Part } from "../lib/chat-parts.js";
-import { PartsRenderer } from "./parts-renderer.js";
-
-type Mounted = {
- container: HTMLElement;
- unmount: () => void;
-};
-
-function render(node: React.ReactElement): Mounted {
- const container = document.createElement("div");
- document.body.appendChild(container);
- let root!: Root;
- act(() => {
- root = createRoot(container);
- root.render(node);
- });
- return {
- container,
- unmount: () => {
- act(() => root.unmount());
- container.remove();
- },
- };
-}
-
-describe("PartsRenderer", () => {
- test("renders nothing for an empty parts array", () => {
- const mounted = render();
- expect(
- mounted.container.querySelector("[data-slot='parts-renderer']"),
- ).toBeNull();
- mounted.unmount();
- });
-
- test("renders a text part as prose", () => {
- const parts: Part[] = [{ kind: "text", text: "Here's the summary." }];
- const mounted = render();
- expect(mounted.container.textContent).toContain("Here's the summary.");
- mounted.unmount();
- });
-
- test("renders a reasoning part collapsed, with its duration in the summary", () => {
- const parts: Part[] = [
- {
- kind: "reasoning",
- text: "Considering two approaches.",
- durationMs: 2300,
- },
- ];
- const mounted = render();
- const details = mounted.container.querySelector(
- "[data-slot='reasoning-part']",
- );
- expect(details?.hasAttribute("open")).toBe(false);
- expect(mounted.container.textContent).toContain("Thought for 2.3s");
- expect(mounted.container.textContent).toContain(
- "Considering two approaches.",
- );
- mounted.unmount();
- });
-
- test("reasoning part omits the duration clause when duration is unknown", () => {
- const parts: Part[] = [
- { kind: "reasoning", text: "Still working it out." },
- ];
- const mounted = render();
- expect(mounted.container.textContent).toContain("Thought");
- expect(mounted.container.textContent).not.toContain("Thought for");
- mounted.unmount();
- });
-
- test("renders a tool-trace part through ToolBlock, mapping status and output", () => {
- const parts: Part[] = [
- {
- kind: "tool-trace",
- toolCallId: "call-1",
- name: "search",
- status: "output-available",
- output: "3 results",
- },
- ];
- const mounted = render();
- const toolBlock = mounted.container.querySelector(
- "[data-slot='tool-block']",
- );
- expect(toolBlock?.getAttribute("data-status")).toBe("output-available");
- mounted.unmount();
- });
-
- test("renders a file part as an attachment chip, linked when a url is present", () => {
- const parts: Part[] = [
- {
- kind: "file",
- name: "report.pdf",
- mediaType: "application/pdf",
- url: "https://x/1",
- },
- ];
- const mounted = render();
- const chip = mounted.container.querySelector("[data-slot='file-part']");
- expect(chip?.tagName).toBe("A");
- expect(chip?.getAttribute("href")).toBe("https://x/1");
- expect(mounted.container.textContent).toContain("report.pdf");
- mounted.unmount();
- });
-
- test("renders a file part with no url as an inert chip", () => {
- const parts: Part[] = [
- { kind: "file", name: "notes.txt", mediaType: "text/plain" },
- ];
- const mounted = render();
- const chip = mounted.container.querySelector("[data-slot='file-part']");
- expect(chip?.tagName).toBe("SPAN");
- mounted.unmount();
- });
-
- test("renders an event part as an inline system line", () => {
- const parts: Part[] = [{ kind: "event", event: "channel.agent-joined" }];
- const mounted = render();
- expect(
- mounted.container.querySelector("[data-slot='event-part']")?.textContent,
- ).toContain("channel agent joined");
- mounted.unmount();
- });
-
- test("renders an unknown block type as a labeled fallback card with its raw payload", () => {
- const parts: Part[] = [
- { kind: "block", block: { type: "poll", data: { title: "Ship it?" } } },
- ];
- const mounted = render();
- expect(
- mounted.container.querySelector("[data-slot='block-card-title']")
- ?.textContent,
- ).toBe("poll");
- expect(mounted.container.textContent).toContain("Ship it?");
- mounted.unmount();
- });
-
- test("renders an unrecognized part kind as a neutral fallback instead of nothing", () => {
- const parts = [{ kind: "poll-vote", choice: "yes" }] as unknown as Part[];
- const mounted = render();
- const fallback = mounted.container.querySelector(
- "[data-slot='unknown-part']",
- );
- expect(fallback).not.toBeNull();
- expect(fallback?.textContent).toContain("poll-vote");
- mounted.unmount();
- });
-
- test("renders interleaved parts in the given order", () => {
- const parts: Part[] = [
- { kind: "reasoning", text: "thinking" },
- {
- kind: "tool-trace",
- toolCallId: "c1",
- name: "search",
- status: "running",
- },
- { kind: "text", text: "answer" },
- ];
- const mounted = render();
- const root = mounted.container.querySelector(
- "[data-slot='parts-renderer']",
- );
- const kinds = Array.from(root?.children ?? []).map((child) =>
- child.getAttribute("data-slot"),
- );
- expect(kinds).toEqual(["reasoning-part", "tool-block", null]);
- mounted.unmount();
- });
-});
diff --git a/src/ui/parts-renderer.tsx b/src/ui/parts-renderer.tsx
deleted file mode 100644
index 36adf7d4..00000000
--- a/src/ui/parts-renderer.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-"use client";
-
-import { ChevronRight, Paperclip } from "lucide-react";
-import { useState } from "react";
-
-import type { Part, PartToolTrace } from "../lib/chat-parts.js";
-import { cn } from "../lib/utils.js";
-import { BlockCard } from "./block-card.js";
-import { ToolBlock, type ToolBlockState } from "./tool-block.js";
-
-export type PartsRendererProps = {
- readonly parts: readonly Part[];
- readonly className?: string;
-};
-
-/** `1.2s`, `45s`, `3m 05s` — never a raw millisecond count. */
-function formatDuration(ms: number): string {
- const totalSeconds = ms / 1000;
- if (totalSeconds < 60) {
- return `${totalSeconds < 10 ? totalSeconds.toFixed(1) : Math.round(totalSeconds)}s`;
- }
- const minutes = Math.floor(totalSeconds / 60);
- const seconds = Math.round(totalSeconds % 60);
- return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
-}
-
-/** Maps the wire's four-outcome status onto `ToolBlock`'s render-side state. */
-export function toolTraceToBlockState(part: PartToolTrace): ToolBlockState {
- switch (part.status) {
- case "pending":
- return { status: "pending" };
- case "running":
- return { status: "running" };
- case "output-available":
- return {
- status: "output-available",
- output: stringifyOutput(part.output),
- };
- case "error":
- return { status: "error", message: stringifyOutput(part.output) };
- case "approval-requested":
- return {
- status: "approval-requested",
- reason: stringifyOutput(part.output),
- };
- case "output-denied":
- return { status: "output-denied", reason: stringifyOutput(part.output) };
- }
-}
-
-function stringifyOutput(output: unknown): string {
- if (output === undefined) return "";
- if (typeof output === "string") return output;
- return JSON.stringify(output, null, 2);
-}
-
-function TextPartView({ text }: { text: string }) {
- if (text.trim().length === 0) return null;
- return (
-
- {text}
-
- );
-}
-
-/**
- * The agent's thinking, folded away, with how long it took once known.
- *
- * Closed by default, including while streaming: a block that expands itself
- * mid-stream makes the transcript jump under the reader. A native
- * ``, not a div with state — it gets disclosure semantics, keyboard
- * behaviour and find-in-page expansion from the browser for free.
- */
-function ReasoningPartView({
- text,
- durationMs,
-}: {
- text: string;
- durationMs?: number | undefined;
-}) {
- const [open, setOpen] = useState(false);
- if (text.trim().length === 0) return null;
-
- return (
-
- setOpen((event.currentTarget as HTMLDetailsElement).open)
- }
- className="text-xs"
- >
-
-
-
- Thought
- {durationMs === undefined ? "" : ` for ${formatDuration(durationMs)}`}
-
-
- {/* Keyed on `open` so the entrance animation replays every time the
- disclosure opens, not just the first time — the node persists
- (still present, natively hidden) while collapsed, preserving
- browser find-in-page's own expand-on-match behaviour. */}
-
- );
-}
-
-/**
- * A block part this renderer has no host-specific registry for. Phase 1
- * ships no generative-UI JSON-spec renderer, so a block always falls back to
- * its type name and raw payload rather than going unrendered.
- */
-function BlockPartFallback({ type, data }: { type: string; data: unknown }) {
- return (
-
-
- {JSON.stringify(data, null, 2)}
-
-
- );
-}
-
-/**
- * A `Part` whose `kind` this renderer has never heard of — a wire value
- * outside `Part`'s own union, which can only ever arrive because this
- * package validates nothing of its own (see `chat-parts.ts`). Renders a
- * neutral labeled card naming the unrecognized kind rather than rendering
- * nothing, so a producer/consumer version skew is visible in the transcript
- * instead of silently swallowing content.
- */
-function UnknownPartFallback({ kind }: { kind: string }) {
- return (
-
- Unsupported part: {kind}
-
- );
-}
-
-/**
- * Renders an ordered `Part[]` in the order the model produced it: text as
- * prose, reasoning as a closed disclosure, tool calls through `ToolBlock`,
- * files as an attachment chip, events as an inline system line, any block
- * part as a labeled fallback card, and any `kind` outside the `Part` union
- * itself as a neutral unknown-part fallback — the host's own generative-UI
- * registry, if it has one, renders blocks by intercepting `kind: "block"`
- * before it reaches this component.
- *
- * An empty `parts` array renders nothing — no placeholder, no empty-state
- * chrome. A message with no parts is not a message a transcript should show
- * anything for.
- */
-export function PartsRenderer({ parts, className }: PartsRendererProps) {
- if (parts.length === 0) return null;
-
- return (
-
- );
-}
diff --git a/src/ui/provider-mark.tsx b/src/ui/provider-mark.tsx
index c0055bc5..f236c5f6 100644
--- a/src/ui/provider-mark.tsx
+++ b/src/ui/provider-mark.tsx
@@ -1,6 +1,5 @@
import type { ReactNode } from "react";
-import type { ToolPart } from "../lib/chat-message.js";
import { cn } from "../lib/utils.js";
export type ProviderMarkProps = {
@@ -49,25 +48,3 @@ export function ProviderMark({
);
}
-
-/**
- * The provider marker for a tool call in a transcript.
- *
- * The provider is read off the tool id's `provider__operation` prefix — the
- * same convention `toolLabel` uses — so a tool with no prefix is local and gets
- * no marker rather than a guessed one.
- */
-export function ToolProviderMark({
- part,
- logo,
- className,
-}: {
- part: ToolPart;
- logo?: ReactNode;
- className?: string;
-}) {
- const [provider, ...rest] = part.toolName.split("__");
- if (rest.length === 0 || provider === undefined || provider === "")
- return null;
- return ;
-}
diff --git a/src/ui/quick-reply-chips.tsx b/src/ui/quick-reply-chips.tsx
deleted file mode 100644
index 7bf57254..00000000
--- a/src/ui/quick-reply-chips.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-"use client";
-
-import type { QuickReply } from "../lib/chat-message.js";
-import { cn } from "../lib/utils.js";
-
-/**
- * Suggested replies. A list, not a row of loose buttons, so a screen reader
- * announces how many suggestions there are before reading them — otherwise the
- * user has to arrow through to find out.
- *
- * Sending is the host's job: this reports which chip was chosen and lets the
- * host decide whether that fills the composer or sends immediately.
- */
-export function QuickReplyChips({
- replies,
- onSelect,
- className,
-}: {
- replies: readonly QuickReply[];
- onSelect: (reply: QuickReply) => void;
- className?: string;
-}) {
- if (replies.length === 0) return null;
-
- return (
-
- {replies.map((reply) => (
-
-
-
- ))}
-
- );
-}
diff --git a/src/ui/reasoning-block.test.tsx b/src/ui/reasoning-block.test.tsx
deleted file mode 100644
index fa3e1e0d..00000000
--- a/src/ui/reasoning-block.test.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import { act, createElement } from "react";
-import type { ComponentProps } from "react";
-import { createRoot } from "react-dom/client";
-
-import { ReasoningBlock } from "./reasoning-block.js";
-
-function mount(initialProps: ComponentProps) {
- const container = document.createElement("div");
- document.body.appendChild(container);
- const root = createRoot(container);
- let props = initialProps;
- act(() => {
- root.render(createElement(ReasoningBlock, props));
- });
- return {
- details: () => container.querySelector("details"),
- summary: () => container.querySelector("summary"),
- body: () => container.querySelector("details > p"),
- rerender: (next: ComponentProps) => {
- props = next;
- act(() => {
- root.render(createElement(ReasoningBlock, props));
- });
- },
- // happy-dom delivers `toggle` on a queued task, same as real browsers.
- flushToggle: () =>
- act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 0));
- }),
- unmount: () => act(() => root.unmount()),
- };
-}
-
-describe("ReasoningBlock", () => {
- test("renders nothing for empty reasoning text", () => {
- const { details, unmount } = mount({ text: " " });
- expect(details()).toBeNull();
- unmount();
- });
-
- test("is collapsed by default and opens by activating the summary", async () => {
- const { details, summary, body, flushToggle, unmount } = mount({
- text: "Weighing two options.",
- });
- expect(details()?.hasAttribute("open")).toBe(false);
- expect(body()).toBeTruthy();
-
- act(() => {
- summary()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushToggle();
-
- expect(details()?.hasAttribute("open")).toBe(true);
- expect(body()?.textContent).toBe("Weighing two options.");
- unmount();
- });
-
- test('shows the duration label once idle, and "Thinking…" while streaming', () => {
- const idle = mount({
- text: "Done thinking.",
- durationLabel: "Thought for 4s",
- });
- expect(idle.summary()?.textContent).toBe("Thought for 4s");
- idle.unmount();
-
- const streaming = mount({
- text: "Still going.",
- streaming: true,
- durationLabel: "Thought for 4s",
- });
- expect(streaming.summary()?.textContent).toBe("Thinking…");
- streaming.unmount();
- });
-
- test("idle without a duration label uses Thinking, not a cute fallback", () => {
- const idle = mount({ text: "Done thinking." });
- expect(idle.summary()?.textContent).toBe("Thinking");
- expect(idle.summary()?.textContent).not.toContain("Thought about this");
- idle.unmount();
- });
-
- test("summary wording is overridable", () => {
- const streaming = mount({
- text: "Going.",
- streaming: true,
- streamingLabel: "Working…",
- });
- expect(streaming.summary()?.textContent).toBe("Working…");
- streaming.unmount();
-
- const idle = mount({ text: "Done.", idleLabel: "Reasoning" });
- expect(idle.summary()?.textContent).toBe("Reasoning");
- idle.unmount();
- });
-
- test("streaming-to-idle on the same instance updates the summary and busy state", () => {
- const handle = mount({ text: "Reasoning.", streaming: true });
- expect(handle.summary()?.textContent).toBe("Thinking…");
- expect(handle.body()?.getAttribute("aria-busy")).toBe("true");
- // aria-live is unconditional so the streaming-to-idle wording change is announced.
- expect(
- handle.summary()?.querySelector("[aria-live]")?.getAttribute("aria-live"),
- ).toBe("polite");
-
- handle.rerender({
- text: "Reasoning.",
- streaming: false,
- durationLabel: "Thought for 4s",
- });
-
- expect(handle.summary()?.textContent).toContain("Thought for 4s");
- expect(handle.body()?.getAttribute("aria-busy")).toBe("false");
- handle.unmount();
- });
-
- test("uncontrolled: activating the summary reports through onOpenChange", async () => {
- const seen: boolean[] = [];
- const { details, summary, flushToggle, unmount } = mount({
- text: "Uncontrolled.",
- onOpenChange: (next) => seen.push(next),
- });
-
- act(() => {
- summary()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await flushToggle();
-
- expect(seen).toEqual([true]);
- expect(details()?.hasAttribute("open")).toBe(true);
- unmount();
- });
-
- test("controlled: user activation reports intent, parent-driven open does not echo", async () => {
- const seen: boolean[] = [];
- const props = (open: boolean): ComponentProps => ({
- text: "Controlled.",
- open,
- onOpenChange: (next) => seen.push(next),
- });
- const handle = mount(props(false));
- expect(handle.details()?.hasAttribute("open")).toBe(false);
-
- act(() => {
- handle
- .summary()
- ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
- });
- await handle.flushToggle();
- expect(seen).toEqual([true]);
-
- // Parent honors the request; the resulting attribute change must not echo.
- handle.rerender(props(true));
- await handle.flushToggle();
- expect(handle.details()?.hasAttribute("open")).toBe(true);
- expect(seen).toEqual([true]);
- handle.unmount();
- });
-
- test("controlled: parent-driven close while open fires no callback", async () => {
- const seen: boolean[] = [];
- const props = (open: boolean): ComponentProps => ({
- text: "Controlled.",
- open,
- onOpenChange: (next) => seen.push(next),
- });
- const handle = mount(props(true));
- await handle.flushToggle();
- expect(seen).toEqual([]);
-
- handle.rerender(props(false));
- await handle.flushToggle();
- expect(handle.details()?.hasAttribute("open")).toBe(false);
- expect(seen).toEqual([]);
- handle.unmount();
- });
-});
diff --git a/src/ui/reasoning-block.tsx b/src/ui/reasoning-block.tsx
deleted file mode 100644
index c19ee730..00000000
--- a/src/ui/reasoning-block.tsx
+++ /dev/null
@@ -1,110 +0,0 @@
-"use client";
-
-import { ChevronRight } from "lucide-react";
-
-import { useControllableState } from "../hooks/use-controllable-state.js";
-import { cn } from "../lib/utils.js";
-
-export type ReasoningBlockProps = {
- /** The agent's thinking. Rendered as text — it is not markup. */
- readonly text: string;
- /** Still streaming. Changes the summary wording and marks the body busy. */
- readonly streaming?: boolean;
- /** Summary while streaming. Defaults to "Thinking…". */
- readonly streamingLabel?: string;
- /** Summary once idle when no `durationLabel` is supplied. Defaults to "Thinking". */
- readonly idleLabel?: string;
- /** e.g. "Thought for 4s". Becomes the summary once reasoning has stopped streaming. */
- readonly durationLabel?: string | undefined;
- readonly defaultOpen?: boolean;
- /**
- * Controlled open state. Pair with `onOpenChange` to lift it to a parent —
- * and honor it: a controlled `` that ignores `onOpenChange` leaves
- * the DOM attribute and React state diverged (the browser mutates `open`
- * without React's knowledge), so the block can sit visibly open while the
- * prop says closed.
- */
- readonly open?: boolean;
- readonly onOpenChange?: (open: boolean) => void;
- readonly className?: string;
-};
-
-/**
- * The agent's thinking, folded away.
- *
- * Collapsed by default, including while streaming: reasoning is context for a
- * reader who wants it and noise for one who does not, and a block that
- * expands itself mid-stream makes the transcript jump under the reader.
- *
- * A native ``, not a div with state: it gets the disclosure
- * semantics, keyboard behaviour and find-in-page expansion from the browser,
- * and those are three things a hand-rolled version gets wrong.
- */
-export function ReasoningBlock({
- text,
- streaming = false,
- streamingLabel = "Thinking…",
- idleLabel = "Thinking",
- durationLabel,
- defaultOpen = false,
- open: openProp,
- onOpenChange,
- className,
-}: ReasoningBlockProps) {
- const [open, setOpen] = useControllableState({
- value: openProp,
- defaultValue: defaultOpen,
- onChange: onOpenChange,
- name: "ReasoningBlock",
- });
-
- if (text.trim().length === 0) return null;
-
- const summary = streaming ? streamingLabel : (durationLabel ?? idleLabel);
-
- return (
- {
- const domOpen = (event.currentTarget as HTMLDetailsElement).open;
- if (domOpen !== open) setOpen(domOpen);
- }}
- className={cn("text-xs", className)}
- >
-
-
- {summary}
-
- {/* Keyed on `open` so the entrance animation replays every time the
- disclosure opens, not just the first time — the node persists
- (still present, natively hidden) while collapsed, preserving
- browser find-in-page's own expand-on-match behaviour. */}
-
- {text}
-
-
- );
-}
diff --git a/src/ui/reasoning.tsx b/src/ui/reasoning.tsx
index 40e48ffb..9bd85a80 100644
--- a/src/ui/reasoning.tsx
+++ b/src/ui/reasoning.tsx
@@ -5,7 +5,7 @@ import { createContext, useContext, useId } from "react";
import type { ComponentProps, ReactNode } from "react";
import { useControllableState } from "../hooks/use-controllable-state.js";
-import type { PartReasoning } from "../lib/chat-parts.js";
+import type { PartReasoning } from "../lib/conversation.js";
import { summarizeActivity } from "../lib/conversation-activity.js";
import { cn } from "../lib/utils.js";
diff --git a/src/ui/textarea.tsx b/src/ui/textarea.tsx
index ad319367..ddc188a6 100644
--- a/src/ui/textarea.tsx
+++ b/src/ui/textarea.tsx
@@ -5,8 +5,8 @@ import { useEffect, useRef } from "react";
import { cn } from "../lib/utils.js";
-// Matches ChatInput's MAX_ROWS_PX — the same cap, so a textarea grown by
-// autoResize behaves identically wherever it's used.
+// The cap autoResize grows to when no `maxHeight` is given, so a textarea
+// grown by autoResize behaves identically wherever it's used.
const DEFAULT_MAX_HEIGHT_PX = 200;
export type TextareaProps = React.ComponentProps<"textarea"> & {
diff --git a/src/ui/tool-block.test.tsx b/src/ui/tool-block.test.tsx
deleted file mode 100644
index 66a5ff8b..00000000
--- a/src/ui/tool-block.test.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { act } from "react";
-import { createRoot, type Root } from "react-dom/client";
-import { describe, expect, test } from "bun:test";
-
-import { ToolBlock } from "./tool-block.js";
-
-type Mounted = {
- container: HTMLElement;
- unmount: () => void;
-};
-
-function render(node: React.ReactElement): Mounted {
- const container = document.createElement("div");
- document.body.appendChild(container);
- let root!: Root;
- act(() => {
- root = createRoot(container);
- root.render(node);
- });
- return {
- container,
- unmount: () => {
- act(() => root.unmount());
- container.remove();
- },
- };
-}
-
-function toggleButton(container: HTMLElement): HTMLButtonElement {
- const button = container.querySelector("[data-slot='tool-block'] button");
- if (button === null) throw new Error("expected a toggle button");
- return button as HTMLButtonElement;
-}
-
-describe("ToolBlock", () => {
- test("humanises a raw tool name when no label is given", () => {
- const mounted = render(
- ,
- );
- expect(mounted.container.textContent).toContain("Post message (Slack)");
- mounted.unmount();
- });
-
- test("prefers an explicit label over the humanised name", () => {
- const mounted = render(
- ,
- );
- expect(mounted.container.textContent).toContain("Posted to #general");
- mounted.unmount();
- });
-
- test("pending and running states start collapsed with no detail toggle", () => {
- const mounted = render(
- ,
- );
- const button = toggleButton(mounted.container);
- expect(button.getAttribute("aria-expanded")).toBe("false");
- expect(button.disabled).toBe(true);
- mounted.unmount();
- });
-
- test("output-available starts collapsed but expands on click", () => {
- const mounted = render(
- ,
- );
- const button = toggleButton(mounted.container);
- expect(button.getAttribute("aria-expanded")).toBe("false");
- act(() => button.click());
- expect(button.getAttribute("aria-expanded")).toBe("true");
- expect(mounted.container.textContent).toContain("3 results");
- mounted.unmount();
- });
-
- test.each([
- ["error", { status: "error", message: "timed out" } as const, "timed out"],
- [
- "approval-requested",
- { status: "approval-requested", reason: "writes to prod" } as const,
- "writes to prod",
- ],
- [
- "output-denied",
- { status: "output-denied", reason: "not authorised" } as const,
- "not authorised",
- ],
- ])(
- "%s state opens by default and shows its detail",
- (_label, state, expectedText) => {
- const mounted = render();
- const button = toggleButton(mounted.container);
- expect(button.getAttribute("aria-expanded")).toBe("true");
- expect(mounted.container.textContent).toContain(expectedText);
- mounted.unmount();
- },
- );
-
- test("shows input JSON alongside output when both are present", () => {
- const mounted = render(
- ,
- );
- expect(mounted.container.textContent).toContain('"query"');
- expect(mounted.container.textContent).toContain("invoices");
- mounted.unmount();
- });
-
- test("detail boxes take focus so the keyboard can scroll long output", () => {
- const mounted = render(
- ,
- );
- const boxes = [...mounted.container.querySelectorAll("pre")];
- expect(boxes.length).toBe(2);
- for (const box of boxes) expect(box.tabIndex).toBe(0);
- mounted.unmount();
- });
-
- test("carries its status as a data attribute for state-based styling", () => {
- const mounted = render(
- ,
- );
- const root = mounted.container.querySelector("[data-slot='tool-block']");
- expect(root?.getAttribute("data-status")).toBe("running");
- mounted.unmount();
- });
-
- test("announces approval-requested as a polite status region", () => {
- const mounted = render(
- ,
- );
- const status = mounted.container.querySelector(
- "[data-slot='tool-block-status']",
- );
- expect(status?.getAttribute("role")).toBe("status");
- expect(status?.getAttribute("aria-live")).toBe("polite");
- mounted.unmount();
- });
-
- test.each([
- ["pending", { status: "pending" } as const],
- ["running", { status: "running" } as const],
- ["output-available", { status: "output-available", output: "ok" } as const],
- ["error", { status: "error", message: "failed" } as const],
- ["output-denied", { status: "output-denied" } as const],
- ])("%s state carries no status-region announcement", (_label, state) => {
- const mounted = render();
- const status = mounted.container.querySelector(
- "[data-slot='tool-block-status']",
- );
- expect(status?.getAttribute("role")).toBeNull();
- expect(status?.getAttribute("aria-live")).toBeNull();
- mounted.unmount();
- });
-});
diff --git a/src/ui/tool-block.tsx b/src/ui/tool-block.tsx
deleted file mode 100644
index 3aa58015..00000000
--- a/src/ui/tool-block.tsx
+++ /dev/null
@@ -1,235 +0,0 @@
-"use client";
-
-import { ChevronRight } from "lucide-react";
-
-import { useControllableState } from "../hooks/use-controllable-state.js";
-import { cn } from "../lib/utils.js";
-import { StatusDot } from "./status-dot.js";
-
-/**
- * A tool call's lifecycle, as a discriminated union rather than a status
- * string plus optional fields — each state carries exactly the data that
- * exists at that point and nothing a caller could set inconsistently (an
- * `error` with an `output`, a `pending` with a `deniedReason`).
- *
- * `approval-requested` and `output-denied` are distinct from `error`: a
- * refused approval is an outcome the human chose, not a failure the tool
- * suffered, and the two read very differently in a transcript.
- */
-export type ToolBlockState =
- | { readonly status: "pending" }
- | { readonly status: "running" }
- | { readonly status: "output-available"; readonly output: string }
- | { readonly status: "error"; readonly message: string }
- | { readonly status: "approval-requested"; readonly reason?: string }
- | { readonly status: "output-denied"; readonly reason?: string };
-
-export type ToolBlockProps = {
- /** Raw tool identifier, e.g. `slack__post_message`. */
- readonly name: string;
- /** Human phrasing for what the tool did. Falls back to `name` when absent. */
- readonly label?: string;
- readonly state: ToolBlockState;
- /** Call arguments, shown in the expanded detail when present. */
- readonly input?: unknown;
- readonly defaultOpen?: boolean;
- /** Controlled open state. Pair with `onOpenChange` to lift it to a parent. */
- readonly open?: boolean;
- readonly onOpenChange?: (open: boolean) => void;
- readonly className?: string;
-};
-
-const STATE_TEXT: Record = {
- pending: "Queued",
- running: "Working",
- "output-available": "Done",
- error: "Failed",
- "approval-requested": "Needs approval",
- "output-denied": "Denied",
-};
-
-/**
- * Orange (`emphasis`) is reserved for `approval-requested` alone: it is the
- * one state that needs a human to act, so it is the one state that earns the
- * brand's single emphasis color. `running` already signals "in progress"
- * through the dot's own pulse animation (`StatusDot`'s `live` prop) — giving
- * it orange too would stack two "look here" signals on a state that isn't
- * actually asking for anything.
- */
-const STATE_TONE: Record<
- ToolBlockState["status"],
- "neutral" | "emphasis" | "danger"
-> = {
- pending: "neutral",
- running: "neutral",
- "output-available": "neutral",
- error: "danger",
- "approval-requested": "emphasis",
- "output-denied": "danger",
-};
-
-/** True when the state carries no `error`/`running` weight in a warning sense but still opens by default. */
-const OPENS_BY_DEFAULT: Record = {
- pending: false,
- running: false,
- "output-available": false,
- error: true,
- "approval-requested": true,
- "output-denied": true,
-};
-
-function humaniseToolName(name: string): string {
- const [provider, ...rest] = name.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(name);
-}
-
-function stateDetailText(state: ToolBlockState): string | undefined {
- switch (state.status) {
- case "output-available":
- return state.output;
- case "error":
- return state.message;
- case "approval-requested":
- case "output-denied":
- return state.reason;
- case "pending":
- case "running":
- return undefined;
- }
-}
-
-function stateDetailTitle(state: ToolBlockState): string {
- switch (state.status) {
- case "error":
- return "Error";
- case "approval-requested":
- return "Reason";
- case "output-denied":
- return "Denied because";
- case "output-available":
- case "pending":
- case "running":
- return "Output";
- }
-}
-
-/**
- * What a tool call did, as one line the transcript can open.
- *
- * Collapsed by default and collapsed even while running: a transcript where
- * every tool call expands itself pushes the answer off screen, and the
- * answer is what the user is waiting for. A failed call, a denied approval
- * and a pending approval request all open by default — those are the three
- * states where the detail is the point, not an afterthought.
- *
- * Uncontrolled by default, seeded from `defaultOpen` (or the state's own
- * default). Pass `open`/`onOpenChange` to drive it from a parent.
- */
-export function ToolBlock({
- name,
- label,
- state,
- input,
- defaultOpen,
- open: openProp,
- onOpenChange,
- className,
-}: ToolBlockProps) {
- const [open, setOpen] = useControllableState({
- value: openProp,
- defaultValue: defaultOpen ?? OPENS_BY_DEFAULT[state.status],
- onChange: onOpenChange,
- name: "ToolBlock",
- });
- const detailText = stateDetailText(state);
- const hasDetail = input !== undefined || detailText !== undefined;
- const displayLabel =
- label !== undefined && label.length > 0 ? label : humaniseToolName(name);
-
- return (
-
- {/* Scrolls in its own box: a long tool result must never make the page
- scroll sideways. Focusable so the keyboard can scroll it too. */}
-
- {children}
-
-
- );
-}
diff --git a/src/ui/tool-narrative.tsx b/src/ui/tool-narrative.tsx
deleted file mode 100644
index 8f8e03e4..00000000
--- a/src/ui/tool-narrative.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-"use client";
-
-import { ChevronRight } from "lucide-react";
-
-import { useControllableState } from "../hooks/use-controllable-state.js";
-import { toolLabel, type ToolPart } from "../lib/chat-message.js";
-import { cn } from "../lib/utils.js";
-import { StatusDot } from "./status-dot.js";
-
-const STATE_TEXT: Record = {
- running: "Working",
- done: "Done",
- error: "Failed",
-};
-
-/**
- * What the agent did, as one line the user can open.
- *
- * Collapsed by default and collapsed even while running: a transcript where
- * every tool call expands itself pushes the answer off screen, and the answer
- * is what the user is waiting for. The line still says which tool and how it
- * ended, so nothing is hidden — only folded.
- *
- * A failed call opens by default. That is the one case where the detail is the
- * point.
- *
- * Uncontrolled by default. Pass `open`/`onOpenChange` to drive it from a parent.
- */
-export type ToolNarrativeProps = {
- readonly part: ToolPart;
- /** Controlled open state. Pair with `onOpenChange` to lift it to a parent. */
- readonly open?: boolean;
- readonly onOpenChange?: (open: boolean) => void;
- readonly className?: string;
-};
-
-export function ToolNarrative({
- part,
- open: openProp,
- onOpenChange,
- className,
-}: ToolNarrativeProps) {
- const [open, setOpen] = useControllableState({
- value: openProp,
- defaultValue: part.state === "error",
- onChange: onOpenChange,
- name: "ToolNarrative",
- });
- const label = toolLabel(part);
- const hasDetail = part.output !== undefined || part.input !== undefined;
-
- return (
-
- );
-};
-
-/** A membership event rendered as an inline system line. */
-export const EventLine = () => (
-
-
-
-);
-
-/** A block part with no host registry falls back to a labeled card with its raw payload. */
-export const UnregisteredBlockFallsBack = () => {
- const parts: Part[] = [
- {
- kind: "block",
- block: {
- type: "poll",
- data: { title: "Ship Friday?", choices: ["Yes", "No"] },
- },
- },
- ];
- return (
-
-
-
- );
-};
-
-/** Long reasoning and tool output each scroll or wrap in place rather than stretching the card. */
-export const LongContent = () => {
- const longThought = Array.from(
- { length: 12 },
- (_, i) =>
- `Step ${i + 1}: checked the ${["orders", "returns", "inventory", "shipping"][i % 4]} table.`,
- ).join(" ");
- const parts: Part[] = [
- { kind: "reasoning", text: longThought, durationMs: 61_500 },
- {
- kind: "tool-trace",
- toolCallId: "call-3",
- name: "reports__export",
- status: "output-available",
- output: Array.from({ length: 40 }, (_, i) => `row ${i + 1}`).join("\n"),
- },
- {
- kind: "text",
- text: "Here's the full breakdown, line by line, so nothing about last month's numbers is left ambiguous or unexplained in the summary above.",
- },
- ];
- return (
-
-
-
- );
-};
diff --git a/stories/chat/tool-block.stories.tsx b/stories/chat/tool-block.stories.tsx
deleted file mode 100644
index 3c3bfa06..00000000
--- a/stories/chat/tool-block.stories.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { ToolBlock } from "../../src/ui/tool-block.js";
-
-export default { title: "Chat / Tool block" };
-
-/** Every lifecycle state, stacked in the order a call actually moves through them. */
-export const LifecycleStates = () => (
-
-
-
-
-
-
-
-
-);
-
-/** No `label` given — the raw `provider__action` id is humanised for display. */
-export const HumanisedName = () => (
-
-
-
-);
-
-/** A caller-supplied `label` wins over the humanised tool id. */
-export const ExplicitLabel = () => (
-
-
-
-);
-
-/** No input, no output — nothing to expand, so the chevron and toggle disappear entirely. */
-export const NoDetailToExpand = () => (
-
-
-
-);
-
-/** Long tool output scrolls inside its own box instead of stretching the card or the page. */
-export const LongOutputScrollsInPlace = () => (
-