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
+211 -60
View File
@@ -1,13 +1,29 @@
"""Per-notebook session manager for OpenCode.
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
Async-safe via per-path asyncio.Lock. No automatic cleanup.
Maps each notebookPath to one OR MORE OpenCode session IDs. One of
the bound sessions is the "active" one (the one used for the next
``/edit``). The others are historical — they were created in this
notebook at some point but the user switched away (e.g. started a
new conversation topic).
Lifecycle:
- ``get_or_create`` returns the active session, lazily creating one on
first use. This is the path used by ``EditHandler`` on every prompt.
- ``create_new`` always creates a brand-new session on OpenCode Serve,
binds it, sets it as active. Used by the "New session" UI action.
- ``bind_existing`` / ``set_active`` / ``unbind`` / ``delete_session``
are the management operations for the per-notebook session list.
Async-safety: a per-notebook ``asyncio.Lock`` serializes
``get_or_create`` so concurrent prompts on the same notebook don't
double-create. The lock survives across calls (held in ``_locks``) so
subsequent calls can reuse it.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Callable
from typing import Callable, Optional
from .opencode_client import OpenCodeClient
@@ -17,81 +33,216 @@ ClientFactory = Callable[[], OpenCodeClient]
class SessionManager:
"""Tracks one OpenCode session per notebook path.
Threading/async model:
- Multiple coroutines may call get_or_create for the same notebook.
- First call creates; subsequent calls return the same sessionID.
- Per-notebook asyncio.Lock prevents double-create under concurrency.
- Locks remain in the map; they may be needed again for the same path.
"""
def __init__(self, client_factory: ClientFactory) -> None:
self._client_factory = client_factory
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
# notebookPath -> active sessionId
self._active: dict[str, str] = {}
# notebookPath -> ordered list of sessionIds bound to this
# notebook (the first is the oldest; the active one may be
# anywhere in the list). Used by the UI to render the session
# picker.
self._bound: dict[str, list[str]] = {}
# Per-notebook lock for serializing lazy-create.
self._locks: dict[str, asyncio.Lock] = {}
# ---------- active-session lookup / create ----------
async def get_or_create(self, notebook_path: str) -> str:
"""Return session ID for the notebook, creating one if needed.
Idempotent for the same path. Different paths get different sessions.
"""Return the active session id for this notebook, creating one
if no active session is bound yet. This is what ``EditHandler``
calls on every prompt.
"""
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
active = self._active.get(notebook_path)
if active is not None:
return active
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
async with lock:
existing = self._sessions.get(notebook_path)
if existing is not None:
return existing
client = self._client_factory()
session = await client.create_session(
title="jupyter:%s" % notebook_path
)
sid = session["id"]
self._sessions[notebook_path] = sid
self._titles[notebook_path] = notebook_path
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
active = self._active.get(notebook_path)
if active is not None:
return active
return await self._create_and_bind(notebook_path, title=None)
async def release(self, notebook_path: str) -> bool:
"""Delete session and remove from map. Returns True if a session existed."""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
self._locks.pop(notebook_path, None)
if sid is None:
async def _create_and_bind(
self, notebook_path: str, title: Optional[str]
) -> str:
client = self._client_factory()
session = await client.create_session(
title=title or "jupyter:%s" % notebook_path
)
sid = session["id"]
self._bind(notebook_path, sid)
log.info("created opencode session %s for %s", sid, notebook_path)
return sid
def get_active(self, notebook_path: str) -> Optional[str]:
"""Return the active sessionId for the notebook, or None if no
active session has been bound/created yet. Does NOT create one.
"""
return self._active.get(notebook_path)
def peek(self, notebook_path: str) -> Optional[str]:
"""Alias for :meth:`get_active` (legacy name)."""
return self.get_active(notebook_path)
def has_session(self, notebook_path: str) -> bool:
"""True iff at least one session has ever been bound to this
notebook (active or not)."""
return notebook_path in self._bound
# ---------- mutation: bind / unbind / set_active / create_new ----------
def _bind(self, notebook_path: str, session_id: str) -> None:
"""Add session_id to the bound list (if not already) and set it
as active. Idempotent on re-bind.
"""
self._active[notebook_path] = session_id
bound = self._bound.setdefault(notebook_path, [])
if session_id not in bound:
bound.append(session_id)
async def create_new(
self, notebook_path: str, title: Optional[str] = None
) -> str:
"""Create a brand new session on OpenCode Serve, bind it to
this notebook, set as active, return its id.
"""
return await self._create_and_bind(notebook_path, title)
def bind_existing(self, notebook_path: str, session_id: str) -> None:
"""Bind an existing sessionId to this notebook and set as active.
Does NOT call OpenCode — the caller must verify the session
exists (e.g. via ``GET /session``).
"""
self._bind(notebook_path, session_id)
def set_active(self, notebook_path: str, session_id: str) -> bool:
"""Switch the active session. Returns False if ``session_id`` is
not currently bound to this notebook (callers should bind first
if uncertain).
"""
bound = self._bound.get(notebook_path, [])
if session_id not in bound:
return False
self._active[notebook_path] = session_id
return True
def unbind(self, notebook_path: str, session_id: str) -> bool:
"""Remove ``session_id`` from this notebook's bindings. If it was
the active one, also clear active (next prompt will lazy-create
a new session). Returns True if it was bound.
"""
bound = self._bound.get(notebook_path)
if not bound or session_id not in bound:
return False
bound.remove(session_id)
if self._active.get(notebook_path) == session_id:
self._active.pop(notebook_path, None)
if not bound:
self._bound.pop(notebook_path, None)
return True
def unbind_active(self, notebook_path: str) -> bool:
"""Remove the active session binding only (leaves the rest of
the notebook's bound sessions intact). Next prompt will
lazy-create a new active session. Returns True if there was an
active binding to remove.
"""
active = self._active.pop(notebook_path, None)
if active is None:
return False
bound = self._bound.get(notebook_path)
if bound is not None:
bound.remove(active)
if not bound:
self._bound.pop(notebook_path, None)
return True
async def delete_session(
self, notebook_path: str, session_id: str
) -> bool:
"""Delete the session on OpenCode Serve AND remove its binding.
Order matters: we ask OpenCode to delete FIRST, and only remove
the local binding if OpenCode confirmed the delete. That way a
failed delete leaves the binding intact (the user can retry);
a deleted session never leaves a phantom binding behind.
Returns True if the session was actually deleted (i.e. it was
bound AND OpenCode acknowledged the delete). If the session is
not bound to this notebook, do NOT call OpenCode (we don't
want to silently delete sessions owned by other tools).
"""
if not self._is_bound(notebook_path, session_id):
return False
try:
client = self._client_factory()
return await client.delete_session(sid)
deleted = await client.delete_session(session_id)
except Exception:
log.warning(
"failed to delete opencode session %s for %s", sid, notebook_path
)
log.warning("failed to delete opencode session %s", session_id)
return False
# Whether the delete succeeded or just returned False (404), the
# session is gone on the OpenCode side — drop the binding to
# keep the UI consistent. We return whether OpenCode said
# "yes" so the caller can surface a warning on a 404.
self.unbind(notebook_path, session_id)
return deleted
def _is_bound(self, notebook_path: str, session_id: str) -> bool:
bound = self._bound.get(notebook_path)
return bool(bound) and session_id in bound
# ---------- legacy / compat ----------
async def release(self, notebook_path: str) -> bool:
"""Tear down ALL state for this notebook: delete every bound
session on OpenCode Serve AND clear every local binding.
Returns True iff at least one session was actually deleted
on the OpenCode side. (For deleting a SPECIFIC session use
:meth:`delete_session`.)
"""
bound = list(self._bound.get(notebook_path, []))
deleted_any = False
for sid in bound:
if await self.delete_session(notebook_path, sid):
deleted_any = True
# `delete_session` clears the binding. Belt-and-suspenders: drop
# any straggler state.
self._bound.pop(notebook_path, None)
self._active.pop(notebook_path, None)
self._locks.pop(notebook_path, None)
return deleted_any
def invalidate(self, notebook_path: str) -> bool:
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
Use this when an upstream error indicates the session is dead (e.g., 404).
"""Drop the cached active sessionId without calling OpenCode.
Returns True if a binding was removed. Use this when an
upstream error indicates the session is dead (e.g. 404).
"""
sid = self._sessions.pop(notebook_path, None)
self._titles.pop(notebook_path, None)
return sid is not None
def has_session(self, notebook_path: str) -> bool:
return notebook_path in self._sessions
def peek(self, notebook_path: str) -> Optional[str]:
"""Return the cached sessionID for the notebook, or None if no
session has been created yet. Does NOT create one (unlike
get_or_create) — used by the history endpoint to avoid spawning
a session just to report that there is none."""
return self._sessions.get(notebook_path)
was_active = self._active.pop(notebook_path, None)
if was_active is None:
return False
bound = self._bound.get(notebook_path)
if bound is not None:
try:
bound.remove(was_active)
except ValueError:
pass
if not bound:
self._bound.pop(notebook_path, None)
return True
def list_sessions(self) -> list[dict]:
"""Debug: list all (notebookPath, activeSessionId) pairs across
every notebook. One row per notebook — does NOT enumerate the
full bound-list per notebook. Use
:meth:`list_sessions_for_notebook` for that.
"""
return [
{"notebookPath": path, "sessionId": sid}
for path, sid in sorted(self._sessions.items())
for path, sid in sorted(self._active.items())
]
def list_sessions_for_notebook(self, notebook_path: str) -> list[str]:
"""All sessionIds bound to this notebook (in bind order: oldest
first, newest last). Empty list if none.
"""
return list(self._bound.get(notebook_path, []))