feat: replace per-cell AI button with global floating panel
The per-cell toolbar AI button (OpenCodeCellActions) is removed. A
single round button is mounted on document.body at the bottom-right of
the JupyterLab shell; clicking it opens a floating panel containing
the same OpenCodeInlinePrompt widget. Clicking again closes it.
The panel resolves the active notebook from app.shell.currentWidget
at open time and re-resolves it whenever currentChanged fires while
the panel is open. The active cell (used by the 📋 插入单元格内容,
↪ Insert, and ⟳ Replace cell actions) is resolved via a new
getActiveCell callback in OpenCodeInlinePrompt, decoupling the prompt
itself from any specific CodeCell instance.
Files:
- src/components/opencode_floating_panel.ts (new) — global panel
- src/components/opencode_cell_actions.ts (deleted) — logic moved up
- src/components/opencode_inline_prompt.ts — getActiveCell option
- src/index.ts — wire floating panel, drop toolbar factory
- style/base.css — .opencode-floating-* styles
- src/__tests__/opencode_floating_panel.spec.ts — new tests (renamed)
All existing functionality preserved: multi-session UI, SSE
streaming, permission/question interaction, history reload.
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
this._panelEl.appendChild(prompt.node);
|
||||
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) {
|
||||
this._prompt.dispose();
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user