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:
@@ -118,6 +118,23 @@ jest.mock('../api/opencode_client', () => ({
|
||||
callOpenCodeSessionMessages: jest.fn().mockResolvedValue({ messages: [] }),
|
||||
callOpenCodeReplyPermission: 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() })
|
||||
}));
|
||||
|
||||
@@ -130,6 +147,10 @@ import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt';
|
||||
import { CodeCell } from '@jupyterlab/cells';
|
||||
import {
|
||||
callOpenCodeEdit,
|
||||
callOpenCodeListAllSessions,
|
||||
callOpenCodeCreateNotebookSession,
|
||||
callOpenCodeSetActiveSession,
|
||||
callOpenCodeBindExistingSession,
|
||||
subscribeOpenCodeEvents
|
||||
} from '../api/opencode_client';
|
||||
import { Notification } from '@jupyterlab/apputils';
|
||||
@@ -141,6 +162,18 @@ const mockedSubscribeOpenCodeEvents =
|
||||
subscribeOpenCodeEvents as jest.MockedFunction<
|
||||
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>;
|
||||
|
||||
function makeFakeOutputs(items: any[]): any {
|
||||
@@ -1005,6 +1038,116 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
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', () => {
|
||||
// Regression: a previous version ran marked.parse on every text
|
||||
// delta. That produced glitchy partial-render states (e.g. an
|
||||
|
||||
Reference in New Issue
Block a user