diff --git a/CLAUDE.md b/CLAUDE.md index a658ecb..b43ec83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,9 +69,10 @@ ### 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/`. -- Keep `docs/react-components.mdx` aligned with the exported surface in `sdks/react/src/index.ts`. +- 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 diff --git a/docs/react-components.mdx b/docs/react-components.mdx index 66199df..0fa41b0 100644 --- a/docs/react-components.mdx +++ b/docs/react-components.mdx @@ -225,4 +225,11 @@ export function ConversationPane({ } ``` +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 baf758f..aec6a55 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,119 @@ 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 +141,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 +207,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 ? ( - + ); + }} + isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)} + renderThinkingState={() => ( +
+ + + Agent is thinking + {thinkingTimerLabel ? ( + - {displayFooter} - + {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 f3cdcd2..8974b35 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} -
-