From ea6c5ee17ce4dde0c1e69fd4f4105870bab7f370 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sat, 7 Mar 2026 23:24:23 -0800 Subject: [PATCH] Extract shared chat UI components --- CLAUDE.md | 7 + docs/react-components.mdx | 125 ++++ frontend/packages/inspector/index.html | 7 + frontend/packages/inspector/src/App.tsx | 8 +- .../src/components/chat/ChatInput.tsx | 37 -- .../src/components/chat/ChatMessages.tsx | 292 --------- .../src/components/chat/ChatPanel.tsx | 71 +-- .../components/chat/InspectorConversation.tsx | 165 +++++ .../src/components/chat/messageUtils.tsx | 19 - .../inspector/src/components/chat/types.ts | 18 - sdks/react/src/AgentConversation.tsx | 85 +++ sdks/react/src/AgentTranscript.tsx | 603 ++++++++++++++++++ sdks/react/src/ChatComposer.tsx | 112 ++++ sdks/react/src/index.ts | 19 + 14 files changed, 1161 insertions(+), 407 deletions(-) delete mode 100644 frontend/packages/inspector/src/components/chat/ChatInput.tsx delete mode 100644 frontend/packages/inspector/src/components/chat/ChatMessages.tsx create mode 100644 frontend/packages/inspector/src/components/chat/InspectorConversation.tsx delete mode 100644 frontend/packages/inspector/src/components/chat/messageUtils.tsx delete mode 100644 frontend/packages/inspector/src/components/chat/types.ts create mode 100644 sdks/react/src/AgentConversation.tsx create mode 100644 sdks/react/src/AgentTranscript.tsx create mode 100644 sdks/react/src/ChatComposer.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 866a3f1..769ea0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,13 @@ - `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`. - Cleanup is `sdk.dispose()`. +### React Component Methodology + +- Shared React UI belongs in `sdks/react` only when it is reusable outside the Inspector. +- Keep shared components unstyled by default: behavior in the package, styling in the consumer via `className`, slot-level `classNames`, render overrides, and `data-*` hooks. +- Prefer extracting reusable pieces such as transcript, composer, and conversation surfaces. Keep Inspector-specific shells such as session selection, session headers, and control-plane actions in `frontend/packages/inspector/`. +- Keep `docs/react-components.mdx` aligned with the exported surface in `sdks/react/src/index.ts`. + ### Docs Source Of Truth - For TypeScript docs/examples, source of truth is implementation in: diff --git a/docs/react-components.mdx b/docs/react-components.mdx index e37e2a3..66199df 100644 --- a/docs/react-components.mdx +++ b/docs/react-components.mdx @@ -6,6 +6,13 @@ icon: "react" `@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK. +Current exports: + +- `AgentConversation` for a combined transcript + composer surface +- `ProcessTerminal` for attaching to a running tty process +- `AgentTranscript` for rendering session/message timelines without bundling any styles +- `ChatComposer` for a reusable prompt input/send surface + ## Install ```bash @@ -101,3 +108,121 @@ export default function TerminalPane() { - `onExit`, `onError`: optional lifecycle callbacks See [Processes](/processes) for the lower-level terminal APIs. + +## Headless transcript + +`AgentTranscript` is intentionally unstyled. It follows the common headless React pattern used by libraries like Radix, Headless UI, and React Aria: behavior lives in the component, while styling stays in your app through `className`, slot-level `classNames`, and `data-*` state attributes on the rendered DOM. + +```tsx TranscriptPane.tsx +import { + AgentTranscript, + type AgentTranscriptClassNames, + type TranscriptEntry, +} from "@sandbox-agent/react"; + +const transcriptClasses: Partial = { + root: "transcript", + message: "transcript-message", + messageContent: "transcript-message-content", + toolGroupContainer: "transcript-tools", + toolGroupHeader: "transcript-tools-header", + toolItem: "transcript-tool-item", + toolItemHeader: "transcript-tool-item-header", + toolItemBody: "transcript-tool-item-body", + divider: "transcript-divider", + dividerText: "transcript-divider-text", + error: "transcript-error", +}; + +export function TranscriptPane({ entries }: { entries: TranscriptEntry[] }) { + return ( +
{entry.text}
} + renderInlinePendingIndicator={() => ...} + renderToolGroupIcon={() => Events} + renderChevron={(expanded) => {expanded ? "Hide" : "Show"}} + /> + ); +} +``` + +```css +.transcript { + display: grid; + gap: 12px; +} + +.transcript [data-slot="message"][data-variant="user"] .transcript-message-content { + background: #161616; + color: white; +} + +.transcript [data-slot="message"][data-variant="assistant"] .transcript-message-content { + background: #f4f4f0; + color: #161616; +} + +.transcript [data-slot="tool-item"][data-failed="true"] { + border-color: #d33; +} + +.transcript [data-slot="tool-item-header"][data-expanded="true"] { + background: rgba(0, 0, 0, 0.06); +} +``` + +`AgentTranscript` accepts `TranscriptEntry[]`, which matches the Inspector timeline shape: + +- `message` entries render user/assistant text +- `tool` entries render expandable tool input/output sections +- `reasoning` entries render expandable reasoning blocks +- `meta` entries render status rows or expandable metadata details + +Useful props: + +- `className`: root class hook +- `classNames`: slot-level class hooks for styling from outside the package +- `renderMessageText`: custom text or markdown renderer +- `renderToolItemIcon`, `renderToolGroupIcon`, `renderChevron`, `renderEventLinkContent`: presentation overrides +- `renderInlinePendingIndicator`, `renderThinkingState`: loading/thinking UI overrides +- `isDividerEntry`, `canOpenEvent`, `getToolGroupSummary`: behavior overrides for grouping and labels + +## Composer and conversation + +`ChatComposer` is the headless message input. `AgentConversation` composes `AgentTranscript` and `ChatComposer` so apps can reuse the transcript/composer pairing without pulling in Inspector session chrome. + +```tsx ConversationPane.tsx +import { AgentConversation, type TranscriptEntry } from "@sandbox-agent/react"; + +export function ConversationPane({ + entries, + message, + onMessageChange, + onSubmit, +}: { + entries: TranscriptEntry[]; + message: string; + onMessageChange: (value: string) => void; + onSubmit: () => void; +}) { + return ( + Start the conversation.} + transcriptProps={{ + renderMessageText: (entry) =>
{entry.text}
, + }} + composerProps={{ + message, + onMessageChange, + onSubmit, + placeholder: "Send a message...", + }} + /> + ); +} +``` + +Use `transcriptProps` and `composerProps` when you want the shared composition but still need custom rendering or behavior. Use `transcriptClassNames` and `composerClassNames` when you want styling hooks for each subcomponent. diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index aeec796..02f1193 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -1347,6 +1347,13 @@ padding: 16px; } + .chat-conversation { + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + } + .messages-container:has(.empty-state) { display: flex; align-items: center; diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index 2dfe24d..2e94e5c 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -1,3 +1,4 @@ +import type { TranscriptEntry } from "@sandbox-agent/react"; import { BookOpen } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -23,7 +24,6 @@ type AgentModeInfo = { id: string; name: string; description: string }; type AgentModelInfo = { id: string; name?: string }; import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; import ChatPanel from "./components/chat/ChatPanel"; -import type { TimelineEntry } from "./components/chat/types"; import ConnectScreen from "./components/ConnectScreen"; import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel"; import SessionSidebar from "./components/SessionSidebar"; @@ -921,7 +921,7 @@ export default function App() { // Build transcript entries from raw SessionEvents const transcriptEntries = useMemo(() => { - const entries: TimelineEntry[] = []; + const entries: TranscriptEntry[] = []; // Accumulators for streaming chunks let assistantAccumId: string | null = null; @@ -954,7 +954,7 @@ export default function App() { }; // Track tool calls by ID for updates - const toolEntryMap = new Map(); + const toolEntryMap = new Map(); for (const event of events) { const payload = event.payload as Record; @@ -1068,7 +1068,7 @@ export default function App() { if (update.title) existing.toolName = update.title as string; existing.time = time; } else { - const entry: TimelineEntry = { + const entry: TranscriptEntry = { id: `tool-${toolCallId}`, eventId: event.id, kind: "tool", diff --git a/frontend/packages/inspector/src/components/chat/ChatInput.tsx b/frontend/packages/inspector/src/components/chat/ChatInput.tsx deleted file mode 100644 index 6f8b2a7..0000000 --- a/frontend/packages/inspector/src/components/chat/ChatInput.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Send } from "lucide-react"; - -const ChatInput = ({ - message, - onMessageChange, - onSendMessage, - onKeyDown, - placeholder, - disabled -}: { - message: string; - onMessageChange: (value: string) => void; - onSendMessage: () => void; - onKeyDown: (event: React.KeyboardEvent) => void; - placeholder: string; - disabled: boolean; -}) => { - return ( -
-
-