mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
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:
parent
6d7e67fe72
commit
0471214d65
19 changed files with 1679 additions and 727 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:*",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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";
|
|
||||||
};
|
|
||||||
|
|
@ -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
3
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||||
|
|
|
||||||
85
sdks/react/src/AgentConversation.tsx
Normal file
85
sdks/react/src/AgentConversation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
785
sdks/react/src/AgentTranscript.tsx
Normal file
785
sdks/react/src/AgentTranscript.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
117
sdks/react/src/ChatComposer.tsx
Normal file
117
sdks/react/src/ChatComposer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue