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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user