Share chat UI components in @sandbox-agent/react (#228)

* Extract shared chat UI components

* chore(release): update version to 0.3.1

* Use shared chat UI in Foundry
This commit is contained in:
Nathan Flurry 2026-03-10 22:31:36 -07:00 committed by GitHub
parent 6d7e67fe72
commit 0471214d65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1679 additions and 727 deletions

View file

@ -66,6 +66,14 @@
- `Session` helpers are `prompt(...)`, `rawSend(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, `getModes()`, `respondPermission(...)`, `rawRespondPermission(...)`, and `onPermissionRequest(...)`. - `Session` helpers are `prompt(...)`, `rawSend(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, `getModes()`, `respondPermission(...)`, `rawRespondPermission(...)`, and `onPermissionRequest(...)`.
- Cleanup is `sdk.dispose()`. - Cleanup is `sdk.dispose()`.
### React Component Methodology
- Shared React UI belongs in `sdks/react` only when it is reusable outside the Inspector.
- If the same UI pattern is shared between the Sandbox Agent Inspector and Foundry, prefer extracting it into `sdks/react` instead of maintaining parallel implementations.
- 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/`.
- Document all shared React components in `docs/react-components.mdx`, and keep that page aligned with the exported surface in `sdks/react/src/index.ts`.
### TypeScript SDK Naming Conventions ### TypeScript SDK Naming Conventions
- Use `respond<Thing>(id, reply)` for SDK methods that reply to an agent-initiated request (e.g. `respondPermission`). This is the standard pattern for answering any inbound JSON-RPC request from the agent. - Use `respond<Thing>(id, reply)` for SDK methods that reply to an agent-initiated request (e.g. `respondPermission`). This is the standard pattern for answering any inbound JSON-RPC request from the agent.

View file

@ -6,6 +6,13 @@ icon: "react"
`@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK. `@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 ## Install
```bash ```bash
@ -101,3 +108,128 @@ export default function TerminalPane() {
- `onExit`, `onError`: optional lifecycle callbacks - `onExit`, `onError`: optional lifecycle callbacks
See [Processes](/processes) for the lower-level terminal APIs. 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...",
}}
/>
);
}
```
Useful `ChatComposer` props:
- `className` and `classNames` for external styling
- `inputRef` to manage focus or autoresize from the consumer
- `textareaProps` for lower-level textarea behavior
- `allowEmptySubmit` when the submit action is valid without draft text, such as a stop button
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

@ -53,6 +53,8 @@ Use `pnpm` workspaces and Turborepo.
- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows. - GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows.
- Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up. - Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up.
- Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain. - Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain.
- If Foundry uses a shared component from `@sandbox-agent/react`, make changes in `sdks/react` instead of copying or forking that component into Foundry.
- When changing shared React components in `sdks/react` for Foundry, verify they still work in the Sandbox Agent Inspector before finishing.
- When making UI changes, verify the live flow with `agent-browser`, take screenshots of the updated UI, and offer to open those screenshots in Preview when you finish. - When making UI changes, verify the live flow with `agent-browser`, take screenshots of the updated UI, and offer to open those screenshots in Preview when you finish.
- When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow. - When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow.
- If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it. - If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it.

View file

@ -10,6 +10,7 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@sandbox-agent/react": "workspace:*",
"@openhandoff/client": "workspace:*", "@openhandoff/client": "workspace:*",
"@openhandoff/frontend-errors": "workspace:*", "@openhandoff/frontend-errors": "workspace:*",
"@openhandoff/shared": "workspace:*", "@openhandoff/shared": "workspace:*",

View file

@ -1,4 +1,5 @@
import { memo, type MutableRefObject, type Ref } from "react"; import { AgentTranscript, type AgentTranscriptClassNames, type TranscriptEntry } from "@sandbox-agent/react";
import { memo, useMemo, type MutableRefObject, type Ref } from "react";
import { useStyletron } from "baseui"; import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography"; import { LabelSmall, LabelXSmall } from "baseui/typography";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
@ -7,61 +8,18 @@ import { HistoryMinimap } from "./history-minimap";
import { SpinnerDot } from "./ui"; import { SpinnerDot } from "./ui";
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model"; import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model";
export const MessageList = memo(function MessageList({ const TranscriptMessageBody = memo(function TranscriptMessageBody({
tab, message,
scrollRef,
messageRefs, messageRefs,
historyEvents,
onSelectHistoryEvent,
copiedMessageId, copiedMessageId,
onCopyMessage, onCopyMessage,
thinkingTimerLabel,
}: { }: {
tab: AgentTab | null | undefined; message: Message;
scrollRef: Ref<HTMLDivElement>;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>; messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
historyEvents: HistoryEvent[];
onSelectHistoryEvent: (event: HistoryEvent) => void;
copiedMessageId: string | null; copiedMessageId: string | null;
onCopyMessage: (message: Message) => void; onCopyMessage: (message: Message) => void;
thinkingTimerLabel: string | null;
}) { }) {
const [css, theme] = useStyletron(); const [css, theme] = useStyletron();
const messages = buildDisplayMessages(tab);
return (
<>
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
<div
ref={scrollRef}
className={css({
padding: "16px 220px 16px 44px",
display: "flex",
flexDirection: "column",
gap: "12px",
flex: 1,
minHeight: 0,
overflowY: "auto",
})}
>
{tab && messages.length === 0 ? (
<div
className={css({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
flex: 1,
minHeight: "200px",
gap: "8px",
})}
>
<LabelSmall color={theme.colors.contentTertiary}>
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
</LabelSmall>
</div>
) : null}
{messages.map((message) => {
const isUser = message.sender === "client"; const isUser = message.sender === "client";
const isCopied = copiedMessageId === message.id; const isCopied = copiedMessageId === message.id;
const messageTimestamp = formatMessageTimestamp(message.createdAtMs); const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
@ -73,7 +31,6 @@ export const MessageList = memo(function MessageList({
return ( return (
<div <div
key={message.id}
ref={(node) => { ref={(node) => {
if (node) { if (node) {
messageRefs.current.set(message.id, node); messageRefs.current.set(message.id, node);
@ -81,11 +38,7 @@ export const MessageList = memo(function MessageList({
messageRefs.current.delete(message.id); messageRefs.current.delete(message.id);
} }
}} }}
className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })}
>
<div
className={css({ className={css({
maxWidth: "80%",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start", alignItems: isUser ? "flex-end" : "flex-start",
@ -165,11 +118,138 @@ export const MessageList = memo(function MessageList({
</button> </button>
</div> </div>
</div> </div>
</div>
); );
});
export const MessageList = memo(function MessageList({
tab,
scrollRef,
messageRefs,
historyEvents,
onSelectHistoryEvent,
copiedMessageId,
onCopyMessage,
thinkingTimerLabel,
}: {
tab: AgentTab | null | undefined;
scrollRef: Ref<HTMLDivElement>;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
historyEvents: HistoryEvent[];
onSelectHistoryEvent: (event: HistoryEvent) => void;
copiedMessageId: string | null;
onCopyMessage: (message: Message) => void;
thinkingTimerLabel: string | null;
}) {
const [css, theme] = useStyletron();
const messages = useMemo(() => buildDisplayMessages(tab), [tab]);
const messagesById = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]);
const transcriptEntries = useMemo<TranscriptEntry[]>(
() =>
messages.map((message) => ({
id: message.id,
eventId: message.id,
kind: "message",
time: new Date(message.createdAtMs).toISOString(),
role: message.sender === "client" ? "user" : "assistant",
text: message.text,
})),
[messages],
);
const messageContentClass = css({
maxWidth: "80%",
display: "flex",
flexDirection: "column",
});
const transcriptClassNames: Partial<AgentTranscriptClassNames> = {
root: css({
display: "flex",
flexDirection: "column",
gap: "12px",
}),
message: css({
display: "flex",
'&[data-variant="user"]': {
justifyContent: "flex-end",
},
'&[data-variant="assistant"]': {
justifyContent: "flex-start",
},
}),
messageContent: messageContentClass,
messageText: css({
width: "100%",
}),
thinkingRow: css({
display: "flex",
alignItems: "center",
gap: "8px",
padding: "4px 0",
}),
thinkingIndicator: css({
display: "flex",
alignItems: "center",
gap: "8px",
color: "#ff4f00",
fontSize: "11px",
fontFamily: '"IBM Plex Mono", monospace',
letterSpacing: "0.01em",
}),
};
return (
<>
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
<div
ref={scrollRef}
className={css({
padding: "16px 220px 16px 44px",
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
overflowY: "auto",
})} })}
{tab && tab.status === "running" && messages.length > 0 ? ( >
<div className={css({ display: "flex", alignItems: "center", gap: "8px", padding: "4px 0" })}> {tab && transcriptEntries.length === 0 ? (
<div
className={css({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
flex: 1,
minHeight: "200px",
gap: "8px",
})}
>
<LabelSmall color={theme.colors.contentTertiary}>
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
</LabelSmall>
</div>
) : (
<AgentTranscript
entries={transcriptEntries}
classNames={transcriptClassNames}
renderMessageText={(entry) => {
const message = messagesById.get(entry.id);
if (!message) {
return null;
}
return (
<TranscriptMessageBody
message={message}
messageRefs={messageRefs}
copiedMessageId={copiedMessageId}
onCopyMessage={onCopyMessage}
/>
);
}}
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
renderThinkingState={() => (
<div className={transcriptClassNames.thinkingRow}>
<SpinnerDot size={12} /> <SpinnerDot size={12} />
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}> <LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>Agent is thinking</span> <span>Agent is thinking</span>
@ -190,7 +270,9 @@ export const MessageList = memo(function MessageList({
) : null} ) : null}
</LabelXSmall> </LabelXSmall>
</div> </div>
) : null} )}
/>
)}
</div> </div>
</> </>
); );

View file

@ -1,9 +1,10 @@
import { memo, type Ref } from "react"; import { memo, type Ref } from "react";
import { useStyletron } from "baseui"; import { useStyletron } from "baseui";
import { ChatComposer, type ChatComposerClassNames } from "@sandbox-agent/react";
import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react"; import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react";
import { ModelPicker } from "./model-picker"; import { ModelPicker } from "./model-picker";
import { PROMPT_TEXTAREA_MIN_HEIGHT, PROMPT_TEXTAREA_MAX_HEIGHT } from "./ui"; import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT } from "./ui";
import { fileName, type LineAttachment, type ModelId } from "./view-model"; import { fileName, type LineAttachment, type ModelId } from "./view-model";
export const PromptComposer = memo(function PromptComposer({ export const PromptComposer = memo(function PromptComposer({
@ -36,6 +37,65 @@ export const PromptComposer = memo(function PromptComposer({
onSetDefaultModel: (model: ModelId) => void; onSetDefaultModel: (model: ModelId) => void;
}) { }) {
const [css, theme] = useStyletron(); const [css, theme] = useStyletron();
const composerClassNames: Partial<ChatComposerClassNames> = {
form: css({
position: "relative",
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
borderRadius: "16px",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
transition: "border-color 200ms ease",
":focus-within": { borderColor: "rgba(255, 255, 255, 0.3)" },
}),
input: css({
display: "block",
width: "100%",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
padding: "12px 58px 12px 14px",
background: "transparent",
border: "none",
borderRadius: "16px",
color: theme.colors.contentPrimary,
fontSize: "13px",
fontFamily: "inherit",
resize: "none",
outline: "none",
lineHeight: "1.4",
maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT}px`,
boxSizing: "border-box",
overflowY: "hidden",
"::placeholder": { color: theme.colors.contentSecondary },
}),
submit: css({
all: "unset",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: theme.colors.contentPrimary,
transition: "background 200ms ease",
backgroundColor: isRunning ? "rgba(255, 255, 255, 0.06)" : "#ff4f00",
":hover": {
backgroundColor: isRunning ? "rgba(255, 255, 255, 0.12)" : "#ff6a00",
},
":disabled": {
cursor: "not-allowed",
opacity: 0.45,
},
}),
submitContent: css({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: isRunning ? theme.colors.contentPrimary : "#ffffff",
}),
};
return ( return (
<div <div
@ -79,97 +139,28 @@ export const PromptComposer = memo(function PromptComposer({
))} ))}
</div> </div>
) : null} ) : null}
<div <ChatComposer
className={css({ message={draft}
position: "relative", onMessageChange={onDraftChange}
backgroundColor: "rgba(255, 255, 255, 0.06)", onSubmit={isRunning ? onStop : onSend}
border: `1px solid ${theme.colors.borderOpaque}`,
borderRadius: "16px",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
transition: "border-color 200ms ease",
":focus-within": { borderColor: "rgba(255, 255, 255, 0.3)" },
})}
>
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); event.preventDefault();
if (isRunning) {
onStop();
} else {
onSend(); onSend();
} }
}
}} }}
placeholder={placeholder} placeholder={placeholder}
inputRef={textareaRef}
rows={1} rows={1}
className={css({ allowEmptySubmit={isRunning}
display: "block", submitLabel={isRunning ? "Stop" : "Send"}
width: "100%", classNames={composerClassNames}
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`, renderSubmitContent={() => (isRunning ? <Square size={16} /> : <ArrowUpFromLine size={16} />)}
padding: "12px 58px 12px 14px",
background: "transparent",
border: "none",
borderRadius: "16px",
color: theme.colors.contentPrimary,
fontSize: "13px",
fontFamily: "inherit",
resize: "none",
outline: "none",
lineHeight: "1.4",
maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT}px`,
boxSizing: "border-box",
overflowY: "hidden",
"::placeholder": { color: theme.colors.contentSecondary },
})}
/> />
{isRunning ? (
<button
onClick={onStop}
className={css({
all: "unset",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 0.06)",
color: theme.colors.contentPrimary,
transition: "background 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.12)" },
})}
>
<Square size={16} />
</button>
) : (
<button
onClick={onSend}
className={css({
all: "unset",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#ff4f00",
color: "#ffffff",
transition: "background 200ms ease",
":hover": { backgroundColor: "#ff6a00" },
})}
>
<ArrowUpFromLine size={16} />
</button>
)}
</div>
<ModelPicker <ModelPicker
value={model} value={model}
defaultModel={defaultModel} defaultModel={defaultModel}

View file

@ -1348,6 +1348,13 @@
padding: 16px; padding: 16px;
} }
.chat-conversation {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.messages-container:has(.empty-state) { .messages-container:has(.empty-state) {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -1,3 +1,4 @@
import type { TranscriptEntry } from "@sandbox-agent/react";
import { BookOpen } from "lucide-react"; import { BookOpen } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
@ -25,7 +26,6 @@ type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string }; type AgentModelInfo = { id: string; name?: string };
import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb";
import ChatPanel from "./components/chat/ChatPanel"; import ChatPanel from "./components/chat/ChatPanel";
import type { TimelineEntry } from "./components/chat/types";
import ConnectScreen from "./components/ConnectScreen"; import ConnectScreen from "./components/ConnectScreen";
import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel"; import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel";
import SessionSidebar from "./components/SessionSidebar"; import SessionSidebar from "./components/SessionSidebar";
@ -977,7 +977,7 @@ export default function App() {
// Build transcript entries from raw SessionEvents // Build transcript entries from raw SessionEvents
const transcriptEntries = useMemo(() => { const transcriptEntries = useMemo(() => {
const entries: TimelineEntry[] = []; const entries: TranscriptEntry[] = [];
// Accumulators for streaming chunks // Accumulators for streaming chunks
let assistantAccumId: string | null = null; let assistantAccumId: string | null = null;
@ -1010,7 +1010,7 @@ export default function App() {
}; };
// Track tool calls by ID for updates // Track tool calls by ID for updates
const toolEntryMap = new Map<string, TimelineEntry>(); const toolEntryMap = new Map<string, TranscriptEntry>();
for (const event of events) { for (const event of events) {
const payload = event.payload as Record<string, unknown>; const payload = event.payload as Record<string, unknown>;
@ -1124,7 +1124,7 @@ export default function App() {
if (update.title) existing.toolName = update.title as string; if (update.title) existing.toolName = update.title as string;
existing.time = time; existing.time = time;
} else { } else {
const entry: TimelineEntry = { const entry: TranscriptEntry = {
id: `tool-${toolCallId}`, id: `tool-${toolCallId}`,
eventId: event.id, eventId: event.id,
kind: "tool", 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,375 +0,0 @@
import { useState } from "react";
import { getMessageClass } from "./messageUtils";
import type { TimelineEntry } from "./types";
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle, Shield, Check, X } 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 PermissionPrompt = ({
entry,
onPermissionReply,
}: {
entry: TimelineEntry;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => {
const perm = entry.permission;
if (!perm) return null;
const resolved = perm.resolved;
const selectedId = perm.selectedOptionId;
const replyForOption = (kind: string): "once" | "always" | "reject" => {
if (kind === "allow_once") return "once";
if (kind === "allow_always") return "always";
return "reject";
};
const labelForKind = (kind: string, name: string): string => {
if (name) return name;
if (kind === "allow_once") return "Allow Once";
if (kind === "allow_always") return "Always Allow";
if (kind === "reject_once") return "Reject";
if (kind === "reject_always") return "Reject Always";
return kind;
};
const classForKind = (kind: string): string => {
if (kind.startsWith("allow")) return "allow";
return "reject";
};
return (
<div className={`permission-prompt ${resolved ? "resolved" : ""}`}>
<div className="permission-header">
<Shield size={14} className="permission-icon" />
<span className="permission-title">{perm.title}</span>
</div>
{perm.description && (
<div className="permission-description">{perm.description}</div>
)}
<div className="permission-actions">
{perm.options.map((opt) => {
const isSelected = resolved && selectedId === opt.optionId;
const wasRejected = resolved && !isSelected && selectedId != null;
return (
<button
key={opt.optionId}
type="button"
className={`permission-btn ${classForKind(opt.kind)} ${isSelected ? "selected" : ""} ${wasRejected ? "dimmed" : ""}`}
disabled={resolved}
onClick={() => onPermissionReply?.(perm.permissionId, replyForOption(opt.kind))}
>
{isSelected && (opt.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />)}
{labelForKind(opt.kind, opt.name)}
</button>
);
})}
{resolved && !selectedId && (
<span className="permission-auto-resolved">Auto-resolved</span>
)}
</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,
onPermissionReply,
}: {
entries: TimelineEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
}) => {
// Group consecutive tool/reasoning/meta entries together
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider" | "permission"; 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 (entry.kind === "permission") {
flushToolGroup();
groupedEntries.push({ type: "permission", entries: [entry] });
} else 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 === "permission") {
const entry = group.entries[0];
return (
<PermissionPrompt
key={entry.id}
entry={entry}
onPermissionReply={onPermissionReply}
/>
);
}
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 { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { AgentInfo } from "sandbox-agent"; import type { AgentInfo } from "sandbox-agent";
@ -6,9 +7,7 @@ import { formatShortId } from "../../utils/format";
type AgentModeInfo = { id: string; name: string; description: string }; type AgentModeInfo = { id: string; name: string; description: string };
type AgentModelInfo = { id: string; name?: string }; type AgentModelInfo = { id: string; name?: string };
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu"; import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput"; import InspectorConversation from "./InspectorConversation";
import ChatMessages from "./ChatMessages";
import type { TimelineEntry } from "./types";
const HistoryLoadingSkeleton = () => ( const HistoryLoadingSkeleton = () => (
<div className="chat-loading-skeleton" aria-hidden> <div className="chat-loading-skeleton" aria-hidden>
@ -60,7 +59,7 @@ const ChatPanel = ({
onPermissionReply, onPermissionReply,
}: { }: {
sessionId: string; sessionId: string;
transcriptEntries: TimelineEntry[]; transcriptEntries: TranscriptEntry[];
isLoadingHistory?: boolean; isLoadingHistory?: boolean;
sessionError: string | null; sessionError: string | null;
message: string; message: string;
@ -214,8 +213,8 @@ const ChatPanel = ({
</div> </div>
)} )}
<div className="messages-container">
{!sessionId ? ( {!sessionId ? (
<div className="messages-container">
<div className="empty-state"> <div className="empty-state">
<div className="empty-state-title">No Session Selected</div> <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> <p className="empty-state-text no-session-subtext">Create a new session to start chatting with an agent.</p>
@ -241,7 +240,17 @@ const ChatPanel = ({
/> />
</div> </div>
</div> </div>
) : transcriptEntries.length === 0 && !sessionError ? ( </div>
) : (
<InspectorConversation
entries={transcriptEntries}
sessionError={sessionError}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
emptyState={
isLoadingHistory ? ( isLoadingHistory ? (
<HistoryLoadingSkeleton /> <HistoryLoadingSkeleton />
) : ( ) : (
@ -251,28 +260,16 @@ const ChatPanel = ({
<p className="empty-state-text">Send a message to start a conversation with the agent.</p> <p className="empty-state-text">Send a message to start a conversation with the agent.</p>
</div> </div>
) )
) : ( }
<ChatMessages
entries={transcriptEntries}
sessionError={sessionError}
eventError={null}
messagesEndRef={messagesEndRef}
onEventClick={onEventClick}
isThinking={isThinking}
agentId={agentId}
onPermissionReply={onPermissionReply}
/>
)}
</div>
<ChatInput
message={message} message={message}
onMessageChange={onMessageChange} onMessageChange={onMessageChange}
onSendMessage={onSendMessage} onSendMessage={onSendMessage}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"} placeholder={sessionEnded ? "Session ended" : "Send a message..."}
disabled={!sessionId || sessionEnded} disabled={sessionEnded}
onPermissionReply={onPermissionReply}
/> />
)}
</div> </div>
); );
}; };

View file

@ -0,0 +1,184 @@
import {
AgentConversation,
type AgentConversationClassNames,
type AgentTranscriptClassNames,
type ChatComposerClassNames,
type PermissionReply,
type TranscriptEntry,
} from "@sandbox-agent/react";
import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Shield, Wrench, X } 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",
permissionPrompt: "permission-prompt",
permissionHeader: "permission-header",
permissionIcon: "permission-icon",
permissionTitle: "permission-title",
permissionDescription: "permission-description",
permissionActions: "permission-actions",
permissionButton: "permission-btn",
permissionAutoResolved: "permission-auto-resolved",
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;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
}
const InspectorConversation = ({
entries,
sessionError,
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId,
emptyState,
message,
onMessageChange,
onSendMessage,
onKeyDown,
placeholder,
disabled,
onPermissionReply,
}: 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} />,
onPermissionReply,
renderPermissionIcon: () => <Shield size={14} />,
renderPermissionOptionContent: ({ option, label, selected }) => (
<>
{selected ? (option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />) : null}
{label}
</>
),
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,33 +0,0 @@
export type PermissionOption = {
optionId: string;
name: string;
kind: string;
};
export type TimelineEntry = {
id: string;
eventId?: string; // Links back to the original event for navigation
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
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" };
// For permission requests:
permission?: {
permissionId: string;
title: string;
description?: string;
options: PermissionOption[];
resolved?: boolean;
selectedOptionId?: string;
};
};

3
pnpm-lock.yaml generated
View file

@ -520,6 +520,9 @@ importers:
'@openhandoff/shared': '@openhandoff/shared':
specifier: workspace:* specifier: workspace:*
version: link:../shared version: link:../shared
'@sandbox-agent/react':
specifier: workspace:*
version: link:../../../sdks/react
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.85.5 specifier: ^5.85.5
version: 5.90.21(react@19.2.4) version: 5.90.21(react@19.2.4)

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,785 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { useMemo, useState } from "react";
export type PermissionReply = "once" | "always" | "reject";
export type PermissionOption = {
optionId: string;
name: string;
kind: string;
};
export type TranscriptEntry = {
id: string;
eventId?: string;
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
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" };
permission?: {
permissionId: string;
title: string;
description?: string;
options: PermissionOption[];
resolved?: boolean;
selectedOptionId?: string;
};
};
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;
permissionPrompt: string;
permissionHeader: string;
permissionIcon: string;
permissionTitle: string;
permissionDescription: string;
permissionActions: string;
permissionButton: string;
permissionAutoResolved: string;
thinkingRow: string;
thinkingAvatar: string;
thinkingAvatarImage: string;
thinkingAvatarLabel: string;
thinkingIndicator: string;
thinkingDot: string;
endAnchor: string;
}
export interface PermissionOptionRenderContext {
entry: TranscriptEntry;
option: PermissionOption;
label: string;
reply: PermissionReply;
selected: boolean;
dimmed: boolean;
resolved: boolean;
}
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;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => 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;
renderPermissionIcon?: (entry: TranscriptEntry) => ReactNode;
renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode;
}
type GroupedEntries =
| { type: "message"; entries: TranscriptEntry[] }
| { type: "tool-group"; entries: TranscriptEntry[] }
| { type: "divider"; entries: TranscriptEntry[] }
| { type: "permission"; 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",
permissionPrompt: "sa-agent-transcript-permission-prompt",
permissionHeader: "sa-agent-transcript-permission-header",
permissionIcon: "sa-agent-transcript-permission-icon",
permissionTitle: "sa-agent-transcript-permission-title",
permissionDescription: "sa-agent-transcript-permission-description",
permissionActions: "sa-agent-transcript-permission-actions",
permissionButton: "sa-agent-transcript-permission-button",
permissionAutoResolved: "sa-agent-transcript-permission-auto-resolved",
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 cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
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),
permissionPrompt: cx(defaults.permissionPrompt, overrides?.permissionPrompt),
permissionHeader: cx(defaults.permissionHeader, overrides?.permissionHeader),
permissionIcon: cx(defaults.permissionIcon, overrides?.permissionIcon),
permissionTitle: cx(defaults.permissionTitle, overrides?.permissionTitle),
permissionDescription: cx(defaults.permissionDescription, overrides?.permissionDescription),
permissionActions: cx(defaults.permissionActions, overrides?.permissionActions),
permissionButton: cx(defaults.permissionButton, overrides?.permissionButton),
permissionAutoResolved: cx(defaults.permissionAutoResolved, overrides?.permissionAutoResolved),
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 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.kind === "permission") return "system";
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 getPermissionReplyForOption = (kind: string): PermissionReply => {
if (kind === "allow_once") return "once";
if (kind === "allow_always") return "always";
return "reject";
};
const getPermissionOptionLabel = (option: PermissionOption) => {
if (option.name) return option.name;
if (option.kind === "allow_once") return "Allow Once";
if (option.kind === "allow_always") return "Always Allow";
if (option.kind === "reject_once") return "Reject";
if (option.kind === "reject_always") return "Reject Always";
return option.kind;
};
const getPermissionOptionTone = (kind: string) => (kind.startsWith("allow") ? "allow" : "reject");
const defaultRenderMessageText = (entry: TranscriptEntry) => entry.text;
const defaultRenderPendingIndicator = () => "...";
const defaultRenderChevron = (expanded: boolean) => (expanded ? "▾" : "▸");
const defaultRenderEventLinkContent = () => "Open";
const defaultRenderPermissionIcon = () => "Permission";
const defaultRenderPermissionOptionContent = ({
label,
}: PermissionOptionRenderContext) => label;
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 === "permission") {
flushToolGroup();
groupedEntries.push({ type: "permission", 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>
);
};
const PermissionPrompt = ({
entry,
classNames,
onPermissionReply,
renderPermissionIcon,
renderPermissionOptionContent,
}: {
entry: TranscriptEntry;
classNames: AgentTranscriptClassNames;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
renderPermissionIcon: (entry: TranscriptEntry) => ReactNode;
renderPermissionOptionContent: (context: PermissionOptionRenderContext) => ReactNode;
}) => {
const permission = entry.permission;
if (!permission) {
return null;
}
const resolved = Boolean(permission.resolved);
const selectedOptionId = permission.selectedOptionId;
const canReply = Boolean(onPermissionReply) && !resolved;
return (
<div
className={cx(classNames.permissionPrompt, resolved && "resolved")}
data-slot="permission-prompt"
data-resolved={resolved ? "true" : undefined}
>
<div className={classNames.permissionHeader} data-slot="permission-header">
<span className={classNames.permissionIcon} data-slot="permission-icon">
{renderPermissionIcon(entry)}
</span>
<span className={classNames.permissionTitle} data-slot="permission-title">
{permission.title}
</span>
</div>
{permission.description ? (
<div className={classNames.permissionDescription} data-slot="permission-description">
{permission.description}
</div>
) : null}
<div className={classNames.permissionActions} data-slot="permission-actions">
{permission.options.map((option) => {
const reply = getPermissionReplyForOption(option.kind);
const label = getPermissionOptionLabel(option);
const selected = resolved && selectedOptionId === option.optionId;
const dimmed = resolved && !selected && selectedOptionId != null;
const tone = getPermissionOptionTone(option.kind);
return (
<button
key={option.optionId}
type="button"
className={cx(classNames.permissionButton, tone, selected && "selected", dimmed && "dimmed")}
data-slot="permission-button"
data-tone={tone}
data-selected={selected ? "true" : undefined}
data-dimmed={dimmed ? "true" : undefined}
disabled={!canReply}
onClick={() => onPermissionReply?.(permission.permissionId, reply)}
>
{renderPermissionOptionContent({
entry,
option,
label,
reply,
selected,
dimmed,
resolved,
})}
</button>
);
})}
{resolved && !selectedOptionId ? (
<span className={classNames.permissionAutoResolved} data-slot="permission-auto-resolved">
Auto-resolved
</span>
) : null}
</div>
</div>
);
};
export const AgentTranscript = ({
entries,
className,
classNames: classNameOverrides,
endRef,
sessionError,
eventError,
isThinking,
agentId,
onEventClick,
onPermissionReply,
isDividerEntry = defaultIsDividerEntry,
canOpenEvent = defaultCanOpenEvent,
getToolGroupSummary = getDefaultToolGroupSummary,
renderMessageText = defaultRenderMessageText,
renderInlinePendingIndicator = defaultRenderPendingIndicator,
renderThinkingState,
renderToolItemIcon = getDefaultToolItemIcon,
renderToolGroupIcon = () => null,
renderChevron = defaultRenderChevron,
renderEventLinkContent = defaultRenderEventLinkContent,
renderPermissionIcon = defaultRenderPermissionIcon,
renderPermissionOptionContent = defaultRenderPermissionOptionContent,
}: 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}
/>
);
}
if (group.type === "permission") {
const entry = group.entries[0];
return (
<PermissionPrompt
key={entry.id}
entry={entry}
classNames={resolvedClassNames}
onPermissionReply={onPermissionReply}
renderPermissionIcon={renderPermissionIcon}
renderPermissionOptionContent={renderPermissionOptionContent}
/>
);
}
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,117 @@
"use client";
import type { KeyboardEvent, ReactNode, Ref, 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;
allowEmptySubmit?: boolean;
submitLabel?: string;
className?: string;
classNames?: Partial<ChatComposerClassNames>;
inputRef?: Ref<HTMLTextAreaElement>;
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,
allowEmptySubmit = false,
submitLabel = "Send",
className,
classNames: classNameOverrides,
inputRef,
rows = 1,
textareaProps,
renderSubmitContent,
}: ChatComposerProps) => {
const resolvedClassNames = mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides);
const isSubmitDisabled = disabled || submitDisabled || (!allowEmptySubmit && 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}
ref={inputRef}
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,27 @@
export { AgentConversation } from "./AgentConversation.tsx";
export { AgentTranscript } from "./AgentTranscript.tsx";
export { ChatComposer } from "./ChatComposer.tsx";
export { ProcessTerminal } from "./ProcessTerminal.tsx"; export { ProcessTerminal } from "./ProcessTerminal.tsx";
export type {
AgentConversationClassNames,
AgentConversationProps,
} from "./AgentConversation.tsx";
export type {
AgentTranscriptClassNames,
AgentTranscriptProps,
PermissionOption,
PermissionOptionRenderContext,
PermissionReply,
TranscriptEntry,
} from "./AgentTranscript.tsx";
export type {
ChatComposerClassNames,
ChatComposerProps,
} from "./ChatComposer.tsx";
export type { export type {
ProcessTerminalClient, ProcessTerminalClient,
ProcessTerminalProps, ProcessTerminalProps,