feat(inspector): improve session UI, skills dropdown, and visual polish (#179)

- 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
This commit is contained in:
NicholasKissel 2026-02-13 05:54:53 +00:00
parent 1c381c552a
commit e134012955
22 changed files with 2283 additions and 395 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
<svg viewBox="0 0 281 124" fill="#ffffff" xmlns="http://www.w3.org/2000/svg"><path d="M236.014 0C260.431 9.71106e-05 280.602 17.4115 280.603 44.7432C280.602 73.5337 260.065 94.1657 233.52 94.166C224.158 94.166 215.639 92.4222 208.63 88.4902C202.886 85.2698 198.203 80.6054 194.919 74.3379L188.115 121.822L187.946 123.016H174.214L174.448 121.423L191.772 2.49414H205.372L203.937 11.3369C212.143 3.86078 223.2 0.000153635 236.014 0ZM47.082 0.154297C56.4435 0.154297 65.0012 1.8991 72.0488 5.84863C77.8222 9.08305 82.5323 13.7713 85.8271 20.085L88.1201 3.69238L88.2861 2.49316H101.863L89.1611 90.6328L88.9873 91.8262H75.4092L76.7227 82.8555C68.5854 90.4564 57.3981 94.3231 44.5889 94.3232C20.1709 94.3232 0.000167223 76.9087 0 49.5771C0.000149745 20.7854 20.54 0.154871 47.082 0.154297ZM116.234 90.6357L116.061 91.8271H102.485L115.351 3.68555L115.521 2.49414H129.083L116.234 90.6357ZM140.673 90.6357L140.499 91.8271H126.924L139.789 3.68555L139.96 2.49414H153.521L140.673 90.6357ZM177.958 2.49414L165.108 90.6357L164.935 91.8271H151.36L164.225 3.68555L164.396 2.49414H177.958ZM48.4854 11.9844C27.8638 11.985 14.0133 28.3799 14.0127 48.9521C14.0127 57.7907 16.8094 66.1771 22.3145 72.334C27.7973 78.4657 36.0631 82.4932 47.2402 82.4932C67.8534 82.4925 81.7122 65.9487 81.7129 45.3682C81.7129 35.4076 78.2493 27.0792 72.4131 21.2441C66.5794 15.4088 58.2871 11.9844 48.4854 11.9844ZM233.362 11.8291C212.749 11.8297 198.89 28.3716 198.89 48.9521C198.89 58.9123 202.356 67.2403 208.189 73.0742C214.023 78.9107 222.315 82.3358 232.116 82.3359C252.738 82.3355 266.589 65.9407 266.59 45.3682C266.59 36.5296 263.795 28.1424 258.29 21.9863C252.807 15.8551 244.542 11.8291 233.362 11.8291Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by Pixelmator Pro 3.6.17 -->
<svg width="1200" height="1200" viewBox="0 0 1200 1200" xmlns="http://www.w3.org/2000/svg">
<g id="g314">
<path id="path147" fill="#d97757" stroke="none" d="M 233.959793 800.214905 L 468.644287 668.536987 L 472.590637 657.100647 L 468.644287 650.738403 L 457.208069 650.738403 L 417.986633 648.322144 L 283.892639 644.69812 L 167.597321 639.865845 L 54.926208 633.825623 L 26.577238 627.785339 L 3.3e-05 592.751709 L 2.73832 575.27533 L 26.577238 559.248352 L 60.724873 562.228149 L 136.187973 567.382629 L 249.422867 575.194763 L 331.570496 580.026978 L 453.261841 592.671082 L 472.590637 592.671082 L 475.328857 584.859009 L 468.724915 580.026978 L 463.570557 575.194763 L 346.389313 495.785217 L 219.543671 411.865906 L 153.100723 363.543762 L 117.181267 339.060425 L 99.060455 316.107361 L 91.248367 266.01355 L 123.865784 230.093994 L 167.677887 233.073853 L 178.872513 236.053772 L 223.248367 270.201477 L 318.040283 343.570496 L 441.825592 434.738342 L 459.946411 449.798706 L 467.194672 444.64447 L 468.080597 441.020203 L 459.946411 427.409485 L 392.617493 305.718323 L 320.778564 181.932983 L 288.80542 130.630859 L 280.348999 99.865845 C 277.369171 87.221436 275.194641 76.590698 275.194641 63.624268 L 312.322174 13.20813 L 332.8591 6.604126 L 382.389313 13.20813 L 403.248352 31.328979 L 434.013519 101.71814 L 483.865753 212.537048 L 561.181274 363.221497 L 583.812134 407.919434 L 595.892639 449.315491 L 600.40271 461.959839 L 608.214783 461.959839 L 608.214783 454.711609 L 614.577271 369.825623 L 626.335632 265.61084 L 637.771851 131.516846 L 641.718201 93.745117 L 660.402832 48.483276 L 697.530334 24.000122 L 726.52356 37.852417 L 750.362549 72 L 747.060486 94.067139 L 732.886047 186.201416 L 705.100708 330.52356 L 686.979919 427.167847 L 697.530334 427.167847 L 709.61084 415.087341 L 758.496704 350.174561 L 840.644348 247.490051 L 876.885925 206.738342 L 919.167847 161.71814 L 946.308838 140.29541 L 997.61084 140.29541 L 1035.38269 196.429626 L 1018.469849 254.416199 L 965.637634 321.422852 L 921.825562 378.201538 L 859.006714 462.765259 L 819.785278 530.41626 L 823.409424 535.812073 L 832.75177 534.92627 L 974.657776 504.724915 L 1051.328979 490.872559 L 1142.818848 475.167786 L 1184.214844 494.496582 L 1188.724854 514.147644 L 1172.456421 554.335693 L 1074.604126 578.496765 L 959.838989 601.449829 L 788.939636 641.879272 L 786.845764 643.409485 L 789.261841 646.389343 L 866.255127 653.637634 L 899.194702 655.409424 L 979.812134 655.409424 L 1129.932861 666.604187 L 1169.154419 692.537109 L 1192.671265 724.268677 L 1188.724854 748.429688 L 1128.322144 779.194641 L 1046.818848 759.865845 L 856.590759 714.604126 L 791.355774 698.335754 L 782.335693 698.335754 L 782.335693 703.731567 L 836.69812 756.885986 L 936.322205 846.845581 L 1061.073975 962.81897 L 1067.436279 991.490112 L 1051.409424 1014.120911 L 1034.496704 1011.704712 L 924.885986 929.234924 L 882.604126 892.107544 L 786.845764 811.48999 L 780.483276 811.48999 L 780.483276 819.946289 L 802.550415 852.241699 L 919.087341 1027.409424 L 925.127625 1081.127686 L 916.671204 1098.604126 L 886.469849 1109.154419 L 853.288696 1103.114136 L 785.073914 1007.355835 L 714.684631 899.516785 L 657.906067 802.872498 L 650.979858 806.81897 L 617.476624 1167.704834 L 601.771851 1186.147705 L 565.530212 1200 L 535.328857 1177.046997 L 519.302124 1139.919556 L 535.328857 1066.550537 L 554.657776 970.792053 L 570.362488 894.68457 L 584.536926 800.134277 L 592.993347 768.724976 L 592.429626 766.630859 L 585.503479 767.516968 L 514.22821 865.369263 L 405.825531 1011.865906 L 320.053711 1103.677979 L 299.516815 1111.812256 L 263.919525 1093.369263 L 267.221497 1060.429688 L 287.114136 1031.114136 L 405.825531 880.107361 L 477.422913 786.52356 L 523.651062 732.483276 L 523.328918 724.671265 L 520.590698 724.671265 L 205.288605 929.395935 L 149.154434 936.644409 L 124.993355 914.01355 L 127.973183 876.885986 L 139.409409 864.80542 L 234.201385 799.570435 L 233.879227 799.8927 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" width="800px" height="800px" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>OpenAI icon</title><path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v 2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v 2.9994l-2.5974 1.4997-2.6067-1.4997Z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg width='32' height='40' viewBox='0 0 32 40' fill='none' xmlns='http://www.w3.org/2000/svg'><g clip-path='url(#clip0_1311_94973)'><path d='M24 32H8V16H24V32Z' fill='#4B4646'/><path d='M24 8H8V32H24V8ZM32 40H0V0H32V40Z' fill='#F1ECEC'/></g><defs><clipPath id='clip0_1311_94973'><rect width='32' height='40' fill='white'/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 347 B

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
<!-- P shape: outer boundary clockwise, inner hole counter-clockwise -->
<path fill="#fff" fill-rule="evenodd" d="
M165.29 165.29
H517.36
V400
H400
V517.36
H282.65
V634.72
H165.29
Z
M282.65 282.65
V400
H400
V282.65
Z
"/>
<!-- i dot -->
<path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 473 B

File diff suppressed because it is too large Load diff

View file

@ -16,7 +16,17 @@ const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp"
amp: "Amp",
pi: "Pi",
cursor: "Cursor"
};
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 SessionCreateMenu = ({
@ -37,7 +47,7 @@ const SessionCreateMenu = ({
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onCreateSession: (agentId: string, config: SessionConfig) => Promise<void>;
onSelectAgent: (agentId: string) => Promise<void>;
open: boolean;
onClose: () => void;
@ -48,7 +58,7 @@ const SessionCreateMenu = ({
const [selectedModel, setSelectedModel] = useState("");
const [customModel, setCustomModel] = useState("");
const [isCustomModel, setIsCustomModel] = useState(false);
const [configLoadDone, setConfigLoadDone] = useState(false);
const [creating, setCreating] = useState(false);
// Reset state when menu closes
useEffect(() => {
@ -59,18 +69,10 @@ const SessionCreateMenu = ({
setSelectedModel("");
setCustomModel("");
setIsCustomModel(false);
setConfigLoadDone(false);
setCreating(false);
}
}, [open]);
// Transition to config phase after load completes — deferred via useEffect
// so parent props (modelsByAgent) have settled before we render the config form
useEffect(() => {
if (phase === "loading-config" && configLoadDone) {
setPhase("config");
}
}, [phase, configLoadDone]);
// Auto-select first mode when modes load for selected agent
useEffect(() => {
if (!selectedAgent) return;
@ -80,6 +82,14 @@ const SessionCreateMenu = ({
}
}, [modesByAgent, selectedAgent, agentMode]);
// Agent-specific config should not leak between agent selections.
useEffect(() => {
setAgentMode("");
setSelectedModel("");
setCustomModel("");
setIsCustomModel(false);
}, [selectedAgent]);
// Auto-select default model when agent is selected
useEffect(() => {
if (!selectedAgent) return;
@ -99,21 +109,21 @@ const SessionCreateMenu = ({
const handleAgentClick = (agentId: string) => {
setSelectedAgent(agentId);
setPhase("loading-config");
setConfigLoadDone(false);
onSelectAgent(agentId).finally(() => {
setConfigLoadDone(true);
setPhase("config");
// Load agent config in background; creation should not block on this call.
void onSelectAgent(agentId).catch((error) => {
console.error("[SessionCreateMenu] Failed to load agent config:", error);
});
};
const handleBack = () => {
if (creating) return;
setPhase("agent");
setSelectedAgent("");
setAgentMode("");
setSelectedModel("");
setCustomModel("");
setIsCustomModel(false);
setConfigLoadDone(false);
};
const handleModelSelectChange = (value: string) => {
@ -129,9 +139,17 @@ const SessionCreateMenu = ({
const resolvedModel = isCustomModel ? customModel : selectedModel;
const handleCreate = () => {
onCreateSession(selectedAgent, { agentMode, model: resolvedModel });
onClose();
const handleCreate = async () => {
if (!selectedAgent) return;
setCreating(true);
try {
await onCreateSession(selectedAgent, { agentMode, model: resolvedModel });
onClose();
} catch (error) {
console.error("[SessionCreateMenu] Failed to create session:", error);
} finally {
setCreating(false);
}
};
if (phase === "agent") {
@ -142,43 +160,57 @@ const SessionCreateMenu = ({
{!agentsLoading && !agentsError && agents.length === 0 && (
<div className="sidebar-add-status">No agents available.</div>
)}
{!agentsLoading && !agentsError &&
agents.map((agent) => (
<button
key={agent.id}
className="sidebar-add-option"
onClick={() => handleAgentClick(agent.id)}
>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-option-version">{agent.version}</span>}
</div>
<div className="agent-option-badges">
{agent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
))}
{!agentsLoading && !agentsError && (() => {
const codingAgents = agents.filter((a) => a.id !== "mock");
const mockAgent = agents.find((a) => a.id === "mock");
return (
<>
{codingAgents.map((agent) => (
<button
key={agent.id}
className="sidebar-add-option"
onClick={() => handleAgentClick(agent.id)}
>
<div className="agent-option-left">
{agentLogos[agent.id] && (
<img src={agentLogos[agent.id]} alt="" className="agent-option-logo" />
)}
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-option-version">{agent.version}</span>}
</div>
<div className="agent-option-badges">
{agent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
))}
{mockAgent && (
<>
<div className="agent-divider" />
<button
className="sidebar-add-option"
onClick={() => handleAgentClick(mockAgent.id)}
>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[mockAgent.id] ?? mockAgent.id}</span>
{mockAgent.version && <span className="agent-option-version">{mockAgent.version}</span>}
</div>
<div className="agent-option-badges">
{mockAgent.installed && <span className="agent-badge installed">Installed</span>}
<ArrowRight size={12} className="agent-option-arrow" />
</div>
</button>
</>
)}
</>
);
})()}
</div>
);
}
const agentLabel = agentLabels[selectedAgent] ?? selectedAgent;
if (phase === "loading-config") {
return (
<div className="session-create-menu">
<div className="session-create-header">
<button className="session-create-back" onClick={handleBack} title="Back to agents">
<ArrowLeft size={14} />
</button>
<span className="session-create-agent-name">{agentLabel}</span>
</div>
<div className="sidebar-add-status">Loading config...</div>
</div>
);
}
// Phase 2: config form
const activeModes = modesByAgent[selectedAgent] ?? [];
const activeModels = modelsByAgent[selectedAgent] ?? [];
@ -257,8 +289,8 @@ const SessionCreateMenu = ({
</div>
<div className="session-create-actions">
<button className="button primary" onClick={handleCreate}>
Create Session
<button className="button primary" onClick={() => void handleCreate()} disabled={creating}>
{creating ? "Creating..." : "Create Session"}
</button>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { Plus, RefreshCw } from "lucide-react";
import { Archive, ArrowLeft, ArrowUpRight, Plus, RefreshCw } 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 };
@ -10,6 +11,7 @@ type SessionListItem = {
sessionId: string;
agent: string;
ended: boolean;
archived: boolean;
};
const agentLabels: Record<string, string> = {
@ -21,6 +23,7 @@ const agentLabels: Record<string, string> = {
cursor: "Cursor"
};
const persistenceDocsUrl = "https://sandboxagent.dev/docs/session-persistence";
const MIN_REFRESH_SPIN_MS = 350;
const SessionSidebar = ({
sessions,
@ -42,7 +45,7 @@ const SessionSidebar = ({
selectedSessionId: string;
onSelectSession: (session: SessionListItem) => void;
onRefresh: () => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onCreateSession: (agentId: string, config: SessionConfig) => Promise<void>;
onSelectAgent: (agentId: string) => Promise<void>;
agents: AgentInfo[];
agentsLoading: boolean;
@ -54,7 +57,16 @@ const SessionSidebar = ({
defaultModelByAgent: Record<string, string>;
}) => {
const [showMenu, setShowMenu] = useState(false);
const [showArchived, setShowArchived] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const archivedCount = sessions.filter((session) => session.archived).length;
const activeSessions = sessions.filter((session) => !session.archived);
const archivedSessions = sessions.filter((session) => session.archived);
const visibleSessions = showArchived ? archivedSessions : activeSessions;
const orderedVisibleSessions = showArchived
? [...visibleSessions].sort((a, b) => Number(a.ended) - Number(b.ended))
: visibleSessions;
useEffect(() => {
if (!showMenu) return;
@ -68,13 +80,54 @@ const SessionSidebar = ({
return () => document.removeEventListener("mousedown", handler);
}, [showMenu]);
useEffect(() => {
// Prevent getting stuck in archived view when there are no archived sessions.
if (!showArchived) return;
if (archivedSessions.length === 0) {
setShowArchived(false);
}
}, [showArchived, archivedSessions.length]);
const handleRefresh = async () => {
if (refreshing) return;
const startedAt = Date.now();
setRefreshing(true);
try {
await Promise.resolve(onRefresh());
} finally {
const elapsedMs = Date.now() - startedAt;
if (elapsedMs < MIN_REFRESH_SPIN_MS) {
await new Promise((resolve) => window.setTimeout(resolve, MIN_REFRESH_SPIN_MS - elapsedMs));
}
setRefreshing(false);
}
};
return (
<div className="session-sidebar">
<div className="sidebar-header">
<span className="sidebar-title">Sessions</span>
<div className="sidebar-header-actions">
<button className="sidebar-icon-btn" onClick={onRefresh} title="Refresh sessions">
<RefreshCw size={14} />
{archivedCount > 0 && (
<button
className={`button secondary small ${showArchived ? "active" : ""}`}
onClick={() => setShowArchived((value) => !value)}
title={showArchived ? "Hide archived sessions" : `Show archived sessions (${archivedCount})`}
>
{showArchived ? (
<ArrowLeft size={12} className="button-icon" />
) : (
<Archive size={12} className="button-icon" />
)}
</button>
)}
<button
className="button secondary small"
onClick={() => void handleRefresh()}
title="Refresh sessions"
disabled={sessionsLoading || refreshing}
>
<RefreshCw size={12} className={`button-icon ${sessionsLoading || refreshing ? "spinner-icon" : ""}`} />
</button>
<div className="sidebar-add-menu-wrapper" ref={menuRef}>
<button
@ -105,30 +158,41 @@ const SessionSidebar = ({
<div className="sidebar-empty">Loading sessions...</div>
) : sessionsError ? (
<div className="sidebar-empty error">{sessionsError}</div>
) : sessions.length === 0 ? (
<div className="sidebar-empty">No sessions yet.</div>
) : visibleSessions.length === 0 ? (
<div className="sidebar-empty">{showArchived ? "No archived sessions." : "No sessions yet."}</div>
) : (
sessions.map((session) => (
<button
key={session.sessionId}
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""}`}
onClick={() => onSelectSession(session)}
>
<div className="session-item-id">{session.sessionId}</div>
<div className="session-item-meta">
<span className="session-item-agent">{agentLabels[session.agent] ?? session.agent}</span>
{session.ended && <span className="session-item-ended">ended</span>}
</div>
</button>
))
<>
{showArchived && <div className="sidebar-empty">Archived Sessions</div>}
{orderedVisibleSessions.map((session) => (
<div
key={session.sessionId}
className={`session-item ${session.sessionId === selectedSessionId ? "active" : ""} ${session.ended ? "ended" : ""} ${session.archived ? "ended" : ""}`}
>
<button
className="session-item-content"
onClick={() => onSelectSession(session)}
>
<div className="session-item-id" title={session.sessionId}>
{formatShortId(session.sessionId)}
</div>
<div className="session-item-meta">
<span className="session-item-agent">
{agentLabels[session.agent] ?? session.agent}
</span>
{(session.archived || session.ended) && <span className="session-item-ended">ended</span>}
</div>
</button>
</div>
))}
</>
)}
</div>
<div className="session-persistence-note">
Sessions are persisted in your browser using IndexedDB.{" "}
<a href={persistenceDocsUrl} target="_blank" rel="noreferrer">
<a href={persistenceDocsUrl} target="_blank" rel="noreferrer" style={{ display: "inline-flex", alignItems: "center", gap: 2 }}>
Configure persistence
<ArrowUpRight size={10} />
</a>
.
</div>
</div>
);

View file

@ -1,130 +1,256 @@
import { useState } from "react";
import { getAvatarLabel, getMessageClass } from "./messageUtils";
import { getMessageClass } from "./messageUtils";
import type { TimelineEntry } from "./types";
import { AlertTriangle, Settings, ChevronRight, ChevronDown } from "lucide-react";
import { AlertTriangle, ChevronRight, ChevronDown, Wrench, Brain, Info, ExternalLink, PlayCircle } from "lucide-react";
const CollapsibleMessage = ({
id,
icon,
label,
children,
className = ""
const ToolItem = ({
entry,
isLast,
onEventClick
}: {
id: string;
icon: React.ReactNode;
label: string;
children: React.ReactNode;
className?: string;
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={`collapsible-message ${className}`}>
<button className="collapsible-header" onClick={() => setExpanded(!expanded)}>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{icon}
<span>{label}</span>
</button>
{expanded && <div className="collapsible-content">{children}</div>}
<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 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,
messagesEndRef
eventError,
messagesEndRef,
onEventClick,
isThinking,
agentId
}: {
entries: TimelineEntry[];
sessionError: string | null;
eventError?: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
onEventClick?: (eventId: string) => void;
isThinking?: boolean;
agentId?: string;
}) => {
// Group consecutive tool/reasoning/meta entries together
const groupedEntries: Array<{ type: "message" | "tool-group" | "divider"; 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 (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">
{entries.map((entry) => {
const messageClass = getMessageClass(entry);
if (entry.kind === "meta") {
const isError = entry.meta?.severity === "error";
{groupedEntries.map((group, idx) => {
if (group.type === "divider") {
const entry = group.entries[0];
const title = entry.meta?.title ?? "Status";
const isStatusDivider = ["Session Started", "Turn Started", "Turn Ended"].includes(title);
if (isStatusDivider) {
return (
<div key={entry.id} className="status-divider">
<div className="status-divider-line" />
<span className="status-divider-text">
<Settings size={12} />
{title}
</span>
<div className="status-divider-line" />
</div>
);
}
return (
<CollapsibleMessage
key={entry.id}
id={entry.id}
icon={isError ? <AlertTriangle size={14} className="error-icon" /> : <Settings size={14} className="system-icon" />}
label={title}
className={isError ? "error" : "system"}
>
{entry.meta?.detail && <div className="part-body">{entry.meta.detail}</div>}
</CollapsibleMessage>
);
}
if (entry.kind === "reasoning") {
return (
<div key={entry.id} className="message assistant">
<div className="avatar">{getAvatarLabel("assistant")}</div>
<div className="message-content">
<div className="message-meta">
<span>reasoning - {entry.reasoning?.visibility ?? "public"}</span>
</div>
<div className="part-body muted">{entry.reasoning?.text ?? ""}</div>
</div>
<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 (entry.kind === "tool") {
const isComplete = entry.toolStatus === "completed" || entry.toolStatus === "failed";
const isFailed = entry.toolStatus === "failed";
const statusLabel = entry.toolStatus && entry.toolStatus !== "completed"
? entry.toolStatus.replace("_", " ")
: "";
return (
<CollapsibleMessage
key={entry.id}
id={entry.id}
icon={<span className="tool-icon">T</span>}
label={`tool call - ${entry.toolName ?? "tool"}${statusLabel ? ` (${statusLabel})` : ""}`}
className={`tool${isFailed ? " error" : ""}`}
>
{entry.toolInput && <pre className="code-block">{entry.toolInput}</pre>}
{isComplete && entry.toolOutput && (
<div className="part">
<div className="part-title">result</div>
<pre className="code-block">{entry.toolOutput}</pre>
</div>
)}
{!isComplete && !entry.toolInput && (
<span className="thinking-indicator">
<span className="thinking-dot" />
<span className="thinking-dot" />
<span className="thinking-dot" />
</span>
)}
</CollapsibleMessage>
);
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}`}>
<div className="avatar">{getAvatarLabel(messageClass)}</div>
<div key={entry.id} className={`message ${messageClass} no-avatar`}>
<div className="message-content">
{entry.text ? (
<div className="part-body">{entry.text}</div>
@ -140,6 +266,23 @@ const ChatMessages = ({
);
})}
{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>
);

View file

@ -1,6 +1,7 @@
import { CheckSquare, MessageSquare, Plus, Square, Terminal } from "lucide-react";
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 };
@ -9,9 +10,27 @@ import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
import type { TimelineEntry } from "./types";
const HistoryLoadingSkeleton = () => (
<div className="chat-loading-skeleton" aria-hidden>
<div className="chat-skeleton-row assistant">
<div className="chat-skeleton-bubble w-lg" />
</div>
<div className="chat-skeleton-row user">
<div className="chat-skeleton-bubble w-md" />
</div>
<div className="chat-skeleton-row assistant">
<div className="chat-skeleton-bubble w-xl" />
</div>
<div className="chat-skeleton-row assistant">
<div className="chat-skeleton-bubble w-sm" />
</div>
</div>
);
const ChatPanel = ({
sessionId,
transcriptEntries,
isLoadingHistory,
sessionError,
message,
onMessageChange,
@ -24,35 +43,53 @@ const ChatPanel = ({
agentsError,
messagesEndRef,
agentLabel,
modelLabel,
currentAgentVersion,
sessionEnded,
sessionArchived,
onEndSession,
onArchiveSession,
onUnarchiveSession,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
onEventClick,
isThinking,
agentId,
tokenUsage,
}: {
sessionId: string;
transcriptEntries: TimelineEntry[];
isLoadingHistory?: boolean;
sessionError: string | null;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: (agentId: string, config: SessionConfig) => 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(() => {
@ -67,21 +104,92 @@ const ChatPanel = ({
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 ? "Session" : "No Session"}</span>
{sessionId && <span className="session-id-display">{sessionId}</span>}
<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" style={{ opacity: 0.5, cursor: "default" }} title="Session ended">
<CheckSquare size={12} />
Ended
</span>
<>
<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"
@ -97,12 +205,18 @@ const ChatPanel = ({
</div>
</div>
{sessionError && (
<div className="error-banner">
<AlertTriangle size={14} />
<span>{sessionError}</span>
</div>
)}
<div className="messages-container">
{!sessionId ? (
<div className="empty-state">
<MessageSquare className="empty-state-icon" />
<div className="empty-state-title">No Session Selected</div>
<p className="empty-state-text">Create a new session to start chatting with an agent.</p>
<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"
@ -126,16 +240,24 @@ const ChatPanel = ({
</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>
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}
/>
)}
</div>
@ -145,24 +267,9 @@ const ChatPanel = ({
onMessageChange={onMessageChange}
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId}
placeholder={sessionEnded ? "Session ended" : sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId || sessionEnded}
/>
{sessionId && (
<div className="session-config-bar">
<div className="session-config-field">
<span className="session-config-label">Agent</span>
<span className="session-config-value">{agentLabel}</span>
</div>
{currentAgentVersion && (
<div className="session-config-field">
<span className="session-config-label">Version</span>
<span className="session-config-value">{currentAgentVersion}</span>
</div>
)}
</div>
)}
</div>
);
};

View file

@ -1,5 +1,5 @@
import type { TimelineEntry } from "./types";
import { Settings, AlertTriangle, User } from "lucide-react";
import { Settings, AlertTriangle } from "lucide-react";
import type { ReactNode } from "react";
export const getMessageClass = (entry: TimelineEntry) => {
@ -11,7 +11,7 @@ export const getMessageClass = (entry: TimelineEntry) => {
};
export const getAvatarLabel = (messageClass: string): ReactNode => {
if (messageClass === "user") return <User size={14} />;
if (messageClass === "user") return null;
if (messageClass === "tool") return "T";
if (messageClass === "system") return <Settings size={14} />;
if (messageClass === "error") return <AlertTriangle size={14} />;

View file

@ -1,5 +1,6 @@
export type TimelineEntry = {
id: string;
eventId?: string; // Links back to the original event for navigation
kind: "message" | "tool" | "meta" | "reasoning";
time: string;
// For messages:

View file

@ -5,6 +5,7 @@ import type { AgentInfo } from "sandbox-agent";
type AgentModeInfo = { id: string; name: string; description: string };
import FeatureCoverageBadges from "../agents/FeatureCoverageBadges";
import { emptyFeatureCoverage } from "../../types/agents";
const MIN_REFRESH_SPIN_MS = 350;
const AgentsTab = ({
agents,
@ -24,6 +25,7 @@ const AgentsTab = ({
error: string | null;
}) => {
const [installingAgent, setInstallingAgent] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const handleInstall = async (agentId: string, reinstall: boolean) => {
setInstallingAgent(agentId);
@ -34,16 +36,30 @@ const AgentsTab = ({
}
};
const handleRefresh = async () => {
if (refreshing) return;
const startedAt = Date.now();
setRefreshing(true);
try {
await Promise.resolve(onRefresh());
} finally {
const elapsedMs = Date.now() - startedAt;
if (elapsedMs < MIN_REFRESH_SPIN_MS) {
await new Promise((resolve) => window.setTimeout(resolve, MIN_REFRESH_SPIN_MS - elapsedMs));
}
setRefreshing(false);
}
};
return (
<>
<div className="inline-row" style={{ marginBottom: 16 }}>
<button className="button secondary small" onClick={onRefresh} disabled={loading}>
<RefreshCw className="button-icon" /> Refresh
<button className="button secondary small" onClick={() => void handleRefresh()} disabled={loading || refreshing}>
<RefreshCw className={`button-icon ${loading || refreshing ? "spinner-icon" : ""}`} /> Refresh
</button>
</div>
{error && <div className="banner error">{error}</div>}
{loading && <div className="card-meta">Loading agents...</div>}
{!loading && agents.length === 0 && (
<div className="card-meta">No agents reported. Click refresh to check.</div>
)}

View file

@ -16,6 +16,8 @@ const DebugPanel = ({
onDebugTabChange,
events,
onResetEvents,
highlightedEventId,
onClearHighlight,
requestLog,
copiedLogId,
onClearRequestLog,
@ -33,6 +35,8 @@ const DebugPanel = ({
onDebugTabChange: (tab: DebugTab) => void;
events: SessionEvent[];
onResetEvents: () => void;
highlightedEventId?: string | null;
onClearHighlight?: () => void;
requestLog: RequestLog[];
copiedLogId: number | null;
onClearRequestLog: () => void;
@ -86,6 +90,8 @@ const DebugPanel = ({
<EventsTab
events={events}
onClear={onResetEvents}
highlightedEventId={highlightedEventId}
onClearHighlight={onClearHighlight}
/>
)}

View file

@ -28,9 +28,9 @@ import {
Wrench,
type LucideIcon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { SessionEvent } from "sandbox-agent";
import { formatJson, formatTime } from "../../utils/format";
import { formatJson, formatShortId, formatTime } from "../../utils/format";
type EventIconInfo = { Icon: LucideIcon; category: string };
@ -111,9 +111,13 @@ function getEventIcon(method: string, payload: Record<string, unknown>): EventIc
const EventsTab = ({
events,
onClear,
highlightedEventId,
onClearHighlight,
}: {
events: SessionEvent[];
onClear: () => void;
highlightedEventId?: string | null;
onClearHighlight?: () => void;
}) => {
const [collapsedEvents, setCollapsedEvents] = useState<Record<string, boolean>>({});
const [copied, setCopied] = useState(false);
@ -155,6 +159,25 @@ const EventsTab = ({
}
}, [events.length]);
// Scroll to highlighted event (with delay to ensure DOM is ready after tab switch)
useEffect(() => {
if (highlightedEventId) {
const scrollToEvent = () => {
const el = document.getElementById(`event-${highlightedEventId}`);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
// Clear highlight after animation
setTimeout(() => {
onClearHighlight?.();
}, 2000);
}
};
// Small delay to ensure tab switch and DOM render completes
const timer = setTimeout(scrollToEvent, 100);
return () => clearTimeout(timer);
}
}, [highlightedEventId, onClearHighlight]);
const getMethod = (event: SessionEvent): string => {
const payload = event.payload as Record<string, unknown>;
return typeof payload.method === "string" ? payload.method : "(response)";
@ -200,8 +223,14 @@ const EventsTab = ({
const time = formatTime(new Date(event.createdAt).toISOString());
const senderClass = event.sender === "client" ? "client" : "agent";
const isHighlighted = highlightedEventId === event.id;
return (
<div key={eventKey} className={`event-item ${isCollapsed ? "collapsed" : "expanded"}`}>
<div
key={eventKey}
id={`event-${event.id}`}
className={`event-item ${isCollapsed ? "collapsed" : "expanded"} ${isHighlighted ? "highlighted" : ""}`}
>
<button
className="event-summary"
type="button"
@ -219,8 +248,8 @@ const EventsTab = ({
</span>
<span className="event-time">{time}</span>
</div>
<div className="event-id">
{event.id}
<div className="event-id" title={event.id}>
{formatShortId(event.id)}
</div>
</div>
<span className="event-chevron">

View file

@ -1,4 +1,4 @@
import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { ChevronDown, ChevronRight, FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { SandboxAgent } from "sandbox-agent";
import { formatJson } from "../../utils/format";
@ -8,15 +8,25 @@ type McpEntry = {
config: Record<string, unknown>;
};
const MCP_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-mcp-directory";
const McpTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const [directory, setDirectory] = useState("/");
const [directory, setDirectory] = useState(() => {
if (typeof window === "undefined") return "/";
try {
return window.localStorage.getItem(MCP_DIRECTORY_STORAGE_KEY) ?? "/";
} catch {
return "/";
}
});
const [entries, setEntries] = useState<McpEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [collapsedServers, setCollapsedServers] = useState<Record<string, boolean>>({});
// Add/edit form state
const [editing, setEditing] = useState(false);
@ -52,6 +62,14 @@ const McpTab = ({
loadAll(directory);
}, [directory, loadAll]);
useEffect(() => {
try {
window.localStorage.setItem(MCP_DIRECTORY_STORAGE_KEY, directory);
} catch {
// Ignore storage failures.
}
}, [directory]);
const startAdd = () => {
setEditing(true);
setEditName("");
@ -158,7 +176,7 @@ const McpTab = ({
value={editJson}
onChange={(e) => { setEditJson(e.target.value); setEditError(null); }}
rows={6}
style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11 }}
style={{ width: "100%", boxSizing: "border-box", fontFamily: "monospace", fontSize: 11, resize: "vertical" }}
/>
{editError && <div className="banner error" style={{ marginTop: 4 }}>{editError}</div>}
</div>
@ -180,29 +198,44 @@ const McpTab = ({
</div>
)}
{entries.map((entry) => (
<div key={entry.name} className="card" style={{ marginBottom: 8 }}>
<div className="card-header">
<span className="card-title">{entry.name}</span>
<div className="card-header-pills">
<span className="pill accent">
{(entry.config as { type?: string }).type ?? "unknown"}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<Trash2 size={12} />
</button>
{entries.map((entry) => {
const isCollapsed = collapsedServers[entry.name] ?? true;
return (
<div key={entry.name} className="card" style={{ marginBottom: 8 }}>
<div className="card-header">
<div className="inline-row" style={{ gap: 6 }}>
<button
className="button ghost small"
onClick={() => setCollapsedServers((prev) => ({ ...prev, [entry.name]: !(prev[entry.name] ?? true) }))}
title={isCollapsed ? "Expand" : "Collapse"}
style={{ padding: "2px 4px" }}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span className="card-title">{entry.name}</span>
</div>
<div className="card-header-pills">
<span className="pill accent">
{(entry.config as { type?: string }).type ?? "unknown"}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<Trash2 size={12} />
</button>
</div>
</div>
{!isCollapsed && (
<pre className="code-block" style={{ marginTop: 4, fontSize: 10 }}>
{formatJson(entry.config)}
</pre>
)}
</div>
<pre className="code-block" style={{ marginTop: 4, fontSize: 10 }}>
{formatJson(entry.config)}
</pre>
</div>
))}
);
})}
</>
);
};

View file

@ -1,4 +1,4 @@
import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { ChevronDown, ChevronRight, FolderOpen, Loader2, Plus, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { SandboxAgent } from "sandbox-agent";
import { formatJson } from "../../utils/format";
@ -8,15 +8,49 @@ type SkillEntry = {
config: { sources: Array<{ source: string; type: string; ref?: string | null; subpath?: string | null; skills?: string[] | null }> };
};
const SKILLS_DIRECTORY_STORAGE_KEY = "sandbox-agent-inspector-skills-directory";
const SkillsTab = ({
getClient,
}: {
getClient: () => SandboxAgent;
}) => {
const [directory, setDirectory] = useState("/");
const officialSkills = [
{
name: "Sandbox Agent SDK",
skillId: "sandbox-agent",
source: "rivet-dev/skills",
summary: "Skills bundle for fast Sandbox Agent SDK setup and consistent workflows.",
},
{
name: "Rivet",
skillId: "rivet",
source: "rivet-dev/skills",
summary: "Open-source platform for building, deploying, and scaling AI agents.",
features: [
"Session Persistence",
"Resumable Sessions",
"Multi-Agent Support",
"Realtime Events",
"Tool Call Visibility",
],
},
];
const [directory, setDirectory] = useState(() => {
if (typeof window === "undefined") return "/";
try {
return window.localStorage.getItem(SKILLS_DIRECTORY_STORAGE_KEY) ?? "/";
} catch {
return "/";
}
});
const [entries, setEntries] = useState<SkillEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [showSdkSkills, setShowSdkSkills] = useState(false);
const [collapsedSkills, setCollapsedSkills] = useState<Record<string, boolean>>({});
// Add form state
const [editing, setEditing] = useState(false);
@ -56,6 +90,14 @@ const SkillsTab = ({
loadAll(directory);
}, [directory, loadAll]);
useEffect(() => {
try {
window.localStorage.setItem(SKILLS_DIRECTORY_STORAGE_KEY, directory);
} catch {
// Ignore storage failures.
}
}, [directory]);
const startAdd = () => {
setEditing(true);
setEditName("");
@ -128,11 +170,66 @@ const SkillsTab = ({
}
};
const fallbackCopy = (text: string) => {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
};
const copyText = async (id: string, text: string) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
fallbackCopy(text);
}
setCopiedId(id);
window.setTimeout(() => {
setCopiedId((current) => (current === id ? null : current));
}, 1800);
} catch {
setError("Failed to copy snippet");
}
};
const applySkillPreset = (skill: typeof officialSkills[0]) => {
setEditing(true);
setEditName(skill.skillId);
setEditSource(skill.source);
setEditType("github");
setEditRef("");
setEditSubpath("");
setEditSkills(skill.skillId);
setEditError(null);
setShowSdkSkills(false);
};
const copySkillToInput = async (skillId: string) => {
const skill = officialSkills.find((s) => s.skillId === skillId);
if (skill) {
applySkillPreset(skill);
await copyText(`skill-input-${skillId}`, skillId);
}
};
return (
<>
<div className="inline-row" style={{ marginBottom: 12, justifyContent: "space-between" }}>
<span className="card-meta">Skills Configuration</span>
<div className="inline-row">
<div className="inline-row" style={{ gap: 6 }}>
<button
className="button secondary small"
onClick={() => setShowSdkSkills((prev) => !prev)}
title="Toggle official skills list"
>
{showSdkSkills ? <ChevronDown className="button-icon" style={{ width: 12, height: 12 }} /> : <ChevronRight className="button-icon" style={{ width: 12, height: 12 }} />}
Official Skills
</button>
{!editing && (
<button className="button secondary small" onClick={startAdd}>
<Plus className="button-icon" style={{ width: 12, height: 12 }} />
@ -142,6 +239,43 @@ const SkillsTab = ({
</div>
</div>
{showSdkSkills && (
<div className="card" style={{ marginBottom: 12 }}>
<div className="card-meta" style={{ marginBottom: 8 }}>
Pick a skill to auto-fill the form.
</div>
{officialSkills.map((skill) => (
<div
key={skill.name}
style={{
border: "1px solid var(--border)",
borderRadius: 6,
padding: "8px 10px",
background: "var(--surface-2)",
marginBottom: 6,
}}
>
<div className="inline-row" style={{ justifyContent: "space-between", gap: 8, marginBottom: 4 }}>
<div style={{ fontWeight: 500, fontSize: 12 }}>{skill.name}</div>
<button className="button ghost small" onClick={() => void copySkillToInput(skill.skillId)}>
{copiedId === `skill-input-${skill.skillId}` ? "Filled" : "Use"}
</button>
</div>
<div className="card-meta" style={{ fontSize: 10, marginBottom: skill.features ? 6 : 0 }}>{skill.summary}</div>
{skill.features && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
{skill.features.map((feature) => (
<span key={feature} className="pill accent" style={{ fontSize: 9 }}>
{feature}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
<div className="inline-row" style={{ marginBottom: 12, gap: 6 }}>
<FolderOpen size={14} className="muted" style={{ flexShrink: 0 }} />
<input
@ -233,29 +367,44 @@ const SkillsTab = ({
</div>
)}
{entries.map((entry) => (
<div key={entry.name} className="card" style={{ marginBottom: 8 }}>
<div className="card-header">
<span className="card-title">{entry.name}</span>
<div className="card-header-pills">
<span className="pill accent">
{entry.config.sources.length} source{entry.config.sources.length !== 1 ? "s" : ""}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<Trash2 size={12} />
</button>
{entries.map((entry) => {
const isCollapsed = collapsedSkills[entry.name] ?? true;
return (
<div key={entry.name} className="card" style={{ marginBottom: 8 }}>
<div className="card-header">
<div className="inline-row" style={{ gap: 6 }}>
<button
className="button ghost small"
onClick={() => setCollapsedSkills((prev) => ({ ...prev, [entry.name]: !(prev[entry.name] ?? true) }))}
title={isCollapsed ? "Expand" : "Collapse"}
style={{ padding: "2px 4px" }}
>
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
</button>
<span className="card-title">{entry.name}</span>
</div>
<div className="card-header-pills">
<span className="pill accent">
{entry.config.sources.length} source{entry.config.sources.length !== 1 ? "s" : ""}
</span>
<button
className="button ghost small"
onClick={() => remove(entry.name)}
title="Remove"
style={{ padding: "2px 4px" }}
>
<Trash2 size={12} />
</button>
</div>
</div>
{!isCollapsed && (
<pre className="code-block" style={{ marginTop: 4, fontSize: 10 }}>
{formatJson(entry.config)}
</pre>
)}
</div>
<pre className="code-block" style={{ marginTop: 4, fontSize: 10 }}>
{formatJson(entry.config)}
</pre>
</div>
))}
);
})}
</>
);
};

View file

@ -16,3 +16,9 @@ export const formatTime = (value: string) => {
};
export const escapeSingleQuotes = (value: string) => value.replace(/'/g, `'\\''`);
export const formatShortId = (value: string, head = 8, tail = 4) => {
if (!value) return "";
if (value.length <= head + tail + 1) return value;
return `${value.slice(0, head)}...${value.slice(-tail)}`;
};

View file

@ -11,10 +11,6 @@ export default defineConfig(({ command }) => ({
target: "http://localhost:2468",
changeOrigin: true,
},
"/v1": {
target: "http://localhost:2468",
changeOrigin: true,
},
},
},
}));

View file

@ -271,6 +271,8 @@ class StreamableHttpAcpTransport {
private closed = false;
private closingPromise: Promise<void> | null = null;
private postedOnce = false;
private readonly seenResponseIds = new Set<string>();
private readonly seenResponseIdOrder: string[] = [];
constructor(options: StreamableHttpAcpTransportOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
@ -535,6 +537,21 @@ class StreamableHttpAcpTransport {
return;
}
const responseId = responseEnvelopeId(envelope);
if (responseId) {
if (this.seenResponseIds.has(responseId)) {
return;
}
this.seenResponseIds.add(responseId);
this.seenResponseIdOrder.push(responseId);
if (this.seenResponseIdOrder.length > 2048) {
const oldest = this.seenResponseIdOrder.shift();
if (oldest) {
this.seenResponseIds.delete(oldest);
}
}
}
this.observeEnvelope(envelope, "inbound");
try {
@ -632,10 +649,32 @@ function buildClientHandlers(client?: Partial<Client>): Client {
waitForTerminalExit: client?.waitForTerminalExit,
killTerminal: client?.killTerminal,
extMethod: client?.extMethod,
extNotification: client?.extNotification,
extNotification: async (method: string, params: Record<string, unknown>) => {
if (client?.extNotification) {
await client.extNotification(method, params);
}
},
};
}
function responseEnvelopeId(message: AnyMessage): string | null {
if (typeof message !== "object" || message === null) {
return null;
}
const record = message as Record<string, unknown>;
if ("method" in record) {
return null;
}
if (!("result" in record) && !("error" in record)) {
return null;
}
const id = record.id;
if (id === null || id === undefined) {
return null;
}
return String(id);
}
async function readProblem(response: Response): Promise<ProblemDetails | undefined> {
try {
const text = await response.clone().text();

View file

@ -175,6 +175,8 @@ export class LiveAcpConnection {
private readonly pendingNewSessionLocals: string[] = [];
private readonly pendingRequestSessionById = new Map<string, string>();
private readonly pendingReplayByLocalSessionId = new Map<string, string>();
private lastAdapterExit: { success: boolean; code: number | null } | null = null;
private lastAdapterExitAt = 0;
private readonly onObservedEnvelope: (
connection: LiveAcpConnection,
@ -230,6 +232,10 @@ export class LiveAcpConnection {
sessionUpdate: async (_notification: SessionNotification) => {
// Session updates are observed via envelope persistence.
},
extNotification: async (method: string, params: Record<string, unknown>) => {
if (!live) return;
live.handleAdapterNotification(method, params);
},
},
onEnvelope: (envelope, direction) => {
if (!live) {
@ -286,6 +292,7 @@ export class LiveAcpConnection {
localSessionId: string,
sessionInit: Omit<NewSessionRequest, "_meta">,
): Promise<NewSessionResponse> {
const createStartedAt = Date.now();
this.pendingNewSessionLocals.push(localSessionId);
try {
@ -297,6 +304,11 @@ export class LiveAcpConnection {
if (index !== -1) {
this.pendingNewSessionLocals.splice(index, 1);
}
const adapterExit = this.lastAdapterExit;
if (adapterExit && this.lastAdapterExitAt >= createStartedAt) {
const suffix = adapterExit.code == null ? "" : ` (code ${adapterExit.code})`;
throw new Error(`Agent process exited while creating session${suffix}`);
}
throw error;
}
}
@ -356,6 +368,17 @@ export class LiveAcpConnection {
this.onObservedEnvelope(this, envelope, direction, localSessionId);
}
private handleAdapterNotification(method: string, params: Record<string, unknown>): void {
if (method !== "_adapter/agent_exited") {
return;
}
this.lastAdapterExit = {
success: params.success === true,
code: typeof params.code === "number" ? params.code : null,
};
this.lastAdapterExitAt = Date.now();
}
private resolveSessionId(envelope: AnyMessage, direction: AcpEnvelopeDirection): string | null {
const id = envelopeId(envelope);
const method = envelopeMethod(envelope);
@ -413,6 +436,7 @@ export class SandboxAgent {
private spawnHandle?: SandboxAgentSpawnHandle;
private readonly liveConnections = new Map<string, LiveAcpConnection>();
private readonly pendingLiveConnections = new Map<string, Promise<LiveAcpConnection>>();
private readonly sessionHandles = new Map<string, Session>();
private readonly eventListeners = new Map<string, Set<SessionEventListener>>();
private readonly nextSessionEventIndexBySession = new Map<string, number>();
@ -463,6 +487,15 @@ export class SandboxAgent {
async dispose(): Promise<void> {
const connections = [...this.liveConnections.values()];
this.liveConnections.clear();
const pending = [...this.pendingLiveConnections.values()];
this.pendingLiveConnections.clear();
const pendingSettled = await Promise.allSettled(pending);
for (const item of pendingSettled) {
if (item.status === "fulfilled") {
connections.push(item.value);
}
}
await Promise.all(
connections.map(async (connection) => {
@ -725,21 +758,43 @@ export class SandboxAgent {
return existing;
}
const serverId = `sdk-${agent}-${randomId()}`;
const created = await LiveAcpConnection.create({
baseUrl: this.baseUrl,
token: this.token,
fetcher: this.fetcher,
headers: this.defaultHeaders,
agent,
serverId,
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
},
});
const pending = this.pendingLiveConnections.get(agent);
if (pending) {
return pending;
}
this.liveConnections.set(agent, created);
return created;
const creating = (async () => {
const serverId = `sdk-${agent}-${randomId()}`;
const created = await LiveAcpConnection.create({
baseUrl: this.baseUrl,
token: this.token,
fetcher: this.fetcher,
headers: this.defaultHeaders,
agent,
serverId,
onObservedEnvelope: (connection, envelope, direction, localSessionId) => {
void this.persistObservedEnvelope(connection, envelope, direction, localSessionId);
},
});
const raced = this.liveConnections.get(agent);
if (raced) {
await created.close();
return raced;
}
this.liveConnections.set(agent, created);
return created;
})();
this.pendingLiveConnections.set(agent, creating);
try {
return await creating;
} finally {
if (this.pendingLiveConnections.get(agent) === creating) {
this.pendingLiveConnections.delete(agent);
}
}
}
private async persistObservedEnvelope(