Use shared chat UI in Foundry

This commit is contained in:
Nathan Flurry 2026-03-10 22:30:38 -07:00
parent b609f1ab2b
commit 0ec470d494
8 changed files with 316 additions and 224 deletions

View file

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

View file

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

View file

@ -53,6 +53,8 @@ Use `pnpm` workspaces and Turborepo.
- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows.
- 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.

View file

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

View file

@ -1,4 +1,5 @@
import { memo, type MutableRefObject, type Ref } from "react";
import { AgentTranscript, type AgentTranscriptClassNames, type TranscriptEntry } from "@sandbox-agent/react";
import { memo, useMemo, type MutableRefObject, type Ref } from "react";
import { useStyletron } from "baseui";
import { 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<Map<string, HTMLDivElement>>;
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 (
<div
ref={(node) => {
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",
})}
>
<div
className={css({
maxWidth: "100%",
padding: "12px 16px",
borderTopLeftRadius: "16px",
borderTopRightRadius: "16px",
...(isUser
? {
backgroundColor: "#ffffff",
color: "#000000",
borderBottomLeftRadius: "16px",
borderBottomRightRadius: "4px",
}
: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
color: "#e4e4e7",
borderBottomLeftRadius: "4px",
borderBottomRightRadius: "16px",
}),
})}
>
<div
data-selectable
className={css({
fontSize: "13px",
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
})}
>
{message.text}
</div>
</div>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "10px",
justifyContent: isUser ? "flex-end" : "flex-start",
minHeight: "16px",
paddingLeft: isUser ? undefined : "2px",
})}
>
{displayFooter ? (
<LabelXSmall
color={theme.colors.contentTertiary}
$style={{ fontFamily: '"IBM Plex Mono", monospace', letterSpacing: "0.01em" }}
>
{displayFooter}
</LabelXSmall>
) : null}
<button
type="button"
data-copy-action="true"
onClick={() => onCopyMessage(message)}
className={css({
all: "unset",
display: "inline-flex",
alignItems: "center",
gap: "5px",
fontSize: "11px",
cursor: "pointer",
color: isCopied ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "color 160ms ease",
":hover": { color: theme.colors.contentPrimary },
})}
>
<Copy size={11} />
{isCopied ? "Copied" : "Copy"}
</button>
</div>
</div>
);
});
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<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 (
<>
@ -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 ? (
<div
className={css({
display: "flex",
@ -60,137 +228,51 @@ export const MessageList = memo(function MessageList({
{!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 isCopied = copiedMessageId === message.id;
const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
const displayFooter = isUser
? messageTimestamp
: message.durationMs
? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}`
: null;
) : (
<AgentTranscript
entries={transcriptEntries}
classNames={transcriptClassNames}
renderMessageText={(entry) => {
const message = messagesById.get(entry.id);
if (!message) {
return null;
}
return (
<div
key={message.id}
ref={(node) => {
if (node) {
messageRefs.current.set(message.id, node);
} else {
messageRefs.current.delete(message.id);
}
}}
className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })}
>
<div
className={css({
maxWidth: "80%",
display: "flex",
flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start",
gap: "6px",
})}
>
<div
className={css({
maxWidth: "100%",
padding: "12px 16px",
borderTopLeftRadius: "16px",
borderTopRightRadius: "16px",
...(isUser
? {
backgroundColor: "#ffffff",
color: "#000000",
borderBottomLeftRadius: "16px",
borderBottomRightRadius: "4px",
}
: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
color: "#e4e4e7",
borderBottomLeftRadius: "4px",
borderBottomRightRadius: "16px",
}),
})}
>
<div
data-selectable
className={css({
fontSize: "13px",
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
})}
>
{message.text}
</div>
</div>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "10px",
justifyContent: isUser ? "flex-end" : "flex-start",
minHeight: "16px",
paddingLeft: isUser ? undefined : "2px",
})}
>
{displayFooter ? (
<LabelXSmall
color={theme.colors.contentTertiary}
$style={{ fontFamily: '"IBM Plex Mono", monospace', letterSpacing: "0.01em" }}
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} />
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>Agent is thinking</span>
{thinkingTimerLabel ? (
<span
className={css({
padding: "2px 7px",
borderRadius: "999px",
backgroundColor: "rgba(255, 79, 0, 0.12)",
border: "1px solid rgba(255, 79, 0, 0.2)",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "10px",
letterSpacing: "0.04em",
})}
>
{displayFooter}
</LabelXSmall>
{thinkingTimerLabel}
</span>
) : null}
<button
type="button"
data-copy-action="true"
onClick={() => onCopyMessage(message)}
className={css({
all: "unset",
display: "inline-flex",
alignItems: "center",
gap: "5px",
fontSize: "11px",
cursor: "pointer",
color: isCopied ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "color 160ms ease",
":hover": { color: theme.colors.contentPrimary },
})}
>
<Copy size={11} />
{isCopied ? "Copied" : "Copy"}
</button>
</div>
</LabelXSmall>
</div>
</div>
);
})}
{tab && tab.status === "running" && messages.length > 0 ? (
<div className={css({ display: "flex", alignItems: "center", gap: "8px", padding: "4px 0" })}>
<SpinnerDot size={12} />
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>Agent is thinking</span>
{thinkingTimerLabel ? (
<span
className={css({
padding: "2px 7px",
borderRadius: "999px",
backgroundColor: "rgba(255, 79, 0, 0.12)",
border: "1px solid rgba(255, 79, 0, 0.2)",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "10px",
letterSpacing: "0.04em",
})}
>
{thinkingTimerLabel}
</span>
) : null}
</LabelXSmall>
</div>
) : null}
)}
/>
)}
</div>
</>
);

View file

@ -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<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 (
<div
@ -79,97 +139,28 @@ export const PromptComposer = memo(function PromptComposer({
))}
</div>
) : null}
<div
className={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)" },
})}
>
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
<ChatComposer
message={draft}
onMessageChange={onDraftChange}
onSubmit={isRunning ? onStop : onSend}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
if (isRunning) {
onStop();
} else {
onSend();
}
}}
placeholder={placeholder}
rows={1}
className={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 },
})}
/>
{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>
}
}}
placeholder={placeholder}
inputRef={textareaRef}
rows={1}
allowEmptySubmit={isRunning}
submitLabel={isRunning ? "Stop" : "Send"}
classNames={composerClassNames}
renderSubmitContent={() => (isRunning ? <Square size={16} /> : <ArrowUpFromLine size={16} />)}
/>
<ModelPicker
value={model}
defaultModel={defaultModel}

3
pnpm-lock.yaml generated
View file

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

View file

@ -1,6 +1,6 @@
"use client";
import type { KeyboardEvent, ReactNode, TextareaHTMLAttributes } from "react";
import type { KeyboardEvent, ReactNode, Ref, TextareaHTMLAttributes } from "react";
export interface ChatComposerClassNames {
root: string;
@ -18,9 +18,11 @@ export interface ChatComposerProps {
placeholder?: string;
disabled?: boolean;
submitDisabled?: boolean;
allowEmptySubmit?: boolean;
submitLabel?: string;
className?: string;
classNames?: Partial<ChatComposerClassNames>;
inputRef?: Ref<HTMLTextAreaElement>;
rows?: number;
textareaProps?: Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
@ -58,15 +60,17 @@ export const ChatComposer = ({
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 || message.trim().length === 0;
const isSubmitDisabled = disabled || submitDisabled || (!allowEmptySubmit && message.trim().length === 0);
return (
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
@ -82,6 +86,7 @@ export const ChatComposer = ({
>
<textarea
{...textareaProps}
ref={inputRef}
className={resolvedClassNames.input}
data-slot="input"
data-disabled={disabled ? "true" : undefined}