Extract shared chat UI components

This commit is contained in:
Nathan Flurry 2026-03-07 23:24:23 -08:00
parent 3d9476ed0b
commit ea6c5ee17c
14 changed files with 1161 additions and 407 deletions

View file

@ -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:

View file

@ -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<AgentTranscriptClassNames> = {
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 (
<AgentTranscript
entries={entries}
classNames={transcriptClasses}
renderMessageText={(entry) => <div>{entry.text}</div>}
renderInlinePendingIndicator={() => <span>...</span>}
renderToolGroupIcon={() => <span>Events</span>}
renderChevron={(expanded) => <span>{expanded ? "Hide" : "Show"}</span>}
/>
);
}
```
```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 (
<AgentConversation
entries={entries}
emptyState={<div>Start the conversation.</div>}
transcriptProps={{
renderMessageText: (entry) => <div>{entry.text}</div>,
}}
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.

View file

@ -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;

View file

@ -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<string, TimelineEntry>();
const toolEntryMap = new Map<string, TranscriptEntry>();
for (const event of events) {
const payload = event.payload as Record<string, unknown>;
@ -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",

View file

@ -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<HTMLTextAreaElement>) => void;
placeholder: string;
disabled: boolean;
}) => {
return (
<div className="input-container">
<div className="input-wrapper">
<textarea
value={message}
onChange={(event) => onMessageChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
rows={1}
disabled={disabled}
/>
<button className="send-button" onClick={onSendMessage} disabled={disabled || !message.trim()}>
<Send />
</button>
</div>
</div>
);
};
export default ChatInput;

View file

@ -1,292 +0,0 @@
import { useState } from "react";
import { getMessageClass } from "./messageUtils";
import type { TimelineEntry } from "./types";
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react";
import MarkdownText from "./MarkdownText";
const ToolItem = ({
entry,
isLast,
onEventClick
}: {
entry: TimelineEntry;
isLast: boolean;
onEventClick?: (eventId: string) => void;
}) => {
const [expanded, setExpanded] = useState(false);
const isTool = entry.kind === "tool";
const isReasoning = entry.kind === "reasoning";
const isMeta = entry.kind === "meta";
const isComplete = isTool && (entry.toolStatus === "completed" || entry.toolStatus === "failed");
const isFailed = isTool && entry.toolStatus === "failed";
const isInProgress = isTool && entry.toolStatus === "in_progress";
let label = "";
let icon = <Info size={12} />;
if (isTool) {
const statusLabel = entry.toolStatus && entry.toolStatus !== "completed"
? ` (${entry.toolStatus.replace("_", " ")})`
: "";
label = `${entry.toolName ?? "tool"}${statusLabel}`;
icon = <Wrench size={12} />;
} else if (isReasoning) {
label = `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`;
icon = <Brain size={12} />;
} else if (isMeta) {
label = entry.meta?.title ?? "Status";
icon = entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
}
const hasContent = isTool
? Boolean(entry.toolInput || entry.toolOutput)
: isReasoning
? Boolean(entry.reasoning?.text?.trim())
: Boolean(entry.meta?.detail?.trim());
const canOpenEvent = Boolean(
entry.eventId &&
onEventClick &&
!(isMeta && entry.meta?.title === "Available commands update"),
);
return (
<div className={`tool-item ${isLast ? "last" : ""} ${isFailed ? "failed" : ""}`}>
<div className="tool-item-connector">
<div className="tool-item-dot" />
{!isLast && <div className="tool-item-line" />}
</div>
<div className="tool-item-content">
<button
className={`tool-item-header ${expanded ? "expanded" : ""}`}
onClick={() => hasContent && setExpanded(!expanded)}
disabled={!hasContent}
>
<span className="tool-item-icon">{icon}</span>
<span className="tool-item-label">{label}</span>
{isInProgress && (
<span className="tool-item-spinner">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
{canOpenEvent && (
<span
className="tool-item-link"
onClick={(e) => {
e.stopPropagation();
onEventClick?.(entry.eventId!);
}}
title="View in Events"
>
<ExternalLink size={10} />
</span>
)}
{hasContent && (
<span className="tool-item-chevron">
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</span>
)}
</button>
{expanded && hasContent && (
<div className="tool-item-body">
{isTool && entry.toolInput && (
<div className="tool-section">
<div className="tool-section-title">Input</div>
<pre className="tool-code">{entry.toolInput}</pre>
</div>
)}
{isTool && isComplete && entry.toolOutput && (
<div className="tool-section">
<div className="tool-section-title">Output</div>
<pre className="tool-code">{entry.toolOutput}</pre>
</div>
)}
{isReasoning && entry.reasoning?.text && (
<div className="tool-section">
<pre className="tool-code muted">{entry.reasoning.text}</pre>
</div>
)}
{isMeta && entry.meta?.detail && (
<div className="tool-section">
<pre className="tool-code">{entry.meta.detail}</pre>
</div>
)}
</div>
)}
</div>
</div>
);
};
const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEventClick?: (eventId: string) => void }) => {
const [expanded, setExpanded] = useState(false);
// If only one item, render it directly without macro wrapper
if (entries.length === 1) {
return (
<div className="tool-group-single">
<ToolItem entry={entries[0]} isLast={true} onEventClick={onEventClick} />
</div>
);
}
const totalCount = entries.length;
const summary = `${totalCount} Event${totalCount > 1 ? "s" : ""}`;
// Check if any are in progress
const hasInProgress = entries.some(e => e.kind === "tool" && e.toolStatus === "in_progress");
const hasFailed = entries.some(e => e.kind === "tool" && e.toolStatus === "failed");
return (
<div className={`tool-group-container ${hasFailed ? "failed" : ""}`}>
<button
className={`tool-group-header ${expanded ? "expanded" : ""}`}
onClick={() => setExpanded(!expanded)}
>
<span className="tool-group-icon">
<PlayCircle size={14} />
</span>
<span className="tool-group-label">{summary}</span>
<span className="tool-group-chevron">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
</button>
{expanded && (
<div className="tool-group">
{entries.map((entry, idx) => (
<ToolItem
key={entry.id}
entry={entry}
isLast={idx === entries.length - 1}
onEventClick={onEventClick}
/>
))}
</div>
)}
</div>
);
};
const agentLogos: Record<string, string> = {
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
};
const ChatMessages = ({
entries,
sessionError,
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId
}: {
entries: TimelineEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
}) => {
// Group consecutive tool/reasoning/meta entries together
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider"; entries: TimelineEntry[] }> = [];
let currentToolGroup: TimelineEntry[] = [];
const flushToolGroup = () => {
if (currentToolGroup.length > 0) {
groupedEntries.push({ type: "tool-group", entries: currentToolGroup });
currentToolGroup = [];
}
};
for (const entry of entries) {
const isStatusDivider = entry.kind === "meta" &&
["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? "");
if (isStatusDivider) {
flushToolGroup();
groupedEntries.push({ type: "divider", entries: [entry] });
} else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) {
currentToolGroup.push(entry);
} else if (entry.kind === "meta" && !entry.meta?.detail) {
// Simple meta without detail - add to tool group as single item
currentToolGroup.push(entry);
} else {
// Regular message
flushToolGroup();
groupedEntries.push({ type: "message", entries: [entry] });
}
}
flushToolGroup();
return (
<div className="messages">
{groupedEntries.map((group, idx) => {
if (group.type === "divider") {
const entry = group.entries[0];
const title = entry.meta?.title ?? "Status";
return (
<div key={entry.id} className="status-divider">
<div className="status-divider-line" />
<span className="status-divider-text">{title}</span>
<div className="status-divider-line" />
</div>
);
}
if (group.type === "tool-group") {
return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />;
}
// Regular message
const entry = group.entries[0];
const messageClass = getMessageClass(entry);
return (
<div key={entry.id} className={`message ${messageClass} no-avatar`}>
<div className="message-content">
{entry.text ? (
<MarkdownText text={entry.text} />
) : (
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
</div>
</div>
);
})}
{sessionError && <div className="message-error">{sessionError}</div>}
{eventError && <div className="message-error">{eventError}</div>}
{isThinking && (
<div className="thinking-row">
<div className="thinking-avatar">
{agentId && agentLogos[agentId] ? (
<img src={agentLogos[agentId]} alt="" className="thinking-avatar-img" />
) : (
<span className="ai-label">AI</span>
)}
</div>
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
);
};
export default ChatMessages;

View file

@ -1,3 +1,4 @@
import type { TranscriptEntry } from "@sandbox-agent/react";
import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo } from "sandbox-agent";
@ -6,9 +7,7 @@ import { formatShortId } from "../../utils/format";
type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string };
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
import type { TimelineEntry } from "./types";
import InspectorConversation from "./InspectorConversation";
const HistoryLoadingSkeleton = () => (
<div className="chat-loading-skeleton" aria-hidden>
@ -59,7 +58,7 @@ const ChatPanel = ({
tokenUsage,
}: {
sessionId: string;
transcriptEntries: TimelineEntry[];
transcriptEntries: TranscriptEntry[];
isLoadingHistory?: boolean;
sessionError: string | null;
message: string;
@ -212,8 +211,8 @@ const ChatPanel = ({
</div>
)}
<div className="messages-container">
{!sessionId ? (
{!sessionId ? (
<div className="messages-container">
<div className="empty-state">
<div className="empty-state-title">No Session Selected</div>
<p className="empty-state-text no-session-subtext">Create a new session to start chatting with an agent.</p>
@ -239,37 +238,35 @@ const ChatPanel = ({
/>
</div>
</div>
) : transcriptEntries.length === 0 && !sessionError ? (
isLoadingHistory ? (
<HistoryLoadingSkeleton />
) : (
<div className="empty-state">
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
</div>
)
) : (
<ChatMessages
entries={transcriptEntries}
sessionError={sessionError}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
/>
)}
</div>
<ChatInput
message={message}
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId || sessionEnded}
/>
</div>
) : (
<InspectorConversation
entries={transcriptEntries}
sessionError={sessionError}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
emptyState={
isLoadingHistory ? (
<HistoryLoadingSkeleton />
) : (
<div className="empty-state">
<Terminal className="empty-state-icon" />
<div className="empty-state-title">Ready to Chat</div>
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
</div>
)
}
message={message}
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : "Send a message..."}
disabled={sessionEnded}
/>
)}
</div>
);
};

View file

@ -0,0 +1,165 @@
import {
AgentConversation,
type AgentConversationClassNames,
type AgentTranscriptClassNames,
type ChatComposerClassNames,
type TranscriptEntry,
} from "@sandbox-agent/react";
import { AlertTriangle, Brain, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Wrench } from "lucide-react";
import type { ReactNode } from "react";
import MarkdownText from "./MarkdownText";
const agentLogos: Record<string, string> = {
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
};
const transcriptClassNames: Partial<AgentTranscriptClassNames> = {
root: "messages",
divider: "status-divider",
dividerLine: "status-divider-line",
dividerText: "status-divider-text",
message: "message",
messageContent: "message-content",
error: "message-error",
toolGroupSingle: "tool-group-single",
toolGroupContainer: "tool-group-container",
toolGroupHeader: "tool-group-header",
toolGroupIcon: "tool-group-icon",
toolGroupLabel: "tool-group-label",
toolGroupChevron: "tool-group-chevron",
toolGroupBody: "tool-group",
toolItem: "tool-item",
toolItemConnector: "tool-item-connector",
toolItemDot: "tool-item-dot",
toolItemLine: "tool-item-line",
toolItemContent: "tool-item-content",
toolItemHeader: "tool-item-header",
toolItemIcon: "tool-item-icon",
toolItemLabel: "tool-item-label",
toolItemSpinner: "tool-item-spinner",
toolItemLink: "tool-item-link",
toolItemChevron: "tool-item-chevron",
toolItemBody: "tool-item-body",
toolSection: "tool-section",
toolSectionTitle: "tool-section-title",
toolCode: "tool-code",
toolCodeMuted: "muted",
thinkingRow: "thinking-row",
thinkingIndicator: "thinking-indicator",
};
const conversationClassNames: Partial<AgentConversationClassNames> = {
root: "chat-conversation",
transcript: "messages-container",
};
const composerClassNames: Partial<ChatComposerClassNames> = {
root: "input-container",
form: "input-wrapper",
submit: "send-button",
};
const ThinkingDots = () => (
<>
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</>
);
export interface InspectorConversationProps {
entries: TranscriptEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
emptyState?: ReactNode;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder: string;
disabled: boolean;
}
const InspectorConversation = ({
entries,
sessionError,
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId,
emptyState,
message,
onMessageChange,
onSendMessage,
onKeyDown,
placeholder,
disabled,
}: InspectorConversationProps) => {
return (
<AgentConversation
entries={entries}
classNames={conversationClassNames}
emptyState={emptyState}
transcriptClassNames={transcriptClassNames}
transcriptProps={{
endRef: messagesEndRef,
sessionError,
eventError,
onEventClick,
isThinking,
agentId,
canOpenEvent: (entry) => !(entry.kind === "meta" && entry.meta?.title === "Available commands update"),
renderMessageText: (entry) => <MarkdownText text={entry.text ?? ""} />,
renderInlinePendingIndicator: () => <ThinkingDots />,
renderToolItemIcon: (entry) => {
if (entry.kind === "tool") {
return <Wrench size={12} />;
}
if (entry.kind === "reasoning") {
return <Brain size={12} />;
}
return entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
},
renderToolGroupIcon: () => <PlayCircle size={14} />,
renderChevron: (expanded) => (expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />),
renderEventLinkContent: () => <ExternalLink size={10} />,
renderThinkingState: ({ agentId: activeAgentId }) => (
<div className="thinking-row">
<div className="thinking-avatar">
{activeAgentId && agentLogos[activeAgentId] ? (
<img src={agentLogos[activeAgentId]} alt="" className="thinking-avatar-img" />
) : (
<span className="ai-label">AI</span>
)}
</div>
<span className="thinking-indicator">
<ThinkingDots />
</span>
</div>
),
}}
composerClassNames={composerClassNames}
composerProps={{
message,
onMessageChange,
onSubmit: onSendMessage,
onKeyDown,
placeholder,
disabled,
submitLabel: "Send",
renderSubmitContent: () => <Send />,
}}
/>
);
};
export default InspectorConversation;

View file

@ -1,19 +0,0 @@
import type { TimelineEntry } from "./types";
import { Settings, AlertTriangle } from "lucide-react";
import type { ReactNode } from "react";
export const getMessageClass = (entry: TimelineEntry) => {
if (entry.kind === "tool") return "tool";
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
if (entry.kind === "reasoning") return "assistant";
if (entry.role === "user") return "user";
return "assistant";
};
export const getAvatarLabel = (messageClass: string): ReactNode => {
if (messageClass === "user") return null;
if (messageClass === "tool") return "T";
if (messageClass === "system") return <Settings size={14} />;
if (messageClass === "error") return <AlertTriangle size={14} />;
return "AI";
};

View file

@ -1,18 +0,0 @@
export type TimelineEntry = {
id: string;
eventId?: string; // Links back to the original event for navigation
kind: "message" | "tool" | "meta" | "reasoning";
time: string;
// For messages:
role?: "user" | "assistant";
text?: string;
// For tool calls:
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolStatus?: string;
// For reasoning:
reasoning?: { text: string; visibility?: string };
// For meta:
meta?: { title: string; detail?: string; severity?: "info" | "error" };
};

View file

@ -0,0 +1,85 @@
"use client";
import type { ReactNode } from "react";
import { AgentTranscript, type AgentTranscriptClassNames, type AgentTranscriptProps, type TranscriptEntry } from "./AgentTranscript.tsx";
import { ChatComposer, type ChatComposerClassNames, type ChatComposerProps } from "./ChatComposer.tsx";
export interface AgentConversationClassNames {
root: string;
transcript: string;
emptyState: string;
composer: string;
}
export interface AgentConversationProps {
entries: TranscriptEntry[];
className?: string;
classNames?: Partial<AgentConversationClassNames>;
emptyState?: ReactNode;
transcriptClassName?: string;
transcriptClassNames?: Partial<AgentTranscriptClassNames>;
composerClassName?: string;
composerClassNames?: Partial<ChatComposerClassNames>;
transcriptProps?: Omit<AgentTranscriptProps, "entries" | "className" | "classNames">;
composerProps?: Omit<ChatComposerProps, "className" | "classNames">;
}
const DEFAULT_CLASS_NAMES: AgentConversationClassNames = {
root: "sa-agent-conversation",
transcript: "sa-agent-conversation-transcript",
emptyState: "sa-agent-conversation-empty-state",
composer: "sa-agent-conversation-composer",
};
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const mergeClassNames = (
defaults: AgentConversationClassNames,
overrides?: Partial<AgentConversationClassNames>,
): AgentConversationClassNames => ({
root: cx(defaults.root, overrides?.root),
transcript: cx(defaults.transcript, overrides?.transcript),
emptyState: cx(defaults.emptyState, overrides?.emptyState),
composer: cx(defaults.composer, overrides?.composer),
});
export const AgentConversation = ({
entries,
className,
classNames: classNameOverrides,
emptyState,
transcriptClassName,
transcriptClassNames,
composerClassName,
composerClassNames,
transcriptProps,
composerProps,
}: AgentConversationProps) => {
const resolvedClassNames = mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides);
const hasTranscriptContent =
entries.length > 0 || Boolean(transcriptProps?.sessionError) || Boolean(transcriptProps?.eventError);
return (
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
{hasTranscriptContent ? (
<AgentTranscript
entries={entries}
className={cx(resolvedClassNames.transcript, transcriptClassName)}
classNames={transcriptClassNames}
{...transcriptProps}
/>
) : emptyState ? (
<div className={resolvedClassNames.emptyState} data-slot="empty-state">
{emptyState}
</div>
) : null}
{composerProps ? (
<ChatComposer
className={cx(resolvedClassNames.composer, composerClassName)}
classNames={composerClassNames}
{...composerProps}
/>
) : null}
</div>
);
};

View file

@ -0,0 +1,603 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { useMemo, useState } from "react";
export type TranscriptEntry = {
id: string;
eventId?: string;
kind: "message" | "tool" | "meta" | "reasoning";
time: string;
role?: "user" | "assistant";
text?: string;
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolStatus?: string;
reasoning?: { text: string; visibility?: string };
meta?: { title: string; detail?: string; severity?: "info" | "error" };
};
export interface AgentTranscriptClassNames {
root: string;
divider: string;
dividerLine: string;
dividerText: string;
message: string;
messageContent: string;
messageText: string;
error: string;
toolGroupSingle: string;
toolGroupContainer: string;
toolGroupHeader: string;
toolGroupIcon: string;
toolGroupLabel: string;
toolGroupChevron: string;
toolGroupBody: string;
toolItem: string;
toolItemConnector: string;
toolItemDot: string;
toolItemLine: string;
toolItemContent: string;
toolItemHeader: string;
toolItemIcon: string;
toolItemLabel: string;
toolItemSpinner: string;
toolItemLink: string;
toolItemChevron: string;
toolItemBody: string;
toolSection: string;
toolSectionTitle: string;
toolCode: string;
toolCodeMuted: string;
thinkingRow: string;
thinkingAvatar: string;
thinkingAvatarImage: string;
thinkingAvatarLabel: string;
thinkingIndicator: string;
thinkingDot: string;
endAnchor: string;
}
export interface AgentTranscriptProps {
entries: TranscriptEntry[];
className?: string;
classNames?: Partial<AgentTranscriptClassNames>;
endRef?: RefObject<HTMLDivElement>;
sessionError?: string | null;
eventError?: string | null;
isThinking?: boolean;
agentId?: string;
onEventClick?: (eventId: string) => void;
isDividerEntry?: (entry: TranscriptEntry) => boolean;
canOpenEvent?: (entry: TranscriptEntry) => boolean;
getToolGroupSummary?: (entries: TranscriptEntry[]) => string;
renderMessageText?: (entry: TranscriptEntry) => ReactNode;
renderInlinePendingIndicator?: () => ReactNode;
renderThinkingState?: (context: { agentId?: string }) => ReactNode;
renderToolItemIcon?: (entry: TranscriptEntry) => ReactNode;
renderToolGroupIcon?: (entries: TranscriptEntry[], expanded: boolean) => ReactNode;
renderChevron?: (expanded: boolean) => ReactNode;
renderEventLinkContent?: (entry: TranscriptEntry) => ReactNode;
}
type GroupedEntries =
| { type: "message"; entries: TranscriptEntry[] }
| { type: "tool-group"; entries: TranscriptEntry[] }
| { type: "divider"; entries: TranscriptEntry[] };
const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
root: "sa-agent-transcript",
divider: "sa-agent-transcript-divider",
dividerLine: "sa-agent-transcript-divider-line",
dividerText: "sa-agent-transcript-divider-text",
message: "sa-agent-transcript-message",
messageContent: "sa-agent-transcript-message-content",
messageText: "sa-agent-transcript-message-text",
error: "sa-agent-transcript-error",
toolGroupSingle: "sa-agent-transcript-tool-group-single",
toolGroupContainer: "sa-agent-transcript-tool-group",
toolGroupHeader: "sa-agent-transcript-tool-group-header",
toolGroupIcon: "sa-agent-transcript-tool-group-icon",
toolGroupLabel: "sa-agent-transcript-tool-group-label",
toolGroupChevron: "sa-agent-transcript-tool-group-chevron",
toolGroupBody: "sa-agent-transcript-tool-group-body",
toolItem: "sa-agent-transcript-tool-item",
toolItemConnector: "sa-agent-transcript-tool-item-connector",
toolItemDot: "sa-agent-transcript-tool-item-dot",
toolItemLine: "sa-agent-transcript-tool-item-line",
toolItemContent: "sa-agent-transcript-tool-item-content",
toolItemHeader: "sa-agent-transcript-tool-item-header",
toolItemIcon: "sa-agent-transcript-tool-item-icon",
toolItemLabel: "sa-agent-transcript-tool-item-label",
toolItemSpinner: "sa-agent-transcript-tool-item-spinner",
toolItemLink: "sa-agent-transcript-tool-item-link",
toolItemChevron: "sa-agent-transcript-tool-item-chevron",
toolItemBody: "sa-agent-transcript-tool-item-body",
toolSection: "sa-agent-transcript-tool-section",
toolSectionTitle: "sa-agent-transcript-tool-section-title",
toolCode: "sa-agent-transcript-tool-code",
toolCodeMuted: "sa-agent-transcript-tool-code-muted",
thinkingRow: "sa-agent-transcript-thinking-row",
thinkingAvatar: "sa-agent-transcript-thinking-avatar",
thinkingAvatarImage: "sa-agent-transcript-thinking-avatar-image",
thinkingAvatarLabel: "sa-agent-transcript-thinking-avatar-label",
thinkingIndicator: "sa-agent-transcript-thinking-indicator",
thinkingDot: "sa-agent-transcript-thinking-dot",
endAnchor: "sa-agent-transcript-end",
};
const DEFAULT_DIVIDER_TITLES = new Set(["Session Started", "Turn Started", "Turn Ended"]);
const mergeClassNames = (
defaults: AgentTranscriptClassNames,
overrides?: Partial<AgentTranscriptClassNames>,
): AgentTranscriptClassNames => ({
root: cx(defaults.root, overrides?.root),
divider: cx(defaults.divider, overrides?.divider),
dividerLine: cx(defaults.dividerLine, overrides?.dividerLine),
dividerText: cx(defaults.dividerText, overrides?.dividerText),
message: cx(defaults.message, overrides?.message),
messageContent: cx(defaults.messageContent, overrides?.messageContent),
messageText: cx(defaults.messageText, overrides?.messageText),
error: cx(defaults.error, overrides?.error),
toolGroupSingle: cx(defaults.toolGroupSingle, overrides?.toolGroupSingle),
toolGroupContainer: cx(defaults.toolGroupContainer, overrides?.toolGroupContainer),
toolGroupHeader: cx(defaults.toolGroupHeader, overrides?.toolGroupHeader),
toolGroupIcon: cx(defaults.toolGroupIcon, overrides?.toolGroupIcon),
toolGroupLabel: cx(defaults.toolGroupLabel, overrides?.toolGroupLabel),
toolGroupChevron: cx(defaults.toolGroupChevron, overrides?.toolGroupChevron),
toolGroupBody: cx(defaults.toolGroupBody, overrides?.toolGroupBody),
toolItem: cx(defaults.toolItem, overrides?.toolItem),
toolItemConnector: cx(defaults.toolItemConnector, overrides?.toolItemConnector),
toolItemDot: cx(defaults.toolItemDot, overrides?.toolItemDot),
toolItemLine: cx(defaults.toolItemLine, overrides?.toolItemLine),
toolItemContent: cx(defaults.toolItemContent, overrides?.toolItemContent),
toolItemHeader: cx(defaults.toolItemHeader, overrides?.toolItemHeader),
toolItemIcon: cx(defaults.toolItemIcon, overrides?.toolItemIcon),
toolItemLabel: cx(defaults.toolItemLabel, overrides?.toolItemLabel),
toolItemSpinner: cx(defaults.toolItemSpinner, overrides?.toolItemSpinner),
toolItemLink: cx(defaults.toolItemLink, overrides?.toolItemLink),
toolItemChevron: cx(defaults.toolItemChevron, overrides?.toolItemChevron),
toolItemBody: cx(defaults.toolItemBody, overrides?.toolItemBody),
toolSection: cx(defaults.toolSection, overrides?.toolSection),
toolSectionTitle: cx(defaults.toolSectionTitle, overrides?.toolSectionTitle),
toolCode: cx(defaults.toolCode, overrides?.toolCode),
toolCodeMuted: cx(defaults.toolCodeMuted, overrides?.toolCodeMuted),
thinkingRow: cx(defaults.thinkingRow, overrides?.thinkingRow),
thinkingAvatar: cx(defaults.thinkingAvatar, overrides?.thinkingAvatar),
thinkingAvatarImage: cx(defaults.thinkingAvatarImage, overrides?.thinkingAvatarImage),
thinkingAvatarLabel: cx(defaults.thinkingAvatarLabel, overrides?.thinkingAvatarLabel),
thinkingIndicator: cx(defaults.thinkingIndicator, overrides?.thinkingIndicator),
thinkingDot: cx(defaults.thinkingDot, overrides?.thinkingDot),
endAnchor: cx(defaults.endAnchor, overrides?.endAnchor),
});
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const getMessageVariant = (entry: TranscriptEntry) => {
if (entry.kind === "tool") return "tool";
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
if (entry.kind === "reasoning") return "assistant";
if (entry.role === "user") return "user";
return "assistant";
};
const getToolItemLabel = (entry: TranscriptEntry) => {
if (entry.kind === "tool") {
const statusLabel =
entry.toolStatus && entry.toolStatus !== "completed"
? ` (${entry.toolStatus.replaceAll("_", " ")})`
: "";
return `${entry.toolName ?? "tool"}${statusLabel}`;
}
if (entry.kind === "reasoning") {
return `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`;
}
return entry.meta?.title ?? "Status";
};
const getDefaultToolItemIcon = (entry: TranscriptEntry) => {
if (entry.kind === "tool") return "Tool";
if (entry.kind === "reasoning") return "Thought";
return entry.meta?.severity === "error" ? "Error" : "Info";
};
const getDefaultToolGroupSummary = (entries: TranscriptEntry[]) => {
const count = entries.length;
return `${count} Event${count === 1 ? "" : "s"}`;
};
const defaultRenderMessageText = (entry: TranscriptEntry) => entry.text;
const defaultRenderPendingIndicator = () => "...";
const defaultRenderChevron = (expanded: boolean) => (expanded ? "▾" : "▸");
const defaultRenderEventLinkContent = () => "Open";
const defaultIsDividerEntry = (entry: TranscriptEntry) =>
entry.kind === "meta" && DEFAULT_DIVIDER_TITLES.has(entry.meta?.title ?? "");
const defaultCanOpenEvent = (entry: TranscriptEntry) => Boolean(entry.eventId);
const buildGroupedEntries = (
entries: TranscriptEntry[],
isDividerEntry: (entry: TranscriptEntry) => boolean,
): GroupedEntries[] => {
const groupedEntries: GroupedEntries[] = [];
let currentToolGroup: TranscriptEntry[] = [];
const flushToolGroup = () => {
if (currentToolGroup.length === 0) {
return;
}
groupedEntries.push({ type: "tool-group", entries: currentToolGroup });
currentToolGroup = [];
};
for (const entry of entries) {
if (isDividerEntry(entry)) {
flushToolGroup();
groupedEntries.push({ type: "divider", entries: [entry] });
continue;
}
if (entry.kind === "tool" || entry.kind === "reasoning" || entry.kind === "meta") {
currentToolGroup.push(entry);
continue;
}
flushToolGroup();
groupedEntries.push({ type: "message", entries: [entry] });
}
flushToolGroup();
return groupedEntries;
};
const ToolItem = ({
entry,
isLast,
classNames,
onEventClick,
canOpenEvent,
renderInlinePendingIndicator,
renderToolItemIcon,
renderChevron,
renderEventLinkContent,
}: {
entry: TranscriptEntry;
isLast: boolean;
classNames: AgentTranscriptClassNames;
onEventClick?: (eventId: string) => void;
canOpenEvent: (entry: TranscriptEntry) => boolean;
renderInlinePendingIndicator: () => ReactNode;
renderToolItemIcon: (entry: TranscriptEntry) => ReactNode;
renderChevron: (expanded: boolean) => ReactNode;
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
}) => {
const [expanded, setExpanded] = useState(false);
const isTool = entry.kind === "tool";
const isReasoning = entry.kind === "reasoning";
const isMeta = entry.kind === "meta";
const isComplete = isTool && (entry.toolStatus === "completed" || entry.toolStatus === "failed");
const isFailed = isTool && entry.toolStatus === "failed";
const isInProgress = isTool && entry.toolStatus === "in_progress";
const hasContent = isTool
? Boolean(entry.toolInput || entry.toolOutput)
: isReasoning
? Boolean(entry.reasoning?.text?.trim())
: Boolean(entry.meta?.detail?.trim());
const showEventLink = Boolean(entry.eventId && onEventClick && canOpenEvent(entry));
return (
<div
className={cx(classNames.toolItem, isLast && "last", isFailed && "failed")}
data-slot="tool-item"
data-kind={entry.kind}
data-state={entry.toolStatus}
data-last={isLast ? "true" : undefined}
data-failed={isFailed ? "true" : undefined}
>
<div className={classNames.toolItemConnector} data-slot="tool-item-connector">
<div className={classNames.toolItemDot} data-slot="tool-item-dot" />
{!isLast ? <div className={classNames.toolItemLine} data-slot="tool-item-line" /> : null}
</div>
<div className={classNames.toolItemContent} data-slot="tool-item-content">
<button
type="button"
className={cx(classNames.toolItemHeader, expanded && "expanded")}
data-slot="tool-item-header"
data-expanded={expanded ? "true" : undefined}
data-has-content={hasContent ? "true" : undefined}
disabled={!hasContent}
onClick={() => {
if (hasContent) {
setExpanded((value) => !value);
}
}}
>
<span className={classNames.toolItemIcon} data-slot="tool-item-icon">
{renderToolItemIcon(entry)}
</span>
<span className={classNames.toolItemLabel} data-slot="tool-item-label">
{getToolItemLabel(entry)}
</span>
{isInProgress ? (
<span className={classNames.toolItemSpinner} data-slot="tool-item-spinner">
{renderInlinePendingIndicator()}
</span>
) : null}
{showEventLink ? (
<span
className={classNames.toolItemLink}
data-slot="tool-item-link"
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
onEventClick?.(entry.eventId!);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onEventClick?.(entry.eventId!);
}
}}
>
{renderEventLinkContent(entry)}
</span>
) : null}
{hasContent ? (
<span className={classNames.toolItemChevron} data-slot="tool-item-chevron">
{renderChevron(expanded)}
</span>
) : null}
</button>
{expanded && hasContent ? (
<div className={classNames.toolItemBody} data-slot="tool-item-body">
{isTool && entry.toolInput ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="input">
<div className={classNames.toolSectionTitle} data-slot="tool-section-title">
Input
</div>
<pre className={classNames.toolCode} data-slot="tool-code">
{entry.toolInput}
</pre>
</div>
) : null}
{isTool && isComplete && entry.toolOutput ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="output">
<div className={classNames.toolSectionTitle} data-slot="tool-section-title">
Output
</div>
<pre className={classNames.toolCode} data-slot="tool-code">
{entry.toolOutput}
</pre>
</div>
) : null}
{isReasoning && entry.reasoning?.text ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="reasoning">
<pre className={cx(classNames.toolCode, classNames.toolCodeMuted)} data-slot="tool-code">
{entry.reasoning.text}
</pre>
</div>
) : null}
{isMeta && entry.meta?.detail ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="meta">
<pre className={classNames.toolCode} data-slot="tool-code">
{entry.meta.detail}
</pre>
</div>
) : null}
</div>
) : null}
</div>
</div>
);
};
const ToolGroup = ({
entries,
classNames,
onEventClick,
canOpenEvent,
getToolGroupSummary,
renderInlinePendingIndicator,
renderToolItemIcon,
renderToolGroupIcon,
renderChevron,
renderEventLinkContent,
}: {
entries: TranscriptEntry[];
classNames: AgentTranscriptClassNames;
onEventClick?: (eventId: string) => void;
canOpenEvent: (entry: TranscriptEntry) => boolean;
getToolGroupSummary: (entries: TranscriptEntry[]) => string;
renderInlinePendingIndicator: () => ReactNode;
renderToolItemIcon: (entry: TranscriptEntry) => ReactNode;
renderToolGroupIcon: (entries: TranscriptEntry[], expanded: boolean) => ReactNode;
renderChevron: (expanded: boolean) => ReactNode;
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
}) => {
const [expanded, setExpanded] = useState(false);
const hasFailed = entries.some((entry) => entry.kind === "tool" && entry.toolStatus === "failed");
if (entries.length === 1) {
return (
<div className={classNames.toolGroupSingle} data-slot="tool-group-single">
<ToolItem
entry={entries[0]}
isLast={true}
classNames={classNames}
onEventClick={onEventClick}
canOpenEvent={canOpenEvent}
renderInlinePendingIndicator={renderInlinePendingIndicator}
renderToolItemIcon={renderToolItemIcon}
renderChevron={renderChevron}
renderEventLinkContent={renderEventLinkContent}
/>
</div>
);
}
return (
<div
className={cx(classNames.toolGroupContainer, hasFailed && "failed")}
data-slot="tool-group"
data-failed={hasFailed ? "true" : undefined}
>
<button
type="button"
className={cx(classNames.toolGroupHeader, expanded && "expanded")}
data-slot="tool-group-header"
data-expanded={expanded ? "true" : undefined}
onClick={() => setExpanded((value) => !value)}
>
<span className={classNames.toolGroupIcon} data-slot="tool-group-icon">
{renderToolGroupIcon(entries, expanded)}
</span>
<span className={classNames.toolGroupLabel} data-slot="tool-group-label">
{getToolGroupSummary(entries)}
</span>
<span className={classNames.toolGroupChevron} data-slot="tool-group-chevron">
{renderChevron(expanded)}
</span>
</button>
{expanded ? (
<div className={classNames.toolGroupBody} data-slot="tool-group-body">
{entries.map((entry, index) => (
<ToolItem
key={entry.id}
entry={entry}
isLast={index === entries.length - 1}
classNames={classNames}
onEventClick={onEventClick}
canOpenEvent={canOpenEvent}
renderInlinePendingIndicator={renderInlinePendingIndicator}
renderToolItemIcon={renderToolItemIcon}
renderChevron={renderChevron}
renderEventLinkContent={renderEventLinkContent}
/>
))}
</div>
) : null}
</div>
);
};
export const AgentTranscript = ({
entries,
className,
classNames: classNameOverrides,
endRef,
sessionError,
eventError,
isThinking,
agentId,
onEventClick,
isDividerEntry = defaultIsDividerEntry,
canOpenEvent = defaultCanOpenEvent,
getToolGroupSummary = getDefaultToolGroupSummary,
renderMessageText = defaultRenderMessageText,
renderInlinePendingIndicator = defaultRenderPendingIndicator,
renderThinkingState,
renderToolItemIcon = getDefaultToolItemIcon,
renderToolGroupIcon = () => null,
renderChevron = defaultRenderChevron,
renderEventLinkContent = defaultRenderEventLinkContent,
}: AgentTranscriptProps) => {
const resolvedClassNames = useMemo(
() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides),
[classNameOverrides],
);
const groupedEntries = useMemo(
() => buildGroupedEntries(entries, isDividerEntry),
[entries, isDividerEntry],
);
return (
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
{groupedEntries.map((group, index) => {
if (group.type === "divider") {
const entry = group.entries[0];
const title = entry.meta?.title ?? "Status";
return (
<div key={entry.id} className={resolvedClassNames.divider} data-slot="divider">
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
<span className={resolvedClassNames.dividerText} data-slot="divider-text">
{title}
</span>
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
</div>
);
}
if (group.type === "tool-group") {
return (
<ToolGroup
key={`tool-group-${index}`}
entries={group.entries}
classNames={resolvedClassNames}
onEventClick={onEventClick}
canOpenEvent={canOpenEvent}
getToolGroupSummary={getToolGroupSummary}
renderInlinePendingIndicator={renderInlinePendingIndicator}
renderToolItemIcon={renderToolItemIcon}
renderToolGroupIcon={renderToolGroupIcon}
renderChevron={renderChevron}
renderEventLinkContent={renderEventLinkContent}
/>
);
}
const entry = group.entries[0];
const messageVariant = getMessageVariant(entry);
return (
<div
key={entry.id}
className={cx(resolvedClassNames.message, messageVariant, "no-avatar")}
data-slot="message"
data-kind={entry.kind}
data-role={entry.role}
data-variant={messageVariant}
data-severity={entry.meta?.severity}
>
<div className={resolvedClassNames.messageContent} data-slot="message-content">
{entry.text ? (
<div className={resolvedClassNames.messageText} data-slot="message-text">
{renderMessageText(entry)}
</div>
) : (
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
{renderInlinePendingIndicator()}
</span>
)}
</div>
</div>
);
})}
{sessionError ? (
<div className={resolvedClassNames.error} data-slot="error" data-source="session">
{sessionError}
</div>
) : null}
{eventError ? (
<div className={resolvedClassNames.error} data-slot="error" data-source="event">
{eventError}
</div>
) : null}
{isThinking
? renderThinkingState?.({ agentId }) ?? (
<div className={resolvedClassNames.thinkingRow} data-slot="thinking-row">
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
Thinking...
</span>
</div>
)
: null}
<div ref={endRef} className={resolvedClassNames.endAnchor} data-slot="end-anchor" />
</div>
);
};

View file

@ -0,0 +1,112 @@
"use client";
import type { KeyboardEvent, ReactNode, TextareaHTMLAttributes } from "react";
export interface ChatComposerClassNames {
root: string;
form: string;
input: string;
submit: string;
submitContent: string;
}
export interface ChatComposerProps {
message: string;
onMessageChange: (value: string) => void;
onSubmit: () => void;
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
disabled?: boolean;
submitDisabled?: boolean;
submitLabel?: string;
className?: string;
classNames?: Partial<ChatComposerClassNames>;
rows?: number;
textareaProps?: Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
"className" | "disabled" | "onChange" | "onKeyDown" | "placeholder" | "rows" | "value"
>;
renderSubmitContent?: () => ReactNode;
}
const DEFAULT_CLASS_NAMES: ChatComposerClassNames = {
root: "sa-chat-composer",
form: "sa-chat-composer-form",
input: "sa-chat-composer-input",
submit: "sa-chat-composer-submit",
submitContent: "sa-chat-composer-submit-content",
};
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const mergeClassNames = (
defaults: ChatComposerClassNames,
overrides?: Partial<ChatComposerClassNames>,
): ChatComposerClassNames => ({
root: cx(defaults.root, overrides?.root),
form: cx(defaults.form, overrides?.form),
input: cx(defaults.input, overrides?.input),
submit: cx(defaults.submit, overrides?.submit),
submitContent: cx(defaults.submitContent, overrides?.submitContent),
});
export const ChatComposer = ({
message,
onMessageChange,
onSubmit,
onKeyDown,
placeholder,
disabled = false,
submitDisabled = false,
submitLabel = "Send",
className,
classNames: classNameOverrides,
rows = 1,
textareaProps,
renderSubmitContent,
}: ChatComposerProps) => {
const resolvedClassNames = mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides);
const isSubmitDisabled = disabled || submitDisabled || message.trim().length === 0;
return (
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
<form
className={resolvedClassNames.form}
data-slot="form"
onSubmit={(event) => {
event.preventDefault();
if (!isSubmitDisabled) {
onSubmit();
}
}}
>
<textarea
{...textareaProps}
className={resolvedClassNames.input}
data-slot="input"
data-disabled={disabled ? "true" : undefined}
data-empty={message.trim().length === 0 ? "true" : undefined}
value={message}
onChange={(event) => onMessageChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
rows={rows}
disabled={disabled}
/>
<button
type="submit"
className={resolvedClassNames.submit}
data-slot="submit"
data-disabled={isSubmitDisabled ? "true" : undefined}
disabled={isSubmitDisabled}
aria-label={submitLabel}
title={submitLabel}
>
<span className={resolvedClassNames.submitContent} data-slot="submit-content">
{renderSubmitContent?.() ?? submitLabel}
</span>
</button>
</form>
</div>
);
};

View file

@ -1,5 +1,24 @@
export { AgentConversation } from "./AgentConversation.tsx";
export { AgentTranscript } from "./AgentTranscript.tsx";
export { ChatComposer } from "./ChatComposer.tsx";
export { ProcessTerminal } from "./ProcessTerminal.tsx";
export type {
AgentConversationClassNames,
AgentConversationProps,
} from "./AgentConversation.tsx";
export type {
AgentTranscriptClassNames,
AgentTranscriptProps,
TranscriptEntry,
} from "./AgentTranscript.tsx";
export type {
ChatComposerClassNames,
ChatComposerProps,
} from "./ChatComposer.tsx";
export type {
ProcessTerminalClient,
ProcessTerminalProps,