diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index 259bce8..d54cabb 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -12,23 +12,23 @@ diff --git a/frontend/packages/inspector/public/logos/amp.svg b/frontend/packages/inspector/public/logos/amp.svg new file mode 100644 index 0000000..624c311 --- /dev/null +++ b/frontend/packages/inspector/public/logos/amp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/packages/inspector/public/logos/claude.svg b/frontend/packages/inspector/public/logos/claude.svg new file mode 100644 index 0000000..879ad81 --- /dev/null +++ b/frontend/packages/inspector/public/logos/claude.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/packages/inspector/public/logos/openai.svg b/frontend/packages/inspector/public/logos/openai.svg new file mode 100644 index 0000000..ee3125f --- /dev/null +++ b/frontend/packages/inspector/public/logos/openai.svg @@ -0,0 +1,2 @@ + +OpenAI icon \ No newline at end of file diff --git a/frontend/packages/inspector/public/logos/opencode.svg b/frontend/packages/inspector/public/logos/opencode.svg new file mode 100644 index 0000000..c2404f2 --- /dev/null +++ b/frontend/packages/inspector/public/logos/opencode.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/packages/inspector/public/logos/pi.svg b/frontend/packages/inspector/public/logos/pi.svg new file mode 100644 index 0000000..ed14b63 --- /dev/null +++ b/frontend/packages/inspector/public/logos/pi.svg @@ -0,0 +1,22 @@ + + + + + + + diff --git a/frontend/packages/inspector/src/App.tsx b/frontend/packages/inspector/src/App.tsx index b99bc72..7010194 100644 --- a/frontend/packages/inspector/src/App.tsx +++ b/frontend/packages/inspector/src/App.tsx @@ -50,11 +50,15 @@ type SessionListItem = { sessionId: string; agent: string; ended: boolean; + archived: boolean; }; const ERROR_TOAST_MS = 6000; const MAX_ERROR_TOASTS = 3; +const CREATE_SESSION_SLOW_WARNING_MS = 90_000; const HTTP_ERROR_EVENT = "inspector-http-error"; +const ARCHIVED_SESSIONS_KEY = "sandbox-agent-inspector-archived-sessions"; +const SESSION_MODELS_KEY = "sandbox-agent-inspector-session-models"; const DEFAULT_ENDPOINT = "http://localhost:2468"; @@ -112,6 +116,29 @@ const getHttpErrorMessage = (status: number, statusText: string, responseBody: s return `${base}: ${clippedBody}`; }; +const shouldIgnoreGlobalError = (value: unknown): boolean => { + const name = value instanceof Error ? value.name : ""; + const message = (() => { + if (typeof value === "string") return value; + if (value instanceof Error) return value.message; + if (value && typeof value === "object" && "message" in value && typeof (value as { message?: unknown }).message === "string") { + return (value as { message: string }).message; + } + return ""; + })().toLowerCase(); + + if (name === "AbortError") return true; + if (!message) return false; + + return ( + message.includes("aborterror") || + message.includes("the operation was aborted") || + message.includes("signal is aborted") || + message.includes("resizeobserver loop limit exceeded") || + message.includes("resizeobserver loop completed with undelivered notifications") + ); +}; + const getSessionIdFromPath = (): string => { const basePath = import.meta.env.BASE_URL; const path = window.location.pathname; @@ -120,6 +147,50 @@ const getSessionIdFromPath = (): string => { return match ? match[1] : ""; }; +const getArchivedSessionIds = (): Set => { + if (typeof window === "undefined") return new Set(); + try { + const raw = window.localStorage.getItem(ARCHIVED_SESSIONS_KEY); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((value): value is string => typeof value === "string" && value.length > 0)); + } catch { + return new Set(); + } +}; + +const archiveSessionId = (id: string): void => { + if (typeof window === "undefined" || !id) return; + const archived = getArchivedSessionIds(); + archived.add(id); + window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived])); +}; + +const unarchiveSessionId = (id: string): void => { + if (typeof window === "undefined" || !id) return; + const archived = getArchivedSessionIds(); + if (!archived.delete(id)) return; + window.localStorage.setItem(ARCHIVED_SESSIONS_KEY, JSON.stringify([...archived])); +}; + +const getPersistedSessionModels = (): Record => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(SESSION_MODELS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => typeof entry[0] === "string" && typeof entry[1] === "string" && entry[1].length > 0 + ) + ); + } catch { + return {}; + } +}; + const updateSessionPath = (id: string) => { const basePath = import.meta.env.BASE_URL; const params = window.location.search; @@ -185,6 +256,7 @@ export default function App() { const [agentId, setAgentId] = useState("claude"); const [sessionId, setSessionId] = useState(getSessionIdFromPath()); const [sessionError, setSessionError] = useState(null); + const [sessionModelById, setSessionModelById] = useState>(() => getPersistedSessionModels()); const [message, setMessage] = useState(""); const [events, setEvents] = useState([]); @@ -198,12 +270,16 @@ export default function App() { const toastTimeoutsRef = useRef>(new Map()); const [debugTab, setDebugTab] = useState("events"); + const [highlightedEventId, setHighlightedEventId] = useState(null); const messagesEndRef = useRef(null); const clientRef = useRef(null); const activeSessionRef = useRef(null); const eventUnsubRef = useRef<(() => void) | null>(null); + const reconnectingAfterCreateFailureRef = useRef(false); + const creatingSessionRef = useRef(false); + const createNoiseIgnoreUntilRef = useRef(0); const logRequest = useCallback((entry: RequestLog) => { setRequestLog((prev) => { @@ -360,7 +436,9 @@ export default function App() { } setEvents(allEvents); }; - hydrateEvents().catch(() => {}); + hydrateEvents().catch((error) => { + console.error("Failed to hydrate events:", error); + }); // Subscribe to new events const unsub = session.onEvent((event) => { @@ -375,6 +453,22 @@ export default function App() { setConnectError(null); } try { + // Ensure reconnects do not keep stale session subscriptions/clients around. + if (eventUnsubRef.current) { + eventUnsubRef.current(); + eventUnsubRef.current = null; + } + activeSessionRef.current = null; + if (clientRef.current) { + try { + await clientRef.current.dispose(); + } catch (disposeError) { + console.warn("Failed to dispose previous client during reconnect:", disposeError); + } finally { + clientRef.current = null; + } + } + const client = await createClient(overrideEndpoint); await client.getHealth(); if (overrideEndpoint) { @@ -383,6 +477,14 @@ export default function App() { setConnected(true); await refreshAgents(); await fetchSessions(); + if (sessionId) { + try { + const resumed = await client.resumeSession(sessionId); + subscribeToSession(resumed); + } catch (resumeError) { + console.warn("Failed to resume current session after reconnect:", resumeError); + } + } if (reportError) { setConnectError(null); } @@ -408,7 +510,9 @@ export default function App() { } activeSessionRef.current = null; if (clientRef.current) { - void clientRef.current.dispose(); + void clientRef.current.dispose().catch((error) => { + console.warn("Failed to dispose client on disconnect:", error); + }); } setConnected(false); clientRef.current = null; @@ -436,12 +540,15 @@ export default function App() { }; const loadAgentConfig = useCallback(async (targetAgentId: string) => { + console.log("[loadAgentConfig] Loading config for agent:", targetAgentId); try { const info = await getClient().getAgent(targetAgentId, { config: true }); + console.log("[loadAgentConfig] Got agent info:", info); setAgents((prev) => prev.map((a) => (a.id === targetAgentId ? { ...a, configOptions: info.configOptions, configError: info.configError } : a)) ); - } catch { + } catch (error) { + console.error("[loadAgentConfig] Failed to load config:", error); // Config loading is best-effort; the menu still works without it. } }, [getClient]); @@ -450,6 +557,7 @@ export default function App() { setSessionsLoading(true); setSessionsError(null); try { + const archivedSessionIds = getArchivedSessionIds(); // TODO: This eagerly paginates all sessions so we can reverse-sort to // show newest first. Replace with a server-side descending sort or a // dedicated "recent sessions" query once the API supports it. @@ -462,6 +570,7 @@ export default function App() { sessionId: s.id, agent: s.agent, ended: s.destroyedAt != null, + archived: archivedSessionIds.has(s.id), }); } cursor = page.nextCursor; @@ -475,6 +584,44 @@ export default function App() { } }; + const archiveSession = async (targetSessionId: string) => { + archiveSessionId(targetSessionId); + try { + try { + await getClient().destroySession(targetSessionId); + } catch (error) { + // If the server already considers the session gone, still archive in local UI. + console.warn("Destroy session returned an error while archiving:", error); + } + setSessions((prev) => + prev.map((session) => + session.sessionId === targetSessionId + ? { ...session, archived: true, ended: true } + : session + ) + ); + setSessionModelById((prev) => { + if (!(targetSessionId in prev)) return prev; + const next = { ...prev }; + delete next[targetSessionId]; + return next; + }); + await fetchSessions(); + } catch (error) { + console.error("Failed to archive session:", error); + } + }; + + const unarchiveSession = async (targetSessionId: string) => { + unarchiveSessionId(targetSessionId); + setSessions((prev) => + prev.map((session) => + session.sessionId === targetSessionId ? { ...session, archived: false } : session + ) + ); + await fetchSessions(); + }; + const installAgent = async (targetId: string, reinstall: boolean) => { try { await getClient().installAgent(targetId, { reinstall }); @@ -509,6 +656,12 @@ export default function App() { setSessionId(session.sessionId); updateSessionPath(session.sessionId); setAgentId(session.agent); + setSessionModelById((prev) => { + if (prev[session.sessionId]) return prev; + const fallbackModel = defaultModelByAgent[session.agent]; + if (!fallbackModel) return prev; + return { ...prev, [session.sessionId]: fallbackModel }; + }); setEvents([]); setSessionError(null); @@ -521,12 +674,14 @@ export default function App() { }; const createNewSession = async (nextAgentId: string, config: { agentMode: string; model: string }) => { - setAgentId(nextAgentId); + console.log("[createNewSession] Creating session for agent:", nextAgentId, "config:", config); setSessionError(null); - setEvents([]); + creatingSessionRef.current = true; + createNoiseIgnoreUntilRef.current = Date.now() + 10_000; try { - const session = await getClient().createSession({ + console.log("[createNewSession] Calling createSession..."); + const createSessionPromise = getClient().createSession({ agent: nextAgentId, sessionInit: { cwd: "/", @@ -534,12 +689,31 @@ export default function App() { }, }); + let slowWarningShown = false; + const slowWarningTimerId = window.setTimeout(() => { + slowWarningShown = true; + setSessionError("Session creation is taking longer than expected. Waiting for agent startup..."); + }, CREATE_SESSION_SLOW_WARNING_MS); + let session: Awaited>; + try { + session = await createSessionPromise; + } finally { + window.clearTimeout(slowWarningTimerId); + } + console.log("[createNewSession] Session created:", session.id); + if (slowWarningShown) { + setSessionError(null); + } + + setAgentId(nextAgentId); + setEvents([]); setSessionId(session.id); updateSessionPath(session.id); subscribeToSession(session); + const skipPostCreateConfig = nextAgentId === "opencode"; // Apply mode if selected - if (config.agentMode) { + if (!skipPostCreateConfig && config.agentMode) { try { await session.send("session/set_mode", { modeId: config.agentMode }); } catch { @@ -549,18 +723,49 @@ export default function App() { // Apply model if selected if (config.model) { - try { - await session.send("unstable/set_session_model", { modelId: config.model }); - } catch { - // Model application is best-effort + setSessionModelById((prev) => ({ ...prev, [session.id]: config.model })); + if (!skipPostCreateConfig) { + try { + const agentInfo = agents.find((agent) => agent.id === nextAgentId); + const modelOption = ((agentInfo?.configOptions ?? []) as ConfigOption[]).find( + (opt) => opt.category === "model" && opt.type === "select" && typeof opt.id === "string" + ); + if (modelOption && config.model !== modelOption.currentValue) { + await session.send("session/set_config_option", { + optionId: modelOption.id, + value: config.model, + }); + } + } catch { + // Model application is best-effort + } } } - await fetchSessions(); + // Refresh session list in background; UI should not stay blocked on list pagination. + void fetchSessions(); } catch (error) { + console.error("[createNewSession] Failed to create session:", error); const messageText = getErrorMessage(error, "Unable to create session"); + console.error("[createNewSession] Error message:", messageText); setSessionError(messageText); pushErrorToast(error, messageText); + if (!reconnectingAfterCreateFailureRef.current) { + reconnectingAfterCreateFailureRef.current = true; + // Run recovery in background so failed creates do not block UI. + void connectToDaemon(false) + .catch((reconnectError) => { + console.error("[createNewSession] Soft reconnect failed:", reconnectError); + }) + .finally(() => { + reconnectingAfterCreateFailureRef.current = false; + }); + } + throw error; + } finally { + creatingSessionRef.current = false; + // Keep a short post-create window for delayed transport rejections. + createNoiseIgnoreUntilRef.current = Date.now() + 2_000; } }; @@ -660,8 +865,13 @@ export default function App() { const params = payload.params as Record | undefined; const promptArray = params?.prompt as Array<{ type: string; text?: string }> | undefined; const text = promptArray?.[0]?.text ?? ""; + // Skip session replay context messages + if (text.startsWith("Previous session history is replayed below")) { + continue; + } entries.push({ id: event.id, + eventId: event.id, kind: "message", time, role: "user", @@ -684,6 +894,7 @@ export default function App() { assistantAccumText = ""; entries.push({ id: assistantAccumId, + eventId: event.id, kind: "message", time, role: "assistant", @@ -707,6 +918,7 @@ export default function App() { thoughtAccumText = ""; entries.push({ id: thoughtAccumId, + eventId: event.id, kind: "reasoning", time, reasoning: { text: "", visibility: "public" }, @@ -726,6 +938,7 @@ export default function App() { const text = content?.type === "text" ? (content.text ?? "") : JSON.stringify(content); entries.push({ id: event.id, + eventId: event.id, kind: "message", time, role: "user", @@ -748,6 +961,7 @@ export default function App() { } else { const entry: TimelineEntry = { id: `tool-${toolCallId}`, + eventId: event.id, kind: "tool", time, toolName: (update.title as string) ?? "tool", @@ -776,6 +990,7 @@ export default function App() { const detail = planEntries.map((e) => `[${e.status}] ${e.content}`).join("\n"); entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Plan", detail, severity: "info" }, @@ -786,6 +1001,7 @@ export default function App() { const title = update.title as string | undefined; entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Session info update", detail: title ? `Title: ${title}` : undefined, severity: "info" }, @@ -793,24 +1009,13 @@ export default function App() { break; } case "usage_update": { - const size = update.size as number | undefined; - const used = update.used as number | undefined; - const cost = update.cost as { total?: number } | undefined; - const parts = [`${used ?? 0}/${size ?? 0} tokens`]; - if (cost?.total != null) { - parts.push(`cost: $${cost.total.toFixed(4)}`); - } - entries.push({ - id: event.id, - kind: "meta", - time, - meta: { title: "Usage update", detail: parts.join(" | "), severity: "info" }, - }); + // Token usage is displayed in the config bar, not in the transcript break; } case "current_mode_update": { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Mode changed", detail: update.currentModeId as string, severity: "info" }, @@ -820,6 +1025,7 @@ export default function App() { case "config_option_update": { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Config option update", severity: "info" }, @@ -829,6 +1035,7 @@ export default function App() { case "available_commands_update": { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: "Available commands update", severity: "info" }, @@ -838,6 +1045,7 @@ export default function App() { default: { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: `session/update: ${update.sessionUpdate}`, severity: "info" }, @@ -852,6 +1060,7 @@ export default function App() { const params = payload.params as { error?: string; location?: string } | undefined; entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { @@ -867,6 +1076,7 @@ export default function App() { if (method) { entries.push({ id: event.id, + eventId: event.id, kind: "meta", time, meta: { title: method, detail: event.sender, severity: "info" }, @@ -887,10 +1097,32 @@ export default function App() { }, []); useEffect(() => { + const shouldIgnoreCreateNoise = (value: unknown): boolean => { + if (Date.now() > createNoiseIgnoreUntilRef.current) return false; + const message = getErrorMessage(value, "").trim().toLowerCase(); + return ( + message.length === 0 || + message === "request failed" || + message.includes("request failed") || + message.includes("unhandled promise rejection") + ); + }; + const handleWindowError = (event: ErrorEvent) => { - pushErrorToast(event.error ?? event.message, "Unexpected error"); + const errorLike = event.error ?? event.message; + if (shouldIgnoreCreateNoise(errorLike)) return; + if (shouldIgnoreGlobalError(errorLike)) return; + pushErrorToast(errorLike, "Unexpected error"); }; const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + if (shouldIgnoreCreateNoise(event.reason)) { + event.preventDefault(); + return; + } + if (shouldIgnoreGlobalError(event.reason)) { + event.preventDefault(); + return; + } pushErrorToast(event.reason, "Unhandled promise rejection"); }; const handleHttpError = (event: Event) => { @@ -966,13 +1198,22 @@ export default function App() { // Auto-load session when sessionId changes useEffect(() => { if (!connected || !sessionId) return; - const hasSession = sessions.some((s) => s.sessionId === sessionId); - if (!hasSession) return; + if (creatingSessionRef.current) return; + const sessionInfo = sessions.find((s) => s.sessionId === sessionId); + if (!sessionInfo) return; if (activeSessionRef.current?.id === sessionId) return; + // Set the correct agent from the session + setAgentId(sessionInfo.agent); + // Clear stale events before loading + setEvents([]); + setSessionError(null); + getClient().resumeSession(sessionId).then((session) => { subscribeToSession(session); - }).catch(() => {}); + }).catch((error) => { + setSessionError(getErrorMessage(error, "Unable to resume session")); + }); }, [connected, sessionId, sessions, getClient, subscribeToSession]); useEffect(() => { @@ -981,7 +1222,59 @@ export default function App() { const currentAgent = agents.find((agent) => agent.id === agentId); const agentLabel = agentDisplayNames[agentId] ?? agentId; - const sessionEnded = sessions.find((s) => s.sessionId === sessionId)?.ended ?? false; + const selectedSession = sessions.find((s) => s.sessionId === sessionId); + const sessionArchived = selectedSession?.archived ?? false; + // Archived sessions are treated as ended in UI so they can never be "ended again". + const sessionEnded = (selectedSession?.ended ?? false) || sessionArchived; + + // Determine if agent is thinking (has in-progress tools or waiting for response) + const isThinking = useMemo(() => { + if (!sessionId || sessionEnded) return false; + // If actively sending a prompt, show thinking + if (sending) return true; + // Check for in-progress tool calls + const hasInProgressTool = transcriptEntries.some( + (e) => e.kind === "tool" && e.toolStatus === "in_progress" + ); + if (hasInProgressTool) return true; + // Check if last message was from user with no subsequent agent activity + const lastUserMessageIndex = [...transcriptEntries].reverse().findIndex((e) => e.kind === "message" && e.role === "user"); + if (lastUserMessageIndex === -1) return false; + // If user message is the very last entry, we're waiting for response + if (lastUserMessageIndex === 0) return true; + // Check if there's any agent response after the user message + const entriesAfterUser = transcriptEntries.slice(-(lastUserMessageIndex)); + const hasAgentResponse = entriesAfterUser.some( + (e) => e.kind === "message" && e.role === "assistant" + ); + // If no assistant message after user, but there are completed tools, not thinking + const hasCompletedTools = entriesAfterUser.some( + (e) => e.kind === "tool" && (e.toolStatus === "completed" || e.toolStatus === "failed") + ); + if (!hasAgentResponse && !hasCompletedTools) return true; + return false; + }, [sessionId, sessionEnded, transcriptEntries, sending]); + + // Extract latest token usage from events + const tokenUsage = useMemo(() => { + let latest: { used: number; size: number; cost?: number } | null = null; + for (const event of events) { + const payload = event.payload as Record; + const method = typeof payload.method === "string" ? payload.method : null; + if (event.sender === "agent" && method === "session/update") { + const params = payload.params as Record | undefined; + const update = params?.update as Record | undefined; + if (update?.sessionUpdate === "usage_update") { + latest = { + used: (update.used as number) ?? 0, + size: (update.size as number) ?? 0, + cost: (update.cost as { total?: number })?.total, + }; + } + } + } + return latest; + }, [events]); // Extract modes and models from configOptions const modesByAgent = useMemo(() => { @@ -1030,6 +1323,88 @@ export default function App() { return result; }, [agents]); + const currentSessionModelId = useMemo(() => { + let latestModelId: string | null = null; + + for (const event of events) { + const payload = event.payload as Record; + const method = typeof payload.method === "string" ? payload.method : null; + const params = payload.params as Record | undefined; + + if (event.sender === "agent" && method === "session/update") { + const update = params?.update as Record | undefined; + if (update?.sessionUpdate !== "config_option_update") continue; + + const category = (update.category as string | undefined) + ?? ((update.option as Record | undefined)?.category as string | undefined); + if (category && category !== "model") continue; + + const optionId = (update.optionId as string | undefined) + ?? (update.configOptionId as string | undefined) + ?? ((update.option as Record | undefined)?.id as string | undefined); + const seemsModelOption = !optionId || optionId.toLowerCase().includes("model"); + if (!seemsModelOption) continue; + + const candidate = (update.value as string | undefined) + ?? (update.currentValue as string | undefined) + ?? (update.selectedValue as string | undefined) + ?? (update.modelId as string | undefined); + if (candidate) { + latestModelId = candidate; + } + continue; + } + + // Capture explicit client-side model changes; these are persisted and survive refresh. + if (event.sender === "client" && method === "unstable/set_session_model") { + const candidate = params?.modelId as string | undefined; + if (candidate) { + latestModelId = candidate; + } + continue; + } + + if (event.sender === "client" && method === "session/set_config_option") { + const category = params?.category as string | undefined; + const optionId = params?.optionId as string | undefined; + const seemsModelOption = category === "model" || (typeof optionId === "string" && optionId.toLowerCase().includes("model")); + if (!seemsModelOption) continue; + const candidate = (params?.value as string | undefined) + ?? (params?.currentValue as string | undefined) + ?? (params?.modelId as string | undefined); + if (candidate) { + latestModelId = candidate; + } + } + } + + return latestModelId; + }, [events]); + + const modelPillLabel = useMemo(() => { + const sessionModelId = + currentSessionModelId + ?? (sessionId ? sessionModelById[sessionId] : undefined) + ?? (sessionId ? defaultModelByAgent[agentId] : undefined); + if (!sessionModelId) return null; + return sessionModelId; + }, [agentId, currentSessionModelId, defaultModelByAgent, sessionId, sessionModelById]); + + useEffect(() => { + if (!sessionId || !currentSessionModelId) return; + setSessionModelById((prev) => + prev[sessionId] === currentSessionModelId ? prev : { ...prev, [sessionId]: currentSessionModelId } + ); + }, [currentSessionModelId, sessionId]); + + useEffect(() => { + try { + window.localStorage.setItem(SESSION_MODELS_KEY, JSON.stringify(sessionModelById)); + } catch { + // Ignore storage write failures. + } + }, [sessionModelById]); + const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); @@ -1041,10 +1416,6 @@ export default function App() {
{errorToasts.map((toast) => (
-
-
Request failed
-
{toast.message}
-
+
+
Request failed
+
{toast.message}
+
))}
@@ -1083,6 +1458,7 @@ export default function App() {
Sandbox Agent + {endpoint}
@@ -1097,7 +1473,6 @@ export default function App() { Issues - {endpoint} @@ -1147,12 +1522,31 @@ export default function App() { agentsError={agentsError} messagesEndRef={messagesEndRef} agentLabel={agentLabel} + modelLabel={modelPillLabel} currentAgentVersion={currentAgent?.version ?? null} sessionEnded={sessionEnded} + sessionArchived={sessionArchived} onEndSession={endSession} + onArchiveSession={() => { + if (sessionId) { + void archiveSession(sessionId); + } + }} + onUnarchiveSession={() => { + if (sessionId) { + void unarchiveSession(sessionId); + } + }} modesByAgent={modesByAgent} modelsByAgent={modelsByAgent} defaultModelByAgent={defaultModelByAgent} + onEventClick={(eventId) => { + setDebugTab("events"); + setHighlightedEventId(eventId); + }} + isThinking={isThinking} + agentId={agentId} + tokenUsage={tokenUsage} /> setEvents([])} + highlightedEventId={highlightedEventId} + onClearHighlight={() => setHighlightedEventId(null)} requestLog={requestLog} copiedLogId={copiedLogId} onClearRequestLog={() => setRequestLog([])} diff --git a/frontend/packages/inspector/src/components/SessionCreateMenu.tsx b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx index b20b0af..e952890 100644 --- a/frontend/packages/inspector/src/components/SessionCreateMenu.tsx +++ b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx @@ -16,7 +16,17 @@ const agentLabels: Record = { claude: "Claude Code", codex: "Codex", opencode: "OpenCode", - amp: "Amp" + amp: "Amp", + pi: "Pi", + cursor: "Cursor" +}; + +const agentLogos: Record = { + 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; modelsByAgent: Record; defaultModelByAgent: Record; - onCreateSession: (agentId: string, config: SessionConfig) => void; + onCreateSession: (agentId: string, config: SessionConfig) => Promise; onSelectAgent: (agentId: string) => Promise; 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 && (
No agents available.
)} - {!agentsLoading && !agentsError && - agents.map((agent) => ( - - ))} + {!agentsLoading && !agentsError && (() => { + const codingAgents = agents.filter((a) => a.id !== "mock"); + const mockAgent = agents.find((a) => a.id === "mock"); + return ( + <> + {codingAgents.map((agent) => ( + + ))} + {mockAgent && ( + <> +
+ + + )} + + ); + })()}
); } const agentLabel = agentLabels[selectedAgent] ?? selectedAgent; - if (phase === "loading-config") { - return ( -
-
- - {agentLabel} -
-
Loading config...
-
- ); - } - // Phase 2: config form const activeModes = modesByAgent[selectedAgent] ?? []; const activeModels = modelsByAgent[selectedAgent] ?? []; @@ -257,8 +289,8 @@ const SessionCreateMenu = ({
-
diff --git a/frontend/packages/inspector/src/components/SessionSidebar.tsx b/frontend/packages/inspector/src/components/SessionSidebar.tsx index d45df21..f8381f9 100644 --- a/frontend/packages/inspector/src/components/SessionSidebar.tsx +++ b/frontend/packages/inspector/src/components/SessionSidebar.tsx @@ -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 = { @@ -42,7 +44,7 @@ const SessionSidebar = ({ selectedSessionId: string; onSelectSession: (session: SessionListItem) => void; onRefresh: () => void; - onCreateSession: (agentId: string, config: SessionConfig) => void; + onCreateSession: (agentId: string, config: SessionConfig) => Promise; onSelectAgent: (agentId: string) => Promise; agents: AgentInfo[]; agentsLoading: boolean; @@ -54,7 +56,15 @@ const SessionSidebar = ({ defaultModelByAgent: Record; }) => { const [showMenu, setShowMenu] = useState(false); + const [showArchived, setShowArchived] = useState(false); const menuRef = useRef(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 +78,34 @@ 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]); + return (
Sessions
- + )} +
) : sessionsError ? (
{sessionsError}
- ) : sessions.length === 0 ? ( -
No sessions yet.
+ ) : visibleSessions.length === 0 ? ( +
{showArchived ? "No archived sessions." : "No sessions yet."}
) : ( - sessions.map((session) => ( - - )) + <> + {showArchived &&
Archived Sessions
} + {orderedVisibleSessions.map((session) => ( +
+ +
+ ))} + )}
Sessions are persisted in your browser using IndexedDB.{" "} - + Configure persistence + - .
); diff --git a/frontend/packages/inspector/src/components/chat/ChatMessages.tsx b/frontend/packages/inspector/src/components/chat/ChatMessages.tsx index ee40edb..d271cf9 100644 --- a/frontend/packages/inspector/src/components/chat/ChatMessages.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatMessages.tsx @@ -1,150 +1,251 @@ 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 = ; + + if (isTool) { + const statusLabel = entry.toolStatus && entry.toolStatus !== "completed" + ? ` (${entry.toolStatus.replace("_", " ")})` + : ""; + label = `${entry.toolName ?? "tool"}${statusLabel}`; + icon = ; + } else if (isReasoning) { + label = `Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`; + icon = ; + } else if (isMeta) { + label = entry.meta?.title ?? "Status"; + icon = entry.meta?.severity === "error" ? : ; + } + + const hasContent = isTool + ? Boolean(entry.toolInput || entry.toolOutput) + : isReasoning + ? Boolean(entry.reasoning?.text?.trim()) + : Boolean(entry.meta?.detail?.trim()); + return ( -
- - {expanded &&
{children}
} +
+
+
+ {!isLast &&
} +
+
+ + {expanded && hasContent && ( +
+ {isTool && entry.toolInput && ( +
+
Input
+
{entry.toolInput}
+
+ )} + {isTool && isComplete && entry.toolOutput && ( +
+
Output
+
{entry.toolOutput}
+
+ )} + {isReasoning && entry.reasoning?.text && ( +
+
{entry.reasoning.text}
+
+ )} + {isMeta && entry.meta?.detail && ( +
+
{entry.meta.detail}
+
+ )} +
+ )} +
); }; +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 ( +
+ +
+ ); + } + + 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 ( +
+ + {expanded && ( +
+ {entries.map((entry, idx) => ( + + ))} +
+ )} +
+ ); +}; + +const agentLogos: Record = { + claude: `${import.meta.env.BASE_URL}logos/claude.svg`, + codex: `${import.meta.env.BASE_URL}logos/openai.svg`, + opencode: `${import.meta.env.BASE_URL}logos/opencode.svg`, + amp: `${import.meta.env.BASE_URL}logos/amp.svg`, + pi: `${import.meta.env.BASE_URL}logos/pi.svg`, +}; + const ChatMessages = ({ entries, sessionError, eventError, - messagesEndRef + messagesEndRef, + onEventClick, + isThinking, + agentId }: { entries: TimelineEntry[]; sessionError: string | null; eventError: string | null; messagesEndRef: React.RefObject; + 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 (
- {entries.map((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 ( -
-
- - - {title} - -
-
- ); - } - - // Other status messages - collapsible only if there's detail - const hasDetail = Boolean(entry.meta?.detail); - if (hasDetail) { - return ( - : } - label={title} - className={isError ? "error" : "system"} - > -
{entry.meta?.detail}
-
- ); - } - - // No detail - simple non-collapsible message return ( -
- {isError ? : } - {title} +
+
+ {title} +
); } - if (entry.kind === "reasoning") { - return ( - } - label={`Reasoning${entry.reasoning?.visibility ? ` (${entry.reasoning.visibility})` : ""}`} - className="system" - > -
{entry.reasoning?.text}
-
- ); - } - - 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 ( - T} - label={`${entry.toolName ?? "tool"}${statusLabel ? ` (${statusLabel})` : ""}`} - className={`tool${isFailed ? " error" : ""}`} - > - {entry.toolInput && ( -
-
input
-
{entry.toolInput}
-
- )} - {isComplete && entry.toolOutput && ( -
-
output
-
{entry.toolOutput}
-
- )} - {!isComplete && !entry.toolInput && ( - - - - - - )} -
- ); + if (group.type === "tool-group") { + return ; } // Regular message + const entry = group.entries[0]; const messageClass = getMessageClass(entry); return ( -
-
{getAvatarLabel(messageClass)}
+
{entry.text ? (
{entry.text}
@@ -161,6 +262,22 @@ const ChatMessages = ({ })} {sessionError &&
{sessionError}
} {eventError &&
{eventError}
} + {isThinking && ( +
+
+ {agentId && agentLogos[agentId] ? ( + + ) : ( + AI + )} +
+ + + + + +
+ )}
); diff --git a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx index 9c64889..1a3545b 100644 --- a/frontend/packages/inspector/src/components/chat/ChatPanel.tsx +++ b/frontend/packages/inspector/src/components/chat/ChatPanel.tsx @@ -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 }; @@ -24,12 +25,20 @@ const ChatPanel = ({ agentsError, messagesEndRef, agentLabel, + modelLabel, currentAgentVersion, sessionEnded, + sessionArchived, onEndSession, + onArchiveSession, + onUnarchiveSession, modesByAgent, modelsByAgent, defaultModelByAgent, + onEventClick, + isThinking, + agentId, + tokenUsage, }: { sessionId: string; transcriptEntries: TimelineEntry[]; @@ -38,21 +47,30 @@ const ChatPanel = ({ onMessageChange: (value: string) => void; onSendMessage: () => void; onKeyDown: (event: React.KeyboardEvent) => void; - onCreateSession: (agentId: string, config: SessionConfig) => void; + onCreateSession: (agentId: string, config: SessionConfig) => Promise; onSelectAgent: (agentId: string) => Promise; agents: AgentInfo[]; agentsLoading: boolean; agentsError: string | null; messagesEndRef: React.RefObject; agentLabel: string; + modelLabel?: string | null; currentAgentVersion?: string | null; sessionEnded: boolean; + sessionArchived: boolean; onEndSession: () => void; + onArchiveSession: () => void; + onUnarchiveSession: () => void; modesByAgent: Record; modelsByAgent: Record; defaultModelByAgent: Record; + 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(null); useEffect(() => { @@ -67,21 +85,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 (
- {sessionId ? "Session" : "No Session"} - {sessionId && {sessionId}} + {sessionId ? agentLabel : "No Session"} + {sessionId && modelLabel && ( + + {modelLabel} + + )} + {sessionId && currentAgentVersion && ( + v{currentAgentVersion} + )} + {sessionId && ( + + )}
+ {sessionId && tokenUsage && ( + {tokenUsage.used.toLocaleString()} tokens + )} {sessionId && ( sessionEnded ? ( - - - Ended - + <> + + + Ended + + + ) : (
+ {sessionError && ( +
+ + {sessionError} +
+ )} +
{!sessionId ? (
-
No Session Selected
-

Create a new session to start chatting with an agent.

+

Create a new session to start chatting with an agent.

@@ -145,24 +244,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 && ( -
-
- Agent - {agentLabel} -
- {currentAgentVersion && ( -
- Version - {currentAgentVersion} -
- )} -
- )}
); }; diff --git a/frontend/packages/inspector/src/components/chat/messageUtils.tsx b/frontend/packages/inspector/src/components/chat/messageUtils.tsx index 9df610e..209cc2e 100644 --- a/frontend/packages/inspector/src/components/chat/messageUtils.tsx +++ b/frontend/packages/inspector/src/components/chat/messageUtils.tsx @@ -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 ; + if (messageClass === "user") return null; if (messageClass === "tool") return "T"; if (messageClass === "system") return ; if (messageClass === "error") return ; diff --git a/frontend/packages/inspector/src/components/chat/types.ts b/frontend/packages/inspector/src/components/chat/types.ts index bd41778..a748b02 100644 --- a/frontend/packages/inspector/src/components/chat/types.ts +++ b/frontend/packages/inspector/src/components/chat/types.ts @@ -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: diff --git a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx index f945355..cd9e816 100644 --- a/frontend/packages/inspector/src/components/debug/DebugPanel.tsx +++ b/frontend/packages/inspector/src/components/debug/DebugPanel.tsx @@ -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 = ({ )} diff --git a/frontend/packages/inspector/src/components/debug/EventsTab.tsx b/frontend/packages/inspector/src/components/debug/EventsTab.tsx index 1568717..c0a69d4 100644 --- a/frontend/packages/inspector/src/components/debug/EventsTab.tsx +++ b/frontend/packages/inspector/src/components/debug/EventsTab.tsx @@ -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): EventIc const EventsTab = ({ events, onClear, + highlightedEventId, + onClearHighlight, }: { events: SessionEvent[]; onClear: () => void; + highlightedEventId?: string | null; + onClearHighlight?: () => void; }) => { const [collapsedEvents, setCollapsedEvents] = useState>({}); 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; 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 ( -
+
-
- {event.id} +
+ {formatShortId(event.id)}
diff --git a/frontend/packages/inspector/src/components/debug/McpTab.tsx b/frontend/packages/inspector/src/components/debug/McpTab.tsx index c6f259c..8d68221 100644 --- a/frontend/packages/inspector/src/components/debug/McpTab.tsx +++ b/frontend/packages/inspector/src/components/debug/McpTab.tsx @@ -158,7 +158,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 &&
{editError}
}
diff --git a/frontend/packages/inspector/src/components/debug/SkillsTab.tsx b/frontend/packages/inspector/src/components/debug/SkillsTab.tsx index cd2dd92..a7f5095 100644 --- a/frontend/packages/inspector/src/components/debug/SkillsTab.tsx +++ b/frontend/packages/inspector/src/components/debug/SkillsTab.tsx @@ -1,5 +1,5 @@ -import { FolderOpen, Loader2, Plus, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { ChevronDown, ChevronRight, FolderOpen, Loader2, Plus, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { SandboxAgent } from "sandbox-agent"; import { formatJson } from "../../utils/format"; @@ -13,10 +13,47 @@ const SkillsTab = ({ }: { getClient: () => SandboxAgent; }) => { + 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("/"); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const [copiedId, setCopiedId] = useState(null); + const [showSdkSkills, setShowSdkSkills] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + if (!showSdkSkills) return; + const handler = (event: MouseEvent) => { + if (!dropdownRef.current) return; + if (!dropdownRef.current.contains(event.target as Node)) { + setShowSdkSkills(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showSdkSkills]); // Add form state const [editing, setEditing] = useState(false); @@ -128,11 +165,118 @@ 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 ( <>
Skills Configuration -
+
+
+ + {showSdkSkills && ( +
+
+ Pick a skill to auto-fill the form. +
+ {officialSkills.map((skill) => ( +
+
+
{skill.name}
+ +
+
{skill.summary}
+ {skill.features && ( +
+ {skill.features.map((feature) => ( + + {feature} + + ))} +
+ )} +
+ ))} +
+ )} +
{!editing && (