diff --git a/CLAUDE.md b/CLAUDE.md index 6f5c3a1..b43ec83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,14 @@ - `Session` helpers are `prompt(...)`, `rawSend(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, `getModes()`, `respondPermission(...)`, `rawRespondPermission(...)`, and `onPermissionRequest(...)`. - 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 - Use `respond(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. diff --git a/docs/react-components.mdx b/docs/react-components.mdx index e37e2a3..0fa41b0 100644 --- a/docs/react-components.mdx +++ b/docs/react-components.mdx @@ -6,6 +6,13 @@ icon: "react" `@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK. +Current exports: + +- `AgentConversation` for a combined transcript + composer surface +- `ProcessTerminal` for attaching to a running tty process +- `AgentTranscript` for rendering session/message timelines without bundling any styles +- `ChatComposer` for a reusable prompt input/send surface + ## Install ```bash @@ -101,3 +108,128 @@ export default function TerminalPane() { - `onExit`, `onError`: optional lifecycle callbacks See [Processes](/processes) for the lower-level terminal APIs. + +## Headless transcript + +`AgentTranscript` is intentionally unstyled. It follows the common headless React pattern used by libraries like Radix, Headless UI, and React Aria: behavior lives in the component, while styling stays in your app through `className`, slot-level `classNames`, and `data-*` state attributes on the rendered DOM. + +```tsx TranscriptPane.tsx +import { + AgentTranscript, + type AgentTranscriptClassNames, + type TranscriptEntry, +} from "@sandbox-agent/react"; + +const transcriptClasses: Partial = { + root: "transcript", + message: "transcript-message", + messageContent: "transcript-message-content", + toolGroupContainer: "transcript-tools", + toolGroupHeader: "transcript-tools-header", + toolItem: "transcript-tool-item", + toolItemHeader: "transcript-tool-item-header", + toolItemBody: "transcript-tool-item-body", + divider: "transcript-divider", + dividerText: "transcript-divider-text", + error: "transcript-error", +}; + +export function TranscriptPane({ entries }: { entries: TranscriptEntry[] }) { + return ( +
{entry.text}
} + renderInlinePendingIndicator={() => ...} + renderToolGroupIcon={() => Events} + renderChevron={(expanded) => {expanded ? "Hide" : "Show"}} + /> + ); +} +``` + +```css +.transcript { + display: grid; + gap: 12px; +} + +.transcript [data-slot="message"][data-variant="user"] .transcript-message-content { + background: #161616; + color: white; +} + +.transcript [data-slot="message"][data-variant="assistant"] .transcript-message-content { + background: #f4f4f0; + color: #161616; +} + +.transcript [data-slot="tool-item"][data-failed="true"] { + border-color: #d33; +} + +.transcript [data-slot="tool-item-header"][data-expanded="true"] { + background: rgba(0, 0, 0, 0.06); +} +``` + +`AgentTranscript` accepts `TranscriptEntry[]`, which matches the Inspector timeline shape: + +- `message` entries render user/assistant text +- `tool` entries render expandable tool input/output sections +- `reasoning` entries render expandable reasoning blocks +- `meta` entries render status rows or expandable metadata details + +Useful props: + +- `className`: root class hook +- `classNames`: slot-level class hooks for styling from outside the package +- `renderMessageText`: custom text or markdown renderer +- `renderToolItemIcon`, `renderToolGroupIcon`, `renderChevron`, `renderEventLinkContent`: presentation overrides +- `renderInlinePendingIndicator`, `renderThinkingState`: loading/thinking UI overrides +- `isDividerEntry`, `canOpenEvent`, `getToolGroupSummary`: behavior overrides for grouping and labels + +## Composer and conversation + +`ChatComposer` is the headless message input. `AgentConversation` composes `AgentTranscript` and `ChatComposer` so apps can reuse the transcript/composer pairing without pulling in Inspector session chrome. + +```tsx ConversationPane.tsx +import { AgentConversation, type TranscriptEntry } from "@sandbox-agent/react"; + +export function ConversationPane({ + entries, + message, + onMessageChange, + onSubmit, +}: { + entries: TranscriptEntry[]; + message: string; + onMessageChange: (value: string) => void; + onSubmit: () => void; +}) { + return ( + Start the conversation.} + transcriptProps={{ + renderMessageText: (entry) =>
{entry.text}
, + }} + composerProps={{ + message, + onMessageChange, + onSubmit, + placeholder: "Send a message...", + }} + /> + ); +} +``` + +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. diff --git a/factory/CLAUDE.md b/factory/CLAUDE.md index 17142ca..7755510 100644 --- a/factory/CLAUDE.md +++ b/factory/CLAUDE.md @@ -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. - 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. +- 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 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. diff --git a/factory/packages/frontend/package.json b/factory/packages/frontend/package.json index 4d00b9f..d9d61f9 100644 --- a/factory/packages/frontend/package.json +++ b/factory/packages/frontend/package.json @@ -10,6 +10,7 @@ "test": "vitest run" }, "dependencies": { + "@sandbox-agent/react": "workspace:*", "@openhandoff/client": "workspace:*", "@openhandoff/frontend-errors": "workspace:*", "@openhandoff/shared": "workspace:*", diff --git a/factory/packages/frontend/src/components/mock-layout/message-list.tsx b/factory/packages/frontend/src/components/mock-layout/message-list.tsx index 8e4c475..e213330 100644 --- a/factory/packages/frontend/src/components/mock-layout/message-list.tsx +++ b/factory/packages/frontend/src/components/mock-layout/message-list.tsx @@ -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 { LabelSmall, LabelXSmall } from "baseui/typography"; import { Copy } from "lucide-react"; @@ -7,6 +8,112 @@ import { HistoryMinimap } from "./history-minimap"; import { SpinnerDot } from "./ui"; import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model"; +const TranscriptMessageBody = memo(function TranscriptMessageBody({ + message, + messageRefs, + copiedMessageId, + onCopyMessage, +}: { + message: Message; + messageRefs: MutableRefObject>; + copiedMessageId: string | null; + onCopyMessage: (message: Message) => void; +}) { + const [css, theme] = useStyletron(); + const isUser = message.sender === "client"; + const isCopied = copiedMessageId === message.id; + const messageTimestamp = formatMessageTimestamp(message.createdAtMs); + const displayFooter = isUser ? messageTimestamp : message.durationMs ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` : null; + + return ( +
{ + if (node) { + messageRefs.current.set(message.id, node); + } else { + messageRefs.current.delete(message.id); + } + }} + className={css({ + display: "flex", + flexDirection: "column", + alignItems: isUser ? "flex-end" : "flex-start", + gap: "6px", + })} + > +
+
+ {message.text} +
+
+
+ {displayFooter ? ( + + {displayFooter} + + ) : null} + +
+
+ ); +}); + export const MessageList = memo(function MessageList({ tab, scrollRef, @@ -27,7 +134,62 @@ export const MessageList = memo(function MessageList({ thinkingTimerLabel: string | null; }) { const [css, theme] = useStyletron(); - const messages = buildDisplayMessages(tab); + const messages = useMemo(() => buildDisplayMessages(tab), [tab]); + const messagesById = useMemo(() => new Map(messages.map((message) => [message.id, message])), [messages]); + const transcriptEntries = useMemo( + () => + 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 = { + 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 ( <> @@ -38,13 +200,12 @@ export const MessageList = memo(function MessageList({ padding: "16px 220px 16px 44px", display: "flex", flexDirection: "column", - gap: "12px", flex: 1, minHeight: 0, overflowY: "auto", })} > - {tab && messages.length === 0 ? ( + {tab && transcriptEntries.length === 0 ? (
- ) : null} - {messages.map((message) => { - const isUser = message.sender === "client"; - const isCopied = copiedMessageId === message.id; - const messageTimestamp = formatMessageTimestamp(message.createdAtMs); - const displayFooter = isUser - ? messageTimestamp - : message.durationMs - ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` - : null; + ) : ( + { + const message = messagesById.get(entry.id); + if (!message) { + return null; + } - return ( -
{ - if (node) { - messageRefs.current.set(message.id, node); - } else { - messageRefs.current.delete(message.id); - } - }} - className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })} - > -
-
-
- {message.text} -
-
-
- {displayFooter ? ( - - {displayFooter} - + return ; + }} + isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)} + renderThinkingState={() => ( +
+ + + Agent is thinking + {thinkingTimerLabel ? ( + + {thinkingTimerLabel} + ) : null} - -
+
-
- ); - })} - {tab && tab.status === "running" && messages.length > 0 ? ( -
- - - Agent is thinking - {thinkingTimerLabel ? ( - - {thinkingTimerLabel} - - ) : null} - -
- ) : null} + )} + /> + )}
); diff --git a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx b/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx index 2e35db8..e809180 100644 --- a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx +++ b/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx @@ -1,9 +1,10 @@ import { memo, type Ref } from "react"; import { useStyletron } from "baseui"; +import { ChatComposer, type ChatComposerClassNames } from "@sandbox-agent/react"; import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react"; 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"; export const PromptComposer = memo(function PromptComposer({ @@ -36,6 +37,65 @@ export const PromptComposer = memo(function PromptComposer({ onSetDefaultModel: (model: ModelId) => void; }) { const [css, theme] = useStyletron(); + const composerClassNames: Partial = { + 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 (
) : null} -
-