feat: inline prompt history area — scrollable session messages

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>
This commit is contained in:
tao.chen
2026-07-23 18:45:03 +08:00
co-authored by Claude Fable 5
parent 1d7f5da9d4
commit ce59502e97
10 changed files with 350 additions and 124 deletions
+10
View File
@@ -91,6 +91,16 @@ class OpenCodeClient:
result = await self._request("DELETE", "/session/%s" % session_id)
return result is not None
async def list_session_messages(self, session_id: str) -> list[dict[str, Any]]:
"""List all messages in the given OpenCode session.
Returns the raw OpenCode response: a list of
`{ info: { role: "user"|"assistant", ... }, parts: [...] }`.
The server extension is responsible for projecting this into a
frontend-friendly `{role, content}[]` shape.
"""
return await self._request("GET", "/session/%s/message" % session_id)
@property
def endpoint(self) -> str:
return self._config.url
+53
View File
@@ -249,6 +249,58 @@ class SessionReleaseHandler(APIHandler):
}))
class SessionMessagesHandler(APIHandler):
"""List the current session's messages for a notebook (scrollable history).
Query param: notebook=<notebook path, URL-encoded>
Returns: { messages: [{ role: "user"|"assistant", content: string }] }
If no session exists for the notebook yet, returns { messages: [] }
(does NOT create a session just to report emptiness).
"""
@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)
sid = sm.peek(notebook_path)
if sid is None:
self.finish(json.dumps({"messages": []}))
return
try:
client = make_client(self)
raw = await client.list_session_messages(sid)
except OpenCodeError as e:
if "404" in str(e) or "not found" in str(e).lower():
sm.invalidate(notebook_path)
log.warning("invalidated dead session for %s", notebook_path)
log.exception("list session messages failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
except Exception as e:
log.exception("list session messages failed")
self.set_status(502)
self.finish(json.dumps({"ok": False, "error": str(e)}))
return
# Project OpenCode's {info, parts}[] into a frontend-friendly
# {role, content}[] by joining the text parts.
messages = []
for m in raw or []:
info = m.get("info") or {}
role = info.get("role") or "assistant"
parts = m.get("parts") or []
content = "\n".join(
p.get("text", "") for p in parts if p.get("type") == "text"
).strip()
messages.append({"role": role, "content": content})
self.finish(json.dumps({"messages": messages}))
def setup_route_handlers(web_app):
host_pattern = ".*$"
base_url = web_app.settings["base_url"]
@@ -260,6 +312,7 @@ def setup_route_handlers(web_app):
(url_path_join(base_url, "opencode-bridge", "edit"), EditHandler),
(url_path_join(base_url, "opencode-bridge", "sessions"), SessionListHandler),
(url_path_join(base_url, "opencode-bridge", "session"), SessionReleaseHandler),
(url_path_join(base_url, "opencode-bridge", "session-messages"), SessionMessagesHandler),
]
web_app.add_handlers(host_pattern, handlers)
+7
View File
@@ -83,6 +83,13 @@ class SessionManager:
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}
+90
View File
@@ -1,5 +1,8 @@
import json
import pytest
import tornado.httpclient
class FakeOpenCodeClient:
"""Drop-in replacement for OpenCodeClient with recording + canned responses."""
@@ -13,6 +16,18 @@ class FakeOpenCodeClient:
"info": {"id": "msg-1"},
"parts": [{"type": "text", "text": "def foo():\n return 42\n"}],
}
# Canned session-messages list response (list_session_messages).
# Default: one user + one assistant message, mixed text parts.
self.messages_response = [
{
"info": {"role": "user", "id": "m1"},
"parts": [{"type": "text", "text": "fix the bug"}],
},
{
"info": {"role": "assistant", "id": "m2"},
"parts": [{"type": "text", "text": "```python\nx = 1\n```"}],
},
]
@property
def endpoint(self):
@@ -38,6 +53,10 @@ class FakeOpenCodeClient:
self.calls.append(("delete_session", session_id))
return True
async def list_session_messages(self, session_id):
self.calls.append(("list_session_messages", session_id))
return self.messages_response
class FakeSessionManager:
"""Drop-in replacement for SessionManager with recording."""
@@ -50,6 +69,12 @@ class FakeSessionManager:
self.calls.append(("get_or_create", notebook_path))
return self._session_id
def peek(self, notebook_path: str):
# Mirror SessionManager.peek: return the session id without
# creating one. None means "no session yet" (used by the no-session
# test to short-circuit the history route).
return self._session_id or None
async def release(self, notebook_path: str) -> bool:
self.calls.append(("release", notebook_path))
return True
@@ -177,6 +202,71 @@ async def test_session_list_handler(monkeypatch, jp_fetch):
assert paths == {"foo.ipynb", "bar.ipynb"}
async def test_session_messages_handler_returns_empty_when_no_session(
monkeypatch, jp_fetch
) -> None:
# No session registered for this notebook -> handler returns
# {"messages": []} WITHOUT calling OpenCodeClient (peek short-circuits).
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager(session_id="")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
response = await jp_fetch(
"opencode-bridge", "session-messages",
method="GET",
params={"notebook": "fresh.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {"messages": []}
# No OpenCode call was made.
assert all(c[0] != "list_session_messages" for c in fake.calls)
async def test_session_messages_handler_projects_opencode_messages(
monkeypatch, jp_fetch
) -> None:
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
fake_sm = FakeSessionManager(session_id="fake-session-123")
monkeypatch.setattr(
"opencode_bridge.routes.get_session_manager", lambda h: fake_sm
)
response = await jp_fetch(
"opencode-bridge", "session-messages",
method="GET",
params={"notebook": "test.ipynb"},
)
assert response.code == 200
payload = json.loads(response.body)
assert payload == {
"messages": [
{"role": "user", "content": "fix the bug"},
{"role": "assistant", "content": "```python\nx = 1\n```"},
]
}
# The OpenCode client was called with the session id from the manager.
assert ("list_session_messages", "fake-session-123") in fake.calls
async def test_session_messages_handler_requires_notebook(
monkeypatch, jp_fetch
) -> None:
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)
# jp_fetch raises HTTPClientError on 4xx; assert the handler 400s
# (the body would contain "missing 'notebook' query parameter").
with pytest.raises(tornado.httpclient.HTTPClientError) as exc_info:
await jp_fetch("opencode-bridge", "session-messages", method="GET")
assert exc_info.value.code == 400
async def test_session_release_handler(monkeypatch, jp_fetch):
fake = FakeOpenCodeClient()
monkeypatch.setattr("opencode_bridge.routes.make_client", lambda h: fake)