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
+261 -1
View File
@@ -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