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:
co-authored by
Claude Fable 5
parent
1d7f5da9d4
commit
ce59502e97
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user