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
+45 -26
View File
@@ -52,6 +52,15 @@ jest.mock('marked', () => ({
marked: { parse: (md: string) => `<mock>${md}</mock>` }
}));
// Mock the network-touching API module so jsdom tests don't try to hit
// a real notebook server (and to keep the refresh-history logic decoupled
// from the network). The cell_actions tests focus on widget behavior.
jest.mock('../api/opencode_client', () => ({
callOpenCodeEdit: jest.fn(),
callOpenCodeProviders: jest.fn(),
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] })
}));
import {
OpenCodeCellActions,
setOpenCodeProviders,
@@ -330,17 +339,21 @@ describe('OpenCodeCellActions', () => {
).toBeNull();
});
it('on a successful response, does NOT replace the cell source (renders to inline output instead)', () => {
it('on a successful response, does NOT replace the cell source and triggers a history refresh', () => {
setOpenCodeProviders(null);
const cell = makeFakeCell('x = 1');
const actions = new OpenCodeCellActions(cell);
// Click once to create the inline prompt (so setOutput has a target).
// Click once to create the inline prompt (so the history refresh
// has a target and the cell source check has a baseline).
Object.defineProperty(actions, 'parent', { value: cell, configurable: true });
(actions as any).onAfterAttach({} as any);
const btn = actions.node.querySelector('button') as HTMLButtonElement;
btn.click();
// Simulate a successful response.
// The history refresh on show is async; spy on _refreshHistory to
// confirm it gets called (without depending on the network).
const refreshSpy = jest.spyOn(actions as any, '_refreshHistory');
(actions as any)._handleResponse({
ok: true,
markdown: '# AI says hi\n\n```python\nprint("hi")\n```',
@@ -350,11 +363,8 @@ describe('OpenCodeCellActions', () => {
// The cell source must remain unchanged.
expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled();
// The inline output area is now visible with the rendered markdown.
const output = cell.node.querySelector(
'.opencode-inline-prompt .opencode-inline-output'
) as HTMLElement;
expect(output.style.display).toBe('block');
// A history refresh was triggered (to pick up the new assistant msg).
expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb');
});
});
@@ -368,11 +378,12 @@ describe('OpenCodeInlinePrompt', () => {
providers: null
});
expect(prompt.node.querySelector('textarea')).not.toBeNull();
// 3 buttons: 发送, 取消, and the output area close (✕).
expect(prompt.node.querySelectorAll('button').length).toBe(3);
// 2 buttons: 发送 and 取消 (no more output-area close button; the
// scrollable history area is the display).
expect(prompt.node.querySelectorAll('button').length).toBe(2);
});
it('hides the output area by default; setOutput reveals it with rendered markdown', () => {
it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => {
const cell = makeFakeCell('x = 1');
const prompt = new OpenCodeInlinePrompt(cell, {
onSubmit: jest.fn(),
@@ -380,22 +391,30 @@ describe('OpenCodeInlinePrompt', () => {
disabled: false,
providers: null
});
const output = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-inline-output'
const history = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-inline-history'
) as HTMLElement;
expect(output).not.toBeNull();
expect(output.style.display).toBe('none');
expect(history).not.toBeNull();
// Initially empty (no messages yet).
expect(history.children.length).toBe(0);
prompt.setOutput('# Hello\n\n```python\nx = 1\n```');
expect(output.style.display).toBe('block');
const content = prompt.node.querySelector(
'.opencode-inline-prompt .opencode-inline-output-content'
) as HTMLElement;
// marked is mocked to wrap the input in <mock>...</mock>.
expect(content.innerHTML).toContain('<mock>');
expect(content.innerHTML).toContain('# Hello');
prompt.hideOutput();
expect(output.style.display).toBe('none');
prompt.setMessages([
{ role: 'user', content: 'fix the bug' },
{
role: 'assistant',
content: '# Here you go\n\n```python\nx = 1\n```'
}
]);
expect(history.children.length).toBe(2);
const userMsg = history.querySelector('.opencode-msg-user') as HTMLElement;
const asstMsg = history.querySelector('.opencode-msg-assistant') as HTMLElement;
expect(userMsg).not.toBeNull();
// User messages are plain text (not rendered as HTML).
expect(userMsg.textContent).toBe('fix the bug');
expect(userMsg.innerHTML).not.toContain('<');
// Assistant messages are rendered as markdown via marked (mocked).
expect(asstMsg).not.toBeNull();
expect(asstMsg.innerHTML).toContain('<mock>');
expect(asstMsg.innerHTML).toContain('# Here you go');
});
});
+32 -1
View File
@@ -4,7 +4,7 @@
import { URLExt } from '@jupyterlab/coreutils';
import { ServerConnection } from '@jupyterlab/services';
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse } from '../types';
import type { OpenCodeProvidersResponse, OpenCodeRequest, OpenCodeResponse, OpenCodeMessagesResponse } from '../types';
/**
* Call POST /opencode-bridge/edit. Returns parsed response (success or failure).
@@ -54,6 +54,37 @@ export async function callOpenCodeEdit(
return parsed as OpenCodeResponse;
}
/**
* Call GET /opencode-bridge/session-messages?notebook=<path>. Returns the
* current session's messages (projected to {role, content}[]) for the
* given notebook, or an empty list if no session exists yet.
*/
export async function callOpenCodeSessionMessages(
notebookPath: string,
serverSettings: ServerConnection.ISettings
): Promise<OpenCodeMessagesResponse> {
const url =
URLExt.join(serverSettings.baseUrl, 'opencode-bridge', 'session-messages') +
'?notebook=' +
encodeURIComponent(notebookPath);
const init: RequestInit = { method: 'GET' };
let response: Response;
try {
response = await ServerConnection.makeRequest(url, init, serverSettings);
} catch (error) {
throw new Error(
`network error calling opencode-bridge/session-messages: ${(error as Error).message}`
);
}
if (!response.ok) {
throw new Error(
`opencode-bridge/session-messages failed: ${response.status} ${response.statusText}`
);
}
return (await response.json()) as OpenCodeMessagesResponse;
}
/**
* Call GET /opencode-bridge/providers. Returns the parsed JSON response.
* Throws on network error or non-2xx status.
+32 -6
View File
@@ -16,7 +16,7 @@ import type { NotebookPanel } from '@jupyterlab/notebook';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
import { callOpenCodeEdit } from '../api/opencode_client';
import { callOpenCodeEdit, callOpenCodeSessionMessages } from '../api/opencode_client';
import { extractCellContext } from '../context/cell_context';
import type {
CellContext,
@@ -123,6 +123,31 @@ export class OpenCodeCellActions extends Widget {
});
Widget.attach(prompt, this._cell.node);
this._prompt = prompt;
// Fetch the current session's message history and render it into
// the prompt's scrollable history area. Empty list (no session yet)
// is a normal no-op render.
const notebookPath = this._context?.notebookPath;
if (notebookPath) {
void this._refreshHistory(notebookPath);
}
}
private async _refreshHistory(notebookPath: string): Promise<void> {
if (!_serverSettings || !this._prompt) {
return;
}
try {
const resp = await callOpenCodeSessionMessages(
notebookPath,
_serverSettings
);
this._prompt.setMessages(resp.messages);
} catch (e) {
// Non-fatal: the history is a convenience. The user can still
// send new prompts; just the scrollback won't update.
console.warn('opencode_bridge: failed to fetch session messages', e);
}
}
private _hidePrompt(): void {
@@ -172,11 +197,12 @@ export class OpenCodeCellActions extends Widget {
private _handleResponse(resp: OpenCodeResponse): void {
this._status = 'idle';
if (resp.ok) {
// Display the AI response in the inline prompt's output area as
// rendered markdown. The cell source is NOT replaced — the user
// decides what to do with the output (close it, or resend).
if (this._prompt) {
this._prompt.setOutput(resp.markdown);
// Refetch the (now-updated) session history and render the new
// assistant message as the last item in the scrollable history.
// The cell source is NOT replaced.
const notebookPath = this._context?.notebookPath;
if (notebookPath) {
void this._refreshHistory(notebookPath);
}
Notification.info(
`OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}`
+33 -45
View File
@@ -1,17 +1,19 @@
/**
* Inline prompt widget attached to a cell's DOM when the AI button is clicked.
* Renders two <select> boxes (provider + model), a textarea, and send/cancel
* buttons. The model select's default for the chosen provider is
* `default[providerID]` (from the /config/providers response) when that
* modelID is present in the provider's models; otherwise the first model.
* Renders two <select> boxes (provider + model), a textarea, send/cancel
* buttons, and a scrollable history area that shows the current session's
* messages (user + assistant). The model select's default for the chosen
* provider is `default[providerID]` from the /config/providers response.
*
* v3-final: no settings — picks come from the OpenCode Serve providers cache.
* v3-final: no settings — picks come from the OpenCode Serve providers
* cache; history comes from /session-messages; cell source is NOT replaced
* (the AI reply is rendered as markdown inside this history area).
*/
import { CodeCell } from '@jupyterlab/cells';
import { Widget } from '@lumino/widgets';
import { marked } from 'marked';
import type { OpenCodeProvidersResponse } from '../types';
import type { OpenCodeMessage, OpenCodeProvidersResponse } from '../types';
export interface IOpenCodeInlinePromptOptions {
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
@@ -70,8 +72,7 @@ export class OpenCodeInlinePrompt extends Widget {
private _modelSelect: HTMLSelectElement | null = null;
private _providerModels: FlatProvider[];
private _defaultMap: { [pid: string]: string };
private _outputArea: HTMLDivElement;
private _outputContent: HTMLDivElement;
private _historyEl: HTMLDivElement;
constructor(
_cell: CodeCell,
@@ -154,33 +155,15 @@ export class OpenCodeInlinePrompt extends Widget {
}
this.node.appendChild(row);
}
// Scrollable history of the current session's messages. Empty until
// setMessages() is called by the owning cell action.
this._historyEl = document.createElement('div');
this._historyEl.className = 'opencode-inline-history';
this.node.appendChild(this._historyEl);
this.node.appendChild(this._textarea);
this.node.appendChild(actions);
// Output area: hidden by default; revealed by setOutput() with the
// markdown-rendered AI response. The cell source is NOT replaced.
this._outputArea = document.createElement('div');
this._outputArea.className = 'opencode-inline-output';
this._outputArea.style.display = 'none';
const outputHeader = document.createElement('div');
outputHeader.className = 'opencode-inline-output-header';
const outputTitle = document.createElement('span');
outputTitle.textContent = '结果';
const outputCloseBtn = document.createElement('button');
outputCloseBtn.className = 'opencode-inline-output-close';
outputCloseBtn.type = 'button';
outputCloseBtn.textContent = '✕';
outputCloseBtn.title = '关闭输出';
outputCloseBtn.addEventListener('click', () => {
this.hideOutput();
});
outputHeader.appendChild(outputTitle);
outputHeader.appendChild(outputCloseBtn);
this._outputContent = document.createElement('div');
this._outputContent.className = 'opencode-inline-output-content';
this._outputArea.appendChild(outputHeader);
this._outputArea.appendChild(this._outputContent);
this.node.appendChild(this._outputArea);
}
private _rebuildModelSelect(providerId: string | undefined): void {
@@ -226,18 +209,23 @@ export class OpenCodeInlinePrompt extends Widget {
}
/**
* Render the AI response as markdown and show the output area in place
* of replacing the cell source. Safe for local-trusted content from the
* user's OpenCode Serve; do not feed untrusted markdown here without
* adding a sanitizer (e.g. DOMPurify).
* Render the current session's messages into the scrollable history
* area. User messages are plain text; assistant messages are rendered
* as markdown via marked. Auto-scrolls to the bottom so the most
* recent message is visible.
*/
setOutput(markdown: string): void {
this._outputContent.innerHTML = marked.parse(markdown) as string;
this._outputArea.style.display = 'block';
}
hideOutput(): void {
this._outputArea.style.display = 'none';
this._outputContent.textContent = '';
setMessages(messages: OpenCodeMessage[]): void {
this._historyEl.textContent = '';
for (const msg of messages) {
const wrap = document.createElement('div');
wrap.className = `opencode-msg opencode-msg-${msg.role}`;
if (msg.role === 'user') {
wrap.textContent = msg.content;
} else {
wrap.innerHTML = marked.parse(msg.content) as string;
}
this._historyEl.appendChild(wrap);
}
this._historyEl.scrollTop = this._historyEl.scrollHeight;
}
}
+13 -6
View File
@@ -49,13 +49,20 @@ export interface OpenCodeFailure {
export type OpenCodeResponse = OpenCodeSuccess | OpenCodeFailure;
/** Response from GET /opencode-bridge/session-messages?notebook=... .
* Projected from OpenCode's {info, parts}[] by the server into a
* frontend-friendly {role, content}[]. */
export interface OpenCodeMessage {
role: "user" | "assistant";
content: string;
}
export interface OpenCodeMessagesResponse {
messages: OpenCodeMessage[];
}
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
*
* Per the OpenCode server docs, `/config/providers` returns
* `{ providers: Provider[], default: { [providerID]: modelID } }`. Each
* `Provider.models` is a Record keyed by modelID (NOT an array), so we
* can reference a model by its string ID (also matching `default[providerID]`).
*/
* Each Provider.models is a Record keyed by modelID (NOT an array). */
export interface OpenCodeModel {
id: string;
name?: string;