Files
notebook-ai-extension/src/components/opencode_floating_panel.ts
T
tao.chen d412130612 fix: hide+show cycles no longer accumulate prompt nodes in floating panel
Bug: clicking the floating button after the first time caused the panel
content to multiply — every hide+show cycle appended a new prompt while
the previous one stayed in the DOM.

Root cause: the prompt's Lumino parent is null (it's attached to a raw
HTMLDivElement, not a Widget, so neither appendChild nor
Widget.attach(widget, HTMLElement) sets a Lumino parent). The
defensive DOM cleanup in _hidePrompt now removes the prompt's node
explicitly before nulling the reference, so no residual nodes linger
across show+hide cycles. Also use Widget.attach in _showPrompt for the
before-attach / after-attach lifecycle messages the prompt may rely on.

Added a regression test that toggles 5 times and asserts the panel
contains exactly one .opencode-inline-prompt node.

Files:
- src/components/opencode_floating_panel.ts — defensive cleanup + attach
- src/__tests__/opencode_floating_panel.spec.ts — regression test
2026-07-29 09:43:59 +08:00

382 lines
12 KiB
TypeScript

/**
* Global floating AI button + panel, mounted on document.body. Replaces
* the per-cell toolbar AI button (formerly OpenCodeCellActions). Click
* the round bottom-right button to expand the OpenCodeInlinePrompt; click
* again to collapse it.
*
* The panel operates on the JupyterLab shell's currently-active
* NotebookPanel — the (notebookPath, activeCell) pair is resolved at
* panel-open time from `app.shell.currentWidget`, and re-resolved when
* `app.shell.currentChanged` fires while the panel is open.
*
* All session/SSE/history state that used to live on
* OpenCodeCellActions now lives here. The prompt is created lazily on
* the first panel open and disposed on close (recreated on every
* re-open so the user always sees a fresh history fetch).
*/
import { JupyterFrontEnd } from '@jupyterlab/application';
import { Notification } from '@jupyterlab/apputils';
import type { CodeCell } from '@jupyterlab/cells';
import { ServerConnection } from '@jupyterlab/services';
import { Widget } from '@lumino/widgets';
import {
callOpenCodeBindExistingSession,
callOpenCodeCreateNotebookSession,
callOpenCodeEdit,
callOpenCodeListAllSessions,
callOpenCodeReplyPermission,
callOpenCodeReplyQuestion,
callOpenCodeSessionMessages,
subscribeOpenCodeEvents,
type OpenCodeEventSubscription
} from '../api/opencode_client';
import type { OpenCodeProvidersResponse } from '../types';
import { OpenCodeInlinePrompt } from './opencode_inline_prompt';
export interface IOpenCodeFloatingPanelOptions {
app: JupyterFrontEnd;
serverSettings: ServerConnection.ISettings;
providers: OpenCodeProvidersResponse | null;
}
type Status = 'idle' | 'loading';
export class OpenCodeFloatingPanel extends Widget {
private _app: JupyterFrontEnd;
private _serverSettings: ServerConnection.ISettings | null;
private _providers: OpenCodeProvidersResponse | null;
private _button: HTMLButtonElement;
private _panelEl: HTMLDivElement;
private _prompt: OpenCodeInlinePrompt | null = null;
private _sseSub: OpenCodeEventSubscription | null = null;
private _notebookPath: string | null = null;
private _status: Status = 'idle';
constructor(options: IOpenCodeFloatingPanelOptions) {
super();
this._app = options.app;
this._serverSettings = options.serverSettings;
this._providers = options.providers;
this.addClass('opencode-floating-root');
this.node.style.position = 'fixed';
this.node.style.bottom = '24px';
this.node.style.right = '24px';
this.node.style.zIndex = '1000';
this._button = document.createElement('button');
this._button.className = 'opencode-floating-button';
this._button.textContent = '🪄';
this._button.title = 'OpenCode AI';
this._button.addEventListener('click', () => this.toggle());
this.node.appendChild(this._button);
this._panelEl = document.createElement('div');
this._panelEl.className = 'opencode-floating-panel';
this._panelEl.hidden = true;
this.node.appendChild(this._panelEl);
// JupyterFrontEnd.shell is typed as `JupyterShell | null` in some
// JupyterLab versions; cast through any to satisfy strict null checks
// while keeping the public API simple.
(this._app.shell as any).currentChanged.connect(this._onShellChanged, this);
}
dispose(): void {
if (this.isDisposed) {
return;
}
(this._app.shell as any).currentChanged.disconnect(
this._onShellChanged,
this
);
this._closeSse();
this._hidePrompt();
super.dispose();
}
/** Update the providers payload after the initial async fetch. */
setProviders(p: OpenCodeProvidersResponse | null): void {
this._providers = p;
}
/** Toggle the panel open/closed. */
toggle(): void {
if (this._panelEl.hidden) {
this._showPrompt();
} else {
this._hidePrompt();
}
}
private _onShellChanged = (): void => {
// The active notebook changed. If the panel is open, refresh the
// underlying prompt so the (provider, model) selection, history,
// and active-cell context all reflect the new notebook.
//
// Implemented as an arrow-function class field (not a method) so
// `this` is bound regardless of how the signal's slot invokes us.
if (!this._panelEl.hidden) {
this._hidePrompt();
this._showPrompt();
}
};
private _resolveNotebookContext(): {
notebookPath: string | null;
getActiveCell: () => CodeCell | null;
} {
const widget = this._app.shell.currentWidget as any;
const notebookPath =
widget && widget.context && typeof widget.context.path === 'string'
? (widget.context.path as string)
: null;
const getActiveCell = (): CodeCell | null => {
const cur = this._app.shell.currentWidget as any;
if (!cur || !cur.content) {
return null;
}
const active = cur.content.activeCell;
return (active as CodeCell) || null;
};
return { notebookPath, getActiveCell };
}
private _showPrompt(): void {
if (this._prompt) {
this._panelEl.hidden = false;
return;
}
const { notebookPath, getActiveCell } = this._resolveNotebookContext();
this._notebookPath = notebookPath;
const prompt = new OpenCodeInlinePrompt({
disabled: !this._notebookPath || this._status === 'loading',
providers: this._providers,
notebookPath: this._notebookPath ?? undefined,
getActiveCell,
onSubmit: (text: string, providerId?: string, modelId?: string) => {
void this._onSubmit(text, providerId, modelId);
},
onCancel: () => {
this._hidePrompt();
},
onPermissionReply: async (permId, response) => {
if (!this._serverSettings) {
return false;
}
const sessionId = (this._prompt as any)._sessionId as string | null;
if (!sessionId) {
return false;
}
try {
const r = await callOpenCodeReplyPermission(
sessionId,
permId,
response,
this._serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: permission reply failed', e);
return false;
}
},
onQuestionReply: async (qId, answer) => {
if (!this._serverSettings) {
return false;
}
const sessionId = (this._prompt as any)._sessionId as string | null;
if (!sessionId) {
return false;
}
try {
const r = await callOpenCodeReplyQuestion(
sessionId,
qId,
answer,
this._serverSettings
);
return r.ok;
} catch (e) {
console.warn('opencode_bridge: question reply failed', e);
return false;
}
},
onStreamEnd: () => {
// Centralized: the prompt detected "idle" and signals us to
// close the SSE stream + restore the input.
this._closeSse();
},
onListSessions: async () => {
if (!this._serverSettings) {
return [];
}
try {
const r = await callOpenCodeListAllSessions(this._serverSettings);
return r.sessions || [];
} catch (e) {
console.warn('opencode_bridge: list all sessions failed', e);
return [];
}
},
onCreateSession: async () => {
if (!this._serverSettings || !this._notebookPath) {
throw new Error('notebook not ready');
}
this._closeSse();
const r = await callOpenCodeCreateNotebookSession(
this._notebookPath,
undefined,
this._serverSettings
);
if (!r.ok || !r.sessionId) {
throw new Error(r.error || 'create_session failed');
}
return r.sessionId;
},
onSwitchSession: async (sessionId: string) => {
if (!this._serverSettings || !this._notebookPath) {
return false;
}
this._closeSse();
// PUT /sessions/notebook binds AND sets active in one call.
const r = await callOpenCodeBindExistingSession(
this._notebookPath,
sessionId,
this._serverSettings
);
return !!r.ok;
},
onReloadHistory: async () => {
if (!this._notebookPath) {
return;
}
await this._refreshHistory(this._notebookPath);
}
});
// Use Widget.attach (not raw appendChild) so Lumino sends the
// before-attach / after-attach messages — the prompt relies on
// these for isAttached/isVisible tracking. The defensive DOM
// cleanup in _hidePrompt handles the case where dispose() leaves
// a residual node behind (the panel host is an HTMLElement, not a
// Widget, so the Lumino parent stays null and dispose() can't
// always clean up the DOM on its own).
Widget.attach(prompt, this._panelEl);
this._prompt = prompt;
// Load the current session's static history before streaming starts.
if (this._notebookPath) {
void this._refreshHistory(this._notebookPath);
}
prompt.initialize();
this._panelEl.hidden = false;
}
private async _refreshHistory(notebookPath: string): Promise<void> {
if (!this._serverSettings || !this._prompt) {
return;
}
try {
const resp = await callOpenCodeSessionMessages(
notebookPath,
this._serverSettings
);
this._prompt.setMessages(resp.messages);
} catch (e) {
console.warn('opencode_bridge: failed to fetch session messages', e);
}
}
private _hidePrompt(): void {
this._closeSse();
if (this._prompt) {
const node = this._prompt.node;
this._prompt.dispose();
// Defensive DOM cleanup: the prompt is attached via raw DOM
// (appendChild/Widget.attach with an HTMLElement host), so its
// Lumino parent is null. In some scenarios — for example when
// the dispose path runs synchronously while another show+hide
// cycle is already in flight — the dispose() call does not
// remove the node from this._panelEl, leaving a residual node
// that the next _showPrompt appends to. Without this defensive
// removeChild, the user would see the panel content multiply
// with every click.
if (node.parentNode === this._panelEl) {
this._panelEl.removeChild(node);
}
this._prompt = null;
}
this._panelEl.hidden = true;
}
private async _onSubmit(
text: string,
providerId?: string,
modelId?: string
): Promise<void> {
if (!this._notebookPath) {
Notification.info('请先打开一个 notebook');
return;
}
if (!this._serverSettings) {
Notification.error('OpenCode 运行时未初始化');
return;
}
const request = {
prompt: text,
context: { notebookPath: this._notebookPath },
providerId,
modelId
};
this._status = 'loading';
if (this._prompt) {
this._prompt.setDisabled(true);
this._prompt.setSessionId(null);
}
let resp;
try {
resp = await callOpenCodeEdit(request, this._serverSettings);
} catch (e) {
this._status = 'idle';
this._prompt?.setDisabled(false);
Notification.error(`OpenCode 失败: ${(e as Error).message}`);
return;
}
if (!resp.ok) {
this._status = 'idle';
this._prompt?.setDisabled(false);
Notification.error(`OpenCode 错误: ${resp.error}`);
return;
}
this._prompt?.setSessionId(resp.sessionId);
this._prompt?.clearInput();
this._sseSub = subscribeOpenCodeEvents(
resp.sessionId,
this._serverSettings,
{
onEvent: ev => {
this._prompt?.applyEvent(ev);
},
onError: err => {
console.warn('opencode_bridge: SSE error', err);
}
}
);
}
private _closeSse(): void {
if (this._sseSub) {
this._sseSub.close();
this._sseSub = null;
}
this._status = 'idle';
this._prompt?.setDisabled(false);
}
}