mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 05:00:20 +00:00
Use shared chat UI in Foundry
This commit is contained in:
parent
b609f1ab2b
commit
0ec470d494
8 changed files with 316 additions and 224 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,6 +8,119 @@ 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";
|
||||||
|
|
||||||
|
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({
|
export const MessageList = memo(function MessageList({
|
||||||
tab,
|
tab,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
|
|
@ -27,7 +141,62 @@ export const MessageList = memo(function MessageList({
|
||||||
thinkingTimerLabel: string | null;
|
thinkingTimerLabel: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [css, theme] = useStyletron();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -38,13 +207,12 @@ export const MessageList = memo(function MessageList({
|
||||||
padding: "16px 220px 16px 44px",
|
padding: "16px 220px 16px 44px",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "12px",
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{tab && messages.length === 0 ? (
|
{tab && transcriptEntries.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: "flex",
|
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"}
|
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
|
||||||
</LabelSmall>
|
</LabelSmall>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
{messages.map((message) => {
|
<AgentTranscript
|
||||||
const isUser = message.sender === "client";
|
entries={transcriptEntries}
|
||||||
const isCopied = copiedMessageId === message.id;
|
classNames={transcriptClassNames}
|
||||||
const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
|
renderMessageText={(entry) => {
|
||||||
const displayFooter = isUser
|
const message = messagesById.get(entry.id);
|
||||||
? messageTimestamp
|
if (!message) {
|
||||||
: message.durationMs
|
return null;
|
||||||
? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}`
|
}
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TranscriptMessageBody
|
||||||
key={message.id}
|
message={message}
|
||||||
ref={(node) => {
|
messageRefs={messageRefs}
|
||||||
if (node) {
|
copiedMessageId={copiedMessageId}
|
||||||
messageRefs.current.set(message.id, node);
|
onCopyMessage={onCopyMessage}
|
||||||
} else {
|
/>
|
||||||
messageRefs.current.delete(message.id);
|
);
|
||||||
}
|
}}
|
||||||
}}
|
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
|
||||||
className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })}
|
renderThinkingState={() => (
|
||||||
>
|
<div className={transcriptClassNames.thinkingRow}>
|
||||||
<div
|
<SpinnerDot size={12} />
|
||||||
className={css({
|
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
||||||
maxWidth: "80%",
|
<span>Agent is thinking</span>
|
||||||
display: "flex",
|
{thinkingTimerLabel ? (
|
||||||
flexDirection: "column",
|
<span
|
||||||
alignItems: isUser ? "flex-end" : "flex-start",
|
className={css({
|
||||||
gap: "6px",
|
padding: "2px 7px",
|
||||||
})}
|
borderRadius: "999px",
|
||||||
>
|
backgroundColor: "rgba(255, 79, 0, 0.12)",
|
||||||
<div
|
border: "1px solid rgba(255, 79, 0, 0.2)",
|
||||||
className={css({
|
fontFamily: '"IBM Plex Mono", monospace',
|
||||||
maxWidth: "100%",
|
fontSize: "10px",
|
||||||
padding: "12px 16px",
|
letterSpacing: "0.04em",
|
||||||
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}
|
{thinkingTimerLabel}
|
||||||
</LabelXSmall>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
</LabelXSmall>
|
||||||
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>
|
</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>
|
</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}`,
|
onKeyDown={(event) => {
|
||||||
borderRadius: "16px",
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
|
event.preventDefault();
|
||||||
transition: "border-color 200ms ease",
|
if (isRunning) {
|
||||||
":focus-within": { borderColor: "rgba(255, 255, 255, 0.3)" },
|
onStop();
|
||||||
})}
|
} else {
|
||||||
>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={draft}
|
|
||||||
onChange={(event) => onDraftChange(event.target.value)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
|
||||||
event.preventDefault();
|
|
||||||
onSend();
|
onSend();
|
||||||
}
|
}
|
||||||
}}
|
}
|
||||||
placeholder={placeholder}
|
}}
|
||||||
rows={1}
|
placeholder={placeholder}
|
||||||
className={css({
|
inputRef={textareaRef}
|
||||||
display: "block",
|
rows={1}
|
||||||
width: "100%",
|
allowEmptySubmit={isRunning}
|
||||||
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
|
submitLabel={isRunning ? "Stop" : "Send"}
|
||||||
padding: "12px 58px 12px 14px",
|
classNames={composerClassNames}
|
||||||
background: "transparent",
|
renderSubmitContent={() => (isRunning ? <Square size={16} /> : <ArrowUpFromLine size={16} />)}
|
||||||
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
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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue