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
|
||||
|
||||
+221
-1
@@ -11,11 +11,14 @@ import { URLExt } from '@jupyterlab/coreutils';
|
||||
import { ServerConnection } from '@jupyterlab/services';
|
||||
|
||||
import type {
|
||||
OpenCodeAllSessionsResponse,
|
||||
OpenCodeEditResponse,
|
||||
OpenCodeEvent,
|
||||
OpenCodeMessagesResponse,
|
||||
OpenCodeNotebookSessionsResponse,
|
||||
OpenCodeProvidersResponse,
|
||||
OpenCodeRequest
|
||||
OpenCodeRequest,
|
||||
OpenCodeSessionOpResponse
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
@@ -215,6 +218,223 @@ export async function callOpenCodeReplyQuestion(
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-session management (1 notebook can bind N sessions; one is active)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function _jsonRequest<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
serverSettings: ServerConnection.ISettings,
|
||||
label: string
|
||||
): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await ServerConnection.makeRequest(url, init, serverSettings);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`network error calling opencode-bridge/${label}: ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
throw new Error(`opencode-bridge/${label} failed: ${res.status} ${text}`);
|
||||
}
|
||||
if (!text) {
|
||||
throw new Error(`empty response from opencode-bridge/${label}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`non-JSON response from opencode-bridge/${label} (status ${res.status})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /opencode-bridge/sessions/all — every session on OpenCode Serve. */
|
||||
export async function callOpenCodeListAllSessions(
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeAllSessionsResponse> {
|
||||
const url = URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'all'
|
||||
);
|
||||
return _jsonRequest(
|
||||
url,
|
||||
{ method: 'GET' },
|
||||
serverSettings,
|
||||
'sessions/all'
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /opencode-bridge/sessions/notebook?notebook=... — sessions bound
|
||||
* to this notebook (enriched with metadata, marked which is active). */
|
||||
export async function callOpenCodeListNotebookSessions(
|
||||
notebookPath: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeNotebookSessionsResponse> {
|
||||
const url =
|
||||
URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'notebook'
|
||||
) +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath);
|
||||
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/notebook');
|
||||
}
|
||||
|
||||
/** POST /opencode-bridge/sessions/notebook?notebook=...&title=... —
|
||||
* create a brand-new session on OpenCode, bind it, set as active. */
|
||||
export async function callOpenCodeCreateNotebookSession(
|
||||
notebookPath: string,
|
||||
title: string | undefined,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeSessionOpResponse> {
|
||||
const url =
|
||||
URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'notebook'
|
||||
) +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath) +
|
||||
(title ? '&title=' + encodeURIComponent(title) : '');
|
||||
return _jsonRequest(
|
||||
url,
|
||||
{ method: 'POST', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
||||
serverSettings,
|
||||
'sessions/notebook (POST)'
|
||||
);
|
||||
}
|
||||
|
||||
/** PUT /opencode-bridge/sessions/notebook?notebook=...&sessionId=... —
|
||||
* bind an existing sessionId to this notebook (no OpenCode round-trip)
|
||||
* and set it as active. */
|
||||
export async function callOpenCodeBindExistingSession(
|
||||
notebookPath: string,
|
||||
sessionId: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeSessionOpResponse> {
|
||||
const url =
|
||||
URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'notebook'
|
||||
) +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath) +
|
||||
'&sessionId=' +
|
||||
encodeURIComponent(sessionId);
|
||||
return _jsonRequest(
|
||||
url,
|
||||
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
||||
serverSettings,
|
||||
'sessions/notebook (PUT)'
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /opencode-bridge/sessions/notebook?notebook=...&sessionId=... —
|
||||
* delete a specific bound session on OpenCode AND remove its binding. */
|
||||
export async function callOpenCodeDeleteNotebookSession(
|
||||
notebookPath: string,
|
||||
sessionId: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeSessionOpResponse> {
|
||||
const url =
|
||||
URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'notebook'
|
||||
) +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath) +
|
||||
'&sessionId=' +
|
||||
encodeURIComponent(sessionId);
|
||||
return _jsonRequest(
|
||||
url,
|
||||
{ method: 'DELETE' },
|
||||
serverSettings,
|
||||
'sessions/notebook (DELETE)'
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /opencode-bridge/sessions/active?notebook=... — get the active
|
||||
* sessionId (or null). */
|
||||
export async function callOpenCodeGetActiveSession(
|
||||
notebookPath: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeSessionOpResponse> {
|
||||
const url =
|
||||
URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'active'
|
||||
) +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath);
|
||||
return _jsonRequest(url, { method: 'GET' }, serverSettings, 'sessions/active');
|
||||
}
|
||||
|
||||
/** PUT /opencode-bridge/sessions/active?notebook=...&sessionId=... —
|
||||
* switch which bound session is active. sessionId MUST already be
|
||||
* in the bound list. */
|
||||
export async function callOpenCodeSetActiveSession(
|
||||
notebookPath: string,
|
||||
sessionId: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeSessionOpResponse> {
|
||||
const url =
|
||||
URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'active'
|
||||
) +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath) +
|
||||
'&sessionId=' +
|
||||
encodeURIComponent(sessionId);
|
||||
return _jsonRequest(
|
||||
url,
|
||||
{ method: 'PUT', body: JSON.stringify({}), headers: { 'Content-Type': 'application/json' } },
|
||||
serverSettings,
|
||||
'sessions/active (PUT)'
|
||||
);
|
||||
}
|
||||
|
||||
/** DELETE /opencode-bridge/sessions/active?notebook=... — unbind the
|
||||
* active session (next /edit lazy-creates a new one). Does NOT delete
|
||||
* the session on OpenCode Serve. */
|
||||
export async function callOpenCodeUnbindActiveSession(
|
||||
notebookPath: string,
|
||||
serverSettings: ServerConnection.ISettings
|
||||
): Promise<OpenCodeSessionOpResponse> {
|
||||
const url =
|
||||
URLExt.join(
|
||||
serverSettings.baseUrl,
|
||||
'opencode-bridge',
|
||||
'sessions',
|
||||
'active'
|
||||
) +
|
||||
'?notebook=' +
|
||||
encodeURIComponent(notebookPath);
|
||||
return _jsonRequest(
|
||||
url,
|
||||
{ method: 'DELETE' },
|
||||
serverSettings,
|
||||
'sessions/active (DELETE)'
|
||||
);
|
||||
}
|
||||
|
||||
export interface OpenCodeEventHandlers {
|
||||
onEvent: (event: OpenCodeEvent) => void;
|
||||
onError?: (err: Error) => void;
|
||||
|
||||
@@ -23,10 +23,14 @@ import { ServerConnection } from '@jupyterlab/services';
|
||||
import { Widget } from '@lumino/widgets';
|
||||
|
||||
import {
|
||||
callOpenCodeBindExistingSession,
|
||||
callOpenCodeCreateNotebookSession,
|
||||
callOpenCodeEdit,
|
||||
callOpenCodeListAllSessions,
|
||||
callOpenCodeReplyPermission,
|
||||
callOpenCodeReplyQuestion,
|
||||
callOpenCodeSessionMessages,
|
||||
callOpenCodeSetActiveSession,
|
||||
subscribeOpenCodeEvents,
|
||||
type OpenCodeEventSubscription
|
||||
} from '../api/opencode_client';
|
||||
@@ -181,6 +185,65 @@ export class OpenCodeCellActions extends Widget {
|
||||
// us to close the SSE stream + restore the input. We no longer
|
||||
// parse event types here.
|
||||
this._closeSse();
|
||||
},
|
||||
onListSessions: async () => {
|
||||
if (!_serverSettings) return [];
|
||||
try {
|
||||
const r = await callOpenCodeListAllSessions(_serverSettings);
|
||||
return r.sessions || [];
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: list all sessions failed', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
onCreateSession: async () => {
|
||||
if (!_serverSettings || !this._context?.notebookPath) {
|
||||
throw new Error('notebook not ready');
|
||||
}
|
||||
// Tear down any in-flight SSE first — the old session is
|
||||
// being replaced.
|
||||
this._closeSse();
|
||||
const r = await callOpenCodeCreateNotebookSession(
|
||||
this._context.notebookPath,
|
||||
undefined,
|
||||
_serverSettings
|
||||
);
|
||||
if (!r.ok || !r.sessionId) {
|
||||
throw new Error(r.error || 'create_session failed');
|
||||
}
|
||||
// Sync the prompt's view of "current session id".
|
||||
this._prompt?.setSessionId(r.sessionId);
|
||||
return r.sessionId;
|
||||
},
|
||||
onSwitchSession: async (sessionId: string) => {
|
||||
if (!_serverSettings || !this._context?.notebookPath) {
|
||||
return false;
|
||||
}
|
||||
// Closing the SSE first ensures the old session's events stop
|
||||
// coming in while we transition.
|
||||
this._closeSse();
|
||||
const r = await callOpenCodeSetActiveSession(
|
||||
this._context.notebookPath,
|
||||
sessionId,
|
||||
_serverSettings
|
||||
);
|
||||
if (!r.ok) {
|
||||
return false;
|
||||
}
|
||||
// The session was not necessarily bound to this notebook yet.
|
||||
// Bind it (idempotent; the server's PUT /sessions/notebook
|
||||
// also sets it as active, so we don't need a separate call).
|
||||
await callOpenCodeBindExistingSession(
|
||||
this._context.notebookPath,
|
||||
sessionId,
|
||||
_serverSettings
|
||||
);
|
||||
this._prompt?.setSessionId(sessionId);
|
||||
return true;
|
||||
},
|
||||
onReloadHistory: async () => {
|
||||
if (!this._context?.notebookPath) return;
|
||||
await this._refreshHistory(this._context.notebookPath);
|
||||
}
|
||||
});
|
||||
Widget.attach(prompt, this._cell.node);
|
||||
@@ -192,6 +255,11 @@ export class OpenCodeCellActions extends Widget {
|
||||
if (notebookPath) {
|
||||
void this._refreshHistory(notebookPath);
|
||||
}
|
||||
|
||||
// Populate the session selector dropdown (loads OpenCode's global
|
||||
// session list async; if the host didn't wire up the callbacks,
|
||||
// the row is simply not rendered).
|
||||
prompt.initialize();
|
||||
}
|
||||
|
||||
private async _refreshHistory(notebookPath: string): Promise<void> {
|
||||
|
||||
@@ -28,7 +28,8 @@ import { marked } from 'marked';
|
||||
import type {
|
||||
OpenCodeEvent,
|
||||
OpenCodeMessage,
|
||||
OpenCodeProvidersResponse
|
||||
OpenCodeProvidersResponse,
|
||||
OpenCodeSessionMeta
|
||||
} from '../types';
|
||||
import {
|
||||
loadModelSelection,
|
||||
@@ -65,8 +66,22 @@ export interface IOpenCodeInlinePromptOptions {
|
||||
* the prompt keeps the cell action from re-parsing event types.
|
||||
*/
|
||||
onStreamEnd?: () => void;
|
||||
// ----- session management (multi-session UI) -----
|
||||
/** List ALL OpenCode sessions (for the session-picker dropdown).
|
||||
* Returns the raw array; the prompt picks the active one out. */
|
||||
onListSessions?: () => Promise<OpenCodeSessionMeta[]>;
|
||||
/** Create a brand-new session, bind it to the current notebook, and
|
||||
* return its id (which becomes the new active session). */
|
||||
onCreateSession?: () => Promise<string>;
|
||||
/** Switch the active session. Returns true on success. */
|
||||
onSwitchSession?: (sessionId: string) => Promise<boolean>;
|
||||
/** Reload the message history for the currently-active session.
|
||||
* Called after a create / switch so the panel reflects the new
|
||||
* conversation's scrollback. */
|
||||
onReloadHistory?: () => Promise<void>;
|
||||
}
|
||||
|
||||
|
||||
interface FlatProvider {
|
||||
id: string;
|
||||
name?: string;
|
||||
@@ -129,6 +144,10 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
// Streaming state. _sessionId is set by the cell action after a successful
|
||||
// /edit response; applyEvent() drops events that don't belong to it.
|
||||
private _sessionId: string | null = null;
|
||||
// Multi-session UI: the dropdown and its row (null when the host
|
||||
// did not wire up the session callbacks).
|
||||
private _sessionRow: HTMLDivElement | null = null;
|
||||
private _sessionSelect: HTMLSelectElement | null = null;
|
||||
// The current assistant message DOM node (re-rendered as markdown on
|
||||
// every text delta so the user sees a properly formatted response in
|
||||
// real-time, not raw ```fence``` source).
|
||||
@@ -224,6 +243,66 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
actions.appendChild(this._sendBtn);
|
||||
actions.appendChild(this._cancelBtn);
|
||||
|
||||
// Session selector (top of the panel, above provider/model).
|
||||
// Only rendered when the host has provided the multi-session
|
||||
// callbacks. The active session id is the option currently
|
||||
// selected; switching the dropdown calls onSwitchSession, picking
|
||||
// "+ 新建会话" calls onCreateSession, and the refresh button
|
||||
// re-fetches the global session list.
|
||||
if (
|
||||
this._options.onListSessions &&
|
||||
this._options.onCreateSession &&
|
||||
this._options.onSwitchSession
|
||||
) {
|
||||
this._sessionRow = document.createElement('div');
|
||||
this._sessionRow.className = 'opencode-prompt-sessions';
|
||||
|
||||
const sLabel = document.createElement('label');
|
||||
const sSpan = document.createElement('span');
|
||||
sSpan.textContent = 'Session:';
|
||||
sLabel.appendChild(sSpan);
|
||||
|
||||
this._sessionSelect = document.createElement('select');
|
||||
this._sessionSelect.className = 'opencode-session-select';
|
||||
// Single placeholder until the list loads.
|
||||
this._sessionSelect.appendChild(
|
||||
this._mkSessionOption('', '加载中…', false)
|
||||
);
|
||||
this._sessionSelect.disabled = true;
|
||||
this._sessionSelect.addEventListener('change', () => {
|
||||
const v = this._sessionSelect!.value;
|
||||
if (v === '__new__') {
|
||||
void this._handleCreateSession();
|
||||
} else if (v && v !== this._sessionId) {
|
||||
void this._handleSwitchSession(v);
|
||||
}
|
||||
});
|
||||
sLabel.appendChild(this._sessionSelect);
|
||||
this._sessionRow.appendChild(sLabel);
|
||||
|
||||
const newBtn = document.createElement('button');
|
||||
newBtn.type = 'button';
|
||||
newBtn.className = 'opencode-session-btn-new';
|
||||
newBtn.textContent = '+ 新建';
|
||||
newBtn.title = '在 OpenCode 上创建新会话并绑定到当前 notebook';
|
||||
newBtn.addEventListener('click', () => {
|
||||
void this._handleCreateSession();
|
||||
});
|
||||
this._sessionRow.appendChild(newBtn);
|
||||
|
||||
const refreshBtn = document.createElement('button');
|
||||
refreshBtn.type = 'button';
|
||||
refreshBtn.className = 'opencode-session-btn-refresh';
|
||||
refreshBtn.textContent = '🔄';
|
||||
refreshBtn.title = '刷新 OpenCode 全局会话列表';
|
||||
refreshBtn.addEventListener('click', () => {
|
||||
void this._refreshSessionList();
|
||||
});
|
||||
this._sessionRow.appendChild(refreshBtn);
|
||||
|
||||
this.node.appendChild(this._sessionRow);
|
||||
}
|
||||
|
||||
if (this._providerSelect || this._modelSelect) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'opencode-prompt-providers';
|
||||
@@ -330,6 +409,159 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
return sel;
|
||||
}
|
||||
|
||||
// ---------- session selector (multi-session UI) ----------
|
||||
|
||||
/**
|
||||
* Repopulate the session dropdown with the given list. The currently
|
||||
* active session (this._sessionId, set by setSessionId or by
|
||||
* _handleSwitchSession) is marked selected.
|
||||
*/
|
||||
private _populateSessionSelect(sessions: OpenCodeSessionMeta[]): void {
|
||||
if (!this._sessionSelect) {
|
||||
return;
|
||||
}
|
||||
// Clear existing options (drop the loading placeholder).
|
||||
while (this._sessionSelect.firstChild) {
|
||||
this._sessionSelect.removeChild(this._sessionSelect.firstChild);
|
||||
}
|
||||
for (const s of sessions) {
|
||||
const label = s.title
|
||||
? `${s.title} (${s.id.slice(0, 8)}…)`
|
||||
: `${s.id.slice(0, 8)}…`;
|
||||
this._sessionSelect.appendChild(
|
||||
this._mkSessionOption(s.id, label, s.id === this._sessionId)
|
||||
);
|
||||
}
|
||||
// Always-available "create new" sentinel.
|
||||
this._sessionSelect.appendChild(
|
||||
this._mkSessionOption('__new__', '+ 新建会话…', false)
|
||||
);
|
||||
// Reflect the current active id (or "" if none).
|
||||
this._sessionSelect.value = this._sessionId || '';
|
||||
if (!this._sessionId) {
|
||||
// No active session; show a hint via the first option being empty.
|
||||
this._sessionSelect.value = '';
|
||||
}
|
||||
this._sessionSelect.disabled = false;
|
||||
}
|
||||
|
||||
private _mkSessionOption(
|
||||
value: string,
|
||||
label: string,
|
||||
selected: boolean
|
||||
): HTMLOptionElement {
|
||||
const o = document.createElement('option');
|
||||
o.value = value;
|
||||
o.textContent = label;
|
||||
if (selected) {
|
||||
o.selected = true;
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the global session list from the host and repopulate the
|
||||
* dropdown. Called on prompt show, on "刷新" click, and after
|
||||
* every create / switch. Errors are non-fatal — the dropdown just
|
||||
* keeps whatever it had (or stays empty).
|
||||
*/
|
||||
async _refreshSessionList(): Promise<void> {
|
||||
if (!this._options.onListSessions) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sessions = await this._options.onListSessions();
|
||||
this._populateSessionSelect(sessions);
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: failed to list sessions', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "新建" button or dropdown's __new__ entry. Creates a session,
|
||||
* binds it, sets as active, refreshes the dropdown, and reloads the
|
||||
* message history for the new (empty) session.
|
||||
*/
|
||||
private async _handleCreateSession(): Promise<void> {
|
||||
if (!this._options.onCreateSession) {
|
||||
return;
|
||||
}
|
||||
if (this._sessionSelect) {
|
||||
this._sessionSelect.disabled = true;
|
||||
}
|
||||
try {
|
||||
const newId = await this._options.onCreateSession();
|
||||
this._sessionId = newId;
|
||||
// Drop any in-progress streaming DOM (the new session is empty).
|
||||
this._resetStreamPointers();
|
||||
this._historyEl.textContent = '';
|
||||
// Re-fetch the global list so the new id is in the dropdown
|
||||
// and marked active.
|
||||
await this._refreshSessionList();
|
||||
// Reload history for the new session (empty list, but this
|
||||
// also re-syncs the prompt's view of "current state").
|
||||
if (this._options.onReloadHistory) {
|
||||
try {
|
||||
await this._options.onReloadHistory();
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: reload history failed', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Notification.error('创建会话失败: ' + (e as Error).message);
|
||||
if (this._sessionSelect) {
|
||||
this._sessionSelect.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User picked a different session from the dropdown. Asks the host
|
||||
* to bind + set active; on success, refreshes history.
|
||||
*/
|
||||
private async _handleSwitchSession(sessionId: string): Promise<void> {
|
||||
if (!this._options.onSwitchSession) {
|
||||
return;
|
||||
}
|
||||
if (this._sessionSelect) {
|
||||
this._sessionSelect.disabled = true;
|
||||
}
|
||||
try {
|
||||
const ok = await this._options.onSwitchSession(sessionId);
|
||||
if (ok) {
|
||||
this._sessionId = sessionId;
|
||||
this._resetStreamPointers();
|
||||
this._historyEl.textContent = '';
|
||||
if (this._options.onReloadHistory) {
|
||||
try {
|
||||
await this._options.onReloadHistory();
|
||||
} catch (e) {
|
||||
console.warn('opencode_bridge: reload history failed', e);
|
||||
}
|
||||
}
|
||||
// Re-mark the now-active option as selected.
|
||||
if (this._sessionSelect) {
|
||||
this._sessionSelect.value = sessionId;
|
||||
}
|
||||
} else {
|
||||
// Roll back the select to the previous value.
|
||||
if (this._sessionSelect) {
|
||||
this._sessionSelect.value = this._sessionId || '';
|
||||
}
|
||||
Notification.error('切换会话失败');
|
||||
}
|
||||
} catch (e) {
|
||||
Notification.error('切换会话失败: ' + (e as Error).message);
|
||||
if (this._sessionSelect) {
|
||||
this._sessionSelect.value = this._sessionId || '';
|
||||
}
|
||||
} finally {
|
||||
if (this._sessionSelect) {
|
||||
this._sessionSelect.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the current (providerSelect.value, modelSelect.value) for
|
||||
* this notebook. No-op if the prompt has no notebookPath, no
|
||||
@@ -356,6 +588,12 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
if (this._modelSelect) {
|
||||
this._modelSelect.disabled = disabled;
|
||||
}
|
||||
if (this._sessionSelect) {
|
||||
// Don't disable the select while streaming — the user can still
|
||||
// switch to a different session to inspect it. The cell action
|
||||
// closes the SSE on switch.
|
||||
this._sessionSelect.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the user input textarea. */
|
||||
@@ -369,12 +607,34 @@ export class OpenCodeInlinePrompt extends Widget {
|
||||
*/
|
||||
setSessionId(sessionId: string | null): void {
|
||||
this._sessionId = sessionId;
|
||||
if (this._sessionSelect) {
|
||||
// Reflect the new active id in the dropdown without rebuilding it.
|
||||
if (sessionId) {
|
||||
const has = Array.from(this._sessionSelect.options).some(
|
||||
o => o.value === sessionId
|
||||
);
|
||||
if (has) {
|
||||
this._sessionSelect.value = sessionId;
|
||||
}
|
||||
} else {
|
||||
this._sessionSelect.value = '';
|
||||
}
|
||||
}
|
||||
if (sessionId) {
|
||||
// Fresh turn: drop any in-progress stream DOM from a previous session.
|
||||
this._resetStreamPointers();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First-time wiring after the prompt is constructed. Loads the global
|
||||
* session list into the dropdown. Call from the host (cell action)
|
||||
* right after constructing the widget.
|
||||
*/
|
||||
initialize(): void {
|
||||
void this._refreshSessionList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current session's static messages (loaded once from
|
||||
* /session-messages when the panel opens). Subsequent assistant turns
|
||||
|
||||
@@ -82,6 +82,54 @@ export interface OpenCodeMessagesResponse {
|
||||
messages: OpenCodeMessage[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-session management types (1 notebook can bind N sessions, one active)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One session on the OpenCode Serve side (returned by GET /session). */
|
||||
export interface OpenCodeSessionMeta {
|
||||
id: string;
|
||||
title?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** Response from GET /opencode-bridge/sessions/all. */
|
||||
export interface OpenCodeAllSessionsResponse {
|
||||
ok: boolean;
|
||||
sessions: OpenCodeSessionMeta[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** One session bound to a notebook (returned by GET /sessions/notebook). */
|
||||
export interface OpenCodeNotebookSession {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/** Response from GET /opencode-bridge/sessions/notebook?notebook=... */
|
||||
export interface OpenCodeNotebookSessionsResponse {
|
||||
ok: boolean;
|
||||
notebookPath: string;
|
||||
activeSessionId: string | null;
|
||||
sessions: OpenCodeNotebookSession[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Response from POST/PUT /sessions/notebook and PUT /sessions/active. */
|
||||
export interface OpenCodeSessionOpResponse {
|
||||
ok: boolean;
|
||||
notebookPath: string;
|
||||
sessionId?: string;
|
||||
activeSessionId?: string | null;
|
||||
deleted?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Response from GET /opencode-bridge/providers (proxies OpenCode /config/providers).
|
||||
* Each Provider.models is a Record keyed by modelID (NOT an array). */
|
||||
export interface OpenCodeModel {
|
||||
|
||||
Reference in New Issue
Block a user