mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 09:02:12 +00:00
feat: add sessions list API and frontend sidebar
This commit is contained in:
parent
ab2c1c2b62
commit
365cf262f3
4 changed files with 256 additions and 4 deletions
|
|
@ -23,6 +23,7 @@ Universal schema guidance:
|
||||||
- `sandbox-agent agents list` ↔ `GET /v1/agents`
|
- `sandbox-agent agents list` ↔ `GET /v1/agents`
|
||||||
- `sandbox-agent agents install` ↔ `POST /v1/agents/{agent}/install`
|
- `sandbox-agent agents install` ↔ `POST /v1/agents/{agent}/install`
|
||||||
- `sandbox-agent agents modes` ↔ `GET /v1/agents/{agent}/modes`
|
- `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 create` ↔ `POST /v1/sessions/{sessionId}`
|
||||||
- `sandbox-agent sessions send-message` ↔ `POST /v1/sessions/{sessionId}/messages`
|
- `sandbox-agent sessions send-message` ↔ `POST /v1/sessions/{sessionId}/messages`
|
||||||
- `sandbox-agent sessions events` / `get-messages` ↔ `GET /v1/sessions/{sessionId}/events`
|
- `sandbox-agent sessions events` / `get-messages` ↔ `GET /v1/sessions/{sessionId}/events`
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ use axum::routing::{get, post};
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use futures::{stream, StreamExt};
|
use futures::{stream, StreamExt};
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
|
use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
|
||||||
use sandbox_agent_universal_agent_schema::{
|
use sandbox_agent_universal_agent_schema::{
|
||||||
|
|
@ -80,6 +81,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||||
.route("/agents", get(list_agents))
|
.route("/agents", get(list_agents))
|
||||||
.route("/agents/:agent/install", post(install_agent))
|
.route("/agents/:agent/install", post(install_agent))
|
||||||
.route("/agents/:agent/modes", get(get_agent_modes))
|
.route("/agents/:agent/modes", get(get_agent_modes))
|
||||||
|
.route("/sessions", get(list_sessions))
|
||||||
.route("/sessions/:session_id", post(create_session))
|
.route("/sessions/:session_id", post(create_session))
|
||||||
.route("/sessions/:session_id/messages", post(post_message))
|
.route("/sessions/:session_id/messages", post(post_message))
|
||||||
.route("/sessions/:session_id/events", get(get_events))
|
.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));
|
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)]
|
#[derive(OpenApi)]
|
||||||
|
|
@ -112,6 +116,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||||
install_agent,
|
install_agent,
|
||||||
get_agent_modes,
|
get_agent_modes,
|
||||||
list_agents,
|
list_agents,
|
||||||
|
list_sessions,
|
||||||
create_session,
|
create_session,
|
||||||
post_message,
|
post_message,
|
||||||
get_events,
|
get_events,
|
||||||
|
|
@ -127,6 +132,8 @@ pub fn build_router(state: AppState) -> Router {
|
||||||
AgentModesResponse,
|
AgentModesResponse,
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
AgentListResponse,
|
AgentListResponse,
|
||||||
|
SessionInfo,
|
||||||
|
SessionListResponse,
|
||||||
HealthResponse,
|
HealthResponse,
|
||||||
CreateSessionRequest,
|
CreateSessionRequest,
|
||||||
CreateSessionResponse,
|
CreateSessionResponse,
|
||||||
|
|
@ -1477,6 +1484,19 @@ async fn list_agents(
|
||||||
Ok(Json(AgentListResponse { 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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/v1/sessions/{session_id}",
|
path = "/v1/sessions/{session_id}",
|
||||||
|
|
|
||||||
|
|
@ -372,11 +372,139 @@
|
||||||
/* Main Layout (Connected) */
|
/* Main Layout (Connected) */
|
||||||
.main-layout {
|
.main-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 200px 1fr 1fr;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
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 */
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1123,11 +1251,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.main-layout {
|
||||||
|
grid-template-columns: 180px 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
.main-layout {
|
.main-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Send,
|
Send,
|
||||||
Shield,
|
Shield,
|
||||||
|
|
@ -23,6 +24,18 @@ type AgentInfo = {
|
||||||
path?: string;
|
path?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SessionInfo = {
|
||||||
|
sessionId: string;
|
||||||
|
agent: string;
|
||||||
|
agentMode: string;
|
||||||
|
permissionMode: string;
|
||||||
|
model?: string;
|
||||||
|
variant?: string;
|
||||||
|
agentSessionId?: string;
|
||||||
|
ended: boolean;
|
||||||
|
eventCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
type AgentMode = {
|
type AgentMode = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -183,6 +196,7 @@ export default function App() {
|
||||||
|
|
||||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
||||||
const [modesByAgent, setModesByAgent] = useState<Record<string, AgentMode[]>>({});
|
const [modesByAgent, setModesByAgent] = useState<Record<string, AgentMode[]>>({});
|
||||||
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
|
||||||
const [agentId, setAgentId] = useState("claude");
|
const [agentId, setAgentId] = useState("claude");
|
||||||
const [agentMode, setAgentMode] = useState("");
|
const [agentMode, setAgentMode] = useState("");
|
||||||
|
|
@ -289,6 +303,7 @@ export default function App() {
|
||||||
await apiFetch(`${API_PREFIX}/health`);
|
await apiFetch(`${API_PREFIX}/health`);
|
||||||
setConnected(true);
|
setConnected(true);
|
||||||
await refreshAgents();
|
await refreshAgents();
|
||||||
|
await fetchSessions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Unable to connect";
|
const message = error instanceof Error ? error.message : "Unable to connect";
|
||||||
setConnectError(message);
|
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) => {
|
const installAgent = async (targetId: string, reinstall: boolean) => {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`${API_PREFIX}/agents/${targetId}/install`, {
|
await apiFetch(`${API_PREFIX}/agents/${targetId}/install`, {
|
||||||
|
|
@ -379,11 +404,35 @@ export default function App() {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body
|
body
|
||||||
});
|
});
|
||||||
|
await fetchSessions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSessionError(error instanceof Error ? error.message : "Unable to create session");
|
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[]) => {
|
const appendEvents = useCallback((incoming: UniversalEvent[]) => {
|
||||||
if (!incoming.length) return;
|
if (!incoming.length) return;
|
||||||
setEvents((prev) => [...prev, ...incoming]);
|
setEvents((prev) => [...prev, ...incoming]);
|
||||||
|
|
@ -719,7 +768,51 @@ export default function App() {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="main-layout">
|
<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="chat-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div className="panel-header-left">
|
<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>
|
<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 key={agent.id} className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
<span className="card-title">{agent.id}</span>
|
<span className="card-title">{agent.id}</span>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue