Parent Epic
Part of #430 — Tier 1 Real-Time Execution Tree
Problem
After #444 the dashboard is single-session per tab: the hook filters envelopes by `?session=` and the WS bridge broadcasts every session's events to every connected client, so cross-session leakage is impossible. Good for correctness — bad for discoverability.
The user has to know the sessionId out-of-band:
# Currently the only way to find a sessionId
./aceclaw-cli/build/install/aceclaw-cli/bin/aceclaw-cli daemon status
# … manually copy …
# … paste into …
http://localhost:5173/?session=2af4b82d-ae5a-…
There's no list of active sessions visible in the dashboard, and no way to switch sessions without editing the URL.
Goal
Two coordinated pieces so a dashboard user can:
- See which sessions are alive on the daemon
- Pick one to watch (and switch later) without leaving the dashboard
Deliverables
Daemon side — `sessions.list` over WebSocket
Two-message handshake, layered on top of the existing #431 bridge. No new transport.
Inbound from client (browser → daemon, new in #433-territory but minimal):
{ "method": "sessions.list" }
Outbound from daemon (one-shot reply, NOT envelope-wrapped — different semantics from event broadcasts):
{
"method": "sessions.list.result",
"sessions": [
{
"sessionId": "2af4b82d-…",
"projectPath": "/Users/.../my-repo",
"model": "claude-opus",
"createdAt": "2026-04-29T08:30:00.000Z",
"active": true
},
…
]
}
\`\`\`
\`SessionManager\` already tracks active sessions; the bridge's existing inbound handler hook (\`setInboundHandler\` from #431) is the natural mounting point.
**Live updates**: the dashboard already receives \`stream.session_started\` envelopes; this issue should additionally make sure \`stream.session_ended\` (or a \`session.destroyed\` envelope) is emitted on \`SessionManager.destroySession\` so the sidebar can prune stale entries without polling.
### Dashboard side — \`SessionList\` sidebar
- New left sidebar (~240px wide) listing sessions: project path tail, model, "started Xm ago", and a status dot (running / paused / closed)
- On mount, send \`{ method: "sessions.list" }\` over the WS, populate from the result
- On every \`stream.session_started\` envelope, prepend a new entry
- On every \`session.destroyed\` envelope (this issue's new emission), mark the entry closed
- Click a session → updates \`?session=<id>\` and the hook re-mounts (the existing \`useEffect\` on \`sessionId\` change in \`useExecutionTree\` already resets the tree, so this is one prop change away)
- Visual highlight on the currently-selected session
- Keep the empty-state \`SessionPrompt\` as a fallback when zero sessions are active
## Out of scope
- Snapshot replay on session switch (covered by #432)
- Editing/cancelling a session from the sidebar (separate issue if ever)
- Persisting "last viewed session" across reloads (cookies / localStorage — minor follow-up)
## Acceptance criteria
- [ ] \`SessionManager.destroySession\` publishes a \`SessionEvent.Closed\` (already exists) AND the \`EventMultiplexer\` translates it to a \`session.destroyed\` envelope on the WS
- [ ] Bridge inbound handler routes \`sessions.list\` to a daemon-side \`SessionsListHandler\` and replies once with the active set
- [ ] Dashboard sidebar renders the active session list, highlights the selection, and updates live on \`stream.session_started\` / \`session.destroyed\`
- [ ] Switching sessions resets the tree (already does, via the \`useEffect\` in \`useExecutionTree\`)
- [ ] Two new daemon integration tests (multi-session bridge fan-out + \`sessions.list\` reply)
- [ ] One new dashboard test for the sidebar's add/remove logic
## Estimated effort
- daemon (\`sessions.list\` + \`session.destroyed\` emission): ~2h
- dashboard sidebar: ~3h
- tests: ~1h
Total ~6h.
Parent Epic
Part of #430 — Tier 1 Real-Time Execution Tree
Problem
After #444 the dashboard is single-session per tab: the hook filters envelopes by `?session=` and the WS bridge broadcasts every session's events to every connected client, so cross-session leakage is impossible. Good for correctness — bad for discoverability.
The user has to know the sessionId out-of-band:
There's no list of active sessions visible in the dashboard, and no way to switch sessions without editing the URL.
Goal
Two coordinated pieces so a dashboard user can:
Deliverables
Daemon side — `sessions.list` over WebSocket
Two-message handshake, layered on top of the existing #431 bridge. No new transport.
Inbound from client (browser → daemon, new in #433-territory but minimal):
{ "method": "sessions.list" }Outbound from daemon (one-shot reply, NOT envelope-wrapped — different semantics from event broadcasts):
{ "method": "sessions.list.result", "sessions": [ { "sessionId": "2af4b82d-…", "projectPath": "/Users/.../my-repo", "model": "claude-opus", "createdAt": "2026-04-29T08:30:00.000Z", "active": true }, … ] } \`\`\` \`SessionManager\` already tracks active sessions; the bridge's existing inbound handler hook (\`setInboundHandler\` from #431) is the natural mounting point. **Live updates**: the dashboard already receives \`stream.session_started\` envelopes; this issue should additionally make sure \`stream.session_ended\` (or a \`session.destroyed\` envelope) is emitted on \`SessionManager.destroySession\` so the sidebar can prune stale entries without polling. ### Dashboard side — \`SessionList\` sidebar - New left sidebar (~240px wide) listing sessions: project path tail, model, "started Xm ago", and a status dot (running / paused / closed) - On mount, send \`{ method: "sessions.list" }\` over the WS, populate from the result - On every \`stream.session_started\` envelope, prepend a new entry - On every \`session.destroyed\` envelope (this issue's new emission), mark the entry closed - Click a session → updates \`?session=<id>\` and the hook re-mounts (the existing \`useEffect\` on \`sessionId\` change in \`useExecutionTree\` already resets the tree, so this is one prop change away) - Visual highlight on the currently-selected session - Keep the empty-state \`SessionPrompt\` as a fallback when zero sessions are active ## Out of scope - Snapshot replay on session switch (covered by #432) - Editing/cancelling a session from the sidebar (separate issue if ever) - Persisting "last viewed session" across reloads (cookies / localStorage — minor follow-up) ## Acceptance criteria - [ ] \`SessionManager.destroySession\` publishes a \`SessionEvent.Closed\` (already exists) AND the \`EventMultiplexer\` translates it to a \`session.destroyed\` envelope on the WS - [ ] Bridge inbound handler routes \`sessions.list\` to a daemon-side \`SessionsListHandler\` and replies once with the active set - [ ] Dashboard sidebar renders the active session list, highlights the selection, and updates live on \`stream.session_started\` / \`session.destroyed\` - [ ] Switching sessions resets the tree (already does, via the \`useEffect\` in \`useExecutionTree\`) - [ ] Two new daemon integration tests (multi-session bridge fan-out + \`sessions.list\` reply) - [ ] One new dashboard test for the sidebar's add/remove logic ## Estimated effort - daemon (\`sessions.list\` + \`session.destroyed\` emission): ~2h - dashboard sidebar: ~3h - tests: ~1h Total ~6h.