The inline prompt now shows the current session's full message
history (user + assistant), scrollable, replacing the single-output
display from the previous commit. The user can scroll back through
prior exchanges in the same notebook's OpenCode session.
Server (opencode_bridge/):
- New route: GET /opencode-bridge/session-messages?notebook=<path>
- Resolves notebook to session via SessionManager.peek (no create).
- If no session: {"messages": []} (no OpenCode call).
- Else: GET /session/{id}/message on OpenCode Serve, project the
raw {info, parts}[] into a frontend-friendly {role, content}[].
- OpenCodeClient.list_session_messages(sid).
- SessionManager.peek(notebook_path) -> Optional[str] (read without
creating, to avoid spawning a session just to report emptiness).
- Wired the new route in setup_route_handlers.
Client (src/):
- types.ts: OpenCodeMessage { role, content } + OpenCodeMessagesResponse.
- api/opencode_client.ts: callOpenCodeSessionMessages(notebook, serverSettings).
- components/opencode_inline_prompt.ts:
- Replaces the single .opencode-inline-output area with a
scrollable .opencode-inline-history (max-height 320px, overflow-y
auto, auto-scrolls to bottom on update).
- setMessages(messages): renders user messages as plain text,
assistant messages via marked.parse. No more setOutput/hideOutput.
- components/opencode_cell_actions.ts:
- _showPrompt now fires a _refreshHistory(notebookPath) which
fetches and calls prompt.setMessages.
- _handleResponse on success also calls _refreshHistory (the new
assistant message appears as the last item in the history).
- Cell source is still NOT replaced.
- api/opencode_client module is mocked in the cell_actions test to
avoid jsdom network calls.
style/base.css:
- .opencode-inline-output* rules replaced with .opencode-inline-history
(max-height 320px, overflow-y auto, border, padding) and
.opencode-msg / .opencode-msg-user / .opencode-msg-assistant.
- pre/code/p/h1-3 content styling scoped under .opencode-inline-history.
Tests:
- pytest 37/37: FakeOpenCodeClient.list_session_messages + 3 new
session_messages route tests (no session, projects messages, 400).
- jest 29/29: setMessages renders user/assistant + history area tests.
- FakeSessionManager.peek (returns None when session_id is falsy).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
"""Per-notebook session manager for OpenCode.
|
|
|
|
Maps notebookPath -> OpenCode sessionID. Lazy create on first use.
|
|
Async-safe via per-path asyncio.Lock. No automatic cleanup.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Callable
|
|
|
|
from .opencode_client import OpenCodeClient
|
|
|
|
log = logging.getLogger("opencode_bridge.session_manager")
|
|
|
|
ClientFactory = Callable[[], OpenCodeClient]
|
|
|
|
|
|
class SessionManager:
|
|
"""Tracks one OpenCode session per notebook path.
|
|
|
|
Threading/async model:
|
|
- Multiple coroutines may call get_or_create for the same notebook.
|
|
- First call creates; subsequent calls return the same sessionID.
|
|
- Per-notebook asyncio.Lock prevents double-create under concurrency.
|
|
- Locks remain in the map; they may be needed again for the same path.
|
|
"""
|
|
|
|
def __init__(self, client_factory: ClientFactory) -> None:
|
|
self._client_factory = client_factory
|
|
self._sessions: dict[str, str] = {} # notebookPath -> sessionID
|
|
self._locks: dict[str, asyncio.Lock] = {} # notebookPath -> lock
|
|
self._titles: dict[str, str] = {} # notebookPath -> title (for debug)
|
|
|
|
async def get_or_create(self, notebook_path: str) -> str:
|
|
"""Return session ID for the notebook, creating one if needed.
|
|
|
|
Idempotent for the same path. Different paths get different sessions.
|
|
"""
|
|
existing = self._sessions.get(notebook_path)
|
|
if existing is not None:
|
|
return existing
|
|
lock = self._locks.setdefault(notebook_path, asyncio.Lock())
|
|
async with lock:
|
|
existing = self._sessions.get(notebook_path)
|
|
if existing is not None:
|
|
return existing
|
|
client = self._client_factory()
|
|
session = await client.create_session(
|
|
title="jupyter:%s" % notebook_path
|
|
)
|
|
sid = session["id"]
|
|
self._sessions[notebook_path] = sid
|
|
self._titles[notebook_path] = notebook_path
|
|
log.info("created opencode session %s for %s", sid, notebook_path)
|
|
return sid
|
|
|
|
async def release(self, notebook_path: str) -> bool:
|
|
"""Delete session and remove from map. Returns True if a session existed."""
|
|
sid = self._sessions.pop(notebook_path, None)
|
|
self._titles.pop(notebook_path, None)
|
|
self._locks.pop(notebook_path, None)
|
|
if sid is None:
|
|
return False
|
|
try:
|
|
client = self._client_factory()
|
|
return await client.delete_session(sid)
|
|
except Exception:
|
|
log.warning(
|
|
"failed to delete opencode session %s for %s", sid, notebook_path
|
|
)
|
|
return False
|
|
|
|
def invalidate(self, notebook_path: str) -> bool:
|
|
"""Drop the cached sessionID without calling OpenCode. Returns True if removed.
|
|
|
|
Use this when an upstream error indicates the session is dead (e.g., 404).
|
|
"""
|
|
sid = self._sessions.pop(notebook_path, None)
|
|
self._titles.pop(notebook_path, None)
|
|
return sid is not None
|
|
|
|
def has_session(self, notebook_path: str) -> bool:
|
|
return notebook_path in self._sessions
|
|
|
|
def peek(self, notebook_path: str) -> Optional[str]:
|
|
"""Return the cached sessionID for the notebook, or None if no
|
|
session has been created yet. Does NOT create one (unlike
|
|
get_or_create) — used by the history endpoint to avoid spawning
|
|
a session just to report that there is none."""
|
|
return self._sessions.get(notebook_path)
|
|
|
|
def list_sessions(self) -> list[dict]:
|
|
return [
|
|
{"notebookPath": path, "sessionId": sid}
|
|
for path, sid in sorted(self._sessions.items())
|
|
]
|