Share chat UI components in @sandbox-agent/react (#228)

* Extract shared chat UI components

* chore(release): update version to 0.3.1

* Use shared chat UI in Foundry
This commit is contained in:
Nathan Flurry 2026-03-10 22:31:36 -07:00 committed by GitHub
parent 6d7e67fe72
commit 0471214d65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1679 additions and 727 deletions

View file

@ -0,0 +1,85 @@
"use client";
import type { ReactNode } from "react";
import { AgentTranscript, type AgentTranscriptClassNames, type AgentTranscriptProps, type TranscriptEntry } from "./AgentTranscript.tsx";
import { ChatComposer, type ChatComposerClassNames, type ChatComposerProps } from "./ChatComposer.tsx";
export interface AgentConversationClassNames {
root: string;
transcript: string;
emptyState: string;
composer: string;
}
export interface AgentConversationProps {
entries: TranscriptEntry[];
className?: string;
classNames?: Partial<AgentConversationClassNames>;
emptyState?: ReactNode;
transcriptClassName?: string;
transcriptClassNames?: Partial<AgentTranscriptClassNames>;
composerClassName?: string;
composerClassNames?: Partial<ChatComposerClassNames>;
transcriptProps?: Omit<AgentTranscriptProps, "entries" | "className" | "classNames">;
composerProps?: Omit<ChatComposerProps, "className" | "classNames">;
}
const DEFAULT_CLASS_NAMES: AgentConversationClassNames = {
root: "sa-agent-conversation",
transcript: "sa-agent-conversation-transcript",
emptyState: "sa-agent-conversation-empty-state",
composer: "sa-agent-conversation-composer",
};
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const mergeClassNames = (
defaults: AgentConversationClassNames,
overrides?: Partial<AgentConversationClassNames>,
): AgentConversationClassNames => ({
root: cx(defaults.root, overrides?.root),
transcript: cx(defaults.transcript, overrides?.transcript),
emptyState: cx(defaults.emptyState, overrides?.emptyState),
composer: cx(defaults.composer, overrides?.composer),
});
export const AgentConversation = ({
entries,
className,
classNames: classNameOverrides,
emptyState,
transcriptClassName,
transcriptClassNames,
composerClassName,
composerClassNames,
transcriptProps,
composerProps,
}: AgentConversationProps) => {
const resolvedClassNames = mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides);
const hasTranscriptContent =
entries.length > 0 || Boolean(transcriptProps?.sessionError) || Boolean(transcriptProps?.eventError);
return (
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
{hasTranscriptContent ? (
<AgentTranscript
entries={entries}
className={cx(resolvedClassNames.transcript, transcriptClassName)}
classNames={transcriptClassNames}
{...transcriptProps}
/>
) : emptyState ? (
<div className={resolvedClassNames.emptyState} data-slot="empty-state">
{emptyState}
</div>
) : null}
{composerProps ? (
<ChatComposer
className={cx(resolvedClassNames.composer, composerClassName)}
classNames={composerClassNames}
{...composerProps}
/>
) : null}
</div>
);
};

View file

@ -0,0 +1,785 @@
"use client";
import type { ReactNode, RefObject } from "react";
import { useMemo, useState } from "react";
export type PermissionReply = "once" | "always" | "reject";
export type PermissionOption = {
optionId: string;
name: string;
kind: string;
};
export type TranscriptEntry = {
id: string;
eventId?: string;
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
time: string;
role?: "user" | "assistant";
text?: string;
toolName?: string;
toolInput?: string;
toolOutput?: string;
toolStatus?: string;
reasoning?: { text: string; visibility?: string };
meta?: { title: string; detail?: string; severity?: "info" | "error" };
permission?: {
permissionId: string;
title: string;
description?: string;
options: PermissionOption[];
resolved?: boolean;
selectedOptionId?: string;
};
};
export interface AgentTranscriptClassNames {
root: string;
divider: string;
dividerLine: string;
dividerText: string;
message: string;
messageContent: string;
messageText: string;
error: string;
toolGroupSingle: string;
toolGroupContainer: string;
toolGroupHeader: string;
toolGroupIcon: string;
toolGroupLabel: string;
toolGroupChevron: string;
toolGroupBody: string;
toolItem: string;
toolItemConnector: string;
toolItemDot: string;
toolItemLine: string;
toolItemContent: string;
toolItemHeader: string;
toolItemIcon: string;
toolItemLabel: string;
toolItemSpinner: string;
toolItemLink: string;
toolItemChevron: string;
toolItemBody: string;
toolSection: string;
toolSectionTitle: string;
toolCode: string;
toolCodeMuted: string;
permissionPrompt: string;
permissionHeader: string;
permissionIcon: string;
permissionTitle: string;
permissionDescription: string;
permissionActions: string;
permissionButton: string;
permissionAutoResolved: string;
thinkingRow: string;
thinkingAvatar: string;
thinkingAvatarImage: string;
thinkingAvatarLabel: string;
thinkingIndicator: string;
thinkingDot: string;
endAnchor: string;
}
export interface PermissionOptionRenderContext {
entry: TranscriptEntry;
option: PermissionOption;
label: string;
reply: PermissionReply;
selected: boolean;
dimmed: boolean;
resolved: boolean;
}
export interface AgentTranscriptProps {
entries: TranscriptEntry[];
className?: string;
classNames?: Partial<AgentTranscriptClassNames>;
endRef?: RefObject<HTMLDivElement>;
sessionError?: string | null;
eventError?: string | null;
isThinking?: boolean;
agentId?: string;
onEventClick?: (eventId: string) => void;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
isDividerEntry?: (entry: TranscriptEntry) => boolean;
canOpenEvent?: (entry: TranscriptEntry) => boolean;
getToolGroupSummary?: (entries: TranscriptEntry[]) => string;
renderMessageText?: (entry: TranscriptEntry) => ReactNode;
renderInlinePendingIndicator?: () => ReactNode;
renderThinkingState?: (context: { agentId?: string }) => ReactNode;
renderToolItemIcon?: (entry: TranscriptEntry) => ReactNode;
renderToolGroupIcon?: (entries: TranscriptEntry[], expanded: boolean) => ReactNode;
renderChevron?: (expanded: boolean) => ReactNode;
renderEventLinkContent?: (entry: TranscriptEntry) => ReactNode;
renderPermissionIcon?: (entry: TranscriptEntry) => ReactNode;
renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode;
}
type GroupedEntries =
| { type: "message"; entries: TranscriptEntry[] }
| { type: "tool-group"; entries: TranscriptEntry[] }
| { type: "divider"; entries: TranscriptEntry[] }
| { type: "permission"; entries: TranscriptEntry[] };
const DEFAULT_CLASS_NAMES: AgentTranscriptClassNames = {
root: "sa-agent-transcript",
divider: "sa-agent-transcript-divider",
dividerLine: "sa-agent-transcript-divider-line",
dividerText: "sa-agent-transcript-divider-text",
message: "sa-agent-transcript-message",
messageContent: "sa-agent-transcript-message-content",
messageText: "sa-agent-transcript-message-text",
error: "sa-agent-transcript-error",
toolGroupSingle: "sa-agent-transcript-tool-group-single",
toolGroupContainer: "sa-agent-transcript-tool-group",
toolGroupHeader: "sa-agent-transcript-tool-group-header",
toolGroupIcon: "sa-agent-transcript-tool-group-icon",
toolGroupLabel: "sa-agent-transcript-tool-group-label",
toolGroupChevron: "sa-agent-transcript-tool-group-chevron",
toolGroupBody: "sa-agent-transcript-tool-group-body",
toolItem: "sa-agent-transcript-tool-item",
toolItemConnector: "sa-agent-transcript-tool-item-connector",
toolItemDot: "sa-agent-transcript-tool-item-dot",
toolItemLine: "sa-agent-transcript-tool-item-line",
toolItemContent: "sa-agent-transcript-tool-item-content",
toolItemHeader: "sa-agent-transcript-tool-item-header",
toolItemIcon: "sa-agent-transcript-tool-item-icon",
toolItemLabel: "sa-agent-transcript-tool-item-label",
toolItemSpinner: "sa-agent-transcript-tool-item-spinner",
toolItemLink: "sa-agent-transcript-tool-item-link",
toolItemChevron: "sa-agent-transcript-tool-item-chevron",
toolItemBody: "sa-agent-transcript-tool-item-body",
toolSection: "sa-agent-transcript-tool-section",
toolSectionTitle: "sa-agent-transcript-tool-section-title",
toolCode: "sa-agent-transcript-tool-code",
toolCodeMuted: "sa-agent-transcript-tool-code-muted",
permissionPrompt: "sa-agent-transcript-permission-prompt",
permissionHeader: "sa-agent-transcript-permission-header",
permissionIcon: "sa-agent-transcript-permission-icon",
permissionTitle: "sa-agent-transcript-permission-title",
permissionDescription: "sa-agent-transcript-permission-description",
permissionActions: "sa-agent-transcript-permission-actions",
permissionButton: "sa-agent-transcript-permission-button",
permissionAutoResolved: "sa-agent-transcript-permission-auto-resolved",
thinkingRow: "sa-agent-transcript-thinking-row",
thinkingAvatar: "sa-agent-transcript-thinking-avatar",
thinkingAvatarImage: "sa-agent-transcript-thinking-avatar-image",
thinkingAvatarLabel: "sa-agent-transcript-thinking-avatar-label",
thinkingIndicator: "sa-agent-transcript-thinking-indicator",
thinkingDot: "sa-agent-transcript-thinking-dot",
endAnchor: "sa-agent-transcript-end",
};
const DEFAULT_DIVIDER_TITLES = new Set(["Session Started", "Turn Started", "Turn Ended"]);
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const mergeClassNames = (
defaults: AgentTranscriptClassNames,
overrides?: Partial<AgentTranscriptClassNames>,
): AgentTranscriptClassNames => ({
root: cx(defaults.root, overrides?.root),
divider: cx(defaults.divider, overrides?.divider),
dividerLine: cx(defaults.dividerLine, overrides?.dividerLine),
dividerText: cx(defaults.dividerText, overrides?.dividerText),
message: cx(defaults.message, overrides?.message),
messageContent: cx(defaults.messageContent, overrides?.messageContent),
messageText: cx(defaults.messageText, overrides?.messageText),
error: cx(defaults.error, overrides?.error),
toolGroupSingle: cx(defaults.toolGroupSingle, overrides?.toolGroupSingle),
toolGroupContainer: cx(defaults.toolGroupContainer, overrides?.toolGroupContainer),
toolGroupHeader: cx(defaults.toolGroupHeader, overrides?.toolGroupHeader),
toolGroupIcon: cx(defaults.toolGroupIcon, overrides?.toolGroupIcon),
toolGroupLabel: cx(defaults.toolGroupLabel, overrides?.toolGroupLabel),
toolGroupChevron: cx(defaults.toolGroupChevron, overrides?.toolGroupChevron),
toolGroupBody: cx(defaults.toolGroupBody, overrides?.toolGroupBody),
toolItem: cx(defaults.toolItem, overrides?.toolItem),
toolItemConnector: cx(defaults.toolItemConnector, overrides?.toolItemConnector),
toolItemDot: cx(defaults.toolItemDot, overrides?.toolItemDot),
toolItemLine: cx(defaults.toolItemLine, overrides?.toolItemLine),
toolItemContent: cx(defaults.toolItemContent, overrides?.toolItemContent),
toolItemHeader: cx(defaults.toolItemHeader, overrides?.toolItemHeader),
toolItemIcon: cx(defaults.toolItemIcon, overrides?.toolItemIcon),
toolItemLabel: cx(defaults.toolItemLabel, overrides?.toolItemLabel),
toolItemSpinner: cx(defaults.toolItemSpinner, overrides?.toolItemSpinner),
toolItemLink: cx(defaults.toolItemLink, overrides?.toolItemLink),
toolItemChevron: cx(defaults.toolItemChevron, overrides?.toolItemChevron),
toolItemBody: cx(defaults.toolItemBody, overrides?.toolItemBody),
toolSection: cx(defaults.toolSection, overrides?.toolSection),
toolSectionTitle: cx(defaults.toolSectionTitle, overrides?.toolSectionTitle),
toolCode: cx(defaults.toolCode, overrides?.toolCode),
toolCodeMuted: cx(defaults.toolCodeMuted, overrides?.toolCodeMuted),
permissionPrompt: cx(defaults.permissionPrompt, overrides?.permissionPrompt),
permissionHeader: cx(defaults.permissionHeader, overrides?.permissionHeader),
permissionIcon: cx(defaults.permissionIcon, overrides?.permissionIcon),
permissionTitle: cx(defaults.permissionTitle, overrides?.permissionTitle),
permissionDescription: cx(defaults.permissionDescription, overrides?.permissionDescription),
permissionActions: cx(defaults.permissionActions, overrides?.permissionActions),
permissionButton: cx(defaults.permissionButton, overrides?.permissionButton),
permissionAutoResolved: cx(defaults.permissionAutoResolved, overrides?.permissionAutoResolved),
thinkingRow: cx(defaults.thinkingRow, overrides?.thinkingRow),
thinkingAvatar: cx(defaults.thinkingAvatar, overrides?.thinkingAvatar),
thinkingAvatarImage: cx(defaults.thinkingAvatarImage, overrides?.thinkingAvatarImage),
thinkingAvatarLabel: cx(defaults.thinkingAvatarLabel, overrides?.thinkingAvatarLabel),
thinkingIndicator: cx(defaults.thinkingIndicator, overrides?.thinkingIndicator),
thinkingDot: cx(defaults.thinkingDot, overrides?.thinkingDot),
endAnchor: cx(defaults.endAnchor, overrides?.endAnchor),
});
const getMessageVariant = (entry: TranscriptEntry) => {
if (entry.kind === "tool") return "tool";
if (entry.kind === "meta") return entry.meta?.severity === "error" ? "error" : "system";
if (entry.kind === "reasoning") return "assistant";
if (entry.kind === "permission") return "system";
if (entry.role === "user") return "user";
return "assistant";
};
const getToolItemLabel = (entry: TranscriptEntry) => {
if (entry.kind === "tool") {
const statusLabel =
entry.toolStatus && entry.toolStatus !== "completed"
? ` (${entry.toolStatus.replaceAll("_", " ")})`
: "";
return `${entry.toolName ?? "tool"}${statusLabel}`;
}
if (entry.kind === "reasoning") {
return `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`;
}
return entry.meta?.title ?? "Status";
};
const getDefaultToolItemIcon = (entry: TranscriptEntry) => {
if (entry.kind === "tool") return "Tool";
if (entry.kind === "reasoning") return "Thought";
return entry.meta?.severity === "error" ? "Error" : "Info";
};
const getDefaultToolGroupSummary = (entries: TranscriptEntry[]) => {
const count = entries.length;
return `${count} Event${count === 1 ? "" : "s"}`;
};
const getPermissionReplyForOption = (kind: string): PermissionReply => {
if (kind === "allow_once") return "once";
if (kind === "allow_always") return "always";
return "reject";
};
const getPermissionOptionLabel = (option: PermissionOption) => {
if (option.name) return option.name;
if (option.kind === "allow_once") return "Allow Once";
if (option.kind === "allow_always") return "Always Allow";
if (option.kind === "reject_once") return "Reject";
if (option.kind === "reject_always") return "Reject Always";
return option.kind;
};
const getPermissionOptionTone = (kind: string) => (kind.startsWith("allow") ? "allow" : "reject");
const defaultRenderMessageText = (entry: TranscriptEntry) => entry.text;
const defaultRenderPendingIndicator = () => "...";
const defaultRenderChevron = (expanded: boolean) => (expanded ? "▾" : "▸");
const defaultRenderEventLinkContent = () => "Open";
const defaultRenderPermissionIcon = () => "Permission";
const defaultRenderPermissionOptionContent = ({
label,
}: PermissionOptionRenderContext) => label;
const defaultIsDividerEntry = (entry: TranscriptEntry) =>
entry.kind === "meta" && DEFAULT_DIVIDER_TITLES.has(entry.meta?.title ?? "");
const defaultCanOpenEvent = (entry: TranscriptEntry) => Boolean(entry.eventId);
const buildGroupedEntries = (
entries: TranscriptEntry[],
isDividerEntry: (entry: TranscriptEntry) => boolean,
): GroupedEntries[] => {
const groupedEntries: GroupedEntries[] = [];
let currentToolGroup: TranscriptEntry[] = [];
const flushToolGroup = () => {
if (currentToolGroup.length === 0) {
return;
}
groupedEntries.push({ type: "tool-group", entries: currentToolGroup });
currentToolGroup = [];
};
for (const entry of entries) {
if (isDividerEntry(entry)) {
flushToolGroup();
groupedEntries.push({ type: "divider", entries: [entry] });
continue;
}
if (entry.kind === "permission") {
flushToolGroup();
groupedEntries.push({ type: "permission", entries: [entry] });
continue;
}
if (entry.kind === "tool" || entry.kind === "reasoning" || entry.kind === "meta") {
currentToolGroup.push(entry);
continue;
}
flushToolGroup();
groupedEntries.push({ type: "message", entries: [entry] });
}
flushToolGroup();
return groupedEntries;
};
const ToolItem = ({
entry,
isLast,
classNames,
onEventClick,
canOpenEvent,
renderInlinePendingIndicator,
renderToolItemIcon,
renderChevron,
renderEventLinkContent,
}: {
entry: TranscriptEntry;
isLast: boolean;
classNames: AgentTranscriptClassNames;
onEventClick?: (eventId: string) => void;
canOpenEvent: (entry: TranscriptEntry) => boolean;
renderInlinePendingIndicator: () => ReactNode;
renderToolItemIcon: (entry: TranscriptEntry) => ReactNode;
renderChevron: (expanded: boolean) => ReactNode;
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
}) => {
const [expanded, setExpanded] = useState(false);
const isTool = entry.kind === "tool";
const isReasoning = entry.kind === "reasoning";
const isMeta = entry.kind === "meta";
const isComplete = isTool && (entry.toolStatus === "completed" || entry.toolStatus === "failed");
const isFailed = isTool && entry.toolStatus === "failed";
const isInProgress = isTool && entry.toolStatus === "in_progress";
const hasContent = isTool
? Boolean(entry.toolInput || entry.toolOutput)
: isReasoning
? Boolean(entry.reasoning?.text?.trim())
: Boolean(entry.meta?.detail?.trim());
const showEventLink = Boolean(entry.eventId && onEventClick && canOpenEvent(entry));
return (
<div
className={cx(classNames.toolItem, isLast && "last", isFailed && "failed")}
data-slot="tool-item"
data-kind={entry.kind}
data-state={entry.toolStatus}
data-last={isLast ? "true" : undefined}
data-failed={isFailed ? "true" : undefined}
>
<div className={classNames.toolItemConnector} data-slot="tool-item-connector">
<div className={classNames.toolItemDot} data-slot="tool-item-dot" />
{!isLast ? <div className={classNames.toolItemLine} data-slot="tool-item-line" /> : null}
</div>
<div className={classNames.toolItemContent} data-slot="tool-item-content">
<button
type="button"
className={cx(classNames.toolItemHeader, expanded && "expanded")}
data-slot="tool-item-header"
data-expanded={expanded ? "true" : undefined}
data-has-content={hasContent ? "true" : undefined}
disabled={!hasContent}
onClick={() => {
if (hasContent) {
setExpanded((value) => !value);
}
}}
>
<span className={classNames.toolItemIcon} data-slot="tool-item-icon">
{renderToolItemIcon(entry)}
</span>
<span className={classNames.toolItemLabel} data-slot="tool-item-label">
{getToolItemLabel(entry)}
</span>
{isInProgress ? (
<span className={classNames.toolItemSpinner} data-slot="tool-item-spinner">
{renderInlinePendingIndicator()}
</span>
) : null}
{showEventLink ? (
<span
className={classNames.toolItemLink}
data-slot="tool-item-link"
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
onEventClick?.(entry.eventId!);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
onEventClick?.(entry.eventId!);
}
}}
>
{renderEventLinkContent(entry)}
</span>
) : null}
{hasContent ? (
<span className={classNames.toolItemChevron} data-slot="tool-item-chevron">
{renderChevron(expanded)}
</span>
) : null}
</button>
{expanded && hasContent ? (
<div className={classNames.toolItemBody} data-slot="tool-item-body">
{isTool && entry.toolInput ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="input">
<div className={classNames.toolSectionTitle} data-slot="tool-section-title">
Input
</div>
<pre className={classNames.toolCode} data-slot="tool-code">
{entry.toolInput}
</pre>
</div>
) : null}
{isTool && isComplete && entry.toolOutput ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="output">
<div className={classNames.toolSectionTitle} data-slot="tool-section-title">
Output
</div>
<pre className={classNames.toolCode} data-slot="tool-code">
{entry.toolOutput}
</pre>
</div>
) : null}
{isReasoning && entry.reasoning?.text ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="reasoning">
<pre className={cx(classNames.toolCode, classNames.toolCodeMuted)} data-slot="tool-code">
{entry.reasoning.text}
</pre>
</div>
) : null}
{isMeta && entry.meta?.detail ? (
<div className={classNames.toolSection} data-slot="tool-section" data-section="meta">
<pre className={classNames.toolCode} data-slot="tool-code">
{entry.meta.detail}
</pre>
</div>
) : null}
</div>
) : null}
</div>
</div>
);
};
const ToolGroup = ({
entries,
classNames,
onEventClick,
canOpenEvent,
getToolGroupSummary,
renderInlinePendingIndicator,
renderToolItemIcon,
renderToolGroupIcon,
renderChevron,
renderEventLinkContent,
}: {
entries: TranscriptEntry[];
classNames: AgentTranscriptClassNames;
onEventClick?: (eventId: string) => void;
canOpenEvent: (entry: TranscriptEntry) => boolean;
getToolGroupSummary: (entries: TranscriptEntry[]) => string;
renderInlinePendingIndicator: () => ReactNode;
renderToolItemIcon: (entry: TranscriptEntry) => ReactNode;
renderToolGroupIcon: (entries: TranscriptEntry[], expanded: boolean) => ReactNode;
renderChevron: (expanded: boolean) => ReactNode;
renderEventLinkContent: (entry: TranscriptEntry) => ReactNode;
}) => {
const [expanded, setExpanded] = useState(false);
const hasFailed = entries.some((entry) => entry.kind === "tool" && entry.toolStatus === "failed");
if (entries.length === 1) {
return (
<div className={classNames.toolGroupSingle} data-slot="tool-group-single">
<ToolItem
entry={entries[0]}
isLast={true}
classNames={classNames}
onEventClick={onEventClick}
canOpenEvent={canOpenEvent}
renderInlinePendingIndicator={renderInlinePendingIndicator}
renderToolItemIcon={renderToolItemIcon}
renderChevron={renderChevron}
renderEventLinkContent={renderEventLinkContent}
/>
</div>
);
}
return (
<div
className={cx(classNames.toolGroupContainer, hasFailed && "failed")}
data-slot="tool-group"
data-failed={hasFailed ? "true" : undefined}
>
<button
type="button"
className={cx(classNames.toolGroupHeader, expanded && "expanded")}
data-slot="tool-group-header"
data-expanded={expanded ? "true" : undefined}
onClick={() => setExpanded((value) => !value)}
>
<span className={classNames.toolGroupIcon} data-slot="tool-group-icon">
{renderToolGroupIcon(entries, expanded)}
</span>
<span className={classNames.toolGroupLabel} data-slot="tool-group-label">
{getToolGroupSummary(entries)}
</span>
<span className={classNames.toolGroupChevron} data-slot="tool-group-chevron">
{renderChevron(expanded)}
</span>
</button>
{expanded ? (
<div className={classNames.toolGroupBody} data-slot="tool-group-body">
{entries.map((entry, index) => (
<ToolItem
key={entry.id}
entry={entry}
isLast={index === entries.length - 1}
classNames={classNames}
onEventClick={onEventClick}
canOpenEvent={canOpenEvent}
renderInlinePendingIndicator={renderInlinePendingIndicator}
renderToolItemIcon={renderToolItemIcon}
renderChevron={renderChevron}
renderEventLinkContent={renderEventLinkContent}
/>
))}
</div>
) : null}
</div>
);
};
const PermissionPrompt = ({
entry,
classNames,
onPermissionReply,
renderPermissionIcon,
renderPermissionOptionContent,
}: {
entry: TranscriptEntry;
classNames: AgentTranscriptClassNames;
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
renderPermissionIcon: (entry: TranscriptEntry) => ReactNode;
renderPermissionOptionContent: (context: PermissionOptionRenderContext) => ReactNode;
}) => {
const permission = entry.permission;
if (!permission) {
return null;
}
const resolved = Boolean(permission.resolved);
const selectedOptionId = permission.selectedOptionId;
const canReply = Boolean(onPermissionReply) && !resolved;
return (
<div
className={cx(classNames.permissionPrompt, resolved && "resolved")}
data-slot="permission-prompt"
data-resolved={resolved ? "true" : undefined}
>
<div className={classNames.permissionHeader} data-slot="permission-header">
<span className={classNames.permissionIcon} data-slot="permission-icon">
{renderPermissionIcon(entry)}
</span>
<span className={classNames.permissionTitle} data-slot="permission-title">
{permission.title}
</span>
</div>
{permission.description ? (
<div className={classNames.permissionDescription} data-slot="permission-description">
{permission.description}
</div>
) : null}
<div className={classNames.permissionActions} data-slot="permission-actions">
{permission.options.map((option) => {
const reply = getPermissionReplyForOption(option.kind);
const label = getPermissionOptionLabel(option);
const selected = resolved && selectedOptionId === option.optionId;
const dimmed = resolved && !selected && selectedOptionId != null;
const tone = getPermissionOptionTone(option.kind);
return (
<button
key={option.optionId}
type="button"
className={cx(classNames.permissionButton, tone, selected && "selected", dimmed && "dimmed")}
data-slot="permission-button"
data-tone={tone}
data-selected={selected ? "true" : undefined}
data-dimmed={dimmed ? "true" : undefined}
disabled={!canReply}
onClick={() => onPermissionReply?.(permission.permissionId, reply)}
>
{renderPermissionOptionContent({
entry,
option,
label,
reply,
selected,
dimmed,
resolved,
})}
</button>
);
})}
{resolved && !selectedOptionId ? (
<span className={classNames.permissionAutoResolved} data-slot="permission-auto-resolved">
Auto-resolved
</span>
) : null}
</div>
</div>
);
};
export const AgentTranscript = ({
entries,
className,
classNames: classNameOverrides,
endRef,
sessionError,
eventError,
isThinking,
agentId,
onEventClick,
onPermissionReply,
isDividerEntry = defaultIsDividerEntry,
canOpenEvent = defaultCanOpenEvent,
getToolGroupSummary = getDefaultToolGroupSummary,
renderMessageText = defaultRenderMessageText,
renderInlinePendingIndicator = defaultRenderPendingIndicator,
renderThinkingState,
renderToolItemIcon = getDefaultToolItemIcon,
renderToolGroupIcon = () => null,
renderChevron = defaultRenderChevron,
renderEventLinkContent = defaultRenderEventLinkContent,
renderPermissionIcon = defaultRenderPermissionIcon,
renderPermissionOptionContent = defaultRenderPermissionOptionContent,
}: AgentTranscriptProps) => {
const resolvedClassNames = useMemo(
() => mergeClassNames(DEFAULT_CLASS_NAMES, classNameOverrides),
[classNameOverrides],
);
const groupedEntries = useMemo(
() => buildGroupedEntries(entries, isDividerEntry),
[entries, isDividerEntry],
);
return (
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
{groupedEntries.map((group, index) => {
if (group.type === "divider") {
const entry = group.entries[0];
const title = entry.meta?.title ?? "Status";
return (
<div key={entry.id} className={resolvedClassNames.divider} data-slot="divider">
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
<span className={resolvedClassNames.dividerText} data-slot="divider-text">
{title}
</span>
<div className={resolvedClassNames.dividerLine} data-slot="divider-line" />
</div>
);
}
if (group.type === "tool-group") {
return (
<ToolGroup
key={`tool-group-${index}`}
entries={group.entries}
classNames={resolvedClassNames}
onEventClick={onEventClick}
canOpenEvent={canOpenEvent}
getToolGroupSummary={getToolGroupSummary}
renderInlinePendingIndicator={renderInlinePendingIndicator}
renderToolItemIcon={renderToolItemIcon}
renderToolGroupIcon={renderToolGroupIcon}
renderChevron={renderChevron}
renderEventLinkContent={renderEventLinkContent}
/>
);
}
if (group.type === "permission") {
const entry = group.entries[0];
return (
<PermissionPrompt
key={entry.id}
entry={entry}
classNames={resolvedClassNames}
onPermissionReply={onPermissionReply}
renderPermissionIcon={renderPermissionIcon}
renderPermissionOptionContent={renderPermissionOptionContent}
/>
);
}
const entry = group.entries[0];
const messageVariant = getMessageVariant(entry);
return (
<div
key={entry.id}
className={cx(resolvedClassNames.message, messageVariant, "no-avatar")}
data-slot="message"
data-kind={entry.kind}
data-role={entry.role}
data-variant={messageVariant}
data-severity={entry.meta?.severity}
>
<div className={resolvedClassNames.messageContent} data-slot="message-content">
{entry.text ? (
<div className={resolvedClassNames.messageText} data-slot="message-text">
{renderMessageText(entry)}
</div>
) : (
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
{renderInlinePendingIndicator()}
</span>
)}
</div>
</div>
);
})}
{sessionError ? (
<div className={resolvedClassNames.error} data-slot="error" data-source="session">
{sessionError}
</div>
) : null}
{eventError ? (
<div className={resolvedClassNames.error} data-slot="error" data-source="event">
{eventError}
</div>
) : null}
{isThinking
? renderThinkingState?.({ agentId }) ?? (
<div className={resolvedClassNames.thinkingRow} data-slot="thinking-row">
<span className={resolvedClassNames.thinkingIndicator} data-slot="thinking-indicator">
Thinking...
</span>
</div>
)
: null}
<div ref={endRef} className={resolvedClassNames.endAnchor} data-slot="end-anchor" />
</div>
);
};

View file

@ -0,0 +1,117 @@
"use client";
import type { KeyboardEvent, ReactNode, Ref, TextareaHTMLAttributes } from "react";
export interface ChatComposerClassNames {
root: string;
form: string;
input: string;
submit: string;
submitContent: string;
}
export interface ChatComposerProps {
message: string;
onMessageChange: (value: string) => void;
onSubmit: () => void;
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
disabled?: boolean;
submitDisabled?: boolean;
allowEmptySubmit?: boolean;
submitLabel?: string;
className?: string;
classNames?: Partial<ChatComposerClassNames>;
inputRef?: Ref<HTMLTextAreaElement>;
rows?: number;
textareaProps?: Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
"className" | "disabled" | "onChange" | "onKeyDown" | "placeholder" | "rows" | "value"
>;
renderSubmitContent?: () => ReactNode;
}
const DEFAULT_CLASS_NAMES: ChatComposerClassNames = {
root: "sa-chat-composer",
form: "sa-chat-composer-form",
input: "sa-chat-composer-input",
submit: "sa-chat-composer-submit",
submitContent: "sa-chat-composer-submit-content",
};
const cx = (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(" ");
const mergeClassNames = (
defaults: ChatComposerClassNames,
overrides?: Partial<ChatComposerClassNames>,
): ChatComposerClassNames => ({
root: cx(defaults.root, overrides?.root),
form: cx(defaults.form, overrides?.form),
input: cx(defaults.input, overrides?.input),
submit: cx(defaults.submit, overrides?.submit),
submitContent: cx(defaults.submitContent, overrides?.submitContent),
});
export const ChatComposer = ({
message,
onMessageChange,
onSubmit,
onKeyDown,
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 || (!allowEmptySubmit && message.trim().length === 0);
return (
<div className={cx(resolvedClassNames.root, className)} data-slot="root">
<form
className={resolvedClassNames.form}
data-slot="form"
onSubmit={(event) => {
event.preventDefault();
if (!isSubmitDisabled) {
onSubmit();
}
}}
>
<textarea
{...textareaProps}
ref={inputRef}
className={resolvedClassNames.input}
data-slot="input"
data-disabled={disabled ? "true" : undefined}
data-empty={message.trim().length === 0 ? "true" : undefined}
value={message}
onChange={(event) => onMessageChange(event.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
rows={rows}
disabled={disabled}
/>
<button
type="submit"
className={resolvedClassNames.submit}
data-slot="submit"
data-disabled={isSubmitDisabled ? "true" : undefined}
disabled={isSubmitDisabled}
aria-label={submitLabel}
title={submitLabel}
>
<span className={resolvedClassNames.submitContent} data-slot="submit-content">
{renderSubmitContent?.() ?? submitLabel}
</span>
</button>
</form>
</div>
);
};

View file

@ -1,5 +1,27 @@
export { AgentConversation } from "./AgentConversation.tsx";
export { AgentTranscript } from "./AgentTranscript.tsx";
export { ChatComposer } from "./ChatComposer.tsx";
export { ProcessTerminal } from "./ProcessTerminal.tsx";
export type {
AgentConversationClassNames,
AgentConversationProps,
} from "./AgentConversation.tsx";
export type {
AgentTranscriptClassNames,
AgentTranscriptProps,
PermissionOption,
PermissionOptionRenderContext,
PermissionReply,
TranscriptEntry,
} from "./AgentTranscript.tsx";
export type {
ChatComposerClassNames,
ChatComposerProps,
} from "./ChatComposer.tsx";
export type {
ProcessTerminalClient,
ProcessTerminalProps,