sandbox-agent/frontend/packages/inspector/src/components/chat/ChatPanel.tsx
Nicholas Kissel 700f7dbae0 feat(inspector): improve session UI, skills dropdown, and visual polish
- Add delete button on ended sessions (visible on hover)
- Darken ended sessions with opacity and "ended" pill badge
- Sort ended sessions to bottom of list
- Add token usage pill in chat header
- Disable input when session ended
- Add Official Skills dropdown with SDK and Rivet presets
- Format session IDs shorter with full ID on hover
- Add arrow icon to "Configure persistence" link
- Add agent logo SVGs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 19:42:18 -08:00

254 lines
8.1 KiB
TypeScript

import { AlertTriangle, Archive, CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo } from "sandbox-agent";
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";
const ChatPanel = ({
sessionId,
transcriptEntries,
sessionError,
message,
onMessageChange,
onSendMessage,
onKeyDown,
onCreateSession,
onSelectAgent,
agents,
agentsLoading,
agentsError,
messagesEndRef,
agentLabel,
modelLabel,
currentAgentVersion,
sessionEnded,
sessionArchived,
onEndSession,
onArchiveSession,
onUnarchiveSession,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
onEventClick,
isThinking,
agentId,
tokenUsage,
}: {
sessionId: string;
transcriptEntries: TimelineEntry[];
sessionError: string | null;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: (agentId: string, config: SessionConfig) => Promise<void>;
onSelectAgent: (agentId: string) => Promise<void>;
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
agentLabel: string;
modelLabel?: string | null;
currentAgentVersion?: string | null;
sessionEnded: boolean;
sessionArchived: boolean;
onEndSession: () => void;
onArchiveSession: () => void;
onUnarchiveSession: () => void;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
tokenUsage?: { used: number; size: number; cost?: number } | null;
}) => {
const [showAgentMenu, setShowAgentMenu] = useState(false);
const [copiedSessionId, setCopiedSessionId] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!showAgentMenu) return;
const handler = (event: MouseEvent) => {
if (!menuRef.current) return;
if (!menuRef.current.contains(event.target as Node)) {
setShowAgentMenu(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showAgentMenu]);
const copySessionId = async () => {
if (!sessionId) return;
const onSuccess = () => {
setCopiedSessionId(true);
window.setTimeout(() => setCopiedSessionId(false), 1200);
};
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(sessionId);
onSuccess();
return;
}
} catch {
// Fallback below for older/insecure contexts.
}
const textarea = document.createElement("textarea");
textarea.value = sessionId;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
onSuccess();
} finally {
document.body.removeChild(textarea);
}
};
const handleArchiveSession = () => {
if (!sessionId) return;
onArchiveSession();
};
const handleUnarchiveSession = () => {
if (!sessionId) return;
onUnarchiveSession();
};
return (
<div className="chat-panel">
<div className="panel-header">
<div className="panel-header-left">
<MessageSquare className="button-icon" />
<span className="panel-title">{sessionId ? agentLabel : "No Session"}</span>
{sessionId && modelLabel && (
<span className="header-meta-pill" title={modelLabel}>
{modelLabel}
</span>
)}
{sessionId && currentAgentVersion && (
<span className="header-meta-pill">v{currentAgentVersion}</span>
)}
{sessionId && (
<button
type="button"
className="session-id-display"
title={copiedSessionId ? "Copied" : `${sessionId} (click to copy)`}
onClick={() => void copySessionId()}
>
{copiedSessionId ? "Copied" : formatShortId(sessionId)}
</button>
)}
</div>
<div className="panel-header-right">
{sessionId && tokenUsage && (
<span className="token-pill">{tokenUsage.used.toLocaleString()} tokens</span>
)}
{sessionId && (
sessionEnded ? (
<>
<span className="button ghost small session-ended-status" title="Session ended">
<CheckSquare size={12} />
Ended
</span>
<button
type="button"
className="button ghost small"
onClick={sessionArchived ? handleUnarchiveSession : handleArchiveSession}
title={sessionArchived ? "Unarchive session" : "Archive session"}
>
<Archive size={12} />
{sessionArchived ? "Unarchive" : "Archive"}
</button>
</>
) : (
<button
type="button"
className="button ghost small"
onClick={onEndSession}
title="End session"
>
<Square size={12} />
End
</button>
)
)}
</div>
</div>
{sessionError && (
<div className="error-banner">
<AlertTriangle size={14} />
<span>{sessionError}</span>
</div>
)}
<div className="messages-container">
{!sessionId ? (
<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>
<div className="empty-state-menu-wrapper" ref={menuRef}>
<button
className="button primary"
onClick={() => setShowAgentMenu((value) => !value)}
>
<Plus className="button-icon" />
Create Session
</button>
<SessionCreateMenu
agents={agents}
agentsLoading={agentsLoading}
agentsError={agentsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
onCreateSession={onCreateSession}
onSelectAgent={onSelectAgent}
open={showAgentMenu}
onClose={() => setShowAgentMenu(false)}
/>
</div>
</div>
) : transcriptEntries.length === 0 && !sessionError ? (
<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}
/>
)}
</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>
);
};
export default ChatPanel;