From 365cf262f3a094f24489e0ce123f5bffebda4844 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 25 Jan 2026 04:13:33 -0800 Subject: [PATCH] feat: add sessions list API and frontend sidebar --- CLAUDE.md | 1 + engine/packages/sandbox-agent/src/router.rs | 22 ++- frontend/packages/web/index.html | 140 +++++++++++++++++++- frontend/packages/web/src/App.tsx | 97 +++++++++++++- 4 files changed, 256 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2aef7e0..72b9627 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,7 @@ Universal schema guidance: - `sandbox-agent agents list` ↔ `GET /v1/agents` - `sandbox-agent agents install` ↔ `POST /v1/agents/{agent}/install` - `sandbox-agent agents modes` ↔ `GET /v1/agents/{agent}/modes` +- `sandbox-agent sessions list` ↔ `GET /v1/sessions` - `sandbox-agent sessions create` ↔ `POST /v1/sessions/{sessionId}` - `sandbox-agent sessions send-message` ↔ `POST /v1/sessions/{sessionId}/messages` - `sandbox-agent sessions events` / `get-messages` ↔ `GET /v1/sessions/{sessionId}/events` diff --git a/engine/packages/sandbox-agent/src/router.rs b/engine/packages/sandbox-agent/src/router.rs index eae41e3..e39aad1 100644 --- a/engine/packages/sandbox-agent/src/router.rs +++ b/engine/packages/sandbox-agent/src/router.rs @@ -15,6 +15,7 @@ use axum::routing::{get, post}; use axum::Json; use axum::Router; use futures::{stream, StreamExt}; +use tower_http::trace::TraceLayer; use reqwest::Client; use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError}; use sandbox_agent_universal_agent_schema::{ @@ -80,6 +81,7 @@ pub fn build_router(state: AppState) -> Router { .route("/agents", get(list_agents)) .route("/agents/:agent/install", post(install_agent)) .route("/agents/:agent/modes", get(get_agent_modes)) + .route("/sessions", get(list_sessions)) .route("/sessions/:session_id", post(create_session)) .route("/sessions/:session_id/messages", post(post_message)) .route("/sessions/:session_id/events", get(get_events)) @@ -102,7 +104,9 @@ pub fn build_router(state: AppState) -> Router { v1_router = v1_router.layer(axum::middleware::from_fn_with_state(shared, require_token)); } - Router::new().nest("/v1", v1_router) + Router::new() + .nest("/v1", v1_router) + .layer(TraceLayer::new_for_http()) } #[derive(OpenApi)] @@ -112,6 +116,7 @@ pub fn build_router(state: AppState) -> Router { install_agent, get_agent_modes, list_agents, + list_sessions, create_session, post_message, get_events, @@ -127,6 +132,8 @@ pub fn build_router(state: AppState) -> Router { AgentModesResponse, AgentInfo, AgentListResponse, + SessionInfo, + SessionListResponse, HealthResponse, CreateSessionRequest, CreateSessionResponse, @@ -1477,6 +1484,19 @@ async fn list_agents( Ok(Json(AgentListResponse { agents })) } +#[utoipa::path( + get, + path = "/v1/sessions", + responses((status = 200, body = SessionListResponse)), + tag = "sessions" +)] +async fn list_sessions( + State(state): State>, +) -> Result, ApiError> { + let sessions = state.session_manager.list_sessions().await; + Ok(Json(SessionListResponse { sessions })) +} + #[utoipa::path( post, path = "/v1/sessions/{session_id}", diff --git a/frontend/packages/web/index.html b/frontend/packages/web/index.html index a9e14b5..d9a3257 100644 --- a/frontend/packages/web/index.html +++ b/frontend/packages/web/index.html @@ -372,11 +372,139 @@ /* Main Layout (Connected) */ .main-layout { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 200px 1fr 1fr; flex: 1; overflow: hidden; } + /* Session Sidebar */ + .session-sidebar { + display: flex; + flex-direction: column; + border-right: 1px solid var(--border); + background: var(--surface-2); + overflow: hidden; + } + + .sidebar-header { + height: 41px; + padding: 0 12px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + } + + .sidebar-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--muted); + } + + .sidebar-add-btn { + width: 24px; + height: 24px; + background: var(--accent); + border: none; + border-radius: 4px; + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background var(--transition); + } + + .sidebar-add-btn:hover { + background: var(--accent-hover); + } + + .session-list { + flex: 1; + overflow-y: auto; + padding: 8px; + } + + .session-item { + display: block; + width: 100%; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + padding: 10px; + margin-bottom: 4px; + cursor: pointer; + text-align: left; + transition: all var(--transition); + } + + .session-item:last-child { + margin-bottom: 0; + } + + .session-item:hover { + background: var(--surface); + border-color: var(--border-2); + } + + .session-item.active { + background: rgba(255, 79, 0, 0.1); + border-color: var(--accent); + } + + .session-item-id { + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace; + font-size: 11px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + } + + .session-item-meta { + display: flex; + align-items: center; + gap: 6px; + font-size: 10px; + color: var(--muted); + } + + .session-item-agent { + font-weight: 600; + color: var(--accent); + } + + .session-item-events { + color: var(--muted); + } + + .session-item-ended { + color: var(--danger); + } + + .sidebar-empty { + padding: 16px; + text-align: center; + color: var(--muted); + font-size: 11px; + } + + .sidebar-refresh { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 8px; + border-top: 1px solid var(--border); + flex-shrink: 0; + } + /* Chat Panel */ .chat-panel { display: flex; @@ -1123,11 +1251,21 @@ } /* Responsive */ + @media (max-width: 1200px) { + .main-layout { + grid-template-columns: 180px 1fr 1fr; + } + } + @media (max-width: 1024px) { .main-layout { grid-template-columns: 1fr; } + .session-sidebar { + display: none; + } + .chat-panel { border-right: none; border-bottom: 1px solid var(--border); diff --git a/frontend/packages/web/src/App.tsx b/frontend/packages/web/src/App.tsx index d653a22..d4b4d52 100644 --- a/frontend/packages/web/src/App.tsx +++ b/frontend/packages/web/src/App.tsx @@ -6,6 +6,7 @@ import { MessageSquare, PauseCircle, PlayCircle, + Plus, RefreshCw, Send, Shield, @@ -23,6 +24,18 @@ type AgentInfo = { path?: string; }; +type SessionInfo = { + sessionId: string; + agent: string; + agentMode: string; + permissionMode: string; + model?: string; + variant?: string; + agentSessionId?: string; + ended: boolean; + eventCount: number; +}; + type AgentMode = { id: string; name: string; @@ -183,6 +196,7 @@ export default function App() { const [agents, setAgents] = useState([]); const [modesByAgent, setModesByAgent] = useState>({}); + const [sessions, setSessions] = useState([]); const [agentId, setAgentId] = useState("claude"); const [agentMode, setAgentMode] = useState(""); @@ -289,6 +303,7 @@ export default function App() { await apiFetch(`${API_PREFIX}/health`); setConnected(true); await refreshAgents(); + await fetchSessions(); } catch (error) { const message = error instanceof Error ? error.message : "Unable to connect"; setConnectError(message); @@ -325,6 +340,16 @@ export default function App() { } }; + const fetchSessions = async () => { + try { + const data = await apiFetch(`${API_PREFIX}/sessions`); + const sessionList = (data as { sessions?: SessionInfo[] })?.sessions ?? []; + setSessions(sessionList); + } catch { + // Silently fail - sessions list is supplementary + } + }; + const installAgent = async (targetId: string, reinstall: boolean) => { try { await apiFetch(`${API_PREFIX}/agents/${targetId}/install`, { @@ -379,11 +404,35 @@ export default function App() { method: "POST", body }); + await fetchSessions(); } catch (error) { setSessionError(error instanceof Error ? error.message : "Unable to create session"); } }; + const selectSession = (session: SessionInfo) => { + setSessionId(session.sessionId); + setAgentId(session.agent); + setAgentMode(session.agentMode); + setPermissionMode(session.permissionMode); + setModel(session.model ?? ""); + setVariant(session.variant ?? ""); + // Reset events and offset when switching sessions + setEvents([]); + setOffset(0); + offsetRef.current = 0; + setSessionError(null); + }; + + const generateSessionId = () => { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let id = "session-"; + for (let i = 0; i < 8; i++) { + id += chars[Math.floor(Math.random() * chars.length)]; + } + setSessionId(id); + }; + const appendEvents = useCallback((incoming: UniversalEvent[]) => { if (!incoming.length) return; setEvents((prev) => [...prev, ...incoming]); @@ -719,7 +768,51 @@ export default function App() {
- {/* Chat Panel - Left */} + {/* Session Sidebar */} +
+
+ Sessions + +
+ +
+ {sessions.length === 0 ? ( +
+ No sessions yet.
+ Create one to get started. +
+ ) : ( + sessions.map((session) => ( + + )) + )} +
+ +
+ +
+
+ + {/* Chat Panel */}
@@ -1123,7 +1216,7 @@ export default function App() {
No agents reported. Click refresh to check.
)} - {(agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false }))).map((agent) => ( + {(agents.length ? agents : defaultAgents.map((id) => ({ id, installed: false, version: undefined, path: undefined }))).map((agent) => (
{agent.id}