feat: add sessions list API and frontend sidebar

This commit is contained in:
Nathan Flurry 2026-01-25 04:13:33 -08:00
parent ab2c1c2b62
commit 365cf262f3
4 changed files with 256 additions and 4 deletions

View file

@ -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`

View file

@ -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<Arc<AppState>>,
) -> Result<Json<SessionListResponse>, ApiError> {
let sessions = state.session_manager.list_sessions().await;
Ok(Json(SessionListResponse { sessions }))
}
#[utoipa::path(
post,
path = "/v1/sessions/{session_id}",

View file

@ -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);

View file

@ -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<AgentInfo[]>([]);
const [modesByAgent, setModesByAgent] = useState<Record<string, AgentMode[]>>({});
const [sessions, setSessions] = useState<SessionInfo[]>([]);
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() {
</header>
<main className="main-layout">
{/* Chat Panel - Left */}
{/* Session Sidebar */}
<div className="session-sidebar">
<div className="sidebar-header">
<span className="sidebar-title">Sessions</span>
<button
className="sidebar-add-btn"
onClick={generateSessionId}
title="New session ID"
>
<Plus size={14} />
</button>
</div>
<div className="session-list">
{sessions.length === 0 ? (
<div className="sidebar-empty">
No sessions yet.<br />
Create one to get started.
</div>
) : (
sessions.map((session) => (
<button
key={session.sessionId}
className={`session-item ${session.sessionId === sessionId ? "active" : ""}`}
onClick={() => selectSession(session)}
>
<div className="session-item-id">{session.sessionId}</div>
<div className="session-item-meta">
<span className="session-item-agent">{session.agent}</span>
<span className="session-item-events">{session.eventCount} events</span>
{session.ended && <span className="session-item-ended">ended</span>}
</div>
</button>
))
)}
</div>
<div className="sidebar-refresh">
<button className="button ghost small" onClick={fetchSessions}>
<RefreshCw size={12} /> Refresh
</button>
</div>
</div>
{/* Chat Panel */}
<div className="chat-panel">
<div className="panel-header">
<div className="panel-header-left">
@ -1123,7 +1216,7 @@ export default function App() {
<div className="card-meta">No agents reported. Click refresh to check.</div>
)}
{(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) => (
<div key={agent.id} className="card">
<div className="card-header">
<span className="card-title">{agent.id}</span>