From d2bd4f1e48aef497a6f48c72064d4eb909701ace Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:25:42 +0800 Subject: [PATCH] feat: multi-session management (browse all + bind N sessions per notebook) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- opencode_bridge/opencode_client.py | 10 + opencode_bridge/routes.py | 240 +++++++++++ opencode_bridge/session_manager.py | 271 ++++++++++--- opencode_bridge/tests/test_client.py | 16 + opencode_bridge/tests/test_routes.py | 372 ++++++++++++++++-- opencode_bridge/tests/test_session_manager.py | 168 +++++++- package.json | 2 +- src/__tests__/opencode_cell_actions.spec.ts | 143 +++++++ src/api/opencode_client.ts | 222 ++++++++++- src/components/opencode_cell_actions.ts | 68 ++++ src/components/opencode_inline_prompt.ts | 262 +++++++++++- src/types.ts | 48 +++ style/base.css | 49 +++ 13 files changed, 1772 insertions(+), 99 deletions(-) diff --git a/opencode_bridge/opencode_client.py b/opencode_bridge/opencode_client.py index d1df205..614b19d 100644 --- a/opencode_bridge/opencode_client.py +++ b/opencode_bridge/opencode_client.py @@ -159,6 +159,16 @@ class OpenCodeClient: """ return await self._request("GET", "/session/%s/message" % session_id) + async def list_all_sessions(self) -> list[dict[str, Any]]: + """List ALL sessions on the OpenCode Serve side (not just the ones + bound to a notebook in our SessionManager). + + Returns the raw OpenCode response: a list of session objects with + metadata (``id``, ``title``, ``createdAt``, ``updatedAt``, + ``messageCount`` etc., whatever the upstream returns). + """ + return await self._request("GET", "/session") + @property def endpoint(self) -> str: return self._config.url diff --git a/opencode_bridge/routes.py b/opencode_bridge/routes.py index c7f2489..726e880 100644 --- a/opencode_bridge/routes.py +++ b/opencode_bridge/routes.py @@ -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/ and /questions//reply # use a URLSpec with a regex so the path param is forwarded to # the handler as a positional argument. diff --git a/opencode_bridge/session_manager.py b/opencode_bridge/session_manager.py index 8409b33..163adfb 100644 --- a/opencode_bridge/session_manager.py +++ b/opencode_bridge/session_manager.py @@ -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, [])) diff --git a/opencode_bridge/tests/test_client.py b/opencode_bridge/tests/test_client.py index 824b23c..ea4db86 100644 --- a/opencode_bridge/tests/test_client.py +++ b/opencode_bridge/tests/test_client.py @@ -266,6 +266,22 @@ async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None: assert body == {"answer": "use 3.12"} +@pytest.mark.asyncio +async def test_list_all_sessions_gets_session(base_config: OpenCodeConfig) -> None: + """list_all_sessions proxies OpenCode Serve's GET /session and returns + the raw list (no projection).""" + payload = [ + {"id": "s1", "title": "first", "createdAt": 1, "updatedAt": 2}, + {"id": "s2", "title": "second", "createdAt": 3, "updatedAt": 4}, + ] + client, mock = _make_client(base_config, [(200, payload)]) + sessions = await client.list_all_sessions() + assert sessions == payload + call = mock.calls[0] + assert call["request"].url == "http://127.0.0.1:4096/session" + assert call["request"].method == "GET" + + @pytest.mark.asyncio async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None: client, mock = _make_client(base_config, [(500, {"error": "boom"})]) diff --git a/opencode_bridge/tests/test_routes.py b/opencode_bridge/tests/test_routes.py index bb9dcea..0041145 100644 --- a/opencode_bridge/tests/test_routes.py +++ b/opencode_bridge/tests/test_routes.py @@ -57,6 +57,29 @@ class FakeOpenCodeClient: self.calls.append(("list_session_messages", session_id)) return self.messages_response + async def list_all_sessions(self): + self.calls.append(("list_all_sessions",)) + return [ + { + "id": "fake-session-123", + "title": "jupyter:foo.ipynb", + "createdAt": 1700000000000, + "updatedAt": 1700000001000, + }, + { + "id": "other-session", + "title": "manual session", + "createdAt": 1700000010000, + "updatedAt": 1700000011000, + }, + ] + + async def create_session(self, title): + self.calls.append(("create_session", title)) + # Return a fresh id (different from the default "fake-session-123"). + new_id = "newly-created-" + title + return {"id": new_id, "title": title} + async def reply_permission(self, session_id, permission_id, response): self.calls.append(("reply_permission", session_id, permission_id, response)) return True @@ -72,35 +95,84 @@ class FakeOpenCodeClient: class FakeSessionManager: - """Drop-in replacement for SessionManager with recording.""" + """Thin wrapper around the real SessionManager with a recording + ``FakeOpenCodeClient``. Lets route tests exercise the real + multi-session logic without touching OpenCode. + """ - def __init__(self, session_id: str = "fake-session-123") -> None: - self._session_id = session_id - self.calls: list = [] + def __init__(self, session_id: str = "fake-session-123", client=None) -> None: + from opencode_bridge.session_manager import SessionManager + # `_client` attribute used by some legacy tests. Allow injection + # so a test can share its FakeOpenCodeClient with the SessionManager + # (the real SessionManager calls into its own client_factory, NOT + # the route's make_client). + self._client = client if client is not None else FakeOpenCodeClient() + self._sm = SessionManager(lambda: self._client) + self.calls: list = self._client.calls + # Legacy attribute used by list_sessions() etc. + self._sessions: dict = {} - async def get_or_create(self, notebook_path: str) -> str: - self.calls.append(("get_or_create", notebook_path)) - return self._session_id + # ---- delegation to the real SessionManager ---- + def _record(self, name, *args): + self.calls.append((name, *args)) + return None + + def get_or_create(self, notebook_path: str): + self._record("get_or_create", notebook_path) + return self._sm.get_or_create(notebook_path) def peek(self, notebook_path: str): - # Mirror SessionManager.peek: return the session id without - # creating one. None means "no session yet" (used by the no-session - # test to short-circuit the history route). - return self._session_id or None + return self._sm.peek(notebook_path) - async def release(self, notebook_path: str) -> bool: - self.calls.append(("release", notebook_path)) - return True + def get_active(self, notebook_path: str): + return self._sm.get_active(notebook_path) def list_sessions(self) -> list: - return [ - {"notebookPath": path, "sessionId": sid} - for path, sid in sorted(self._sessions.items()) - ] + return self._sm.list_sessions() + + def list_sessions_for_notebook(self, notebook_path: str): + return self._sm.list_sessions_for_notebook(notebook_path) + + def bind_existing(self, notebook_path: str, session_id: str) -> None: + self._record("bind_existing", notebook_path, session_id) + self._sm.bind_existing(notebook_path, session_id) + + def set_active(self, notebook_path: str, session_id: str) -> bool: + self._record("set_active", notebook_path, session_id) + return self._sm.set_active(notebook_path, session_id) + + def unbind(self, notebook_path: str, session_id: str) -> bool: + self._record("unbind", notebook_path, session_id) + return self._sm.unbind(notebook_path, session_id) + + def unbind_active(self, notebook_path: str) -> bool: + self._record("unbind_active", notebook_path) + return self._sm.unbind_active(notebook_path) def invalidate(self, notebook_path: str) -> bool: - self.calls.append(("invalidate", notebook_path)) - return True + self._record("invalidate", notebook_path) + return self._sm.invalidate(notebook_path) + + async def release(self, notebook_path: str) -> bool: + self._record("release", notebook_path) + return await self._sm.release(notebook_path) + + async def delete_session(self, notebook_path: str, session_id: str) -> bool: + self._record("delete_session", notebook_path, session_id) + return await self._sm.delete_session(notebook_path, session_id) + + async def create_new(self, notebook_path: str, title: str | None = None) -> str: + self._record("create_new", notebook_path, title) + return await self._sm.create_new(notebook_path, title) + + # ---- internal-state proxies (used by tests that poke internals) ---- + @property + def _active(self) -> dict: + return self._sm._active + + @property + def _bound(self) -> dict: + return self._sm._bound async def test_hello(jp_fetch): @@ -178,11 +250,11 @@ async def test_edit_handler(monkeypatch, jp_fetch): ) assert response.code == 200 payload = json.loads(response.body) - assert payload == { - "ok": True, - "sessionId": "fake-session-123", - "notebookPath": "test.ipynb", - } + # The exact sessionId comes from the FakeClient; just check the + # shape — keys are present and ok is true. + assert payload["ok"] is True + assert isinstance(payload["sessionId"], str) and payload["sessionId"] + assert payload["notebookPath"] == "test.ipynb" # Async path: no `markdown` field. The LLM reply is consumed via SSE. assert "markdown" not in payload @@ -193,16 +265,21 @@ async def test_edit_handler(monkeypatch, jp_fetch): assert "send_message_async" in call_names assert "send_message_sync" not in call_names # legacy sync path is gone - # send_message_async received the session ID from the manager. + # send_message_async received a session id from the manager (the + # exact value comes from the FakeClient; just verify it matches + # the response's sessionId). send_call = [c for c in fake.calls if c[0] == "send_message_async"][0] - assert send_call[1] == "fake-session-123" + assert send_call[1] == payload["sessionId"] # system prompt was passed. assert "你是一个代码助手" in send_call[3] # SessionManager.get_or_create was called with the notebook path. + # (The real SessionManager internally calls create_session on the + # FakeClient too, so the shared calls list has both entries.) sm_call_names = [c[0] for c in fake_sm.calls] - assert sm_call_names == ["get_or_create"] - assert fake_sm.calls[0][1] == "test.ipynb" + assert "get_or_create" in sm_call_names + get_or_create_call = [c for c in fake_sm.calls if c[0] == "get_or_create"][0] + assert get_or_create_call[1] == "test.ipynb" async def test_edit_handler_includes_model_when_provider_and_model_given( @@ -242,10 +319,12 @@ async def test_edit_handler_includes_model_when_provider_and_model_given( async def test_session_list_handler(monkeypatch, jp_fetch): fake_sm = FakeSessionManager() - fake_sm._sessions = { - "foo.ipynb": "sid-1", - "bar.ipynb": "sid-2", - } # direct injection + # Seed the underlying real SessionManager's state so list_sessions + # returns the expected debug mapping. + fake_sm._active["foo.ipynb"] = "sid-1" + fake_sm._active["bar.ipynb"] = "sid-2" + fake_sm._bound["foo.ipynb"] = ["sid-1"] + fake_sm._bound["bar.ipynb"] = ["sid-2"] monkeypatch.setattr( "opencode_bridge.routes.get_session_manager", lambda h: fake_sm ) @@ -290,6 +369,10 @@ async def test_session_messages_handler_projects_opencode_messages( monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) fake_sm = FakeSessionManager(session_id="fake-session-123") + # Seed the bound state so peek() returns the session id (the route + # only fetches messages if there's an active session). + fake_sm._active["test.ipynb"] = "fake-session-123" + fake_sm._bound["test.ipynb"] = ["fake-session-123"] monkeypatch.setattr( "opencode_bridge.routes.get_session_manager", lambda h: fake_sm ) @@ -332,6 +415,11 @@ async def test_session_release_handler(monkeypatch, jp_fetch): "opencode_bridge.routes.get_session_manager", lambda h: fake_sm ) + # Set up state: get_or_create actually binds a session to the + # notebook in the real SessionManager (via the FakeClient). release + # then has something to delete. + await fake_sm.get_or_create("foo.ipynb") + response = await jp_fetch( "opencode-bridge", "session", method="DELETE", @@ -459,3 +547,221 @@ async def test_global_event_handler_forwards_all_events_unfiltered( assert '"server.connected"' in body # The body should use the SSE format: each event as a `data:` line. assert body.count("data: ") == 3 + + +# --------------------------------------------------------------------------- +# Multi-session management endpoints +# --------------------------------------------------------------------------- + + +async def test_all_sessions_handler_returns_global_list(monkeypatch, jp_fetch): + """GET /sessions/all proxies OpenCode Serve's GET /session.""" + fake = FakeOpenCodeClient() + monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) + response = await jp_fetch("opencode-bridge", "sessions", "all") + assert response.code == 200 + payload = json.loads(response.body) + assert payload["ok"] is True + assert len(payload["sessions"]) == 2 + # The fake returns both the default session and a hand-rolled "other-session". + ids = {s["id"] for s in payload["sessions"]} + assert "fake-session-123" in ids + assert "other-session" in ids + assert ("list_all_sessions",) in fake.calls + + +async def test_notebook_sessions_get_returns_bound_with_metadata( + monkeypatch, jp_fetch +): + """GET /sessions/notebook?notebook=... returns the bound sessions + enriched with title/createdAt from the global list.""" + fake = FakeOpenCodeClient() + monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) + sm = FakeSessionManager() + sm._active["foo.ipynb"] = "fake-session-123" + sm._bound["foo.ipynb"] = ["fake-session-123"] + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", lambda h: sm + ) + + response = await jp_fetch( + "opencode-bridge", "sessions", "notebook", + params={"notebook": "foo.ipynb"}, + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["notebookPath"] == "foo.ipynb" + assert payload["activeSessionId"] == "fake-session-123" + assert len(payload["sessions"]) == 1 + s = payload["sessions"][0] + assert s["sessionId"] == "fake-session-123" + assert s["title"] == "jupyter:foo.ipynb" + assert s["isActive"] is True + + +async def test_notebook_sessions_post_creates_and_binds(monkeypatch, jp_fetch): + """POST /sessions/notebook creates a new session on OpenCode and + binds it as active.""" + fake = FakeOpenCodeClient() + monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) + # Share the client with the SessionManager so the test can assert + # against `fake.calls` (the real SessionManager calls its own + # client_factory, NOT the route's make_client). + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", + lambda h: FakeSessionManager(client=fake), + ) + + response = await jp_fetch( + "opencode-bridge", "sessions", "notebook", + method="POST", + params={"notebook": "foo.ipynb", "title": "fresh"}, + body=json.dumps({}), + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["ok"] is True + assert payload["sessionId"] == "newly-created-fresh" + # The new session is the active one. + assert payload["activeSessionId"] == "newly-created-fresh" + # OpenCode was called. + assert ("create_session", "fresh") in fake.calls + + +async def test_notebook_sessions_put_binds_existing(monkeypatch, jp_fetch): + """PUT /sessions/notebook binds an existing sessionId without + calling OpenCode. Sets it as active.""" + fake = FakeOpenCodeClient() + monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) + sm = FakeSessionManager() + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", lambda h: sm + ) + + response = await jp_fetch( + "opencode-bridge", "sessions", "notebook", + method="PUT", + params={"notebook": "foo.ipynb", "sessionId": "some-existing-sid"}, + body=json.dumps({}), + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["sessionId"] == "some-existing-sid" + assert payload["activeSessionId"] == "some-existing-sid" + # No OpenCode round-trip for the bind. + assert ("create_session", ...) not in [ + c for c in fake.calls if c[0] == "create_session" + ] + + +async def test_active_session_set_requires_bound(monkeypatch, jp_fetch): + """PUT /sessions/active?sessionId=X fails with 400 if X is not bound.""" + monkeypatch.setattr( + "opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient() + ) + sm = FakeSessionManager() + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", lambda h: sm + ) + with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info: + await jp_fetch( + "opencode-bridge", "sessions", "active", + method="PUT", + params={"notebook": "foo.ipynb", "sessionId": "never-bound"}, + body=json.dumps({}), + ) + assert exc_info.value.code == 400 + + +async def test_active_session_set_switches(monkeypatch, jp_fetch): + """PUT /sessions/active successfully switches when the sessionId is bound.""" + monkeypatch.setattr( + "opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient() + ) + sm = FakeSessionManager() + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", lambda h: sm + ) + response = await jp_fetch( + "opencode-bridge", "sessions", "active", + method="PUT", + params={"notebook": "foo.ipynb", "sessionId": "sid-A"}, + body=json.dumps({}), + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["activeSessionId"] == "sid-A" + + +async def test_active_session_delete_unbinds_only(monkeypatch, jp_fetch): + """DELETE /sessions/active unbinds the active session; other + bound sessions are kept; OpenCode is NOT called.""" + fake = FakeOpenCodeClient() + monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) + sm = FakeSessionManager() + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", lambda h: sm + ) + response = await jp_fetch( + "opencode-bridge", "sessions", "active", + method="DELETE", + params={"notebook": "foo.ipynb"}, + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["activeSessionId"] is None + # sid-A is still bound; sid-B was the active and is gone. + assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"] + # No OpenCode call. + assert not any(c[0] == "delete_session" for c in fake.calls) + + +async def test_active_session_get_returns_id_or_null(monkeypatch, jp_fetch): + monkeypatch.setattr( + "opencode_bridge.routes.make_client", lambda h: FakeOpenCodeClient() + ) + sm = FakeSessionManager() + sm.bind_existing("foo.ipynb", "sid-A") + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", lambda h: sm + ) + response = await jp_fetch( + "opencode-bridge", "sessions", "active", + params={"notebook": "foo.ipynb"}, + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["activeSessionId"] == "sid-A" + + +async def test_notebook_sessions_delete_removes_session(monkeypatch, jp_fetch): + """DELETE /sessions/notebook?notebook=...&sessionId=... deletes + the session on OpenCode AND removes its binding.""" + fake = FakeOpenCodeClient() + monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake) + # Share the client with the SessionManager so the test can assert + # against `fake.calls` (the real SessionManager calls its own + # client_factory, NOT the route's make_client). + sm = FakeSessionManager(client=fake) + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + monkeypatch.setattr( + "opencode_bridge.routes.get_session_manager", lambda h: sm + ) + response = await jp_fetch( + "opencode-bridge", "sessions", "notebook", + method="DELETE", + params={"notebook": "foo.ipynb", "sessionId": "sid-A"}, + ) + assert response.code == 200 + payload = json.loads(response.body) + assert payload["deleted"] is True + # OpenCode delete was called for sid-A. + assert ("delete_session", "sid-A") in fake.calls + # sid-A is gone; sid-B remains active. + assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-B"] + assert sm.get_active("foo.ipynb") == "sid-B" diff --git a/opencode_bridge/tests/test_session_manager.py b/opencode_bridge/tests/test_session_manager.py index c120eeb..a21a1da 100644 --- a/opencode_bridge/tests/test_session_manager.py +++ b/opencode_bridge/tests/test_session_manager.py @@ -7,13 +7,31 @@ from opencode_bridge.session_manager import SessionManager class FakeClient: - """Mimics OpenCodeClient just enough for SessionManager.""" - def __init__(self, session_id: str = "sid-from-fake") -> None: + """Mimics OpenCodeClient just enough for SessionManager. + + By default returns the same id for every create_session call + (matching the original legacy behavior; several pre-existing tests + rely on that). Tests that need a fresh id per call should set + ``unique_ids_per_call=True`` in the constructor. + """ + def __init__( + self, + session_id: str = "sid-from-fake", + unique_ids_per_call: bool = False, + ) -> None: self._session_id = session_id + self._unique_ids = unique_ids_per_call + self._next_id = 0 self.calls: list = [] async def create_session(self, title: str) -> dict: self.calls.append(("create_session", title)) + if self._unique_ids: + self._next_id += 1 + return { + "id": "%s-%d" % (self._session_id, self._next_id), + "title": title, + } return {"id": self._session_id, "title": title} async def delete_session(self, session_id: str) -> bool: @@ -112,7 +130,7 @@ async def test_list_sessions_returns_all_mappings(): sm = SessionManager(lambda: client) await sm.get_or_create("a.ipynb") # Manually inject a second to test listing - sm._sessions["b.ipynb"] = "sid-b" + sm._active["b.ipynb"] = "sid-b" listing = sm.list_sessions() paths = {s["notebookPath"] for s in listing} assert paths == {"a.ipynb", "b.ipynb"} @@ -133,3 +151,147 @@ async def test_concurrent_get_or_create_does_not_double_create(): assert sids[0] == sids[1] == sids[2] create_calls = [c for c in client.calls if c[0] == "create_session"] assert len(create_calls) == 1, "concurrent get_or_create must not double-create" + + +# --------------------------------------------------------------------------- +# Multi-session: 1 notebook can bind N sessions, one is active +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_new_binds_and_makes_active(): + client = FakeClient(unique_ids_per_call=True) + sm = SessionManager(lambda: client) + sid = await sm.create_new("foo.ipynb", title="custom") + assert sid == "sid-from-fake-1" + # New session is the active one and the only bound one. + assert sm.get_active("foo.ipynb") == sid + assert sm.list_sessions_for_notebook("foo.ipynb") == [sid] + + +@pytest.mark.asyncio +async def test_create_new_when_active_exists_keeps_old_bound(): + client = FakeClient(unique_ids_per_call=True) + sm = SessionManager(lambda: client) + first = await sm.get_or_create("foo.ipynb") + second = await sm.create_new("foo.ipynb") + # Two distinct session ids. + assert first != second + assert first == "sid-from-fake-1" + assert second == "sid-from-fake-2" + # Both are bound; the new one is active. + bound = sm.list_sessions_for_notebook("foo.ipynb") + assert set(bound) == {first, second} + assert sm.get_active("foo.ipynb") == second + # get_or_create still returns the active one (does NOT auto-pick the latest). + assert await sm.get_or_create("foo.ipynb") == second + + +def test_bind_existing_does_not_call_opencode(): + client = FakeClient() + sm = SessionManager(lambda: client) + sm.bind_existing("foo.ipynb", "sid-from-list") + assert client.calls == [] # no OpenCode round-trip + assert sm.get_active("foo.ipynb") == "sid-from-list" + assert "sid-from-list" in sm.list_sessions_for_notebook("foo.ipynb") + + +def test_set_active_requires_session_to_be_bound(): + sm = SessionManager(lambda: FakeClient()) + sm.bind_existing("foo.ipynb", "sid-A") + assert sm.set_active("foo.ipynb", "sid-A") is True + # Switching to a session that is NOT bound must fail. + assert sm.set_active("foo.ipynb", "sid-UNKNOWN") is False + # Active didn't change. + assert sm.get_active("foo.ipynb") == "sid-A" + + +def test_unbind_removes_from_bound_and_clears_active_if_was_active(): + sm = SessionManager(lambda: FakeClient()) + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + # B is the active one (last bind wins). Unbind B. + assert sm.get_active("foo.ipynb") == "sid-B" + assert sm.unbind("foo.ipynb", "sid-B") is True + # Active falls back to None (no other session auto-promotes). + assert sm.get_active("foo.ipynb") is None + assert sm.list_sessions_for_notebook("foo.ipynb") == ["sid-A"] + + +def test_unbind_non_active_leaves_active_intact(): + sm = SessionManager(lambda: FakeClient()) + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + assert sm.get_active("foo.ipynb") == "sid-B" + # Unbind A (not the active one). + assert sm.unbind("foo.ipynb", "sid-A") is True + # Active is still B. + assert sm.get_active("foo.ipynb") == "sid-B" + + +def test_unbind_active_keeps_other_sessions(): + sm = SessionManager(lambda: FakeClient()) + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + sm.unbind_active("foo.ipynb") + assert sm.get_active("foo.ipynb") is None + # B is gone from the bound list; A remains. + assert "sid-A" in sm.list_sessions_for_notebook("foo.ipynb") + assert "sid-B" not in sm.list_sessions_for_notebook("foo.ipynb") + + +@pytest.mark.asyncio +async def test_delete_session_unbinds_and_calls_opencode(): + client = FakeClient() + sm = SessionManager(lambda: client) + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + deleted = await sm.delete_session("foo.ipynb", "sid-A") + assert deleted is True + # OpenCode was called. + assert any( + c[0] == "delete_session" and c[1] == "sid-A" for c in client.calls + ) + # sid-A is gone from bindings; sid-B remains as active. + assert "sid-A" not in sm.list_sessions_for_notebook("foo.ipynb") + assert sm.get_active("foo.ipynb") == "sid-B" + + +@pytest.mark.asyncio +async def test_delete_session_not_bound_returns_false(): + client = FakeClient() + sm = SessionManager(lambda: client) + assert await sm.delete_session("foo.ipynb", "never-bound") is False + # No OpenCode call. + assert not any(c[0] == "delete_session" for c in client.calls) + + +@pytest.mark.asyncio +async def test_release_drops_all_bindings_and_deletes_active(): + client = FakeClient() + sm = SessionManager(lambda: client) + sm.bind_existing("foo.ipynb", "sid-A") + sm.bind_existing("foo.ipynb", "sid-B") + ok = await sm.release("foo.ipynb") + assert ok is True + # OpenCode delete called for the active one (sid-B). + assert any( + c[0] == "delete_session" and c[1] == "sid-B" for c in client.calls + ) + # Both bindings cleared. + assert sm.list_sessions_for_notebook("foo.ipynb") == [] + assert sm.get_active("foo.ipynb") is None + + +@pytest.mark.asyncio +async def test_get_or_create_after_set_active_returns_the_active_one(): + client = FakeClient() + sm = SessionManager(lambda: client) + first = await sm.get_or_create("foo.ipynb") + sm.bind_existing("foo.ipynb", "another-sid") + sm.set_active("foo.ipynb", "another-sid") + # Next /edit-style call returns the manually-set active session. + assert await sm.get_or_create("foo.ipynb") == "another-sid" + # No additional create_session call. + creates = [c for c in client.calls if c[0] == "create_session"] + assert len(creates) == 1 # only the initial get_or_create diff --git a/package.json b/package.json index 2561445..79259d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode_bridge", - "version": "0.1.0", + "version": "0.1.1", "description": "A JupyterLab extension.", "keywords": [ "jupyter", diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 2953ae2..54ab25f 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -118,6 +118,23 @@ jest.mock('../api/opencode_client', () => ({ callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }), callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }), callOpenCodeReplyQuestion: jest.fn().mockResolvedValue({ ok: true }), + callOpenCodeListAllSessions: jest.fn().mockResolvedValue({ sessions: [] }), + callOpenCodeCreateNotebookSession: jest.fn().mockResolvedValue({ + ok: true, + notebookPath: '', + sessionId: 'newly-created', + activeSessionId: 'newly-created', + }), + callOpenCodeSetActiveSession: jest.fn().mockResolvedValue({ + ok: true, + notebookPath: '', + activeSessionId: 'sess-B', + }), + callOpenCodeBindExistingSession: jest.fn().mockResolvedValue({ + ok: true, + notebookPath: '', + sessionId: 'sess-B', + }), subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() }) })); @@ -130,6 +147,10 @@ import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt'; import { CodeCell } from '@jupyterlab/cells'; import { callOpenCodeEdit, + callOpenCodeListAllSessions, + callOpenCodeCreateNotebookSession, + callOpenCodeSetActiveSession, + callOpenCodeBindExistingSession, subscribeOpenCodeEvents } from '../api/opencode_client'; import { Notification } from '@jupyterlab/apputils'; @@ -141,6 +162,18 @@ const mockedSubscribeOpenCodeEvents = subscribeOpenCodeEvents as jest.MockedFunction< typeof subscribeOpenCodeEvents >; +const mockedListAllSessions = callOpenCodeListAllSessions as jest.MockedFunction< + typeof callOpenCodeListAllSessions +>; +const mockedCreateSession = callOpenCodeCreateNotebookSession as jest.MockedFunction< + typeof callOpenCodeCreateNotebookSession +>; +const mockedSetActiveSession = callOpenCodeSetActiveSession as jest.MockedFunction< + typeof callOpenCodeSetActiveSession +>; +const mockedBindExistingSession = callOpenCodeBindExistingSession as jest.MockedFunction< + typeof callOpenCodeBindExistingSession +>; const mockedNotification = Notification as jest.Mocked; function makeFakeOutputs(items: any[]): any { @@ -1005,6 +1038,116 @@ describe('OpenCodeInlinePrompt', () => { warnSpy.mockRestore(); }); + it('renders the session selector when onListSessions is provided', () => { + mockedListAllSessions.mockResolvedValue({ + ok: true, + sessions: [ + { id: 'sess-1', title: 'old chat' }, + { id: 'sess-2', title: 'current chat' }, + ] + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + const actions = new OpenCodeCellActions(cell); + Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); + (actions as any).onAfterAttach({} as any); + (actions.node.querySelector('button') as HTMLButtonElement).click(); + const prompt = (actions as any)._prompt; + + // The session selector row + select are present. + const row = prompt.node.querySelector('.opencode-prompt-sessions'); + expect(row).not.toBeNull(); + const select = prompt.node.querySelector('.opencode-session-select'); + expect(select).not.toBeNull(); + // + 新建 / 🔄 刷新 buttons are present. + const newBtn = prompt.node.querySelector('.opencode-session-btn-new'); + const refreshBtn = prompt.node.querySelector('.opencode-session-btn-refresh'); + expect(newBtn).not.toBeNull(); + expect(refreshBtn).not.toBeNull(); + }); + + it('initialize() fetches and populates the session list', async () => { + mockedListAllSessions.mockResolvedValue({ + ok: true, + sessions: [ + { id: 'sess-A', title: 'foo' }, + { id: 'sess-B', title: 'bar' }, + ] + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + const actions = new OpenCodeCellActions(cell); + Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); + (actions as any).onAfterAttach({} as any); + (actions.node.querySelector('button') as HTMLButtonElement).click(); + // initialize() is called inside _showPrompt. + await new Promise(r => setTimeout(r, 5)); + expect(mockedListAllSessions).toHaveBeenCalled(); + const prompt = (actions as any)._prompt; + const select = prompt.node.querySelector( + '.opencode-session-select' + ) as HTMLSelectElement; + // Both sessions appear + the "new" sentinel. + const labels = Array.from(select.options).map(o => o.textContent); + expect(labels.length).toBe(3); // sess-A, sess-B, + 新建会话… + }); + + it('clicking + 新建 creates a new session, binds, and reloads history', async () => { + mockedListAllSessions.mockResolvedValue({ ok: true, sessions: [] }); + mockedCreateSession.mockResolvedValue({ + ok: true, + notebookPath: 'foo.ipynb', + sessionId: 'fresh-sid', + activeSessionId: 'fresh-sid', + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + const actions = new OpenCodeCellActions(cell); + Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); + (actions as any).onAfterAttach({} as any); + (actions.node.querySelector('button') as HTMLButtonElement).click(); + await new Promise(r => setTimeout(r, 5)); + const prompt = (actions as any)._prompt; + const newBtn = prompt.node.querySelector( + '.opencode-session-btn-new' + ) as HTMLButtonElement; + newBtn.click(); + await new Promise(r => setTimeout(r, 5)); + expect(mockedCreateSession).toHaveBeenCalled(); + // The prompt's _sessionId is now the new one. + expect((prompt as any)._sessionId).toBe('fresh-sid'); + }); + + it('switching session calls onSwitchSession and reloads history', async () => { + mockedListAllSessions.mockResolvedValue({ + ok: true, + sessions: [ + { id: 'sess-A', title: 'first' }, + { id: 'sess-B', title: 'second' }, + ] + }); + mockedSetActiveSession.mockResolvedValue({ + ok: true, notebookPath: 'foo.ipynb', activeSessionId: 'sess-B', + }); + mockedBindExistingSession.mockResolvedValue({ + ok: true, notebookPath: 'foo.ipynb', sessionId: 'sess-B', + }); + const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); + const actions = new OpenCodeCellActions(cell); + Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); + (actions as any).onAfterAttach({} as any); + (actions.node.querySelector('button') as HTMLButtonElement).click(); + await new Promise(r => setTimeout(r, 5)); + const prompt = (actions as any)._prompt; + // Mark sess-A as currently active. + prompt.setSessionId('sess-A'); + const select = prompt.node.querySelector( + '.opencode-session-select' + ) as HTMLSelectElement; + select.value = 'sess-B'; + select.dispatchEvent(new Event('change')); + await new Promise(r => setTimeout(r, 5)); + expect(mockedSetActiveSession).toHaveBeenCalled(); + expect((prompt as any)._sessionId).toBe('sess-B'); + }); + it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => { // Regression: a previous version ran marked.parse on every text // delta. That produced glitchy partial-render states (e.g. an diff --git a/src/api/opencode_client.ts b/src/api/opencode_client.ts index 7a11204..6bf85dc 100644 --- a/src/api/opencode_client.ts +++ b/src/api/opencode_client.ts @@ -11,11 +11,14 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import type { + OpenCodeAllSessionsResponse, OpenCodeEditResponse, OpenCodeEvent, OpenCodeMessagesResponse, + OpenCodeNotebookSessionsResponse, OpenCodeProvidersResponse, - OpenCodeRequest + OpenCodeRequest, + OpenCodeSessionOpResponse } from '../types'; /** @@ -215,6 +218,223 @@ export async function callOpenCodeReplyQuestion( return JSON.parse(text); } +// --------------------------------------------------------------------------- +// Multi-session management (1 notebook can bind N sessions; one is active) +// --------------------------------------------------------------------------- + +async function _jsonRequest( + url: string, + init: RequestInit, + serverSettings: ServerConnection.ISettings, + label: string +): Promise { + let res: Response; + try { + res = await ServerConnection.makeRequest(url, init, serverSettings); + } catch (error) { + throw new Error( + `network error calling opencode-bridge/${label}: ${(error as Error).message}` + ); + } + const text = await res.text(); + if (!res.ok) { + throw new Error(`opencode-bridge/${label} failed: ${res.status} ${text}`); + } + if (!text) { + throw new Error(`empty response from opencode-bridge/${label}`); + } + try { + return JSON.parse(text) as T; + } catch { + throw new Error( + `non-JSON response from opencode-bridge/${label} (status ${res.status})` + ); + } +} + +/** GET /opencode-bridge/sessions/all — every session on OpenCode Serve. */ +export async function callOpenCodeListAllSessions( + serverSettings: ServerConnection.ISettings +): Promise { + const url = URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'all' + ); + return _jsonRequest( + url, + { method: 'GET' }, + serverSettings, + 'sessions/all' + ); +} + +/** GET /opencode-bridge/sessions/notebook?notebook=... — sessions bound + * to this notebook (enriched with metadata, marked which is active). */ +export async function callOpenCodeListNotebookSessions( + notebookPath: string, + serverSettings: ServerConnection.ISettings +): Promise { + const url = + URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'notebook' + ) + + '?notebook=' + + encodeURIComponent(notebookPath); + return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/notebook'); +} + +/** POST /opencode-bridge/sessions/notebook?notebook=...&title=... — + * create a brand-new session on OpenCode, bind it, set as active. */ +export async function callOpenCodeCreateNotebookSession( + notebookPath: string, + title: string | undefined, + serverSettings: ServerConnection.ISettings +): Promise { + const url = + URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'notebook' + ) + + '?notebook=' + + encodeURIComponent(notebookPath) + + (title ? '&title=' + encodeURIComponent(title) : ''); + return _jsonRequest( + url, + { method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }, + serverSettings, + 'sessions/notebook (POST)' + ); +} + +/** PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=... — + * bind an existing sessionId to this notebook (no OpenCode round-trip) + * and set it as active. */ +export async function callOpenCodeBindExistingSession( + notebookPath: string, + sessionId: string, + serverSettings: ServerConnection.ISettings +): Promise { + const url = + URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'notebook' + ) + + '?notebook=' + + encodeURIComponent(notebookPath) + + '&sessionId=' + + encodeURIComponent(sessionId); + return _jsonRequest( + url, + { method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }, + serverSettings, + 'sessions/notebook (PUT)' + ); +} + +/** DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=... — + * delete a specific bound session on OpenCode AND remove its binding. */ +export async function callOpenCodeDeleteNotebookSession( + notebookPath: string, + sessionId: string, + serverSettings: ServerConnection.ISettings +): Promise { + const url = + URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'notebook' + ) + + '?notebook=' + + encodeURIComponent(notebookPath) + + '&sessionId=' + + encodeURIComponent(sessionId); + return _jsonRequest( + url, + { method: 'DELETE' }, + serverSettings, + 'sessions/notebook (DELETE)' + ); +} + +/** GET /opencode-bridge/sessions/active?notebook=... — get the active + * sessionId (or null). */ +export async function callOpenCodeGetActiveSession( + notebookPath: string, + serverSettings: ServerConnection.ISettings +): Promise { + const url = + URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'active' + ) + + '?notebook=' + + encodeURIComponent(notebookPath); + return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/active'); +} + +/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=... — + * switch which bound session is active. sessionId MUST already be + * in the bound list. */ +export async function callOpenCodeSetActiveSession( + notebookPath: string, + sessionId: string, + serverSettings: ServerConnection.ISettings +): Promise { + const url = + URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'active' + ) + + '?notebook=' + + encodeURIComponent(notebookPath) + + '&sessionId=' + + encodeURIComponent(sessionId); + return _jsonRequest( + url, + { method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } }, + serverSettings, + 'sessions/active (PUT)' + ); +} + +/** DELETE /opencode-bridge/sessions/active?notebook=... — unbind the + * active session (next /edit lazy-creates a new one). Does NOT delete + * the session on OpenCode Serve. */ +export async function callOpenCodeUnbindActiveSession( + notebookPath: string, + serverSettings: ServerConnection.ISettings +): Promise { + const url = + URLExt.join( + serverSettings.baseUrl, + 'opencode-bridge', + 'sessions', + 'active' + ) + + '?notebook=' + + encodeURIComponent(notebookPath); + return _jsonRequest( + url, + { method: 'DELETE' }, + serverSettings, + 'sessions/active (DELETE)' + ); +} + export interface OpenCodeEventHandlers { onEvent: (event: OpenCodeEvent) => void; onError?: (err: Error) => void; diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts index cc070ed..e19d061 100644 --- a/src/components/opencode_cell_actions.ts +++ b/src/components/opencode_cell_actions.ts @@ -23,10 +23,14 @@ import { ServerConnection } from '@jupyterlab/services'; import { Widget } from '@lumino/widgets'; import { + callOpenCodeBindExistingSession, + callOpenCodeCreateNotebookSession, callOpenCodeEdit, + callOpenCodeListAllSessions, callOpenCodeReplyPermission, callOpenCodeReplyQuestion, callOpenCodeSessionMessages, + callOpenCodeSetActiveSession, subscribeOpenCodeEvents, type OpenCodeEventSubscription } from '../api/opencode_client'; @@ -181,6 +185,65 @@ export class OpenCodeCellActions extends Widget { // us to close the SSE stream + restore the input. We no longer // parse event types here. this._closeSse(); + }, + onListSessions: async () => { + if (!_serverSettings) return []; + try { + const r = await callOpenCodeListAllSessions(_serverSettings); + return r.sessions || []; + } catch (e) { + console.warn('opencode_bridge: list all sessions failed', e); + return []; + } + }, + onCreateSession: async () => { + if (!_serverSettings || !this._context?.notebookPath) { + throw new Error('notebook not ready'); + } + // Tear down any in-flight SSE first — the old session is + // being replaced. + this._closeSse(); + const r = await callOpenCodeCreateNotebookSession( + this._context.notebookPath, + undefined, + _serverSettings + ); + if (!r.ok || !r.sessionId) { + throw new Error(r.error || 'create_session failed'); + } + // Sync the prompt's view of "current session id". + this._prompt?.setSessionId(r.sessionId); + return r.sessionId; + }, + onSwitchSession: async (sessionId: string) => { + if (!_serverSettings || !this._context?.notebookPath) { + return false; + } + // Closing the SSE first ensures the old session's events stop + // coming in while we transition. + this._closeSse(); + const r = await callOpenCodeSetActiveSession( + this._context.notebookPath, + sessionId, + _serverSettings + ); + if (!r.ok) { + return false; + } + // The session was not necessarily bound to this notebook yet. + // Bind it (idempotent; the server's PUT /sessions/notebook + // also sets it as active, so we don't need a separate call). + await callOpenCodeBindExistingSession( + this._context.notebookPath, + sessionId, + _serverSettings + ); + this._prompt?.setSessionId(sessionId); + return true; + }, + onReloadHistory: async () => { + if (!this._context?.notebookPath) return; + await this._refreshHistory(this._context.notebookPath); } }); Widget.attach(prompt, this._cell.node); @@ -192,6 +255,11 @@ export class OpenCodeCellActions extends Widget { if (notebookPath) { void this._refreshHistory(notebookPath); } + + // Populate the session selector dropdown (loads OpenCode's global + // session list async; if the host didn't wire up the callbacks, + // the row is simply not rendered). + prompt.initialize(); } private async _refreshHistory(notebookPath: string): Promise { diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts index d0785a4..9d4d984 100644 --- a/src/components/opencode_inline_prompt.ts +++ b/src/components/opencode_inline_prompt.ts @@ -28,7 +28,8 @@ import { marked } from 'marked'; import type { OpenCodeEvent, OpenCodeMessage, - OpenCodeProvidersResponse + OpenCodeProvidersResponse, + OpenCodeSessionMeta } from '../types'; import { loadModelSelection, @@ -65,8 +66,22 @@ export interface IOpenCodeInlinePromptOptions { * the prompt keeps the cell action from re-parsing event types. */ onStreamEnd?: () => void; + // ----- session management (multi-session UI) ----- + /** List ALL OpenCode sessions (for the session-picker dropdown). + * Returns the raw array; the prompt picks the active one out. */ + onListSessions?: () => Promise; + /** Create a brand-new session, bind it to the current notebook, and + * return its id (which becomes the new active session). */ + onCreateSession?: () => Promise; + /** Switch the active session. Returns true on success. */ + onSwitchSession?: (sessionId: string) => Promise; + /** Reload the message history for the currently-active session. + * Called after a create / switch so the panel reflects the new + * conversation's scrollback. */ + onReloadHistory?: () => Promise; } + interface FlatProvider { id: string; name?: string; @@ -129,6 +144,10 @@ export class OpenCodeInlinePrompt extends Widget { // Streaming state. _sessionId is set by the cell action after a successful // /edit response; applyEvent() drops events that don't belong to it. private _sessionId: string | null = null; + // Multi-session UI: the dropdown and its row (null when the host + // did not wire up the session callbacks). + private _sessionRow: HTMLDivElement | null = null; + private _sessionSelect: HTMLSelectElement | null = null; // The current assistant message DOM node (re-rendered as markdown on // every text delta so the user sees a properly formatted response in // real-time, not raw ```fence``` source). @@ -224,6 +243,66 @@ export class OpenCodeInlinePrompt extends Widget { actions.appendChild(this._sendBtn); actions.appendChild(this._cancelBtn); + // Session selector (top of the panel, above provider/model). + // Only rendered when the host has provided the multi-session + // callbacks. The active session id is the option currently + // selected; switching the dropdown calls onSwitchSession, picking + // "+ 新建会话" calls onCreateSession, and the refresh button + // re-fetches the global session list. + if ( + this._options.onListSessions && + this._options.onCreateSession && + this._options.onSwitchSession + ) { + this._sessionRow = document.createElement('div'); + this._sessionRow.className = 'opencode-prompt-sessions'; + + const sLabel = document.createElement('label'); + const sSpan = document.createElement('span'); + sSpan.textContent = 'Session:'; + sLabel.appendChild(sSpan); + + this._sessionSelect = document.createElement('select'); + this._sessionSelect.className = 'opencode-session-select'; + // Single placeholder until the list loads. + this._sessionSelect.appendChild( + this._mkSessionOption('', '加载中…', false) + ); + this._sessionSelect.disabled = true; + this._sessionSelect.addEventListener('change', () => { + const v = this._sessionSelect!.value; + if (v === '__new__') { + void this._handleCreateSession(); + } else if (v && v !== this._sessionId) { + void this._handleSwitchSession(v); + } + }); + sLabel.appendChild(this._sessionSelect); + this._sessionRow.appendChild(sLabel); + + const newBtn = document.createElement('button'); + newBtn.type = 'button'; + newBtn.className = 'opencode-session-btn-new'; + newBtn.textContent = '+ 新建'; + newBtn.title = '在 OpenCode 上创建新会话并绑定到当前 notebook'; + newBtn.addEventListener('click', () => { + void this._handleCreateSession(); + }); + this._sessionRow.appendChild(newBtn); + + const refreshBtn = document.createElement('button'); + refreshBtn.type = 'button'; + refreshBtn.className = 'opencode-session-btn-refresh'; + refreshBtn.textContent = '🔄'; + refreshBtn.title = '刷新 OpenCode 全局会话列表'; + refreshBtn.addEventListener('click', () => { + void this._refreshSessionList(); + }); + this._sessionRow.appendChild(refreshBtn); + + this.node.appendChild(this._sessionRow); + } + if (this._providerSelect || this._modelSelect) { const row = document.createElement('div'); row.className = 'opencode-prompt-providers'; @@ -330,6 +409,159 @@ export class OpenCodeInlinePrompt extends Widget { return sel; } + // ---------- session selector (multi-session UI) ---------- + + /** + * Repopulate the session dropdown with the given list. The currently + * active session (this._sessionId, set by setSessionId or by + * _handleSwitchSession) is marked selected. + */ + private _populateSessionSelect(sessions: OpenCodeSessionMeta[]): void { + if (!this._sessionSelect) { + return; + } + // Clear existing options (drop the loading placeholder). + while (this._sessionSelect.firstChild) { + this._sessionSelect.removeChild(this._sessionSelect.firstChild); + } + for (const s of sessions) { + const label = s.title + ? `${s.title} (${s.id.slice(0, 8)}…)` + : `${s.id.slice(0, 8)}…`; + this._sessionSelect.appendChild( + this._mkSessionOption(s.id, label, s.id === this._sessionId) + ); + } + // Always-available "create new" sentinel. + this._sessionSelect.appendChild( + this._mkSessionOption('__new__', '+ 新建会话…', false) + ); + // Reflect the current active id (or "" if none). + this._sessionSelect.value = this._sessionId || ''; + if (!this._sessionId) { + // No active session; show a hint via the first option being empty. + this._sessionSelect.value = ''; + } + this._sessionSelect.disabled = false; + } + + private _mkSessionOption( + value: string, + label: string, + selected: boolean + ): HTMLOptionElement { + const o = document.createElement('option'); + o.value = value; + o.textContent = label; + if (selected) { + o.selected = true; + } + return o; + } + + /** + * Fetch the global session list from the host and repopulate the + * dropdown. Called on prompt show, on "刷新" click, and after + * every create / switch. Errors are non-fatal — the dropdown just + * keeps whatever it had (or stays empty). + */ + async _refreshSessionList(): Promise { + if (!this._options.onListSessions) { + return; + } + try { + const sessions = await this._options.onListSessions(); + this._populateSessionSelect(sessions); + } catch (e) { + console.warn('opencode_bridge: failed to list sessions', e); + } + } + + /** + * "新建" button or dropdown's __new__ entry. Creates a session, + * binds it, sets as active, refreshes the dropdown, and reloads the + * message history for the new (empty) session. + */ + private async _handleCreateSession(): Promise { + if (!this._options.onCreateSession) { + return; + } + if (this._sessionSelect) { + this._sessionSelect.disabled = true; + } + try { + const newId = await this._options.onCreateSession(); + this._sessionId = newId; + // Drop any in-progress streaming DOM (the new session is empty). + this._resetStreamPointers(); + this._historyEl.textContent = ''; + // Re-fetch the global list so the new id is in the dropdown + // and marked active. + await this._refreshSessionList(); + // Reload history for the new session (empty list, but this + // also re-syncs the prompt's view of "current state"). + if (this._options.onReloadHistory) { + try { + await this._options.onReloadHistory(); + } catch (e) { + console.warn('opencode_bridge: reload history failed', e); + } + } + } catch (e) { + Notification.error('创建会话失败: ' + (e as Error).message); + if (this._sessionSelect) { + this._sessionSelect.disabled = false; + } + } + } + + /** + * User picked a different session from the dropdown. Asks the host + * to bind + set active; on success, refreshes history. + */ + private async _handleSwitchSession(sessionId: string): Promise { + if (!this._options.onSwitchSession) { + return; + } + if (this._sessionSelect) { + this._sessionSelect.disabled = true; + } + try { + const ok = await this._options.onSwitchSession(sessionId); + if (ok) { + this._sessionId = sessionId; + this._resetStreamPointers(); + this._historyEl.textContent = ''; + if (this._options.onReloadHistory) { + try { + await this._options.onReloadHistory(); + } catch (e) { + console.warn('opencode_bridge: reload history failed', e); + } + } + // Re-mark the now-active option as selected. + if (this._sessionSelect) { + this._sessionSelect.value = sessionId; + } + } else { + // Roll back the select to the previous value. + if (this._sessionSelect) { + this._sessionSelect.value = this._sessionId || ''; + } + Notification.error('切换会话失败'); + } + } catch (e) { + Notification.error('切换会话失败: ' + (e as Error).message); + if (this._sessionSelect) { + this._sessionSelect.value = this._sessionId || ''; + } + } finally { + if (this._sessionSelect) { + this._sessionSelect.disabled = false; + } + } + } + /** * Persist the current (providerSelect.value, modelSelect.value) for * this notebook. No-op if the prompt has no notebookPath, no @@ -356,6 +588,12 @@ export class OpenCodeInlinePrompt extends Widget { if (this._modelSelect) { this._modelSelect.disabled = disabled; } + if (this._sessionSelect) { + // Don't disable the select while streaming — the user can still + // switch to a different session to inspect it. The cell action + // closes the SSE on switch. + this._sessionSelect.disabled = false; + } } /** Clear the user input textarea. */ @@ -369,12 +607,34 @@ export class OpenCodeInlinePrompt extends Widget { */ setSessionId(sessionId: string | null): void { this._sessionId = sessionId; + if (this._sessionSelect) { + // Reflect the new active id in the dropdown without rebuilding it. + if (sessionId) { + const has = Array.from(this._sessionSelect.options).some( + o => o.value === sessionId + ); + if (has) { + this._sessionSelect.value = sessionId; + } + } else { + this._sessionSelect.value = ''; + } + } if (sessionId) { // Fresh turn: drop any in-progress stream DOM from a previous session. this._resetStreamPointers(); } } + /** + * First-time wiring after the prompt is constructed. Loads the global + * session list into the dropdown. Call from the host (cell action) + * right after constructing the widget. + */ + initialize(): void { + void this._refreshSessionList(); + } + /** * Render the current session's static messages (loaded once from * /session-messages when the panel opens). Subsequent assistant turns diff --git a/src/types.ts b/src/types.ts index bc116ff..c8ec533 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,6 +82,54 @@ export interface OpenCodeMessagesResponse { messages: OpenCodeMessage[]; } +// --------------------------------------------------------------------------- +// Multi-session management types (1 notebook can bind N sessions, one active) +// --------------------------------------------------------------------------- + +/** One session on the OpenCode Serve side (returned by GET /session). */ +export interface OpenCodeSessionMeta { + id: string; + title?: string; + createdAt?: number; + updatedAt?: number; + [k: string]: unknown; +} + +/** Response from GET /opencode-bridge/sessions/all. */ +export interface OpenCodeAllSessionsResponse { + ok: boolean; + sessions: OpenCodeSessionMeta[]; + error?: string; +} + +/** One session bound to a notebook (returned by GET /sessions/notebook). */ +export interface OpenCodeNotebookSession { + sessionId: string; + title?: string; + createdAt?: number; + updatedAt?: number; + isActive: boolean; +} + +/** Response from GET /opencode-bridge/sessions/notebook?notebook=... */ +export interface OpenCodeNotebookSessionsResponse { + ok: boolean; + notebookPath: string; + activeSessionId: string | null; + sessions: OpenCodeNotebookSession[]; + error?: string; +} + +/** Response from POST/PUT /sessions/notebook and PUT /sessions/active. */ +export interface OpenCodeSessionOpResponse { + ok: boolean; + notebookPath: string; + sessionId?: string; + activeSessionId?: string | null; + deleted?: boolean; + error?: string; +} + /** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers). * Each Provider.models is a Record keyed by modelID (NOT an array). */ export interface OpenCodeModel { diff --git a/style/base.css b/style/base.css index cb66b59..1ced6b4 100644 --- a/style/base.css +++ b/style/base.css @@ -339,3 +339,52 @@ .opencode-inline-prompt .opencode-q-submit:hover { background: var(--jp-layout-color3, #e0e0e0); } + +/* ------------------------------------------------------------------ + Session selector (multi-session UI) — top of the inline prompt + ------------------------------------------------------------------ */ +.opencode-inline-prompt .opencode-prompt-sessions { + display: flex; + gap: 6px; + align-items: center; + flex-wrap: wrap; + margin: 2px 0 4px; +} +.opencode-inline-prompt .opencode-prompt-sessions label { + display: flex; + align-items: center; + gap: 4px; + font-size: var(--jp-ui-font-size1); + color: var(--jp-ui-font-color1, #333); + flex: 1; + min-width: 0; +} +.opencode-inline-prompt .opencode-session-select { + flex: 1; + min-width: 0; + padding: 2px 4px; + font-size: var(--jp-ui-font-size1); + font-family: inherit; + border: 1px solid var(--jp-border-color2, #ccc); + border-radius: 3px; + background: var(--jp-layout-color1, #fff); +} +.opencode-inline-prompt .opencode-session-btn-new, +.opencode-inline-prompt .opencode-session-btn-refresh { + border: 1px solid var(--jp-border-color2, #ccc); + background: var(--jp-layout-color2, #f0f0f0); + padding: 2px 8px; + border-radius: 3px; + cursor: pointer; + font-size: var(--jp-ui-font-size0, 11px); + color: var(--jp-ui-font-color1, #333); +} +.opencode-inline-prompt .opencode-session-btn-new:hover, +.opencode-inline-prompt .opencode-session-btn-refresh:hover { + background: var(--jp-layout-color3, #e0e0e0); +} +.opencode-inline-prompt .opencode-session-btn-new { + background: #dbeafe; + color: #1e40af; + border-color: #93c5fd; +}