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 ### React Component Methodology
- Shared React UI belongs in `sdks/react` only when it is reusable outside the Inspector. - 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. - 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/`. - 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 ### 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. Use `transcriptProps` and `composerProps` when you want the shared composition but still need custom rendering or behavior. Use `transcriptClassNames` and `composerClassNames` when you want styling hooks for each subcomponent.

View file

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

View file

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

View file

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

View file

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

3
pnpm-lock.yaml generated
View file

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

View file

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