Merge branch 'main' into feat/support-pi

This commit is contained in:
Nathan Flurry 2026-02-10 22:27:03 -08:00
commit 4c6c5983c0
156 changed files with 16196 additions and 2338 deletions

View file

@ -1,15 +1,20 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
RUN npm install -g pnpm@9
# Copy package files for all workspaces
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
COPY sdks/typescript/package.json ./sdks/typescript/
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
# Install dependencies
RUN pnpm install --filter @sandbox-agent/inspector...
# Copy cli-shared source and build it
COPY sdks/cli-shared ./sdks/cli-shared
RUN cd sdks/cli-shared && pnpm exec tsup
# Copy SDK source (with pre-generated types)
COPY sdks/typescript ./sdks/typescript

View file

@ -336,6 +336,12 @@
color: var(--danger);
}
.banner.config-note {
background: rgba(255, 159, 10, 0.12);
border-left: 3px solid var(--warning);
color: var(--warning);
}
.banner.success {
background: rgba(48, 209, 88, 0.1);
border-left: 3px solid var(--success);
@ -471,11 +477,12 @@
position: relative;
}
.sidebar-add-menu {
.sidebar-add-menu,
.session-create-menu {
position: absolute;
top: 36px;
left: 0;
min-width: 200px;
min-width: 220px;
background: var(--surface);
border: 1px solid var(--border-2);
border-radius: 8px;
@ -487,6 +494,405 @@
z-index: 60;
}
.session-create-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 6px 4px;
margin-bottom: 4px;
}
.session-create-back {
width: 24px;
height: 24px;
background: transparent;
border: 1px solid var(--border-2);
border-radius: 4px;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
flex-shrink: 0;
}
.session-create-back:hover {
border-color: var(--accent);
color: var(--accent);
}
.session-create-agent-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
}
.session-create-form {
display: flex;
flex-direction: column;
gap: 0;
padding: 4px 2px;
}
.session-create-form .setup-field {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
height: 28px;
}
.session-create-form .setup-label {
width: 72px;
flex-shrink: 0;
text-align: right;
}
.session-create-form .setup-select,
.session-create-form .setup-input {
flex: 1;
min-width: 0;
}
.session-create-section {
overflow: hidden;
}
.session-create-section-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 28px;
padding: 0;
background: transparent;
border: none;
color: var(--text-secondary);
font-size: 11px;
cursor: pointer;
transition: color var(--transition);
}
.session-create-section-toggle:hover {
color: var(--text);
}
.session-create-section-toggle .setup-label {
width: 72px;
flex-shrink: 0;
text-align: right;
}
.session-create-section-count {
font-size: 11px;
font-weight: 400;
color: var(--muted);
}
.session-create-section-arrow {
margin-left: auto;
color: var(--muted-2);
flex-shrink: 0;
}
.session-create-section-body {
margin: 4px 0 6px;
padding: 8px;
border: 1px solid var(--border-2);
border-radius: 4px;
background: var(--surface-2);
}
.session-create-textarea {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--border-2);
border-radius: 4px;
padding: 6px 8px;
font-size: 10px;
color: var(--text);
outline: none;
resize: vertical;
min-height: 60px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
transition: border-color var(--transition);
}
.session-create-textarea:focus {
border-color: var(--accent);
}
.session-create-textarea::placeholder {
color: var(--muted-2);
}
.session-create-inline-error {
font-size: 10px;
color: var(--danger);
margin-top: 4px;
line-height: 1.4;
}
.session-create-skill-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
.session-create-skill-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px 3px 8px;
background: var(--surface-2);
border: 1px solid var(--border-2);
border-radius: 4px;
}
.session-create-skill-path {
flex: 1;
min-width: 0;
font-size: 10px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-create-skill-remove {
width: 18px;
height: 18px;
background: transparent;
border: none;
border-radius: 3px;
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all var(--transition);
}
.session-create-skill-remove:hover {
color: var(--danger);
background: rgba(255, 59, 48, 0.12);
}
.session-create-skill-add-row {
display: flex;
}
.session-create-skill-input {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 8px;
font-size: 10px;
color: var(--text);
outline: none;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace;
}
.session-create-skill-input::placeholder {
color: var(--muted-2);
}
.session-create-skill-type-badge {
display: inline-flex;
align-items: center;
padding: 1px 5px;
border-radius: 3px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
background: rgba(255, 79, 0, 0.15);
color: var(--accent);
flex-shrink: 0;
}
.session-create-skill-type-row {
display: flex;
gap: 4px;
}
.session-create-skill-type-select {
width: 80px;
flex-shrink: 0;
background: var(--surface-2);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 6px;
font-size: 10px;
color: var(--text);
outline: none;
cursor: pointer;
}
.session-create-mcp-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 4px;
}
.session-create-mcp-item {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 4px 3px 8px;
background: var(--surface-2);
border: 1px solid var(--border-2);
border-radius: 4px;
}
.session-create-mcp-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.session-create-mcp-name {
font-size: 11px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.session-create-mcp-type {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
color: var(--muted);
background: var(--surface);
padding: 1px 4px;
border-radius: 3px;
white-space: nowrap;
}
.session-create-mcp-summary {
font-size: 10px;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.session-create-mcp-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.session-create-mcp-edit {
display: flex;
flex-direction: column;
gap: 4px;
}
.session-create-mcp-name-input {
width: 100%;
background: var(--surface-2);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
color: var(--text);
outline: none;
}
.session-create-mcp-name-input:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.session-create-mcp-name-input::placeholder {
color: var(--muted-2);
}
.session-create-mcp-edit-actions {
display: flex;
gap: 4px;
}
.session-create-mcp-save,
.session-create-mcp-cancel {
flex: 1;
padding: 4px 8px;
border-radius: 4px;
border: none;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: background var(--transition);
}
.session-create-mcp-save {
background: var(--accent);
color: #fff;
}
.session-create-mcp-save:hover {
background: var(--accent-hover);
}
.session-create-mcp-cancel {
background: var(--border-2);
color: var(--text-secondary);
}
.session-create-mcp-cancel:hover {
background: var(--muted-2);
}
.session-create-add-btn {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
padding: 4px 8px;
background: transparent;
border: 1px dashed var(--border-2);
border-radius: 4px;
color: var(--muted);
font-size: 10px;
cursor: pointer;
transition: all var(--transition);
}
.session-create-add-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.session-create-actions {
padding: 4px 2px 2px;
margin-top: 4px;
}
.session-create-actions .button.primary {
width: 100%;
padding: 8px 12px;
font-size: 12px;
}
/* Empty state variant of session-create-menu */
.empty-state-menu-wrapper .session-create-menu {
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 8px;
}
.sidebar-add-option {
background: transparent;
border: 1px solid transparent;
@ -515,12 +921,40 @@
.agent-option-left {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.agent-option-name {
white-space: nowrap;
min-width: 0;
}
.agent-option-version {
font-size: 10px;
color: var(--muted);
white-space: nowrap;
}
.sidebar-add-option:hover .agent-option-version {
color: rgba(255, 255, 255, 0.6);
}
.agent-option-badges {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.agent-option-arrow {
color: var(--muted-2);
transition: color var(--transition);
}
.sidebar-add-option:hover .agent-option-arrow {
color: rgba(255, 255, 255, 0.6);
}
.agent-badge {
@ -535,9 +969,6 @@
flex-shrink: 0;
}
.agent-badge.version {
color: var(--muted);
}
.sidebar-add-status {
padding: 6px 8px;
@ -1043,6 +1474,36 @@
height: 16px;
}
/* Session Config Bar */
.session-config-bar {
display: flex;
align-items: flex-start;
gap: 20px;
padding: 10px 16px 12px;
border-top: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.session-config-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.session-config-label {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
}
.session-config-value {
font-size: 12px;
color: #8e8e93;
}
/* Setup Row */
.setup-row {
display: flex;
@ -1207,6 +1668,29 @@
color: #fff;
}
.setup-config-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.setup-config-btn {
border: 1px solid var(--border-2);
border-radius: 4px;
background: var(--surface);
color: var(--text-secondary);
}
.setup-config-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.setup-config-btn.error {
color: var(--danger);
border-color: rgba(255, 59, 48, 0.4);
}
.setup-version {
font-size: 10px;
color: var(--muted);
@ -1311,6 +1795,15 @@
margin-bottom: 0;
}
.config-textarea {
min-height: 130px;
}
.config-inline-error {
margin-top: 8px;
margin-bottom: 0;
}
.card-header {
display: flex;
align-items: center;
@ -1319,6 +1812,16 @@
margin-bottom: 8px;
}
.card-header-pills {
display: flex;
align-items: center;
gap: 6px;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
.card-title {
font-size: 13px;
font-weight: 600;

View file

@ -3,11 +3,13 @@ import {
SandboxAgentError,
SandboxAgent,
type AgentInfo,
type CreateSessionRequest,
type AgentModelInfo,
type AgentModeInfo,
type PermissionEventData,
type QuestionEventData,
type SessionInfo,
type SkillSource,
type UniversalEvent,
type UniversalItem
} from "sandbox-agent";
@ -32,6 +34,41 @@ type ItemDeltaEventData = {
delta: string;
};
export type McpServerEntry = {
name: string;
configJson: string;
error: string | null;
};
type ParsedMcpConfig = {
value: NonNullable<CreateSessionRequest["mcp"]>;
count: number;
error: string | null;
};
const buildMcpConfig = (entries: McpServerEntry[]): ParsedMcpConfig => {
if (entries.length === 0) {
return { value: {}, count: 0, error: null };
}
const firstError = entries.find((e) => e.error);
if (firstError) {
return { value: {}, count: entries.length, error: `${firstError.name}: ${firstError.error}` };
}
const value: NonNullable<CreateSessionRequest["mcp"]> = {};
for (const entry of entries) {
try {
value[entry.name] = JSON.parse(entry.configJson);
} catch {
return { value: {}, count: entries.length, error: `${entry.name}: Invalid JSON` };
}
}
return { value, count: entries.length, error: null };
};
const buildSkillsConfig = (sources: SkillSource[]): NonNullable<CreateSessionRequest["skills"]> => {
return { sources };
};
const buildStubItem = (itemId: string, nativeItemId?: string | null): UniversalItem => {
return {
item_id: itemId,
@ -53,6 +90,23 @@ const getCurrentOriginEndpoint = () => {
return window.location.origin;
};
const getSessionIdFromPath = (): string => {
const basePath = import.meta.env.BASE_URL;
const path = window.location.pathname;
const relative = path.startsWith(basePath) ? path.slice(basePath.length) : path;
const match = relative.match(/^sessions\/(.+)/);
return match ? match[1] : "";
};
const updateSessionPath = (id: string) => {
const basePath = import.meta.env.BASE_URL;
const params = window.location.search;
const newPath = id ? `${basePath}sessions/${id}${params}` : `${basePath}${params}`;
if (window.location.pathname + window.location.search !== newPath) {
window.history.replaceState(null, "", newPath);
}
};
const getInitialConnection = () => {
if (typeof window === "undefined") {
return { endpoint: "http://127.0.0.1:2468", token: "", headers: {} as Record<string, string>, hasUrlParam: false };
@ -103,11 +157,7 @@ export default function App() {
const [modelsErrorByAgent, setModelsErrorByAgent] = useState<Record<string, string | null>>({});
const [agentId, setAgentId] = useState("claude");
const [agentMode, setAgentMode] = useState("");
const [permissionMode, setPermissionMode] = useState("default");
const [model, setModel] = useState("");
const [variant, setVariant] = useState("");
const [sessionId, setSessionId] = useState("");
const [sessionId, setSessionId] = useState(getSessionIdFromPath());
const [sessionError, setSessionError] = useState<string | null>(null);
const [message, setMessage] = useState("");
@ -115,6 +165,8 @@ export default function App() {
const [offset, setOffset] = useState(0);
const offsetRef = useRef(0);
const [eventsLoading, setEventsLoading] = useState(false);
const [mcpServers, setMcpServers] = useState<McpServerEntry[]>([]);
const [skillSources, setSkillSources] = useState<SkillSource[]>([]);
const [polling, setPolling] = useState(false);
const pollTimerRef = useRef<number | null>(null);
@ -377,50 +429,52 @@ export default function App() {
stopSse();
stopTurnStream();
setSessionId(session.sessionId);
updateSessionPath(session.sessionId);
setAgentId(session.agent);
setAgentMode(session.agentMode);
setPermissionMode(session.permissionMode);
setModel(session.model ?? "");
setVariant(session.variant ?? "");
setEvents([]);
setOffset(0);
offsetRef.current = 0;
setSessionError(null);
};
const createNewSession = async (nextAgentId?: string) => {
const createNewSession = async (
nextAgentId: string,
config: { model: string; agentMode: string; permissionMode: string; variant: string }
) => {
stopPolling();
stopSse();
stopTurnStream();
const selectedAgent = nextAgentId ?? agentId;
if (nextAgentId) {
setAgentId(nextAgentId);
setAgentId(nextAgentId);
if (parsedMcpConfig.error) {
setSessionError(parsedMcpConfig.error);
return;
}
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
let id = "session-";
for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
setSessionId(id);
setEvents([]);
setOffset(0);
offsetRef.current = 0;
setSessionError(null);
try {
const body: {
agent: string;
agentMode?: string;
permissionMode?: string;
model?: string;
variant?: string;
} = { agent: selectedAgent };
if (agentMode) body.agentMode = agentMode;
if (permissionMode) body.permissionMode = permissionMode;
if (model) body.model = model;
if (variant) body.variant = variant;
const body: CreateSessionRequest = { agent: nextAgentId };
if (config.agentMode) body.agentMode = config.agentMode;
if (config.permissionMode) body.permissionMode = config.permissionMode;
if (config.model) body.model = config.model;
if (config.variant) body.variant = config.variant;
if (parsedMcpConfig.count > 0) {
body.mcp = parsedMcpConfig.value;
}
if (parsedSkillsConfig.sources.length > 0) {
body.skills = parsedSkillsConfig;
}
await getClient().createSession(id, body);
setSessionId(id);
updateSessionPath(id);
setEvents([]);
setOffset(0);
offsetRef.current = 0;
await fetchSessions();
} catch (error) {
setSessionError(getErrorMessage(error, "Unable to create session"));
@ -762,6 +816,30 @@ export default function App() {
});
break;
}
case "turn.started": {
entries.push({
id: event.event_id,
kind: "meta",
time: event.time,
meta: {
title: "Turn started",
severity: "info"
}
});
break;
}
case "turn.ended": {
entries.push({
id: event.event_id,
kind: "meta",
time: event.time,
meta: {
title: "Turn ended",
severity: "info"
}
});
break;
}
default:
break;
}
@ -852,38 +930,10 @@ export default function App() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [transcriptEntries]);
useEffect(() => {
if (connected && agentId && !modesByAgent[agentId]) {
loadModes(agentId);
}
}, [connected, agentId]);
useEffect(() => {
if (connected && agentId && !modelsByAgent[agentId]) {
loadModels(agentId);
}
}, [connected, agentId]);
useEffect(() => {
const modes = modesByAgent[agentId];
if (modes && modes.length > 0 && !agentMode) {
setAgentMode(modes[0].id);
}
}, [modesByAgent, agentId]);
const currentAgent = agents.find((agent) => agent.id === agentId);
const activeModes = modesByAgent[agentId] ?? [];
const modesLoading = modesLoadingByAgent[agentId] ?? false;
const modesError = modesErrorByAgent[agentId] ?? null;
const modelOptions = modelsByAgent[agentId] ?? [];
const modelsLoading = modelsLoadingByAgent[agentId] ?? false;
const modelsError = modelsErrorByAgent[agentId] ?? null;
const defaultModel = defaultModelByAgent[agentId] ?? "";
const selectedModelId = model || defaultModel;
const selectedModel = modelOptions.find((entry) => entry.id === selectedModelId);
const variantOptions = selectedModel?.variants ?? [];
const defaultVariant = selectedModel?.defaultVariant ?? "";
const supportsVariants = Boolean(currentAgent?.capabilities?.variants);
const currentSessionInfo = sessions.find((s) => s.sessionId === sessionId);
const parsedMcpConfig = useMemo(() => buildMcpConfig(mcpServers), [mcpServers]);
const parsedSkillsConfig = useMemo(() => buildSkillsConfig(skillSources), [skillSources]);
const agentDisplayNames: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
@ -894,6 +944,15 @@ export default function App() {
};
const agentLabel = agentDisplayNames[agentId] ?? agentId;
const handleSelectAgent = useCallback((targetAgentId: string) => {
if (connected && !modesByAgent[targetAgentId]) {
loadModes(targetAgentId);
}
if (connected && !modelsByAgent[targetAgentId]) {
loadModels(targetAgentId);
}
}, [connected, modesByAgent, modelsByAgent]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
@ -957,17 +1016,28 @@ export default function App() {
onSelectSession={selectSession}
onRefresh={fetchSessions}
onCreateSession={createNewSession}
onSelectAgent={handleSelectAgent}
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
agentsLoading={agentsLoading}
agentsError={agentsError}
sessionsLoading={sessionsLoading}
sessionsError={sessionsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={setMcpServers}
mcpConfigError={parsedMcpConfig.error}
skillSources={skillSources}
onSkillSourcesChange={setSkillSources}
/>
<ChatPanel
sessionId={sessionId}
polling={polling}
turnStreaming={turnStreaming}
transcriptEntries={transcriptEntries}
sessionError={sessionError}
message={message}
@ -975,36 +1045,19 @@ export default function App() {
onSendMessage={sendMessage}
onKeyDown={handleKeyDown}
onCreateSession={createNewSession}
onSelectAgent={handleSelectAgent}
agents={agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, capabilities: {} }) as AgentInfo)}
agentsLoading={agentsLoading}
agentsError={agentsError}
messagesEndRef={messagesEndRef}
agentId={agentId}
agentLabel={agentLabel}
agentMode={agentMode}
permissionMode={permissionMode}
model={model}
variant={variant}
modelOptions={modelOptions}
defaultModel={defaultModel}
modelsLoading={modelsLoading}
modelsError={modelsError}
variantOptions={variantOptions}
defaultVariant={defaultVariant}
supportsVariants={supportsVariants}
streamMode={streamMode}
activeModes={activeModes}
currentAgentVersion={currentAgent?.version ?? null}
modesLoading={modesLoading}
modesError={modesError}
onAgentModeChange={setAgentMode}
onPermissionModeChange={setPermissionMode}
onModelChange={setModel}
onVariantChange={setVariant}
onStreamModeChange={setStreamMode}
onToggleStream={toggleStream}
sessionModel={currentSessionInfo?.model ?? null}
sessionVariant={currentSessionInfo?.variant ?? null}
sessionPermissionMode={currentSessionInfo?.permissionMode ?? null}
sessionMcpServerCount={currentSessionInfo?.mcp ? Object.keys(currentSessionInfo.mcp).length : 0}
sessionSkillSourceCount={currentSessionInfo?.skills?.sources?.length ?? 0}
onEndSession={endSession}
hasSession={Boolean(sessionId)}
eventError={eventError}
questionRequests={questionRequests}
permissionRequests={permissionRequests}
@ -1013,6 +1066,18 @@ export default function App() {
onAnswerQuestion={answerQuestion}
onRejectQuestion={rejectQuestion}
onReplyPermission={replyPermission}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={setMcpServers}
mcpConfigError={parsedMcpConfig.error}
skillSources={skillSources}
onSkillSourcesChange={setSkillSources}
/>
<DebugPanel

View file

@ -0,0 +1,750 @@
import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, Pencil, Plus, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../App";
export type SessionConfig = {
model: string;
agentMode: string;
permissionMode: string;
variant: string;
};
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
mock: "Mock"
};
const validateServerJson = (json: string): string | null => {
const trimmed = json.trim();
if (!trimmed) return "Config is required";
try {
const parsed = JSON.parse(trimmed);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return "Must be a JSON object";
}
if (!parsed.type) return 'Missing "type" field';
if (parsed.type !== "local" && parsed.type !== "remote") {
return 'Type must be "local" or "remote"';
}
if (parsed.type === "local" && !parsed.command) return 'Local server requires "command"';
if (parsed.type === "remote" && !parsed.url) return 'Remote server requires "url"';
return null;
} catch {
return "Invalid JSON";
}
};
const getServerType = (configJson: string): string | null => {
try {
const parsed = JSON.parse(configJson);
return parsed?.type ?? null;
} catch {
return null;
}
};
const getServerSummary = (configJson: string): string => {
try {
const parsed = JSON.parse(configJson);
if (parsed?.type === "local") {
const cmd = Array.isArray(parsed.command) ? parsed.command.join(" ") : parsed.command;
return cmd ?? "local";
}
if (parsed?.type === "remote") {
return parsed.url ?? "remote";
}
return parsed?.type ?? "";
} catch {
return "";
}
};
const skillSourceSummary = (source: SkillSource): string => {
let summary = source.source;
if (source.skills && source.skills.length > 0) {
summary += ` [${source.skills.join(", ")}]`;
}
return summary;
};
const SessionCreateMenu = ({
agents,
agentsLoading,
agentsError,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange,
onSelectAgent,
onCreateSession,
open,
onClose
}: {
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
onSelectAgent: (agentId: string) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
open: boolean;
onClose: () => void;
}) => {
const [phase, setPhase] = useState<"agent" | "config">("agent");
const [selectedAgent, setSelectedAgent] = useState("");
const [agentMode, setAgentMode] = useState("");
const [permissionMode, setPermissionMode] = useState("default");
const [model, setModel] = useState("");
const [variant, setVariant] = useState("");
const [mcpExpanded, setMcpExpanded] = useState(false);
const [skillsExpanded, setSkillsExpanded] = useState(false);
// Skill add/edit state
const [addingSkill, setAddingSkill] = useState(false);
const [editingSkillIndex, setEditingSkillIndex] = useState<number | null>(null);
const [skillType, setSkillType] = useState<"github" | "local" | "git">("github");
const [skillSource, setSkillSource] = useState("");
const [skillFilter, setSkillFilter] = useState("");
const [skillRef, setSkillRef] = useState("");
const [skillSubpath, setSkillSubpath] = useState("");
const [skillLocalError, setSkillLocalError] = useState<string | null>(null);
const skillSourceRef = useRef<HTMLInputElement>(null);
// MCP add/edit state
const [addingMcp, setAddingMcp] = useState(false);
const [editingMcpIndex, setEditingMcpIndex] = useState<number | null>(null);
const [mcpName, setMcpName] = useState("");
const [mcpJson, setMcpJson] = useState("");
const [mcpLocalError, setMcpLocalError] = useState<string | null>(null);
const mcpNameRef = useRef<HTMLInputElement>(null);
const mcpJsonRef = useRef<HTMLTextAreaElement>(null);
const cancelSkillEdit = () => {
setAddingSkill(false);
setEditingSkillIndex(null);
setSkillType("github");
setSkillSource("");
setSkillFilter("");
setSkillRef("");
setSkillSubpath("");
setSkillLocalError(null);
};
// Reset state when menu closes
useEffect(() => {
if (!open) {
setPhase("agent");
setSelectedAgent("");
setAgentMode("");
setPermissionMode("default");
setModel("");
setVariant("");
setMcpExpanded(false);
setSkillsExpanded(false);
cancelSkillEdit();
setAddingMcp(false);
setEditingMcpIndex(null);
setMcpName("");
setMcpJson("");
setMcpLocalError(null);
}
}, [open]);
// Auto-select first mode when modes load for selected agent
useEffect(() => {
if (!selectedAgent) return;
const modes = modesByAgent[selectedAgent];
if (modes && modes.length > 0 && !agentMode) {
setAgentMode(modes[0].id);
}
}, [modesByAgent, selectedAgent, agentMode]);
// Focus skill source input when adding
useEffect(() => {
if ((addingSkill || editingSkillIndex !== null) && skillSourceRef.current) {
skillSourceRef.current.focus();
}
}, [addingSkill, editingSkillIndex]);
// Focus MCP name input when adding
useEffect(() => {
if (addingMcp && mcpNameRef.current) {
mcpNameRef.current.focus();
}
}, [addingMcp]);
// Focus MCP json textarea when editing
useEffect(() => {
if (editingMcpIndex !== null && mcpJsonRef.current) {
mcpJsonRef.current.focus();
}
}, [editingMcpIndex]);
if (!open) return null;
const handleAgentClick = (agentId: string) => {
setSelectedAgent(agentId);
setPhase("config");
onSelectAgent(agentId);
};
const handleBack = () => {
setPhase("agent");
setSelectedAgent("");
setAgentMode("");
setPermissionMode("default");
setModel("");
setVariant("");
};
const handleCreate = () => {
if (mcpConfigError) return;
onCreateSession(selectedAgent, { model, agentMode, permissionMode, variant });
onClose();
};
// Skill source helpers
const startAddSkill = () => {
setAddingSkill(true);
setEditingSkillIndex(null);
setSkillType("github");
setSkillSource("rivet-dev/skills");
setSkillFilter("sandbox-agent");
setSkillRef("");
setSkillSubpath("");
setSkillLocalError(null);
};
const startEditSkill = (index: number) => {
const entry = skillSources[index];
setEditingSkillIndex(index);
setAddingSkill(false);
setSkillType(entry.type as "github" | "local" | "git");
setSkillSource(entry.source);
setSkillFilter(entry.skills?.join(", ") ?? "");
setSkillRef(entry.ref ?? "");
setSkillSubpath(entry.subpath ?? "");
setSkillLocalError(null);
};
const commitSkill = () => {
const src = skillSource.trim();
if (!src) {
setSkillLocalError("Source is required");
return;
}
const entry: SkillSource = {
type: skillType,
source: src,
};
const filterList = skillFilter.trim()
? skillFilter.split(",").map((s) => s.trim()).filter(Boolean)
: undefined;
if (filterList && filterList.length > 0) entry.skills = filterList;
if (skillRef.trim()) entry.ref = skillRef.trim();
if (skillSubpath.trim()) entry.subpath = skillSubpath.trim();
if (editingSkillIndex !== null) {
const updated = [...skillSources];
updated[editingSkillIndex] = entry;
onSkillSourcesChange(updated);
} else {
onSkillSourcesChange([...skillSources, entry]);
}
cancelSkillEdit();
};
const removeSkill = (index: number) => {
onSkillSourcesChange(skillSources.filter((_, i) => i !== index));
if (editingSkillIndex === index) {
cancelSkillEdit();
}
};
const isEditingSkill = addingSkill || editingSkillIndex !== null;
const startAddMcp = () => {
setAddingMcp(true);
setEditingMcpIndex(null);
setMcpName("everything");
setMcpJson('{\n "type": "local",\n "command": "npx",\n "args": ["@modelcontextprotocol/server-everything"]\n}');
setMcpLocalError(null);
};
const startEditMcp = (index: number) => {
const entry = mcpServers[index];
setEditingMcpIndex(index);
setAddingMcp(false);
setMcpName(entry.name);
setMcpJson(entry.configJson);
setMcpLocalError(entry.error);
};
const cancelMcpEdit = () => {
setAddingMcp(false);
setEditingMcpIndex(null);
setMcpName("");
setMcpJson("");
setMcpLocalError(null);
};
const commitMcp = () => {
const name = mcpName.trim();
if (!name) {
setMcpLocalError("Server name is required");
return;
}
const error = validateServerJson(mcpJson);
if (error) {
setMcpLocalError(error);
return;
}
// Check for duplicate names (except when editing the same entry)
const duplicate = mcpServers.findIndex((e) => e.name === name);
if (duplicate !== -1 && duplicate !== editingMcpIndex) {
setMcpLocalError(`Server "${name}" already exists`);
return;
}
const entry: McpServerEntry = { name, configJson: mcpJson.trim(), error: null };
if (editingMcpIndex !== null) {
const updated = [...mcpServers];
updated[editingMcpIndex] = entry;
onMcpServersChange(updated);
} else {
onMcpServersChange([...mcpServers, entry]);
}
cancelMcpEdit();
};
const removeMcp = (index: number) => {
onMcpServersChange(mcpServers.filter((_, i) => i !== index));
if (editingMcpIndex === index) {
cancelMcpEdit();
}
};
const isEditingMcp = addingMcp || editingMcpIndex !== null;
if (phase === "agent") {
return (
<div className="session-create-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!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>
))}
</div>
);
}
// Phase 2: config form
const activeModes = modesByAgent[selectedAgent] ?? [];
const modesLoading = modesLoadingByAgent[selectedAgent] ?? false;
const modesError = modesErrorByAgent[selectedAgent] ?? null;
const modelOptions = modelsByAgent[selectedAgent] ?? [];
const modelsLoading = modelsLoadingByAgent[selectedAgent] ?? false;
const modelsError = modelsErrorByAgent[selectedAgent] ?? null;
const defaultModel = defaultModelByAgent[selectedAgent] ?? "";
const selectedModelId = model || defaultModel;
const selectedModelObj = modelOptions.find((entry) => entry.id === selectedModelId);
const variantOptions = selectedModelObj?.variants ?? [];
const showModelSelect = modelsLoading || Boolean(modelsError) || modelOptions.length > 0;
const hasModelOptions = modelOptions.length > 0;
const modelCustom =
model && hasModelOptions && !modelOptions.some((entry) => entry.id === model);
const supportsVariants =
modelsLoading ||
Boolean(modelsError) ||
modelOptions.some((entry) => (entry.variants?.length ?? 0) > 0);
const showVariantSelect =
supportsVariants && (modelsLoading || Boolean(modelsError) || variantOptions.length > 0);
const hasVariantOptions = variantOptions.length > 0;
const variantCustom = variant && hasVariantOptions && !variantOptions.includes(variant);
const agentLabel = agentLabels[selectedAgent] ?? selectedAgent;
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="session-create-form">
<div className="setup-field">
<span className="setup-label">Model</span>
{showModelSelect ? (
<select
className="setup-select"
value={model}
onChange={(e) => { setModel(e.target.value); setVariant(""); }}
title="Model"
disabled={modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading models...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultModel ? `Default (${defaultModel})` : "Default"}
</option>
{modelCustom && <option value={model}>{model} (custom)</option>}
{modelOptions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name ?? entry.id}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="Model"
title="Model"
/>
)}
</div>
<div className="setup-field">
<span className="setup-label">Mode</span>
<select
className="setup-select"
value={agentMode}
onChange={(e) => setAgentMode(e.target.value)}
title="Mode"
disabled={modesLoading || Boolean(modesError)}
>
{modesLoading ? (
<option value="">Loading modes...</option>
) : modesError ? (
<option value="">{modesError}</option>
) : activeModes.length > 0 ? (
activeModes.map((m) => (
<option key={m.id} value={m.id}>
{m.name || m.id}
</option>
))
) : (
<option value="">Mode</option>
)}
</select>
</div>
<div className="setup-field">
<span className="setup-label">Permission</span>
<select
className="setup-select"
value={permissionMode}
onChange={(e) => setPermissionMode(e.target.value)}
title="Permission Mode"
>
<option value="default">Default</option>
<option value="plan">Plan</option>
<option value="bypass">Bypass</option>
</select>
</div>
{supportsVariants && (
<div className="setup-field">
<span className="setup-label">Variant</span>
{showVariantSelect ? (
<select
className="setup-select"
value={variant}
onChange={(e) => setVariant(e.target.value)}
title="Variant"
disabled={modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading variants...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">Default</option>
{variantCustom && <option value={variant}>{variant} (custom)</option>}
{variantOptions.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={variant}
onChange={(e) => setVariant(e.target.value)}
placeholder="Variant"
title="Variant"
/>
)}
</div>
)}
{/* MCP Servers - collapsible */}
<div className="session-create-section">
<button
type="button"
className="session-create-section-toggle"
onClick={() => setMcpExpanded(!mcpExpanded)}
>
<span className="setup-label">MCP</span>
<span className="session-create-section-count">{mcpServers.length} server{mcpServers.length !== 1 ? "s" : ""}</span>
{mcpExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />}
</button>
{mcpExpanded && (
<div className="session-create-section-body">
{mcpServers.length > 0 && !isEditingMcp && (
<div className="session-create-mcp-list">
{mcpServers.map((entry, index) => (
<div key={entry.name} className="session-create-mcp-item">
<div className="session-create-mcp-info">
<span className="session-create-mcp-name">{entry.name}</span>
{getServerType(entry.configJson) && (
<span className="session-create-mcp-type">{getServerType(entry.configJson)}</span>
)}
<span className="session-create-mcp-summary mono">{getServerSummary(entry.configJson)}</span>
</div>
<div className="session-create-mcp-actions">
<button
type="button"
className="session-create-skill-remove"
onClick={() => startEditMcp(index)}
title="Edit server"
>
<Pencil size={10} />
</button>
<button
type="button"
className="session-create-skill-remove"
onClick={() => removeMcp(index)}
title="Remove server"
>
<X size={12} />
</button>
</div>
</div>
))}
</div>
)}
{isEditingMcp ? (
<div className="session-create-mcp-edit">
<input
ref={mcpNameRef}
className="session-create-mcp-name-input"
value={mcpName}
onChange={(e) => { setMcpName(e.target.value); setMcpLocalError(null); }}
placeholder="server-name"
disabled={editingMcpIndex !== null}
/>
<textarea
ref={mcpJsonRef}
className="session-create-textarea mono"
value={mcpJson}
onChange={(e) => { setMcpJson(e.target.value); setMcpLocalError(null); }}
placeholder='{"type":"local","command":"node","args":["./server.js"]}'
rows={4}
/>
{mcpLocalError && (
<div className="session-create-inline-error">{mcpLocalError}</div>
)}
<div className="session-create-mcp-edit-actions">
<button type="button" className="session-create-mcp-save" onClick={commitMcp}>
{editingMcpIndex !== null ? "Save" : "Add"}
</button>
<button type="button" className="session-create-mcp-cancel" onClick={cancelMcpEdit}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="session-create-add-btn"
onClick={startAddMcp}
>
<Plus size={12} />
Add server
</button>
)}
{mcpConfigError && !isEditingMcp && (
<div className="session-create-inline-error">{mcpConfigError}</div>
)}
</div>
)}
</div>
{/* Skills - collapsible with source-based list */}
<div className="session-create-section">
<button
type="button"
className="session-create-section-toggle"
onClick={() => setSkillsExpanded(!skillsExpanded)}
>
<span className="setup-label">Skills</span>
<span className="session-create-section-count">{skillSources.length} source{skillSources.length !== 1 ? "s" : ""}</span>
{skillsExpanded ? <ChevronDown size={12} className="session-create-section-arrow" /> : <ChevronRight size={12} className="session-create-section-arrow" />}
</button>
{skillsExpanded && (
<div className="session-create-section-body">
{skillSources.length > 0 && !isEditingSkill && (
<div className="session-create-skill-list">
{skillSources.map((entry, index) => (
<div key={`${entry.type}-${entry.source}-${index}`} className="session-create-skill-item">
<span className="session-create-skill-type-badge">{entry.type}</span>
<span className="session-create-skill-path mono">{skillSourceSummary(entry)}</span>
<div className="session-create-mcp-actions">
<button
type="button"
className="session-create-skill-remove"
onClick={() => startEditSkill(index)}
title="Edit source"
>
<Pencil size={10} />
</button>
<button
type="button"
className="session-create-skill-remove"
onClick={() => removeSkill(index)}
title="Remove source"
>
<X size={12} />
</button>
</div>
</div>
))}
</div>
)}
{isEditingSkill ? (
<div className="session-create-mcp-edit">
<div className="session-create-skill-type-row">
<select
className="session-create-skill-type-select"
value={skillType}
onChange={(e) => { setSkillType(e.target.value as "github" | "local" | "git"); setSkillLocalError(null); }}
>
<option value="github">github</option>
<option value="local">local</option>
<option value="git">git</option>
</select>
<input
ref={skillSourceRef}
className="session-create-skill-input mono"
value={skillSource}
onChange={(e) => { setSkillSource(e.target.value); setSkillLocalError(null); }}
placeholder={skillType === "github" ? "owner/repo" : skillType === "local" ? "/path/to/skill" : "https://git.example.com/repo.git"}
/>
</div>
<input
className="session-create-skill-input mono"
value={skillFilter}
onChange={(e) => setSkillFilter(e.target.value)}
placeholder="Filter skills (comma-separated, optional)"
/>
{skillType !== "local" && (
<div className="session-create-skill-type-row">
<input
className="session-create-skill-input mono"
value={skillRef}
onChange={(e) => setSkillRef(e.target.value)}
placeholder="Branch/tag (optional)"
/>
<input
className="session-create-skill-input mono"
value={skillSubpath}
onChange={(e) => setSkillSubpath(e.target.value)}
placeholder="Subpath (optional)"
/>
</div>
)}
{skillLocalError && (
<div className="session-create-inline-error">{skillLocalError}</div>
)}
<div className="session-create-mcp-edit-actions">
<button type="button" className="session-create-mcp-save" onClick={commitSkill}>
{editingSkillIndex !== null ? "Save" : "Add"}
</button>
<button type="button" className="session-create-mcp-cancel" onClick={cancelSkillEdit}>
Cancel
</button>
</div>
</div>
) : (
<button
type="button"
className="session-create-add-btn"
onClick={startAddSkill}
>
<Plus size={12} />
Add source
</button>
)}
</div>
)}
</div>
</div>
<div className="session-create-actions">
<button
className="button primary"
onClick={handleCreate}
disabled={Boolean(mcpConfigError)}
>
Create Session
</button>
</div>
</div>
);
};
export default SessionCreateMenu;

View file

@ -1,6 +1,17 @@
import { Plus, RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, SessionInfo } from "sandbox-agent";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, SessionInfo, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../App";
import SessionCreateMenu, { type SessionConfig } from "./SessionCreateMenu";
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
mock: "Mock"
};
const SessionSidebar = ({
sessions,
@ -8,22 +19,48 @@ const SessionSidebar = ({
onSelectSession,
onRefresh,
onCreateSession,
onSelectAgent,
agents,
agentsLoading,
agentsError,
sessionsLoading,
sessionsError
sessionsError,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange
}: {
sessions: SessionInfo[];
selectedSessionId: string;
onSelectSession: (session: SessionInfo) => void;
onRefresh: () => void;
onCreateSession: (agentId: string) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => void;
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
sessionsLoading: boolean;
sessionsError: string | null;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
}) => {
const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@ -40,15 +77,6 @@ const SessionSidebar = ({
return () => document.removeEventListener("mousedown", handler);
}, [showMenu]);
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
mock: "Mock"
};
return (
<div className="session-sidebar">
<div className="sidebar-header">
@ -65,32 +93,27 @@ const SessionSidebar = ({
>
<Plus size={14} />
</button>
{showMenu && (
<div className="sidebar-add-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!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={() => {
onCreateSession(agent.id);
setShowMenu(false);
}}
>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</div>
{agent.installed && <span className="agent-badge installed">Installed</span>}
</button>
))}
</div>
)}
<SessionCreateMenu
agents={agents}
agentsLoading={agentsLoading}
agentsError={agentsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={onMcpServersChange}
mcpConfigError={mcpConfigError}
skillSources={skillSources}
onSkillSourcesChange={onSkillSourcesChange}
onSelectAgent={onSelectAgent}
onCreateSession={onCreateSession}
open={showMenu}
onClose={() => setShowMenu(false)}
/>
</div>
</div>
</div>

View file

@ -1,16 +1,15 @@
import { MessageSquare, PauseCircle, PlayCircle, Plus, Square, Terminal } from "lucide-react";
import { MessageSquare, Plus, Square, Terminal } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData } from "sandbox-agent";
import type { AgentInfo, AgentModelInfo, AgentModeInfo, PermissionEventData, QuestionEventData, SkillSource } from "sandbox-agent";
import type { McpServerEntry } from "../../App";
import ApprovalsTab from "../debug/ApprovalsTab";
import SessionCreateMenu, { type SessionConfig } from "../SessionCreateMenu";
import ChatInput from "./ChatInput";
import ChatMessages from "./ChatMessages";
import ChatSetup from "./ChatSetup";
import type { TimelineEntry } from "./types";
const ChatPanel = ({
sessionId,
polling,
turnStreaming,
transcriptEntries,
sessionError,
message,
@ -18,35 +17,18 @@ const ChatPanel = ({
onSendMessage,
onKeyDown,
onCreateSession,
onSelectAgent,
agents,
agentsLoading,
agentsError,
messagesEndRef,
agentId,
agentLabel,
agentMode,
permissionMode,
model,
variant,
modelOptions,
defaultModel,
modelsLoading,
modelsError,
variantOptions,
defaultVariant,
supportsVariants,
streamMode,
activeModes,
currentAgentVersion,
hasSession,
modesLoading,
modesError,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange,
onStreamModeChange,
onToggleStream,
sessionModel,
sessionVariant,
sessionPermissionMode,
sessionMcpServerCount,
sessionSkillSourceCount,
onEndSession,
eventError,
questionRequests,
@ -55,47 +37,40 @@ const ChatPanel = ({
onSelectQuestionOption,
onAnswerQuestion,
onRejectQuestion,
onReplyPermission
onReplyPermission,
modesByAgent,
modelsByAgent,
defaultModelByAgent,
modesLoadingByAgent,
modelsLoadingByAgent,
modesErrorByAgent,
modelsErrorByAgent,
mcpServers,
onMcpServersChange,
mcpConfigError,
skillSources,
onSkillSourcesChange
}: {
sessionId: string;
polling: boolean;
turnStreaming: boolean;
transcriptEntries: TimelineEntry[];
sessionError: string | null;
message: string;
onMessageChange: (value: string) => void;
onSendMessage: () => void;
onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onCreateSession: (agentId: string) => void;
onCreateSession: (agentId: string, config: SessionConfig) => void;
onSelectAgent: (agentId: string) => void;
agents: AgentInfo[];
agentsLoading: boolean;
agentsError: string | null;
messagesEndRef: React.RefObject<HTMLDivElement>;
agentId: string;
agentLabel: string;
agentMode: string;
permissionMode: string;
model: string;
variant: string;
modelOptions: AgentModelInfo[];
defaultModel: string;
modelsLoading: boolean;
modelsError: string | null;
variantOptions: string[];
defaultVariant: string;
supportsVariants: boolean;
streamMode: "poll" | "sse" | "turn";
activeModes: AgentModeInfo[];
currentAgentVersion?: string | null;
hasSession: boolean;
modesLoading: boolean;
modesError: string | null;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
onStreamModeChange: (value: "poll" | "sse" | "turn") => void;
onToggleStream: () => void;
sessionModel?: string | null;
sessionVariant?: string | null;
sessionPermissionMode?: string | null;
sessionMcpServerCount: number;
sessionSkillSourceCount: number;
onEndSession: () => void;
eventError: string | null;
questionRequests: QuestionEventData[];
@ -105,6 +80,18 @@ const ChatPanel = ({
onAnswerQuestion: (request: QuestionEventData) => void;
onRejectQuestion: (requestId: string) => void;
onReplyPermission: (requestId: string, reply: "once" | "always" | "reject") => void;
modesByAgent: Record<string, AgentModeInfo[]>;
modelsByAgent: Record<string, AgentModelInfo[]>;
defaultModelByAgent: Record<string, string>;
modesLoadingByAgent: Record<string, boolean>;
modelsLoadingByAgent: Record<string, boolean>;
modesErrorByAgent: Record<string, string | null>;
modelsErrorByAgent: Record<string, string | null>;
mcpServers: McpServerEntry[];
onMcpServersChange: (servers: McpServerEntry[]) => void;
mcpConfigError: string | null;
skillSources: SkillSource[];
onSkillSourcesChange: (sources: SkillSource[]) => void;
}) => {
const [showAgentMenu, setShowAgentMenu] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
@ -121,19 +108,7 @@ const ChatPanel = ({
return () => document.removeEventListener("mousedown", handler);
}, [showAgentMenu]);
const agentLabels: Record<string, string> = {
claude: "Claude Code",
codex: "Codex",
opencode: "OpenCode",
amp: "Amp",
pi: "Pi",
mock: "Mock"
};
const hasApprovals = questionRequests.length > 0 || permissionRequests.length > 0;
const isTurnMode = streamMode === "turn";
const isStreaming = isTurnMode ? turnStreaming : polling;
const turnLabel = turnStreaming ? "Streaming" : "On Send";
return (
<div className="chat-panel">
@ -142,12 +117,6 @@ const ChatPanel = ({
<MessageSquare className="button-icon" />
<span className="panel-title">{sessionId ? "Session" : "No Session"}</span>
{sessionId && <span className="session-id-display">{sessionId}</span>}
{sessionId && (
<span className="session-agent-display">
{agentLabel}
{currentAgentVersion && <span className="session-agent-version">v{currentAgentVersion}</span>}
</span>
)}
</div>
<div className="panel-header-right">
{sessionId && (
@ -161,42 +130,6 @@ const ChatPanel = ({
End
</button>
)}
<div className="setup-stream">
<select
className="setup-select-small"
value={streamMode}
onChange={(e) => onStreamModeChange(e.target.value as "poll" | "sse" | "turn")}
title="Stream Mode"
disabled={!sessionId}
>
<option value="poll">Poll</option>
<option value="sse">SSE</option>
<option value="turn">Turn</option>
</select>
<button
className={`setup-stream-btn ${isStreaming ? "active" : ""}`}
onClick={onToggleStream}
title={isTurnMode ? "Turn streaming starts on send" : polling ? "Stop streaming" : "Start streaming"}
disabled={!sessionId || isTurnMode}
>
{isTurnMode ? (
<>
<PlayCircle size={14} />
<span>{turnLabel}</span>
</>
) : polling ? (
<>
<PauseCircle size={14} />
<span>Pause</span>
</>
) : (
<>
<PlayCircle size={14} />
<span>Resume</span>
</>
)}
</button>
</div>
</div>
</div>
@ -214,32 +147,27 @@ const ChatPanel = ({
<Plus className="button-icon" />
Create Session
</button>
{showAgentMenu && (
<div className="empty-state-menu">
{agentsLoading && <div className="sidebar-add-status">Loading agents...</div>}
{agentsError && <div className="sidebar-add-status error">{agentsError}</div>}
{!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={() => {
onCreateSession(agent.id);
setShowAgentMenu(false);
}}
>
<div className="agent-option-left">
<span className="agent-option-name">{agentLabels[agent.id] ?? agent.id}</span>
{agent.version && <span className="agent-badge version">v{agent.version}</span>}
</div>
{agent.installed && <span className="agent-badge installed">Installed</span>}
</button>
))}
</div>
)}
<SessionCreateMenu
agents={agents}
agentsLoading={agentsLoading}
agentsError={agentsError}
modesByAgent={modesByAgent}
modelsByAgent={modelsByAgent}
defaultModelByAgent={defaultModelByAgent}
modesLoadingByAgent={modesLoadingByAgent}
modelsLoadingByAgent={modelsLoadingByAgent}
modesErrorByAgent={modesErrorByAgent}
modelsErrorByAgent={modelsErrorByAgent}
mcpServers={mcpServers}
onMcpServersChange={onMcpServersChange}
mcpConfigError={mcpConfigError}
skillSources={skillSources}
onSkillSourcesChange={onSkillSourcesChange}
onSelectAgent={onSelectAgent}
onCreateSession={onCreateSession}
open={showAgentMenu}
onClose={() => setShowAgentMenu(false)}
/>
</div>
</div>
) : transcriptEntries.length === 0 && !sessionError ? (
@ -247,7 +175,7 @@ const ChatPanel = ({
<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>
{agentId === "mock" && (
{agentLabel === "Mock" && (
<div className="mock-agent-hint">
The mock agent simulates agent responses for testing the inspector UI without requiring API credentials. Send <code>help</code> for available commands.
</div>
@ -284,30 +212,37 @@ const ChatPanel = ({
onSendMessage={onSendMessage}
onKeyDown={onKeyDown}
placeholder={sessionId ? "Send a message..." : "Select or create a session first"}
disabled={!sessionId || turnStreaming}
disabled={!sessionId}
/>
<ChatSetup
agentMode={agentMode}
permissionMode={permissionMode}
model={model}
variant={variant}
modelOptions={modelOptions}
defaultModel={defaultModel}
modelsLoading={modelsLoading}
modelsError={modelsError}
variantOptions={variantOptions}
defaultVariant={defaultVariant}
supportsVariants={supportsVariants}
activeModes={activeModes}
modesLoading={modesLoading}
modesError={modesError}
onAgentModeChange={onAgentModeChange}
onPermissionModeChange={onPermissionModeChange}
onModelChange={onModelChange}
onVariantChange={onVariantChange}
hasSession={hasSession}
/>
{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>
<div className="session-config-field">
<span className="session-config-label">Model</span>
<span className="session-config-value">{sessionModel || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Variant</span>
<span className="session-config-value">{sessionVariant || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Permission</span>
<span className="session-config-value">{sessionPermissionMode || "-"}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">MCP Servers</span>
<span className="session-config-value">{sessionMcpServerCount}</span>
</div>
<div className="session-config-field">
<span className="session-config-label">Skills</span>
<span className="session-config-value">{sessionSkillSourceCount}</span>
</div>
</div>
)}
</div>
);
};

View file

@ -1,178 +0,0 @@
import type { AgentModelInfo, AgentModeInfo } from "sandbox-agent";
const ChatSetup = ({
agentMode,
permissionMode,
model,
variant,
modelOptions,
defaultModel,
modelsLoading,
modelsError,
variantOptions,
defaultVariant,
supportsVariants,
activeModes,
hasSession,
modesLoading,
modesError,
onAgentModeChange,
onPermissionModeChange,
onModelChange,
onVariantChange
}: {
agentMode: string;
permissionMode: string;
model: string;
variant: string;
modelOptions: AgentModelInfo[];
defaultModel: string;
modelsLoading: boolean;
modelsError: string | null;
variantOptions: string[];
defaultVariant: string;
supportsVariants: boolean;
activeModes: AgentModeInfo[];
hasSession: boolean;
modesLoading: boolean;
modesError: string | null;
onAgentModeChange: (value: string) => void;
onPermissionModeChange: (value: string) => void;
onModelChange: (value: string) => void;
onVariantChange: (value: string) => void;
}) => {
const hasModelOptions = modelOptions.length > 0;
const showModelSelect = hasModelOptions && !modelsError;
const hasVariantOptions = variantOptions.length > 0;
const showVariantSelect = supportsVariants && hasVariantOptions && !modelsError;
const modelCustom =
model && hasModelOptions && !modelOptions.some((entry) => entry.id === model);
const variantCustom =
variant && hasVariantOptions && !variantOptions.includes(variant);
return (
<div className="setup-row">
<div className="setup-field">
<span className="setup-label">Mode</span>
<select
className="setup-select"
value={agentMode}
onChange={(e) => onAgentModeChange(e.target.value)}
title="Mode"
disabled={!hasSession || modesLoading || Boolean(modesError)}
>
{modesLoading ? (
<option value="">Loading modes...</option>
) : modesError ? (
<option value="">{modesError}</option>
) : activeModes.length > 0 ? (
activeModes.map((mode) => (
<option key={mode.id} value={mode.id}>
{mode.name || mode.id}
</option>
))
) : (
<option value="">Mode</option>
)}
</select>
</div>
<div className="setup-field">
<span className="setup-label">Permission</span>
<select
className="setup-select"
value={permissionMode}
onChange={(e) => onPermissionModeChange(e.target.value)}
title="Permission Mode"
disabled={!hasSession}
>
<option value="default">Default</option>
<option value="plan">Plan</option>
<option value="bypass">Bypass</option>
</select>
</div>
<div className="setup-field">
<span className="setup-label">Model</span>
{showModelSelect ? (
<select
className="setup-select"
value={model}
onChange={(e) => onModelChange(e.target.value)}
title="Model"
disabled={!hasSession || modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading models...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultModel ? `Default (${defaultModel})` : "Default"}
</option>
{modelCustom && <option value={model}>{model} (custom)</option>}
{modelOptions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name ?? entry.id}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={model}
onChange={(e) => onModelChange(e.target.value)}
placeholder="Model"
title="Model"
disabled={!hasSession}
/>
)}
</div>
<div className="setup-field">
<span className="setup-label">Variant</span>
{showVariantSelect ? (
<select
className="setup-select"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
title="Variant"
disabled={!hasSession || !supportsVariants || modelsLoading || Boolean(modelsError)}
>
{modelsLoading ? (
<option value="">Loading variants...</option>
) : modelsError ? (
<option value="">{modelsError}</option>
) : (
<>
<option value="">
{defaultVariant ? `Default (${defaultVariant})` : "Default"}
</option>
{variantCustom && <option value={variant}>{variant} (custom)</option>}
{variantOptions.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</>
)}
</select>
) : (
<input
className="setup-input"
value={variant}
onChange={(e) => onVariantChange(e.target.value)}
placeholder={supportsVariants ? "Variant" : "Variants unsupported"}
title="Variant"
disabled={!hasSession || !supportsVariants}
/>
)}
</div>
</div>
);
};
export default ChatSetup;

View file

@ -1,4 +1,5 @@
import { Download, RefreshCw } from "lucide-react";
import { Download, Loader2, RefreshCw } from "lucide-react";
import { useState } from "react";
import type { AgentInfo, AgentModeInfo } from "sandbox-agent";
import FeatureCoverageBadges from "../agents/FeatureCoverageBadges";
import { emptyFeatureCoverage } from "../../types/agents";
@ -16,10 +17,21 @@ const AgentsTab = ({
defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>;
onRefresh: () => void;
onInstall: (agentId: string, reinstall: boolean) => void;
onInstall: (agentId: string, reinstall: boolean) => Promise<void>;
loading: boolean;
error: string | null;
}) => {
const [installingAgent, setInstallingAgent] = useState<string | null>(null);
const handleInstall = async (agentId: string, reinstall: boolean) => {
setInstallingAgent(agentId);
try {
await onInstall(agentId, reinstall);
} finally {
setInstallingAgent(null);
}
};
return (
<>
<div className="inline-row" style={{ marginBottom: 16 }}>
@ -39,42 +51,57 @@ const AgentsTab = ({
: defaultAgents.map((id) => ({
id,
installed: false,
credentialsAvailable: false,
version: undefined,
path: undefined,
capabilities: emptyFeatureCoverage
}))).map((agent) => (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.installed ? "Installed" : "Missing"}
</span>
</div>
<div className="card-meta">
{agent.version ? `v${agent.version}` : "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
Feature coverage
</div>
<div style={{ marginTop: 8 }}>
<FeatureCoverageBadges featureCoverage={agent.capabilities ?? emptyFeatureCoverage} />
</div>
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
<div className="card-meta" style={{ marginTop: 8 }}>
Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")}
}))).map((agent) => {
const isInstalling = installingAgent === agent.id;
return (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>
<div className="card-header-pills">
<span className={`pill ${agent.installed ? "success" : "danger"}`}>
{agent.installed ? "Installed" : "Missing"}
</span>
<span className={`pill ${agent.credentialsAvailable ? "success" : "warning"}`}>
{agent.credentialsAvailable ? "Authenticated" : "No Credentials"}
</span>
</div>
</div>
<div className="card-meta">
{agent.version ?? "Version unknown"}
{agent.path && <span className="mono muted" style={{ marginLeft: 8 }}>{agent.path}</span>}
</div>
<div className="card-meta" style={{ marginTop: 8 }}>
Feature coverage
</div>
<div style={{ marginTop: 8 }}>
<FeatureCoverageBadges featureCoverage={agent.capabilities ?? emptyFeatureCoverage} />
</div>
{modesByAgent[agent.id] && modesByAgent[agent.id].length > 0 && (
<div className="card-meta" style={{ marginTop: 8 }}>
Modes: {modesByAgent[agent.id].map((mode) => mode.id).join(", ")}
</div>
)}
<div className="card-actions">
<button
className="button secondary small"
onClick={() => handleInstall(agent.id, agent.installed)}
disabled={isInstalling}
>
{isInstalling ? (
<Loader2 className="button-icon spinner-icon" />
) : (
<Download className="button-icon" />
)}
{isInstalling ? "Installing..." : agent.installed ? "Reinstall" : "Install"}
</button>
</div>
)}
<div className="card-actions">
<button className="button secondary small" onClick={() => onInstall(agent.id, false)}>
<Download className="button-icon" /> Install
</button>
<button className="button ghost small" onClick={() => onInstall(agent.id, true)}>
Reinstall
</button>
</div>
</div>
))}
);
})}
</>
);
};

View file

@ -40,7 +40,7 @@ const DebugPanel = ({
defaultAgents: string[];
modesByAgent: Record<string, AgentModeInfo[]>;
onRefreshAgents: () => void;
onInstallAgent: (agentId: string, reinstall: boolean) => void;
onInstallAgent: (agentId: string, reinstall: boolean) => Promise<void>;
agentsLoading: boolean;
agentsError: string | null;
}) => {

View file

@ -30,6 +30,10 @@ export const getEventIcon = (type: string) => {
return PlayCircle;
case "session.ended":
return PauseCircle;
case "turn.started":
return PlayCircle;
case "turn.ended":
return PauseCircle;
case "item.started":
return MessageSquare;
case "item.delta":

View file

@ -1,6 +1,6 @@
FROM node:22-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
RUN npm install -g pnpm@9
# Copy website package
COPY frontend/packages/website/package.json ./