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:
tao.chen
2026-07-28 16:25:42 +08:00
co-authored by Claude
parent fcd015ac0e
commit d2bd4f1e48
13 changed files with 1772 additions and 99 deletions
+143
View File
@@ -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