mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 09:01:17 +00:00
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:
parent
6d7e67fe72
commit
0471214d65
19 changed files with 1679 additions and 727 deletions
|
|
@ -1348,6 +1348,13 @@
|
|||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-conversation {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.messages-container:has(.empty-state) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { TranscriptEntry } from "@sandbox-agent/react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
|
|
@ -25,7 +26,6 @@ type AgentModeInfo = { id: string; name: string; description: string };
|
|||
type AgentModelInfo = { id: string; name?: string };
|
||||
import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb";
|
||||
import ChatPanel from "./components/chat/ChatPanel";
|
||||
import type { TimelineEntry } from "./components/chat/types";
|
||||
import ConnectScreen from "./components/ConnectScreen";
|
||||
import DebugPanel, { type DebugTab } from "./components/debug/DebugPanel";
|
||||
import SessionSidebar from "./components/SessionSidebar";
|
||||
|
|
@ -977,7 +977,7 @@ export default function App() {
|
|||
|
||||
// Build transcript entries from raw SessionEvents
|
||||
const transcriptEntries = useMemo(() => {
|
||||
const entries: TimelineEntry[] = [];
|
||||
const entries: TranscriptEntry[] = [];
|
||||
|
||||
// Accumulators for streaming chunks
|
||||
let assistantAccumId: string | null = null;
|
||||
|
|
@ -1010,7 +1010,7 @@ export default function App() {
|
|||
};
|
||||
|
||||
// Track tool calls by ID for updates
|
||||
const toolEntryMap = new Map<string, TimelineEntry>();
|
||||
const toolEntryMap = new Map<string, TranscriptEntry>();
|
||||
|
||||
for (const event of events) {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
|
|
@ -1124,7 +1124,7 @@ export default function App() {
|
|||
if (update.title) existing.toolName = update.title as string;
|
||||
existing.time = time;
|
||||
} else {
|
||||
const entry: TimelineEntry = {
|
||||
const entry: TranscriptEntry = {
|
||||
id: `tool-${toolCallId}`,
|
||||
eventId: event.id,
|
||||
kind: "tool",
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
import { Send } from "lucide-react";
|
||||
|
||||
const ChatInput = ({
|
||||
message,
|
||||
onMessageChange,
|
||||
onSendMessage,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
disabled
|
||||
}: {
|
||||
message: string;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSendMessage: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className="input-container">
|
||||
<div className="input-wrapper">
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(event) => onMessageChange(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={placeholder}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button className="send-button" onClick={onSendMessage} disabled={disabled || !message.trim()}>
|
||||
<Send />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
|
|
@ -1,375 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { getMessageClass } from "./messageUtils";
|
||||
import type { TimelineEntry } from "./types";
|
||||
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle, Shield, Check, X } from "lucide-react";
|
||||
import MarkdownText from "./MarkdownText";
|
||||
|
||||
const ToolItem = ({
|
||||
entry,
|
||||
isLast,
|
||||
onEventClick
|
||||
}: {
|
||||
entry: TimelineEntry;
|
||||
isLast: boolean;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
}) => {
|
||||
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";
|
||||
|
||||
let label = "";
|
||||
let icon = <Info size={12} />;
|
||||
|
||||
if (isTool) {
|
||||
const statusLabel = entry.toolStatus && entry.toolStatus !== "completed"
|
||||
? ` (${entry.toolStatus.replace("_", " ")})`
|
||||
: "";
|
||||
label = `${entry.toolName ?? "tool"}${statusLabel}`;
|
||||
icon = <Wrench size={12} />;
|
||||
} else if (isReasoning) {
|
||||
label = `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`;
|
||||
icon = <Brain size={12} />;
|
||||
} else if (isMeta) {
|
||||
label = entry.meta?.title ?? "Status";
|
||||
icon = entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
|
||||
}
|
||||
|
||||
const hasContent = isTool
|
||||
? Boolean(entry.toolInput || entry.toolOutput)
|
||||
: isReasoning
|
||||
? Boolean(entry.reasoning?.text?.trim())
|
||||
: Boolean(entry.meta?.detail?.trim());
|
||||
const canOpenEvent = Boolean(
|
||||
entry.eventId &&
|
||||
onEventClick &&
|
||||
!(isMeta && entry.meta?.title === "Available commands update"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`tool-item ${isLast ? "last" : ""} ${isFailed ? "failed" : ""}`}>
|
||||
<div className="tool-item-connector">
|
||||
<div className="tool-item-dot" />
|
||||
{!isLast && <div className="tool-item-line" />}
|
||||
</div>
|
||||
<div className="tool-item-content">
|
||||
<button
|
||||
className={`tool-item-header ${expanded ? "expanded" : ""}`}
|
||||
onClick={() => hasContent && setExpanded(!expanded)}
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<span className="tool-item-icon">{icon}</span>
|
||||
<span className="tool-item-label">{label}</span>
|
||||
{isInProgress && (
|
||||
<span className="tool-item-spinner">
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
</span>
|
||||
)}
|
||||
{canOpenEvent && (
|
||||
<span
|
||||
className="tool-item-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick?.(entry.eventId!);
|
||||
}}
|
||||
title="View in Events"
|
||||
>
|
||||
<ExternalLink size={10} />
|
||||
</span>
|
||||
)}
|
||||
{hasContent && (
|
||||
<span className="tool-item-chevron">
|
||||
{expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{expanded && hasContent && (
|
||||
<div className="tool-item-body">
|
||||
{isTool && entry.toolInput && (
|
||||
<div className="tool-section">
|
||||
<div className="tool-section-title">Input</div>
|
||||
<pre className="tool-code">{entry.toolInput}</pre>
|
||||
</div>
|
||||
)}
|
||||
{isTool && isComplete && entry.toolOutput && (
|
||||
<div className="tool-section">
|
||||
<div className="tool-section-title">Output</div>
|
||||
<pre className="tool-code">{entry.toolOutput}</pre>
|
||||
</div>
|
||||
)}
|
||||
{isReasoning && entry.reasoning?.text && (
|
||||
<div className="tool-section">
|
||||
<pre className="tool-code muted">{entry.reasoning.text}</pre>
|
||||
</div>
|
||||
)}
|
||||
{isMeta && entry.meta?.detail && (
|
||||
<div className="tool-section">
|
||||
<pre className="tool-code">{entry.meta.detail}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolGroup = ({ entries, onEventClick }: { entries: TimelineEntry[]; onEventClick?: (eventId: string) => void }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// If only one item, render it directly without macro wrapper
|
||||
if (entries.length === 1) {
|
||||
return (
|
||||
<div className="tool-group-single">
|
||||
<ToolItem entry={entries[0]} isLast={true} onEventClick={onEventClick} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalCount = entries.length;
|
||||
const summary = `${totalCount} Event${totalCount > 1 ? "s" : ""}`;
|
||||
|
||||
// Check if any are in progress
|
||||
const hasInProgress = entries.some(e => e.kind === "tool" && e.toolStatus === "in_progress");
|
||||
const hasFailed = entries.some(e => e.kind === "tool" && e.toolStatus === "failed");
|
||||
|
||||
return (
|
||||
<div className={`tool-group-container ${hasFailed ? "failed" : ""}`}>
|
||||
<button
|
||||
className={`tool-group-header ${expanded ? "expanded" : ""}`}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<span className="tool-group-icon">
|
||||
<PlayCircle size={14} />
|
||||
</span>
|
||||
<span className="tool-group-label">{summary}</span>
|
||||
<span className="tool-group-chevron">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="tool-group">
|
||||
{entries.map((entry, idx) => (
|
||||
<ToolItem
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isLast={idx === entries.length - 1}
|
||||
onEventClick={onEventClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PermissionPrompt = ({
|
||||
entry,
|
||||
onPermissionReply,
|
||||
}: {
|
||||
entry: TimelineEntry;
|
||||
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
|
||||
}) => {
|
||||
const perm = entry.permission;
|
||||
if (!perm) return null;
|
||||
|
||||
const resolved = perm.resolved;
|
||||
const selectedId = perm.selectedOptionId;
|
||||
|
||||
const replyForOption = (kind: string): "once" | "always" | "reject" => {
|
||||
if (kind === "allow_once") return "once";
|
||||
if (kind === "allow_always") return "always";
|
||||
return "reject";
|
||||
};
|
||||
|
||||
const labelForKind = (kind: string, name: string): string => {
|
||||
if (name) return name;
|
||||
if (kind === "allow_once") return "Allow Once";
|
||||
if (kind === "allow_always") return "Always Allow";
|
||||
if (kind === "reject_once") return "Reject";
|
||||
if (kind === "reject_always") return "Reject Always";
|
||||
return kind;
|
||||
};
|
||||
|
||||
const classForKind = (kind: string): string => {
|
||||
if (kind.startsWith("allow")) return "allow";
|
||||
return "reject";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`permission-prompt ${resolved ? "resolved" : ""}`}>
|
||||
<div className="permission-header">
|
||||
<Shield size={14} className="permission-icon" />
|
||||
<span className="permission-title">{perm.title}</span>
|
||||
</div>
|
||||
{perm.description && (
|
||||
<div className="permission-description">{perm.description}</div>
|
||||
)}
|
||||
<div className="permission-actions">
|
||||
{perm.options.map((opt) => {
|
||||
const isSelected = resolved && selectedId === opt.optionId;
|
||||
const wasRejected = resolved && !isSelected && selectedId != null;
|
||||
return (
|
||||
<button
|
||||
key={opt.optionId}
|
||||
type="button"
|
||||
className={`permission-btn ${classForKind(opt.kind)} ${isSelected ? "selected" : ""} ${wasRejected ? "dimmed" : ""}`}
|
||||
disabled={resolved}
|
||||
onClick={() => onPermissionReply?.(perm.permissionId, replyForOption(opt.kind))}
|
||||
>
|
||||
{isSelected && (opt.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />)}
|
||||
{labelForKind(opt.kind, opt.name)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{resolved && !selectedId && (
|
||||
<span className="permission-auto-resolved">Auto-resolved</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const agentLogos: Record<string, string> = {
|
||||
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
|
||||
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
|
||||
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
|
||||
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
|
||||
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
|
||||
};
|
||||
|
||||
const ChatMessages = ({
|
||||
entries,
|
||||
sessionError,
|
||||
eventError,
|
||||
messagesEndRef,
|
||||
onEventClick,
|
||||
isThinking,
|
||||
agentId,
|
||||
onPermissionReply,
|
||||
}: {
|
||||
entries: TimelineEntry[];
|
||||
sessionError: string | null;
|
||||
eventError?: string | null;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
onPermissionReply?: (permissionId: string, reply: "once" | "always" | "reject") => void;
|
||||
}) => {
|
||||
// Group consecutive tool/reasoning/meta entries together
|
||||
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider" | "permission"; entries: TimelineEntry[] }> = [];
|
||||
|
||||
let currentToolGroup: TimelineEntry[] = [];
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (currentToolGroup.length > 0) {
|
||||
groupedEntries.push({ type: "tool-group", entries: currentToolGroup });
|
||||
currentToolGroup = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
const isStatusDivider = entry.kind === "meta" &&
|
||||
["Session Started", "Turn Started", "Turn Ended"].includes(entry.meta?.title ?? "");
|
||||
|
||||
if (entry.kind === "permission") {
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "permission", entries: [entry] });
|
||||
} else if (isStatusDivider) {
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "divider", entries: [entry] });
|
||||
} else if (entry.kind === "tool" || entry.kind === "reasoning" || (entry.kind === "meta" && entry.meta?.detail)) {
|
||||
currentToolGroup.push(entry);
|
||||
} else if (entry.kind === "meta" && !entry.meta?.detail) {
|
||||
// Simple meta without detail - add to tool group as single item
|
||||
currentToolGroup.push(entry);
|
||||
} else {
|
||||
// Regular message
|
||||
flushToolGroup();
|
||||
groupedEntries.push({ type: "message", entries: [entry] });
|
||||
}
|
||||
}
|
||||
flushToolGroup();
|
||||
|
||||
return (
|
||||
<div className="messages">
|
||||
{groupedEntries.map((group, idx) => {
|
||||
if (group.type === "divider") {
|
||||
const entry = group.entries[0];
|
||||
const title = entry.meta?.title ?? "Status";
|
||||
return (
|
||||
<div key={entry.id} className="status-divider">
|
||||
<div className="status-divider-line" />
|
||||
<span className="status-divider-text">{title}</span>
|
||||
<div className="status-divider-line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.type === "permission") {
|
||||
const entry = group.entries[0];
|
||||
return (
|
||||
<PermissionPrompt
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
onPermissionReply={onPermissionReply}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (group.type === "tool-group") {
|
||||
return <ToolGroup key={`group-${idx}`} entries={group.entries} onEventClick={onEventClick} />;
|
||||
}
|
||||
|
||||
// Regular message
|
||||
const entry = group.entries[0];
|
||||
const messageClass = getMessageClass(entry);
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={`message ${messageClass} no-avatar`}>
|
||||
<div className="message-content">
|
||||
{entry.text ? (
|
||||
<MarkdownText text={entry.text} />
|
||||
) : (
|
||||
<span className="thinking-indicator">
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sessionError && <div className="message-error">{sessionError}</div>}
|
||||
{eventError && <div className="message-error">{eventError}</div>}
|
||||
{isThinking && (
|
||||
<div className="thinking-row">
|
||||
<div className="thinking-avatar">
|
||||
{agentId && agentLogos[agentId] ? (
|
||||
<img src={agentLogos[agentId]} alt="" className="thinking-avatar-img" />
|
||||
) : (
|
||||
<span className="ai-label">AI</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="thinking-indicator">
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessages;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import type { TranscriptEntry } from "@sandbox-agent/react";
|
||||
import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AgentInfo } from "sandbox-agent";
|
||||
|
|
@ -6,9 +7,7 @@ import { formatShortId } from "../../utils/format";
|
|||
type AgentModeInfo = { id: string; name: string; description: string };
|
||||
type AgentModelInfo = { id: string; name?: string };
|
||||
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
|
||||
import ChatInput from "./ChatInput";
|
||||
import ChatMessages from "./ChatMessages";
|
||||
import type { TimelineEntry } from "./types";
|
||||
import InspectorConversation from "./InspectorConversation";
|
||||
|
||||
const HistoryLoadingSkeleton = () => (
|
||||
<div className="chat-loading-skeleton" aria-hidden>
|
||||
|
|
@ -60,7 +59,7 @@ const ChatPanel = ({
|
|||
onPermissionReply,
|
||||
}: {
|
||||
sessionId: string;
|
||||
transcriptEntries: TimelineEntry[];
|
||||
transcriptEntries: TranscriptEntry[];
|
||||
isLoadingHistory?: boolean;
|
||||
sessionError: string | null;
|
||||
message: string;
|
||||
|
|
@ -214,8 +213,8 @@ const ChatPanel = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="messages-container">
|
||||
{!sessionId ? (
|
||||
{!sessionId ? (
|
||||
<div className="messages-container">
|
||||
<div className="empty-state">
|
||||
<div className="empty-state-title">No Session Selected</div>
|
||||
<p className="empty-state-text no-session-subtext">Create a new session to start chatting with an agent.</p>
|
||||
|
|
@ -241,38 +240,36 @@ const ChatPanel = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : transcriptEntries.length === 0 && !sessionError ? (
|
||||
isLoadingHistory ? (
|
||||
<HistoryLoadingSkeleton />
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<Terminal className="empty-state-icon" />
|
||||
<div className="empty-state-title">Ready to Chat</div>
|
||||
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<ChatMessages
|
||||
entries={transcriptEntries}
|
||||
sessionError={sessionError}
|
||||
eventError={null}
|
||||
messagesEndRef={messagesEndRef}
|
||||
onEventClick={onEventClick}
|
||||
isThinking={isThinking}
|
||||
agentId={agentId}
|
||||
onPermissionReply={onPermissionReply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChatInput
|
||||
message={message}
|
||||
onMessageChange={onMessageChange}
|
||||
onSendMessage={onSendMessage}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"}
|
||||
disabled={!sessionId || sessionEnded}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<InspectorConversation
|
||||
entries={transcriptEntries}
|
||||
sessionError={sessionError}
|
||||
eventError={null}
|
||||
messagesEndRef={messagesEndRef}
|
||||
onEventClick={onEventClick}
|
||||
isThinking={isThinking}
|
||||
agentId={agentId}
|
||||
emptyState={
|
||||
isLoadingHistory ? (
|
||||
<HistoryLoadingSkeleton />
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<Terminal className="empty-state-icon" />
|
||||
<div className="empty-state-title">Ready to Chat</div>
|
||||
<p className="empty-state-text">Send a message to start a conversation with the agent.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
message={message}
|
||||
onMessageChange={onMessageChange}
|
||||
onSendMessage={onSendMessage}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={sessionEnded ? "Session ended" : "Send a message..."}
|
||||
disabled={sessionEnded}
|
||||
onPermissionReply={onPermissionReply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import {
|
||||
AgentConversation,
|
||||
type AgentConversationClassNames,
|
||||
type AgentTranscriptClassNames,
|
||||
type ChatComposerClassNames,
|
||||
type PermissionReply,
|
||||
type TranscriptEntry,
|
||||
} from "@sandbox-agent/react";
|
||||
import { AlertTriangle, Brain, Check, ChevronDown, ChevronRight, ExternalLink, Info, PlayCircle, Send, Shield, Wrench, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import MarkdownText from "./MarkdownText";
|
||||
|
||||
const agentLogos: Record<string, string> = {
|
||||
claude: `${import.meta.env.BASE_URL}logos/claude.svg`,
|
||||
codex: `${import.meta.env.BASE_URL}logos/openai.svg`,
|
||||
opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`,
|
||||
amp: `${import.meta.env.BASE_URL}logos/amp.svg`,
|
||||
pi: `${import.meta.env.BASE_URL}logos/pi.svg`,
|
||||
};
|
||||
|
||||
const transcriptClassNames: Partial<AgentTranscriptClassNames> = {
|
||||
root: "messages",
|
||||
divider: "status-divider",
|
||||
dividerLine: "status-divider-line",
|
||||
dividerText: "status-divider-text",
|
||||
message: "message",
|
||||
messageContent: "message-content",
|
||||
error: "message-error",
|
||||
toolGroupSingle: "tool-group-single",
|
||||
toolGroupContainer: "tool-group-container",
|
||||
toolGroupHeader: "tool-group-header",
|
||||
toolGroupIcon: "tool-group-icon",
|
||||
toolGroupLabel: "tool-group-label",
|
||||
toolGroupChevron: "tool-group-chevron",
|
||||
toolGroupBody: "tool-group",
|
||||
toolItem: "tool-item",
|
||||
toolItemConnector: "tool-item-connector",
|
||||
toolItemDot: "tool-item-dot",
|
||||
toolItemLine: "tool-item-line",
|
||||
toolItemContent: "tool-item-content",
|
||||
toolItemHeader: "tool-item-header",
|
||||
toolItemIcon: "tool-item-icon",
|
||||
toolItemLabel: "tool-item-label",
|
||||
toolItemSpinner: "tool-item-spinner",
|
||||
toolItemLink: "tool-item-link",
|
||||
toolItemChevron: "tool-item-chevron",
|
||||
toolItemBody: "tool-item-body",
|
||||
toolSection: "tool-section",
|
||||
toolSectionTitle: "tool-section-title",
|
||||
toolCode: "tool-code",
|
||||
toolCodeMuted: "muted",
|
||||
permissionPrompt: "permission-prompt",
|
||||
permissionHeader: "permission-header",
|
||||
permissionIcon: "permission-icon",
|
||||
permissionTitle: "permission-title",
|
||||
permissionDescription: "permission-description",
|
||||
permissionActions: "permission-actions",
|
||||
permissionButton: "permission-btn",
|
||||
permissionAutoResolved: "permission-auto-resolved",
|
||||
thinkingRow: "thinking-row",
|
||||
thinkingIndicator: "thinking-indicator",
|
||||
};
|
||||
|
||||
const conversationClassNames: Partial<AgentConversationClassNames> = {
|
||||
root: "chat-conversation",
|
||||
transcript: "messages-container",
|
||||
};
|
||||
|
||||
const composerClassNames: Partial<ChatComposerClassNames> = {
|
||||
root: "input-container",
|
||||
form: "input-wrapper",
|
||||
submit: "send-button",
|
||||
};
|
||||
|
||||
const ThinkingDots = () => (
|
||||
<>
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
<span className="thinking-dot" />
|
||||
</>
|
||||
);
|
||||
|
||||
export interface InspectorConversationProps {
|
||||
entries: TranscriptEntry[];
|
||||
sessionError: string | null;
|
||||
eventError?: string | null;
|
||||
messagesEndRef: React.RefObject<HTMLDivElement>;
|
||||
onEventClick?: (eventId: string) => void;
|
||||
isThinking?: boolean;
|
||||
agentId?: string;
|
||||
emptyState?: ReactNode;
|
||||
message: string;
|
||||
onMessageChange: (value: string) => void;
|
||||
onSendMessage: () => void;
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
placeholder: string;
|
||||
disabled: boolean;
|
||||
onPermissionReply?: (permissionId: string, reply: PermissionReply) => void;
|
||||
}
|
||||
|
||||
const InspectorConversation = ({
|
||||
entries,
|
||||
sessionError,
|
||||
eventError,
|
||||
messagesEndRef,
|
||||
onEventClick,
|
||||
isThinking,
|
||||
agentId,
|
||||
emptyState,
|
||||
message,
|
||||
onMessageChange,
|
||||
onSendMessage,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
disabled,
|
||||
onPermissionReply,
|
||||
}: InspectorConversationProps) => {
|
||||
return (
|
||||
<AgentConversation
|
||||
entries={entries}
|
||||
classNames={conversationClassNames}
|
||||
emptyState={emptyState}
|
||||
transcriptClassNames={transcriptClassNames}
|
||||
transcriptProps={{
|
||||
endRef: messagesEndRef,
|
||||
sessionError,
|
||||
eventError,
|
||||
onEventClick,
|
||||
isThinking,
|
||||
agentId,
|
||||
canOpenEvent: (entry) => !(entry.kind === "meta" && entry.meta?.title === "Available commands update"),
|
||||
renderMessageText: (entry) => <MarkdownText text={entry.text ?? ""} />,
|
||||
renderInlinePendingIndicator: () => <ThinkingDots />,
|
||||
renderToolItemIcon: (entry) => {
|
||||
if (entry.kind === "tool") {
|
||||
return <Wrench size={12} />;
|
||||
}
|
||||
if (entry.kind === "reasoning") {
|
||||
return <Brain size={12} />;
|
||||
}
|
||||
return entry.meta?.severity === "error" ? <AlertTriangle size={12} /> : <Info size={12} />;
|
||||
},
|
||||
renderToolGroupIcon: () => <PlayCircle size={14} />,
|
||||
renderChevron: (expanded) => (expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />),
|
||||
renderEventLinkContent: () => <ExternalLink size={10} />,
|
||||
onPermissionReply,
|
||||
renderPermissionIcon: () => <Shield size={14} />,
|
||||
renderPermissionOptionContent: ({ option, label, selected }) => (
|
||||
<>
|
||||
{selected ? (option.kind.startsWith("allow") ? <Check size={12} /> : <X size={12} />) : null}
|
||||
{label}
|
||||
</>
|
||||
),
|
||||
renderThinkingState: ({ agentId: activeAgentId }) => (
|
||||
<div className="thinking-row">
|
||||
<div className="thinking-avatar">
|
||||
{activeAgentId && agentLogos[activeAgentId] ? (
|
||||
<img src={agentLogos[activeAgentId]} alt="" className="thinking-avatar-img" />
|
||||
) : (
|
||||
<span className="ai-label">AI</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="thinking-indicator">
|
||||
<ThinkingDots />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
composerClassNames={composerClassNames}
|
||||
composerProps={{
|
||||
message,
|
||||
onMessageChange,
|
||||
onSubmit: onSendMessage,
|
||||
onKeyDown,
|
||||
placeholder,
|
||||
disabled,
|
||||
submitLabel: "Send",
|
||||
renderSubmitContent: () => <Send />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InspectorConversation;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import type { TimelineEntry } from "./types";
|
||||
import { Settings, AlertTriangle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export const getMessageClass = (entry: TimelineEntry) => {
|
||||
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.role === "user") return "user";
|
||||
return "assistant";
|
||||
};
|
||||
|
||||
export const getAvatarLabel = (messageClass: string): ReactNode => {
|
||||
if (messageClass === "user") return null;
|
||||
if (messageClass === "tool") return "T";
|
||||
if (messageClass === "system") return <Settings size={14} />;
|
||||
if (messageClass === "error") return <AlertTriangle size={14} />;
|
||||
return "AI";
|
||||
};
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
export type PermissionOption = {
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
};
|
||||
|
||||
export type TimelineEntry = {
|
||||
id: string;
|
||||
eventId?: string; // Links back to the original event for navigation
|
||||
kind: "message" | "tool" | "meta" | "reasoning" | "permission";
|
||||
time: string;
|
||||
// For messages:
|
||||
role?: "user" | "assistant";
|
||||
text?: string;
|
||||
// For tool calls:
|
||||
toolName?: string;
|
||||
toolInput?: string;
|
||||
toolOutput?: string;
|
||||
toolStatus?: string;
|
||||
// For reasoning:
|
||||
reasoning?: { text: string; visibility?: string };
|
||||
// For meta:
|
||||
meta?: { title: string; detail?: string; severity?: "info" | "error" };
|
||||
// For permission requests:
|
||||
permission?: {
|
||||
permissionId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
options: PermissionOption[];
|
||||
resolved?: boolean;
|
||||
selectedOptionId?: string;
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue