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:
tao.chen
2026-07-28 19:34:52 +08:00
parent f9d52f7b8e
commit c0eba7e0e6
6 changed files with 1015 additions and 1122 deletions
+78 -53
View File
@@ -20,7 +20,7 @@
* history area. The user can still Copy / Insert / Replace individual
* code blocks from a finished assistant message.
*/
import { CodeCell } from '@jupyterlab/cells';
import type { CodeCell } from '@jupyterlab/cells';
import { Notification } from '@jupyterlab/apputils';
import { Widget } from '@lumino/widgets';
import { marked } from 'marked';
@@ -31,10 +31,7 @@ import type {
OpenCodeProvidersResponse,
OpenCodeSessionMeta
} from '../types';
import {
loadModelSelection,
saveModelSelection
} from './model_selection';
import { loadModelSelection, saveModelSelection } from './model_selection';
export interface IOpenCodeInlinePromptOptions {
onSubmit: (text: string, providerId?: string, modelId?: string) => void;
@@ -48,6 +45,16 @@ export interface IOpenCodeInlinePromptOptions {
* to the default selection (first provider / `default[pid]` model).
*/
notebookPath?: string;
/**
* Return the currently active CodeCell, used by the cell-edit actions
* ("📋 插入单元格内容" / "插入到光标" / "替换单元格"). When the host is
* decoupled from a specific cell (e.g. a global floating panel that
* targets whatever cell the user last focused), it passes a lambda
* that resolves the active cell from the JupyterLab shell at click
* time. Returns null if no cell is currently selectable — the prompt
* then surfaces a "请先选中一个 cell" notification instead of acting.
*/
getActiveCell?: () => CodeCell | null;
/**
* Respond to a `permission.asked` event. Returns a promise that resolves
* to true on success, false on failure (the prompt updates the UI in
@@ -81,19 +88,22 @@ export interface IOpenCodeInlinePromptOptions {
onReloadHistory?: () => Promise<void>;
}
interface FlatProvider {
id: string;
name?: string;
models: { [modelID: string]: unknown };
}
function flattenProviders(
providers: OpenCodeProvidersResponse | null
): { providers: FlatProvider[]; defaultMap: { [pid: string]: string } } {
function flattenProviders(providers: OpenCodeProvidersResponse | null): {
providers: FlatProvider[];
defaultMap: { [pid: string]: string };
} {
const out: FlatProvider[] = [];
if (!providers || !providers.providers) {
return { providers: out, defaultMap: (providers && providers.default) || {} };
return {
providers: out,
defaultMap: (providers && providers.default) || {}
};
}
for (const p of providers.providers) {
if (!p.models || typeof p.models !== 'object') {
@@ -118,7 +128,10 @@ function pickDefaultModelId(
return undefined;
}
const fromDefault = defaultMap[providerId];
if (fromDefault && Object.prototype.hasOwnProperty.call(models, fromDefault)) {
if (
fromDefault &&
Object.prototype.hasOwnProperty.call(models, fromDefault)
) {
return fromDefault;
}
return keys[0];
@@ -130,7 +143,6 @@ interface BlockEntry {
}
export class OpenCodeInlinePrompt extends Widget {
private _cell: CodeCell;
private _options: IOpenCodeInlinePromptOptions;
private _textarea: HTMLTextAreaElement;
private _sendBtn: HTMLButtonElement;
@@ -161,12 +173,8 @@ export class OpenCodeInlinePrompt extends Widget {
// Active collapsible blocks (reasoning / tool / etc.), keyed by type.
private _activeBlocks: { [key: string]: BlockEntry } = {};
constructor(
cell: CodeCell,
options: IOpenCodeInlinePromptOptions
) {
constructor(options: IOpenCodeInlinePromptOptions) {
super();
this._cell = cell;
this._options = options;
this.addClass('opencode-inline-prompt');
@@ -226,7 +234,7 @@ export class OpenCodeInlinePrompt extends Widget {
const modelId = this._modelSelect?.value || undefined;
options.onSubmit(text, providerId, modelId);
});
this._textarea.addEventListener('keydown', (e) => {
this._textarea.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey) {
e.preventDefault();
this._sendBtn.click();
@@ -247,7 +255,8 @@ export class OpenCodeInlinePrompt extends Widget {
this._insertCellBtn.type = 'button';
this._insertCellBtn.className = 'opencode-btn-insert-cell';
this._insertCellBtn.textContent = '📋 插入单元格内容';
this._insertCellBtn.title = '把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
this._insertCellBtn.title =
'把当前 cell 的源码作为 markdown code block 追加到输入框末尾';
this._insertCellBtn.addEventListener('click', () => {
this._insertCellSource();
});
@@ -348,6 +357,15 @@ export class OpenCodeInlinePrompt extends Widget {
this.node.appendChild(this._textarea);
this.node.appendChild(actions);
// Apply the initial disabled state to ALL interactive elements
// (provider/model selects, send button, AND the insert-cell
// button — the last one depends on `getActiveCell()` which only
// resolves correctly here in the constructor). Without this, the
// insert button would stay enabled until the first external
// setDisabled() call, which is too late for the user's first
// click.
this.setDisabled(options.disabled);
}
private _rebuildModelSelect(
@@ -606,6 +624,14 @@ export class OpenCodeInlinePrompt extends Widget {
// closes the SSE on switch.
this._sessionSelect.disabled = false;
}
// The "📋 插入单元格内容" button is only meaningful when there is
// an active cell. Reflect availability in its disabled state so
// the user can see at a glance whether the action will work, and
// the click handler can still no-op gracefully via Notification.
if (this._insertCellBtn) {
const hasActiveCell = !!this._options.getActiveCell?.();
this._insertCellBtn.disabled = disabled || !hasActiveCell;
}
}
/** Clear the user input textarea. */
@@ -618,13 +644,18 @@ export class OpenCodeInlinePrompt extends Widget {
* markdown code block to the textarea. The LLM only sees what the
* user explicitly chose to include — the cell source is NEVER
* auto-injected (v0.2.x change).
*
* The cell is resolved at click time through the host-provided
* `getActiveCell` callback, so the prompt itself stays decoupled
* from any specific CodeCell instance.
*/
private _insertCellSource(): void {
if (!this._cell || !this._cell.model || !this._cell.model.sharedModel) {
Notification.info('当前 cell 没有内容');
const cell = this._options.getActiveCell?.() ?? null;
if (!cell || !cell.model || !cell.model.sharedModel) {
Notification.info('请先选中一个 cell');
return;
}
const source = this._cell.model.sharedModel.getSource() as string;
const source = cell.model.sharedModel.getSource() as string;
if (!source || !source.trim()) {
Notification.info('当前 cell 为空');
return;
@@ -632,10 +663,9 @@ export class OpenCodeInlinePrompt extends Widget {
// Use the cell's language as the fence info string when it
// looks like a real language identifier; otherwise use a plain
// fence so markdown stays portable.
const langRaw =
(this._cell.model as any).sharedModel && (this._cell.model as any).type
? ((this._cell.model as any).type as string)
: 'python';
const langRaw = (cell.model as any).type
? ((cell.model as any).type as string)
: 'python';
const lang = /^[a-zA-Z0-9_+\-#]+$/.test(langRaw) ? langRaw : '';
const block = '\n```' + lang + '\n' + source.replace(/\n$/, '') + '\n```\n';
const before = this._textarea.value;
@@ -710,9 +740,7 @@ export class OpenCodeInlinePrompt extends Widget {
applyEvent(event: OpenCodeEvent): void {
const type = event.type || (event.payload && event.payload.type) || '';
const props =
event.properties ||
(event.payload && event.payload.properties) ||
{};
event.properties || (event.payload && event.payload.properties) || {};
const sessionID =
props.sessionID ||
props.sessionId ||
@@ -761,13 +789,14 @@ export class OpenCodeInlinePrompt extends Widget {
type: string,
props: { [k: string]: any }
): boolean {
if (type === 'permission.asked') {
const permId = String(props.id || props.permissionID || '');
this._createPermissionBlock(
permId,
String(
props.description || props.title || '系统请求执行权限(命令行/文件修改)'
props.description ||
props.title ||
'系统请求执行权限(命令行/文件修改)'
)
);
return true;
@@ -819,7 +848,11 @@ export class OpenCodeInlinePrompt extends Widget {
return true;
}
if (type === 'session.next.tool.input.delta') {
this._appendToBlock('tool', '🛠️ 工具输入与参数', String(props.delta || ''));
this._appendToBlock(
'tool',
'🛠️ 工具输入与参数',
String(props.delta || '')
);
return true;
}
if (type === 'session.next.tool.progress') {
@@ -881,10 +914,7 @@ export class OpenCodeInlinePrompt extends Widget {
}
const payload = event.payload;
if (payload) {
return check(
payload.type || topType,
payload.properties || topProps
);
return check(payload.type || topType, payload.properties || topProps);
}
return false;
}
@@ -970,8 +1000,7 @@ export class OpenCodeInlinePrompt extends Widget {
}
const details = document.createElement('details');
details.id = domId;
details.className =
'opencode-msg-block opencode-msg-block-interaction';
details.className = 'opencode-msg-block opencode-msg-block-interaction';
details.open = true;
const summary = document.createElement('summary');
summary.textContent = '🔐 权限请求等待处理';
@@ -1053,8 +1082,7 @@ export class OpenCodeInlinePrompt extends Widget {
}
const details = document.createElement('details');
details.id = domId;
details.className =
'opencode-msg-block opencode-msg-block-interaction';
details.className = 'opencode-msg-block opencode-msg-block-interaction';
details.open = true;
const summary = document.createElement('summary');
summary.textContent = '❓ Agent 提问等待回复';
@@ -1077,14 +1105,9 @@ export class OpenCodeInlinePrompt extends Widget {
submit.className = 'opencode-q-submit';
submit.textContent = '提交回答';
submit.addEventListener('click', () => {
void this._handleQuestionResponse(
questionId,
input,
inputRow,
details
);
void this._handleQuestionResponse(questionId, input, inputRow, details);
});
input.addEventListener('keydown', (e) => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
e.preventDefault();
submit.click();
@@ -1112,7 +1135,8 @@ export class OpenCodeInlinePrompt extends Widget {
return;
}
if (!this._options.onQuestionReply) {
inputRow.innerHTML = '<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
inputRow.innerHTML =
'<span style="color: #64748b;">(未配置 onQuestionReply 回调)</span>';
return;
}
inputRow.innerHTML = '<span style="color: #64748b;">正在提交…</span>';
@@ -1242,8 +1266,9 @@ export class OpenCodeInlinePrompt extends Widget {
}
private _insertAtCursor(text: string): void {
const cell = this._cell;
const cell = this._options.getActiveCell?.() ?? null;
if (!cell || !cell.model || !cell.model.sharedModel) {
Notification.info('请先选中一个 cell');
return;
}
const sharedModel = cell.model.sharedModel;
@@ -1251,9 +1276,8 @@ export class OpenCodeInlinePrompt extends Widget {
let offset = source.length;
try {
const editor = (cell as any).editor;
const pos = editor && editor.getCursorPosition
? editor.getCursorPosition()
: null;
const pos =
editor && editor.getCursorPosition ? editor.getCursorPosition() : null;
if (pos) {
const lines = source.split('\n');
const line = Math.max(0, Math.min(pos.line, lines.length - 1));
@@ -1276,8 +1300,9 @@ export class OpenCodeInlinePrompt extends Widget {
}
private _replaceCell(text: string): void {
const cell = this._cell;
const cell = this._options.getActiveCell?.() ?? null;
if (!cell || !cell.model || !cell.model.sharedModel) {
Notification.info('请先选中一个 cell');
return;
}
cell.model.sharedModel.setSource(text);