mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 11:02:20 +00:00
- 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>
254 lines
8.1 KiB
TypeScript
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;
|