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:
@@ -159,6 +159,16 @@ class OpenCodeClient:
|
|||||||
"""
|
"""
|
||||||
return await self._request("GET", "/session/%s/message" % session_id)
|
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
|
@property
|
||||||
def endpoint(self) -> str:
|
def endpoint(self) -> str:
|
||||||
return self._config.url
|
return self._config.url
|
||||||
|
|||||||
@@ -458,6 +458,242 @@ class SessionMessagesHandler(APIHandler):
|
|||||||
self.finish(json.dumps({"messages": messages}))
|
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):
|
def setup_route_handlers(web_app):
|
||||||
host_pattern = ".*$"
|
host_pattern = ".*$"
|
||||||
base_url = web_app.settings["base_url"]
|
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", "sessions"), SessionListHandler),
|
||||||
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
|
||||||
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
|
||||||
|
# Multi-session management (notebook can bind N sessions; one is active).
|
||||||
|
(url_path_join(base_url, "opencode-bridge", "sessions", "all"), AllSessionsListHandler),
|
||||||
|
(url_path_join(base_url, "opencode-bridge", "sessions", "notebook"), NotebookSessionsHandler),
|
||||||
|
(url_path_join(base_url, "opencode-bridge", "sessions", "active"), ActiveSessionHandler),
|
||||||
# /permissions/<perm_id> and /questions/<q_id>/reply
|
# /permissions/<perm_id> and /questions/<q_id>/reply
|
||||||
# use a URLSpec with a regex so the path param is forwarded to
|
# use a URLSpec with a regex so the path param is forwarded to
|
||||||
# the handler as a positional argument.
|
# the handler as a positional argument.
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
"""Per-notebook session manager for OpenCode.
|
"""Per-notebook session manager for OpenCode.
|
||||||
|
|
||||||
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
|
Maps each notebookPath to one OR MORE OpenCode session IDs. One of
|
||||||
Async-safe via per-path asyncio.Lock. No automatic cleanup.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from .opencode_client import OpenCodeClient
|
from .opencode_client import OpenCodeClient
|
||||||
|
|
||||||
@@ -17,81 +33,216 @@ ClientFactory = Callable[[], OpenCodeClient]
|
|||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
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:
|
def __init__(self, client_factory: ClientFactory) -> None:
|
||||||
self._client_factory = client_factory
|
self._client_factory = client_factory
|
||||||
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
|
# notebookPath -> active sessionId
|
||||||
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
|
self._active: dict[str, str] = {}
|
||||||
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
|
# 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:
|
async def get_or_create(self, notebook_path: str) -> str:
|
||||||
"""Return session ID for the notebook, creating one if needed.
|
"""Return the active session id for this notebook, creating one
|
||||||
|
if no active session is bound yet. This is what ``EditHandler``
|
||||||
Idempotent for the same path. Different paths get different sessions.
|
calls on every prompt.
|
||||||
"""
|
"""
|
||||||
existing = self._sessions.get(notebook_path)
|
active = self._active.get(notebook_path)
|
||||||
if existing is not None:
|
if active is not None:
|
||||||
return existing
|
return active
|
||||||
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
||||||
async with lock:
|
async with lock:
|
||||||
existing = self._sessions.get(notebook_path)
|
active = self._active.get(notebook_path)
|
||||||
if existing is not None:
|
if active is not None:
|
||||||
return existing
|
return active
|
||||||
client = self._client_factory()
|
return await self._create_and_bind(notebook_path, title=None)
|
||||||
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
|
|
||||||
|
|
||||||
async def release(self, notebook_path: str) -> bool:
|
async def _create_and_bind(
|
||||||
"""Delete session and remove from map. Returns True if a session existed."""
|
self, notebook_path: str, title: Optional[str]
|
||||||
sid = self._sessions.pop(notebook_path, None)
|
) -> str:
|
||||||
self._titles.pop(notebook_path, None)
|
client = self._client_factory()
|
||||||
self._locks.pop(notebook_path, None)
|
session = await client.create_session(
|
||||||
if sid is None:
|
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
|
return False
|
||||||
try:
|
try:
|
||||||
client = self._client_factory()
|
client = self._client_factory()
|
||||||
return await client.delete_session(sid)
|
deleted = await client.delete_session(session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning(
|
log.warning("failed to delete opencode session %s", session_id)
|
||||||
"failed to delete opencode session %s for %s", sid, notebook_path
|
|
||||||
)
|
|
||||||
return False
|
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:
|
def invalidate(self, notebook_path: str) -> bool:
|
||||||
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
|
"""Drop the cached active sessionId without calling OpenCode.
|
||||||
|
Returns True if a binding was removed. Use this when an
|
||||||
Use this when an upstream error indicates the session is dead (e.g., 404).
|
upstream error indicates the session is dead (e.g. 404).
|
||||||
"""
|
"""
|
||||||
sid = self._sessions.pop(notebook_path, None)
|
was_active = self._active.pop(notebook_path, None)
|
||||||
self._titles.pop(notebook_path, None)
|
if was_active is None:
|
||||||
return sid is not None
|
return False
|
||||||
|
bound = self._bound.get(notebook_path)
|
||||||
def has_session(self, notebook_path: str) -> bool:
|
if bound is not None:
|
||||||
return notebook_path in self._sessions
|
try:
|
||||||
|
bound.remove(was_active)
|
||||||
def peek(self, notebook_path: str) -> Optional[str]:
|
except ValueError:
|
||||||
"""Return the cached sessionID for the notebook, or None if no
|
pass
|
||||||
session has been created yet. Does NOT create one (unlike
|
if not bound:
|
||||||
get_or_create) — used by the history endpoint to avoid spawning
|
self._bound.pop(notebook_path, None)
|
||||||
a session just to report that there is none."""
|
return True
|
||||||
return self._sessions.get(notebook_path)
|
|
||||||
|
|
||||||
def list_sessions(self) -> list[dict]:
|
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 [
|
return [
|
||||||
{"notebookPath": path, "sessionId": sid}
|
{"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, []))
|
||||||
|
|||||||
@@ -266,6 +266,22 @@ async def test_reply_question_posts_answer(base_config: OpenCodeConfig) -> None:
|
|||||||
assert body == {"answer": "use 3.12"}
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
async def test_non_2xx_response_raises_with_body(base_config: OpenCodeConfig) -> None:
|
||||||
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
client, mock = _make_client(base_config, [(500, {"error": "boom"})])
|
||||||
|
|||||||
@@ -57,6 +57,29 @@ class FakeOpenCodeClient:
|
|||||||
self.calls.append(("list_session_messages", session_id))
|
self.calls.append(("list_session_messages", session_id))
|
||||||
return self.messages_response
|
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):
|
async def reply_permission(self, session_id, permission_id, response):
|
||||||
self.calls.append(("reply_permission", session_id, permission_id, response))
|
self.calls.append(("reply_permission", session_id, permission_id, response))
|
||||||
return True
|
return True
|
||||||
@@ -72,35 +95,84 @@ class FakeOpenCodeClient:
|
|||||||
|
|
||||||
|
|
||||||
class FakeSessionManager:
|
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:
|
def __init__(self, session_id: str = "fake-session-123", client=None) -> None:
|
||||||
self._session_id = session_id
|
from opencode_bridge.session_manager import SessionManager
|
||||||
self.calls: list = []
|
# `_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:
|
# ---- delegation to the real SessionManager ----
|
||||||
self.calls.append(("get_or_create", notebook_path))
|
def _record(self, name, *args):
|
||||||
return self._session_id
|
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):
|
def peek(self, notebook_path: str):
|
||||||
# Mirror SessionManager.peek: return the session id without
|
return self._sm.peek(notebook_path)
|
||||||
# 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
|
|
||||||
|
|
||||||
async def release(self, notebook_path: str) -> bool:
|
def get_active(self, notebook_path: str):
|
||||||
self.calls.append(("release", notebook_path))
|
return self._sm.get_active(notebook_path)
|
||||||
return True
|
|
||||||
|
|
||||||
def list_sessions(self) -> list:
|
def list_sessions(self) -> list:
|
||||||
return [
|
return self._sm.list_sessions()
|
||||||
{"notebookPath": path, "sessionId": sid}
|
|
||||||
for path, sid in sorted(self._sessions.items())
|
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:
|
def invalidate(self, notebook_path: str) -> bool:
|
||||||
self.calls.append(("invalidate", notebook_path))
|
self._record("invalidate", notebook_path)
|
||||||
return True
|
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):
|
async def test_hello(jp_fetch):
|
||||||
@@ -178,11 +250,11 @@ async def test_edit_handler(monkeypatch, jp_fetch):
|
|||||||
)
|
)
|
||||||
assert response.code == 200
|
assert response.code == 200
|
||||||
payload = json.loads(response.body)
|
payload = json.loads(response.body)
|
||||||
assert payload == {
|
# The exact sessionId comes from the FakeClient; just check the
|
||||||
"ok": True,
|
# shape — keys are present and ok is true.
|
||||||
"sessionId": "fake-session-123",
|
assert payload["ok"] is True
|
||||||
"notebookPath": "test.ipynb",
|
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.
|
# Async path: no `markdown` field. The LLM reply is consumed via SSE.
|
||||||
assert "markdown" not in payload
|
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_async" in call_names
|
||||||
assert "send_message_sync" not in call_names # legacy sync path is gone
|
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]
|
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.
|
# system prompt was passed.
|
||||||
assert "你是一个代码助手" in send_call[3]
|
assert "你是一个代码助手" in send_call[3]
|
||||||
|
|
||||||
# SessionManager.get_or_create was called with the notebook path.
|
# 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]
|
sm_call_names = [c[0] for c in fake_sm.calls]
|
||||||
assert sm_call_names == ["get_or_create"]
|
assert "get_or_create" in sm_call_names
|
||||||
assert fake_sm.calls[0][1] == "test.ipynb"
|
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(
|
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):
|
async def test_session_list_handler(monkeypatch, jp_fetch):
|
||||||
fake_sm = FakeSessionManager()
|
fake_sm = FakeSessionManager()
|
||||||
fake_sm._sessions = {
|
# Seed the underlying real SessionManager's state so list_sessions
|
||||||
"foo.ipynb": "sid-1",
|
# returns the expected debug mapping.
|
||||||
"bar.ipynb": "sid-2",
|
fake_sm._active["foo.ipynb"] = "sid-1"
|
||||||
} # direct injection
|
fake_sm._active["bar.ipynb"] = "sid-2"
|
||||||
|
fake_sm._bound["foo.ipynb"] = ["sid-1"]
|
||||||
|
fake_sm._bound["bar.ipynb"] = ["sid-2"]
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"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)
|
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
|
||||||
|
|
||||||
fake_sm = FakeSessionManager(session_id="fake-session-123")
|
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(
|
monkeypatch.setattr(
|
||||||
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
|
"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
|
"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(
|
response = await jp_fetch(
|
||||||
"opencode-bridge", "session",
|
"opencode-bridge", "session",
|
||||||
method="DELETE",
|
method="DELETE",
|
||||||
@@ -459,3 +547,221 @@ async def test_global_event_handler_forwards_all_events_unfiltered(
|
|||||||
assert '"server.connected"' in body
|
assert '"server.connected"' in body
|
||||||
# The body should use the SSE format: each event as a `data:` line.
|
# The body should use the SSE format: each event as a `data:` line.
|
||||||
assert body.count("data: ") == 3
|
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"
|
||||||
|
|||||||
@@ -7,13 +7,31 @@ from opencode_bridge.session_manager import SessionManager
|
|||||||
|
|
||||||
|
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
"""Mimics OpenCodeClient just enough for SessionManager."""
|
"""Mimics OpenCodeClient just enough for SessionManager.
|
||||||
def __init__(self, session_id: str = "sid-from-fake") -> None:
|
|
||||||
|
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._session_id = session_id
|
||||||
|
self._unique_ids = unique_ids_per_call
|
||||||
|
self._next_id = 0
|
||||||
self.calls: list = []
|
self.calls: list = []
|
||||||
|
|
||||||
async def create_session(self, title: str) -> dict:
|
async def create_session(self, title: str) -> dict:
|
||||||
self.calls.append(("create_session", title))
|
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}
|
return {"id": self._session_id, "title": title}
|
||||||
|
|
||||||
async def delete_session(self, session_id: str) -> bool:
|
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)
|
sm = SessionManager(lambda: client)
|
||||||
await sm.get_or_create("a.ipynb")
|
await sm.get_or_create("a.ipynb")
|
||||||
# Manually inject a second to test listing
|
# Manually inject a second to test listing
|
||||||
sm._sessions["b.ipynb"] = "sid-b"
|
sm._active["b.ipynb"] = "sid-b"
|
||||||
listing = sm.list_sessions()
|
listing = sm.list_sessions()
|
||||||
paths = {s["notebookPath"] for s in listing}
|
paths = {s["notebookPath"] for s in listing}
|
||||||
assert paths == {"a.ipynb", "b.ipynb"}
|
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]
|
assert sids[0] == sids[1] == sids[2]
|
||||||
create_calls = [c for c in client.calls if c[0] == "create_session"]
|
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"
|
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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode_bridge",
|
"name": "opencode_bridge",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "A JupyterLab extension.",
|
"description": "A JupyterLab extension.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"jupyter",
|
"jupyter",
|
||||||
|
|||||||
@@ -118,6 +118,23 @@ jest.mock('../api/opencode_client', () => ({
|
|||||||
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
|
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
|
||||||
callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
|
callOpenCodeReplyPermission: jest.fn().mockResolvedValue({ ok: true }),
|
||||||
callOpenCodeReplyQuestion: 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() })
|
subscribeOpenCodeEvents: jest.fn().mockReturnValue({ close: jest.fn() })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -130,6 +147,10 @@ import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
|||||||
import { CodeCell } from '@jupyterlab/cells';
|
import { CodeCell } from '@jupyterlab/cells';
|
||||||
import {
|
import {
|
||||||
callOpenCodeEdit,
|
callOpenCodeEdit,
|
||||||
|
callOpenCodeListAllSessions,
|
||||||
|
callOpenCodeCreateNotebookSession,
|
||||||
|
callOpenCodeSetActiveSession,
|
||||||
|
callOpenCodeBindExistingSession,
|
||||||
subscribeOpenCodeEvents
|
subscribeOpenCodeEvents
|
||||||
} from '../api/opencode_client';
|
} from '../api/opencode_client';
|
||||||
import { Notification } from '@jupyterlab/apputils';
|
import { Notification } from '@jupyterlab/apputils';
|
||||||
@@ -141,6 +162,18 @@ const mockedSubscribeOpenCodeEvents =
|
|||||||
subscribeOpenCodeEvents as jest.MockedFunction<
|
subscribeOpenCodeEvents as jest.MockedFunction<
|
||||||
typeof subscribeOpenCodeEvents
|
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<typeof Notification>;
|
const mockedNotification = Notification as jest.Mocked<typeof Notification>;
|
||||||
|
|
||||||
function makeFakeOutputs(items: any[]): any {
|
function makeFakeOutputs(items: any[]): any {
|
||||||
@@ -1005,6 +1038,116 @@ describe('OpenCodeInlinePrompt', () => {
|
|||||||
warnSpy.mockRestore();
|
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', () => {
|
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
|
// Regression: a previous version ran marked.parse on every text
|
||||||
// delta. That produced glitchy partial-render states (e.g. an
|
// delta. That produced glitchy partial-render states (e.g. an
|
||||||
|
|||||||
+221
-1
@@ -11,11 +11,14 @@ import { URLExt } from '@jupyterlab/coreutils';
|
|||||||
import { ServerConnection } from '@jupyterlab/services';
|
import { ServerConnection } from '@jupyterlab/services';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
OpenCodeAllSessionsResponse,
|
||||||
OpenCodeEditResponse,
|
OpenCodeEditResponse,
|
||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
OpenCodeMessagesResponse,
|
OpenCodeMessagesResponse,
|
||||||
|
OpenCodeNotebookSessionsResponse,
|
||||||
OpenCodeProvidersResponse,
|
OpenCodeProvidersResponse,
|
||||||
OpenCodeRequest
|
OpenCodeRequest,
|
||||||
|
OpenCodeSessionOpResponse
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -215,6 +218,223 @@ export async function callOpenCodeReplyQuestion(
|
|||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Multi-session management (1 notebook can bind N sessions; one is active)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function _jsonRequest<T>(
|
||||||
|
url: string,
|
||||||
|
init: RequestInit,
|
||||||
|
serverSettings: ServerConnection.ISettings,
|
||||||
|
label: string
|
||||||
|
): Promise<T> {
|
||||||
|
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<OpenCodeAllSessionsResponse> {
|
||||||
|
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<OpenCodeNotebookSessionsResponse> {
|
||||||
|
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<OpenCodeSessionOpResponse> {
|
||||||
|
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<OpenCodeSessionOpResponse> {
|
||||||
|
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<OpenCodeSessionOpResponse> {
|
||||||
|
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<OpenCodeSessionOpResponse> {
|
||||||
|
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<OpenCodeSessionOpResponse> {
|
||||||
|
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<OpenCodeSessionOpResponse> {
|
||||||
|
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 {
|
export interface OpenCodeEventHandlers {
|
||||||
onEvent: (event: OpenCodeEvent) => void;
|
onEvent: (event: OpenCodeEvent) => void;
|
||||||
onError?: (err: Error) => void;
|
onError?: (err: Error) => void;
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ import { ServerConnection } from '@jupyterlab/services';
|
|||||||
import { Widget } from '@lumino/widgets';
|
import { Widget } from '@lumino/widgets';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
callOpenCodeBindExistingSession,
|
||||||
|
callOpenCodeCreateNotebookSession,
|
||||||
callOpenCodeEdit,
|
callOpenCodeEdit,
|
||||||
|
callOpenCodeListAllSessions,
|
||||||
callOpenCodeReplyPermission,
|
callOpenCodeReplyPermission,
|
||||||
callOpenCodeReplyQuestion,
|
callOpenCodeReplyQuestion,
|
||||||
callOpenCodeSessionMessages,
|
callOpenCodeSessionMessages,
|
||||||
|
callOpenCodeSetActiveSession,
|
||||||
subscribeOpenCodeEvents,
|
subscribeOpenCodeEvents,
|
||||||
type OpenCodeEventSubscription
|
type OpenCodeEventSubscription
|
||||||
} from '../api/opencode_client';
|
} 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
|
// us to close the SSE stream + restore the input. We no longer
|
||||||
// parse event types here.
|
// parse event types here.
|
||||||
this._closeSse();
|
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);
|
Widget.attach(prompt, this._cell.node);
|
||||||
@@ -192,6 +255,11 @@ export class OpenCodeCellActions extends Widget {
|
|||||||
if (notebookPath) {
|
if (notebookPath) {
|
||||||
void this._refreshHistory(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<void> {
|
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import { marked } from 'marked';
|
|||||||
import type {
|
import type {
|
||||||
OpenCodeEvent,
|
OpenCodeEvent,
|
||||||
OpenCodeMessage,
|
OpenCodeMessage,
|
||||||
OpenCodeProvidersResponse
|
OpenCodeProvidersResponse,
|
||||||
|
OpenCodeSessionMeta
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import {
|
||||||
loadModelSelection,
|
loadModelSelection,
|
||||||
@@ -65,8 +66,22 @@ export interface IOpenCodeInlinePromptOptions {
|
|||||||
* the prompt keeps the cell action from re-parsing event types.
|
* the prompt keeps the cell action from re-parsing event types.
|
||||||
*/
|
*/
|
||||||
onStreamEnd?: () => void;
|
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<OpenCodeSessionMeta[]>;
|
||||||
|
/** Create a brand-new session, bind it to the current notebook, and
|
||||||
|
* return its id (which becomes the new active session). */
|
||||||
|
onCreateSession?: () => Promise<string>;
|
||||||
|
/** Switch the active session. Returns true on success. */
|
||||||
|
onSwitchSession?: (sessionId: string) => Promise<boolean>;
|
||||||
|
/** 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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface FlatProvider {
|
interface FlatProvider {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -129,6 +144,10 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
// Streaming state. _sessionId is set by the cell action after a successful
|
// Streaming state. _sessionId is set by the cell action after a successful
|
||||||
// /edit response; applyEvent() drops events that don't belong to it.
|
// /edit response; applyEvent() drops events that don't belong to it.
|
||||||
private _sessionId: string | null = null;
|
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
|
// The current assistant message DOM node (re-rendered as markdown on
|
||||||
// every text delta so the user sees a properly formatted response in
|
// every text delta so the user sees a properly formatted response in
|
||||||
// real-time, not raw ```fence``` source).
|
// real-time, not raw ```fence``` source).
|
||||||
@@ -224,6 +243,66 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
actions.appendChild(this._sendBtn);
|
actions.appendChild(this._sendBtn);
|
||||||
actions.appendChild(this._cancelBtn);
|
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) {
|
if (this._providerSelect || this._modelSelect) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'opencode-prompt-providers';
|
row.className = 'opencode-prompt-providers';
|
||||||
@@ -330,6 +409,159 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
return sel;
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
* Persist the current (providerSelect.value, modelSelect.value) for
|
||||||
* this notebook. No-op if the prompt has no notebookPath, no
|
* this notebook. No-op if the prompt has no notebookPath, no
|
||||||
@@ -356,6 +588,12 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
if (this._modelSelect) {
|
if (this._modelSelect) {
|
||||||
this._modelSelect.disabled = disabled;
|
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. */
|
/** Clear the user input textarea. */
|
||||||
@@ -369,12 +607,34 @@ export class OpenCodeInlinePrompt extends Widget {
|
|||||||
*/
|
*/
|
||||||
setSessionId(sessionId: string | null): void {
|
setSessionId(sessionId: string | null): void {
|
||||||
this._sessionId = sessionId;
|
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) {
|
if (sessionId) {
|
||||||
// Fresh turn: drop any in-progress stream DOM from a previous session.
|
// Fresh turn: drop any in-progress stream DOM from a previous session.
|
||||||
this._resetStreamPointers();
|
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
|
* Render the current session's static messages (loaded once from
|
||||||
* /session-messages when the panel opens). Subsequent assistant turns
|
* /session-messages when the panel opens). Subsequent assistant turns
|
||||||
|
|||||||
@@ -82,6 +82,54 @@ export interface OpenCodeMessagesResponse {
|
|||||||
messages: OpenCodeMessage[];
|
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).
|
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
||||||
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
||||||
export interface OpenCodeModel {
|
export interface OpenCodeModel {
|
||||||
|
|||||||
@@ -339,3 +339,52 @@
|
|||||||
.opencode-inline-prompt .opencode-q-submit:hover {
|
.opencode-inline-prompt .opencode-q-submit:hover {
|
||||||
background: var(--jp-layout-color3, #e0e0e0);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user