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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user