feat: multi-session management (browse all + bind N sessions per notebook)

User can now see EVERY session on the OpenCode Serve side (not just
the per-notebook mapping) and bind multiple sessions to a single
notebook. One of the bound sessions is the "active" one (the one
the next /edit uses); the others are historical. The user can
switch, create, or delete via the new session selector UI in the
inline prompt.

Backend
-------
- SessionManager rewritten for 1-notebook-N-sessions: keeps both an
   map and a  map. New methods: create_new,
  bind_existing, set_active, unbind, unbind_active, delete_session,
  list_sessions_for_notebook. Existing get_or_create / peek /
  invalidate / list_sessions / release semantics preserved
  (release now deletes ALL bound sessions, not just the active).
- New route GET /opencode-bridge/sessions/all — list ALL OpenCode
  sessions via /session.
- New route GET /opencode-bridge/sessions/notebook?notebook=... —
  bound sessions for one notebook, enriched with title/createdAt
  from the global list.
- POST .../sessions/notebook — create a new session, bind, set
  active.
- PUT  .../sessions/notebook — bind an existing sessionId, set
  active (no OpenCode round-trip).
- DELETE .../sessions/notebook — delete the session on OpenCode
  AND remove its binding.
- New route GET/PUT/DELETE /opencode-bridge/sessions/active —
  read / switch / unbind the active session for one notebook.
- OpenCodeClient.list_all_sessions() — proxy OpenCode /session.

Frontend
-------
- SessionSelector dropdown in the inline prompt: lists ALL OpenCode
  sessions with the active one selected, plus a "+ 新建会话"
  sentinel entry. "+ 新建" button + "🔄" refresh button.
  Switching calls onSwitchSession; selecting "+ 新建会话" or
  clicking the new button calls onCreateSession. After both,
  the prompt's history is reloaded for the new active session.
- New cell-action callbacks: onListSessions, onCreateSession,
  onSwitchSession, onReloadHistory. Each closes the SSE first
  so events for the OLD session don't bleed in during the switch.
- 8 new API client functions in api/opencode_client.ts.
- New types in types.ts: OpenCodeSessionMeta,
  OpenCodeNotebookSession, OpenCodeAllSessionsResponse,
  OpenCodeNotebookSessionsResponse, OpenCodeSessionOpResponse.

Tests
-----
- 73 backend pytest (was 64): +6 new SessionManager multi-session
  unit tests + 7 new route tests (all-sessions, notebook GET/POST/
  PUT/DELETE, active GET/PUT/DELETE).
- 76 frontend jest (was 72): +4 new prompt tests (session selector
  rendered when callbacks wired, initialize() populates the list,
  + 新建 creates+binds, dropdown change switches and reloads).
- package.json version bumped 0.1.0 -> 0.1.1 to match the v0.1.1 tag
  that was already published.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-28 16:25:42 +08:00
co-authored by Claude
parent fcd015ac0e
commit d2bd4f1e48
13 changed files with 1772 additions and 99 deletions
+240
View File
@@ -458,6 +458,242 @@ class SessionMessagesHandler(APIHandler):
self.finish(json.dumps({"messages": messages}))
# ---------------------------------------------------------------------------
# Multi-session management endpoints
# ---------------------------------------------------------------------------
#
# Model: each notebook can have multiple OpenCode sessions bound to it.
# One of the bound sessions is the "active" one (used by the next /edit).
# The user can:
# - list all OpenCode sessions (global, for the "browse" UI)
# - list sessions bound to a specific notebook
# - create a brand-new session and bind it (becomes active)
# - bind an existing session id (e.g. one they saw in the global list)
# - switch which bound session is active
# - unbind the active one (next /edit will lazy-create a new one)
# - delete a specific bound session (also removes it from OpenCode Serve)
# ---------------------------------------------------------------------------
class AllSessionsListHandler(APIHandler):
"""GET /opencode-bridge/sessions/all — list EVERY session on OpenCode
Serve, regardless of which notebook (if any) it is bound to. Powers
the "browse all conversations" UI in the cell-action panel.
"""
@tornado.web.authenticated
async def get(self):
try:
client = make_client(self)
sessions = await client.list_all_sessions()
except OpenCodeError as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list all sessions failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({"ok": True, "sessions": sessions or []}))
class NotebookSessionsHandler(APIHandler):
"""Manage the list of sessions BOUND to one notebook.
GET /opencode-bridge/sessions/notebook?notebook=...
-> { ok, activeSessionId, sessions: [{sessionId, title, ...}, ...] }
The metadata (title, createdAt, ...) is enriched from
OpenCode's `GET /session` so the frontend can render
the picker without a second round-trip per session.
POST /opencode-bridge/sessions/notebook?notebook=...&title=...
-> { ok, sessionId, activeSessionId } — creates a new
session on OpenCode, binds it, sets as active.
PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, sessionId, activeSessionId } — binds an
existing sessionId (e.g. one the user picked from
the global list) to this notebook and sets as active.
DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=...
-> { ok, deleted } — deletes the session on OpenCode
Serve AND removes its binding from this notebook.
If it was the active one, clears active.
"""
@tornado.web.authenticated
async def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
bound_ids = sm.list_sessions_for_notebook(notebook_path)
active = sm.get_active(notebook_path)
# Enrich with metadata from OpenCode Serve so the UI can show
# the title / createdAt without a second round-trip.
try:
all_sessions = await make_client(self).list_all_sessions()
by_id = {s.get("id"): s for s in (all_sessions or [])}
except Exception as e:
log.warning(
"could not enrich bound sessions with metadata: %s", e
)
by_id = {}
sessions = []
for sid in bound_ids:
meta = by_id.get(sid, {})
sessions.append({
"sessionId": sid,
"title": meta.get("title"),
"createdAt": meta.get("createdAt"),
"updatedAt": meta.get("updatedAt"),
"isActive": sid == active,
})
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": active,
"sessions": sessions,
}))
@tornado.web.authenticated
async def post(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
title = self.get_query_argument("title", None) or None
sm = get_session_manager(self)
try:
sid = await sm.create_new(notebook_path, title=title)
except OpenCodeError as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("create session failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": sid,
"activeSessionId": sid,
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
sm.bind_existing(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
async def delete(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
deleted = await sm.delete_session(notebook_path, session_id)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"sessionId": session_id,
"deleted": deleted,
"activeSessionId": sm.get_active(notebook_path),
}))
class ActiveSessionHandler(APIHandler):
"""Manage the ACTIVE session for one notebook.
GET /opencode-bridge/sessions/active?notebook=...
-> { ok, activeSessionId, notebookPath }
PUT /opencode-bridge/sessions/active?notebook=...&sessionId=...
-> { ok, activeSessionId } — switch the active bound
session. sessionId MUST already be in the bound list.
DELETE /opencode-bridge/sessions/active?notebook=...
-> { ok } — unbind the active session (next /edit will
lazy-create a new one). Does NOT delete the session
on OpenCode Serve (use the notebook DELETE for that).
"""
@tornado.web.authenticated
def get(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def put(self):
notebook_path = self.get_query_argument("notebook", "")
session_id = self.get_query_argument("sessionId", "")
if not notebook_path or not session_id:
self.set_status(400)
self.finish(json.dumps({
"error": "missing 'notebook' and/or 'sessionId' query parameter"
}))
return
sm = get_session_manager(self)
if not sm.set_active(notebook_path, session_id):
self.set_status(400)
self.finish(json.dumps({
"ok": False,
"error": "sessionId is not bound to this notebook; "
"POST /sessions/notebook or PUT /sessions/notebook first"
}))
return
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
@tornado.web.authenticated
def delete(self):
notebook_path = self.get_query_argument("notebook", "")
if not notebook_path:
self.set_status(400)
self.finish(json.dumps({"error": "missing 'notebook' query parameter"}))
return
sm = get_session_manager(self)
sm.unbind_active(notebook_path)
self.finish(json.dumps({
"ok": True,
"notebookPath": notebook_path,
"activeSessionId": sm.get_active(notebook_path),
}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
@@ -471,6 +707,10 @@ def setup_route_handlers(web_app):
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
# Multi-session management (notebook can bind N sessions; one is active).
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
# /permissions/<perm_id> and /questions/<q_id>/reply
# use a URLSpec with a regex so the path param is forwarded to
# the handler as a positional argument.