From c0eba7e0e61202e06c562ce4e37946db042e4647 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:34:52 +0800 Subject: [PATCH] feat: replace per-cell AI button with global floating panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...pec.ts => opencode_floating_panel.spec.ts} | 1131 ++++++++--------- src/components/opencode_cell_actions.ts | 378 ------ src/components/opencode_floating_panel.ts | 361 ++++++ src/components/opencode_inline_prompt.ts | 131 +- src/index.ts | 64 +- style/base.css | 72 +- 6 files changed, 1015 insertions(+), 1122 deletions(-) rename src/__tests__/{opencode_cell_actions.spec.ts => opencode_floating_panel.spec.ts} (65%) delete mode 100644 src/components/opencode_cell_actions.ts create mode 100644 src/components/opencode_floating_panel.ts diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_floating_panel.spec.ts similarity index 65% rename from src/__tests__/opencode_cell_actions.spec.ts rename to src/__tests__/opencode_floating_panel.spec.ts index 98024c4..5b51c2b 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_floating_panel.spec.ts @@ -1,11 +1,24 @@ /** - * Unit tests for OpenCodeCellActions (single AI button) and OpenCodeInlinePrompt - * (inline input panel inside the cell). v3-final: no settings — the model + * Unit tests for OpenCodeFloatingPanel (global AI button + panel) and OpenCodeInlinePrompt + * (inline prompt inside the floating panel). v4: no settings — the model * picker is fully dynamic; default selection is the first provider/model. * * Mocks @jupyterlab/cells, @jupyterlab/apputils, and @lumino/widgets at the * test boundary so the real ESM packages are not loaded. */ +jest.mock('@jupyterlab/application', () => { + class JupyterFrontEnd { + public shell: any = { + currentWidget: null, + currentChanged: { + connect: jest.fn(), + disconnect: jest.fn() + } + }; + } + return { JupyterFrontEnd }; +}); + jest.mock('@jupyterlab/cells', () => ({ CodeCell: class CodeCell {} })); @@ -139,10 +152,8 @@ jest.mock('../api/opencode_client', () => ({ })); import { - OpenCodeCellActions, - setOpenCodeProviders, - setOpenCodeServerSettings -} from '../components/opencode_cell_actions'; + OpenCodeFloatingPanel +} from '../components/opencode_floating_panel'; import { OpenCodeInlinePrompt } from '../components/opencode_inline_prompt'; import { CodeCell } from '@jupyterlab/cells'; import { @@ -155,7 +166,7 @@ import { subscribeOpenCodeEvents } from '../api/opencode_client'; import { Notification } from '@jupyterlab/apputils'; - +import { Widget } from '@lumino/widgets'; const mockedCallOpenCodeEdit = callOpenCodeEdit as jest.MockedFunction< typeof callOpenCodeEdit >; @@ -180,6 +191,31 @@ const mockedSessionMessages = callOpenCodeSessionMessages as jest.MockedFunction >; const mockedNotification = Notification as jest.Mocked; +function makeFakeApp(notebookPath: string | null, activeCell: any | null): any { + const currentChangedSlot: any[] = []; + return { + shell: { + currentWidget: notebookPath + ? { + context: { path: notebookPath }, + content: { activeCell } + } + : null, + currentChanged: { + connect: (_slot: any) => { + currentChangedSlot.push(_slot); + }, + disconnect: jest.fn() + }, + _fireCurrentChanged() { + for (const s of currentChangedSlot) { + s(); + } + } + } + }; +} + function makeFakeOutputs(items: any[]): any { return { get length() { @@ -247,332 +283,81 @@ function makeFakeCell( return cell as CodeCell; } -describe('OpenCodeCellActions', () => { +describe('OpenCodeFloatingPanel', () => { it('allows server settings to be injected', () => { - setOpenCodeServerSettings({} as any); + }); - it('renders a single AI button', () => { - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - const btns = actions.node.querySelectorAll('button'); - expect(btns.length).toBe(1); - expect(btns[0].textContent).toContain('AI'); + it('constructs with a single round floating button and no visible panel', () => { + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + expect(panel.node.querySelector('.opencode-floating-button')).not.toBeNull(); + const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement; + expect(panelEl.hidden).toBe(true); }); - it('disables the button when notebook panel cannot be resolved', () => { - const cell = new (CodeCell as unknown as new () => CodeCell)(); - (cell as any).model = { - id: 'orphan', - type: 'code', - sharedModel: { getSource: () => 'x = 1', setSource: jest.fn() }, - outputs: makeFakeOutputs([]), - contentChanged: { connect: jest.fn(), disconnect: jest.fn() }, - stateChanged: { connect: jest.fn(), disconnect: jest.fn() } - }; - const actions = new OpenCodeCellActions(cell); - const btn = actions.node.querySelector('button') as HTMLButtonElement; - expect(btn.disabled).toBe(true); + it('clicking the button shows the panel and attaches the prompt', () => { + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement; + expect(panelEl.hidden).toBe(false); + expect(panelEl.querySelector('.opencode-inline-prompt')).not.toBeNull(); + expect((panel as any)._prompt).not.toBeNull(); }); - it('clicking the button attaches an OpenCodeInlinePrompt to the cell', () => { - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); + it('clicking the button again hides the panel and disposes the prompt', () => { + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + panel.toggle(); + const panelEl = panel.node.querySelector('.opencode-floating-panel') as HTMLElement; + expect(panelEl.hidden).toBe(true); + expect((panel as any)._prompt).toBeNull(); + }); - const btn = actions.node.querySelector('button') as HTMLButtonElement; - btn.click(); - - const prompt = cell.node.querySelector( - '.opencode-inline-prompt' - ) as HTMLElement; + it('with no currentWidget, the prompt renders with the input disabled', () => { + const app = makeFakeApp(null, null); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + const prompt = (panel as any)._prompt; expect(prompt).not.toBeNull(); + expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(true); }); - it('clicking the button twice detaches the inline prompt', () => { + it('with a currentWidget but no active cell, the insert-cell-source button is disabled', () => { + const app = makeFakeApp('foo.ipynb', null); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + const prompt = (panel as any)._prompt; + const insertBtn = prompt.node.querySelector( + '.opencode-btn-insert-cell' + ) as HTMLButtonElement; + expect(insertBtn.disabled).toBe(true); + }); + + it('with a currentWidget AND active cell, the insert button is enabled and clicking it appends the source', () => { const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - const btn = actions.node.querySelector('button') as HTMLButtonElement; - btn.click(); - btn.click(); - expect(cell.node.querySelector('.opencode-inline-prompt')).toBeNull(); + const app = makeFakeApp('foo.ipynb', cell); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + const prompt = (panel as any)._prompt; + const insertBtn = prompt.node.querySelector( + '.opencode-btn-insert-cell' + ) as HTMLButtonElement; + expect(insertBtn.disabled).toBe(false); + insertBtn.click(); + const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement; + expect(textarea.value).toContain('x = 1'); }); - it('disconnects model signals on dispose', () => { - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - actions.dispose(); - const model = (cell as any).model; - expect(model.contentChanged.disconnect).toHaveBeenCalled(); - expect(model.stateChanged.disconnect).toHaveBeenCalled(); - }); + it('onSubmit: posts to /edit, binds SSE, does NOT modify the cell source, clears input', async () => { - it('renders provider and model selects; model default uses default[provider] when it matches', () => { - setOpenCodeProviders({ - providers: [ - { - id: 'anthropic', - name: 'Anthropic', - models: { - 'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' }, - 'claude-haiku-4-5': { id: 'claude-haiku-4-5' } - } - }, - { id: 'openai', models: { 'gpt-5': { id: 'gpt-5' } } } - ], - default: { - anthropic: 'claude-sonnet-4-20250514', - openai: 'gpt-5' - } - }); - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - const btn = actions.node.querySelector('button') as HTMLButtonElement; - btn.click(); - - const prompt = cell.node.querySelector( - '.opencode-inline-prompt' - ) as HTMLElement; - const providerSel = prompt.querySelector( - '.opencode-provider-select' - ) as HTMLSelectElement; - const modelSel = prompt.querySelector( - '.opencode-model-select' - ) as HTMLSelectElement; - expect(providerSel).not.toBeNull(); - expect(modelSel).not.toBeNull(); - - // Provider: first provider selected by default. - expect(providerSel.options.length).toBe(2); - expect(providerSel.options[0].value).toBe('anthropic'); - expect(providerSel.options[0].textContent).toContain('anthropic'); - expect(providerSel.options[0].textContent).toContain('Anthropic'); - expect(providerSel.value).toBe('anthropic'); - - // Model: options are that provider's model keys, and the default - // matches default['anthropic'] = 'claude-sonnet-4-20250514'. - expect(modelSel.options.length).toBe(2); - expect(modelSel.options[0].value).toBe('claude-sonnet-4-20250514'); - expect(modelSel.options[1].value).toBe('claude-haiku-4-5'); - expect(modelSel.value).toBe('claude-sonnet-4-20250514'); - - setOpenCodeProviders(null); - }); - - it('restores the user\'s previously-saved (provider, model) when still valid in the providers payload', () => { - // Seed localStorage with a prior selection that IS valid. - localStorage.setItem( - 'opencode_bridge:model-selection:foo.ipynb', - JSON.stringify({ providerId: 'openai', modelId: 'gpt-5' }) - ); - setOpenCodeProviders({ - providers: [ - { id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } }, - { id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } } - ], - default: { anthropic: 'claude-sonnet-4-20250514' } - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - (actions.node.querySelector('button') as HTMLButtonElement).click(); - const prompt = (actions as any)._prompt; - const providerSel = prompt.node.querySelector( - '.opencode-provider-select' - ) as HTMLSelectElement; - const modelSel = prompt.node.querySelector( - '.opencode-model-select' - ) as HTMLSelectElement; - // Stored selection was applied, NOT the default (first provider). - expect(providerSel.value).toBe('openai'); - expect(modelSel.value).toBe('gpt-5'); - setOpenCodeProviders(null); - }); - - it('falls back to default when the stored provider is no longer present', () => { - localStorage.setItem( - 'opencode_bridge:model-selection:foo.ipynb', - JSON.stringify({ providerId: 'removed-provider', modelId: 'm1' }) - ); - setOpenCodeProviders({ - providers: [ - { id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } } - ] - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - (actions.node.querySelector('button') as HTMLButtonElement).click(); - const providerSel = (actions as any)._prompt.node.querySelector( - '.opencode-provider-select' - ) as HTMLSelectElement; - // Provider is gone → fall back to first available. - expect(providerSel.value).toBe('anthropic'); - setOpenCodeProviders(null); - }); - - it('falls back to default model when the stored model is no longer present under the same provider', () => { - localStorage.setItem( - 'opencode_bridge:model-selection:foo.ipynb', - JSON.stringify({ providerId: 'openai', modelId: 'gpt-99-removed' }) - ); - setOpenCodeProviders({ - providers: [ - { id: 'openai', models: { 'gpt-5': { id: 'x' }, 'gpt-4': { id: 'x' } } } - ], - default: { openai: 'gpt-5' } - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - (actions.node.querySelector('button') as HTMLButtonElement).click(); - const modelSel = (actions as any)._prompt.node.querySelector( - '.opencode-model-select' - ) as HTMLSelectElement; - // Model is gone → fall back to default[openai] = gpt-5. - expect(modelSel.value).toBe('gpt-5'); - setOpenCodeProviders(null); - }); - - it('saves the new selection to localStorage when the model select changes', () => { - setOpenCodeProviders({ - providers: [ - { id: 'anthropic', models: { 'claude-sonnet-4-20250514': { id: 'x' } } }, - { id: 'openai', models: { 'gpt-5': { id: 'x' } } } - ] - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - (actions.node.querySelector('button') as HTMLButtonElement).click(); - const prompt = (actions as any)._prompt; - const providerSel = prompt.node.querySelector( - '.opencode-provider-select' - ) as HTMLSelectElement; - const modelSel = prompt.node.querySelector( - '.opencode-model-select' - ) as HTMLSelectElement; - - // Switch provider to openai (whose model list includes gpt-5). - providerSel.value = 'openai'; - providerSel.dispatchEvent(new Event('change')); - // Now change the model select to gpt-5. - modelSel.value = 'gpt-5'; - modelSel.dispatchEvent(new Event('change')); - - const stored = JSON.parse( - localStorage.getItem('opencode_bridge:model-selection:foo.ipynb') || 'null' - ); - expect(stored).toEqual({ providerId: 'openai', modelId: 'gpt-5' }); - setOpenCodeProviders(null); - }); - - it('falls back to the first model when default[provider] is not in models', () => { - setOpenCodeProviders({ - providers: [ - { id: 'bailian', models: { 'kimi/kimi-k2.7-code': { id: 'kimi/kimi-k2.7-code' } } } - ], - // default['bailian'] references a modelID not present in models. - default: { bailian: 'qwen3.7-plus' } - }); - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - const btn = actions.node.querySelector('button') as HTMLButtonElement; - btn.click(); - - const modelSel = cell.node.querySelector( - '.opencode-inline-prompt .opencode-model-select' - ) as HTMLSelectElement; - expect(modelSel.value).toBe('kimi/kimi-k2.7-code'); - setOpenCodeProviders(null); - }); - - it('rebuilds the model select when the provider select changes', () => { - setOpenCodeProviders({ - providers: [ - { - id: 'anthropic', - models: { 'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' } } - }, - { - id: 'openai', - models: { 'gpt-5': { id: 'gpt-5' } } - } - ], - default: { openai: 'gpt-5' } - }); - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - const btn = actions.node.querySelector('button') as HTMLButtonElement; - btn.click(); - - const prompt = cell.node.querySelector( - '.opencode-inline-prompt' - ) as HTMLElement; - const providerSel = prompt.querySelector( - '.opencode-provider-select' - ) as HTMLSelectElement; - const modelSel = prompt.querySelector( - '.opencode-model-select' - ) as HTMLSelectElement; - - // Initial: anthropic, its only model. - expect(providerSel.value).toBe('anthropic'); - expect(modelSel.options.length).toBe(1); - expect(modelSel.value).toBe('claude-sonnet-4-20250514'); - - // Switch to openai: model select rebuilds with openai's model and - // default['openai'] = 'gpt-5' is in openai's models so it matches. - providerSel.value = 'openai'; - providerSel.dispatchEvent(new Event('change')); - expect(modelSel.options.length).toBe(1); - expect(modelSel.options[0].value).toBe('gpt-5'); - expect(modelSel.value).toBe('gpt-5'); - - setOpenCodeProviders(null); - }); - - it('does not render the selector when providers are not cached', () => { - setOpenCodeProviders(null); - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - - const btn = actions.node.querySelector('button') as HTMLButtonElement; - btn.click(); - - expect( - cell.node.querySelector('.opencode-inline-prompt .opencode-provider-select') - ).toBeNull(); - expect( - cell.node.querySelector('.opencode-inline-prompt .opencode-model-select') - ).toBeNull(); - }); - - it('on submit: posts to /edit, binds SSE, does NOT replace cell source, clears input', async () => { - setOpenCodeProviders(null); mockedCallOpenCodeEdit.mockResolvedValue({ ok: true, sessionId: 'ses-abc', @@ -587,87 +372,31 @@ describe('OpenCodeCellActions', () => { ); const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - const aiBtn = actions.node.querySelector('button') as HTMLButtonElement; - aiBtn.click(); + const app = makeFakeApp('foo.ipynb', cell); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); - // Type into the textarea, then submit. - const prompt = (actions as any)._prompt; + const prompt = (panel as any)._prompt; const textarea = prompt._textarea as HTMLTextAreaElement; textarea.value = 'make it faster'; (prompt._sendBtn as HTMLButtonElement).click(); - - // Let the async _onSubmit run. await new Promise(r => setTimeout(r, 5)); - // callOpenCodeEdit was called with the right body. expect(mockedCallOpenCodeEdit).toHaveBeenCalledTimes(1); expect(mockedCallOpenCodeEdit.mock.calls[0][0].prompt).toBe('make it faster'); expect(mockedCallOpenCodeEdit.mock.calls[0][0].context.notebookPath).toBe('foo.ipynb'); - // SSE subscription was opened with the returned sessionId. expect(subscribeCalls).toHaveLength(1); expect(subscribeCalls[0].sid).toBe('ses-abc'); expect(typeof subscribeCalls[0].handlers.onEvent).toBe('function'); - // The cell source must remain unchanged. expect((cell as any).model.sharedModel.setSource).not.toHaveBeenCalled(); - - // The textarea is cleared so the user can type a follow-up. expect(textarea.value).toBe(''); }); - it('on submit: forwards SSE events to the prompt and closes the stream on session.idle', async () => { - setOpenCodeProviders(null); - mockedCallOpenCodeEdit.mockResolvedValue({ - ok: true, - sessionId: 'ses-1', - notebookPath: 'foo.ipynb' - }); + it('SSE idle event closes the stream and re-enables the input', async () => { - const fakeSse = { close: jest.fn() }; - let captured: any = null; - mockedSubscribeOpenCodeEvents.mockImplementation( - ((_sid: string, _s: any, handlers: any) => { - captured = handlers; - return fakeSse; - }) as any - ); - - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - const aiBtn = actions.node.querySelector('button') as HTMLButtonElement; - aiBtn.click(); - const prompt = (actions as any)._prompt; - const textarea = prompt._textarea as HTMLTextAreaElement; - textarea.value = 'go'; - (prompt._sendBtn as HTMLButtonElement).click(); - - await new Promise(r => setTimeout(r, 5)); - expect(captured).not.toBeNull(); - - // Simulate a text-delta event arriving. - const applySpy = jest.spyOn(prompt, 'applyEvent'); - captured.onEvent({ - type: 'session.next.text.delta', - properties: { sessionID: 'ses-1', delta: 'hel' } - }); - expect(applySpy).toHaveBeenCalledWith( - expect.objectContaining({ type: 'session.next.text.delta' }) - ); - - // session.idle should close the stream and re-enable the input. - captured.onEvent({ type: 'session.idle', properties: { sessionID: 'ses-1' } }); - expect(fakeSse.close).toHaveBeenCalled(); - expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false); - }); - - it('on submit: session.status with status.type=idle also closes the stream (idle shape variants)', async () => { - setOpenCodeProviders(null); mockedCallOpenCodeEdit.mockResolvedValue({ ok: true, sessionId: 'ses-1', @@ -682,97 +411,336 @@ describe('OpenCodeCellActions', () => { }) as any ); - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - (actions.node.querySelector('button') as HTMLButtonElement).click(); - const prompt = (actions as any)._prompt; + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + + const prompt = (panel as any)._prompt; (prompt._textarea as HTMLTextAreaElement).value = 'go'; (prompt._sendBtn as HTMLButtonElement).click(); await new Promise(r => setTimeout(r, 5)); - // session.status with status.type === 'idle' should also close the - // stream (some OpenCode versions emit this shape instead of - // session.idle). captured.onEvent({ - type: 'session.status', - properties: { sessionID: 'ses-1', status: { type: 'idle' } } + type: 'session.idle', + properties: { sessionID: 'ses-1' } }); expect(fakeSse.close).toHaveBeenCalled(); expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false); }); - it('on submit: idle nested under payload also closes the stream', async () => { - setOpenCodeProviders(null); + it('SSE error is logged but does not break the input', async () => { + mockedCallOpenCodeEdit.mockResolvedValue({ ok: true, sessionId: 'ses-1', notebookPath: 'foo.ipynb' }); - const fakeSse = { close: jest.fn() }; let captured: any = null; mockedSubscribeOpenCodeEvents.mockImplementation( ((_sid: string, _s: any, handlers: any) => { captured = handlers; - return fakeSse; + return { close: jest.fn() }; }) as any ); - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - (actions.node.querySelector('button') as HTMLButtonElement).click(); - const prompt = (actions as any)._prompt; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + + const prompt = (panel as any)._prompt; (prompt._textarea as HTMLTextAreaElement).value = 'go'; (prompt._sendBtn as HTMLButtonElement).click(); await new Promise(r => setTimeout(r, 5)); - // Some OpenCode events have type at top level AND in payload. - captured.onEvent({ - type: 'unknown.wrapper', - payload: { - type: 'session.idle', - properties: { sessionID: 'ses-1' } - } - }); - expect(fakeSse.close).toHaveBeenCalled(); - expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false); + captured.onError(new Error('sse timeout')); + expect(warnSpy).toHaveBeenCalledWith( + 'opencode_bridge: SSE error', + expect.any(Error) + ); + warnSpy.mockRestore(); }); - it('on submit failure: re-enables the input and shows an error notification', async () => { - setOpenCodeProviders(null); + it('onSubmit failure: re-enables input and shows an error notification', async () => { + mockedCallOpenCodeEdit.mockResolvedValue({ ok: false, error: 'opencode down' }); - const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - const aiBtn = actions.node.querySelector('button') as HTMLButtonElement; - aiBtn.click(); - const prompt = (actions as any)._prompt; + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + + const prompt = (panel as any)._prompt; const textarea = prompt._textarea as HTMLTextAreaElement; textarea.value = 'go'; (prompt._sendBtn as HTMLButtonElement).click(); - await new Promise(r => setTimeout(r, 5)); + expect(mockedNotification.error).toHaveBeenCalledWith('OpenCode 错误: opencode down'); expect((prompt._sendBtn as HTMLButtonElement).disabled).toBe(false); }); + + it('shell currentChanged hides and re-shows the panel to refresh context', () => { + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + expect((panel as any)._prompt).not.toBeNull(); + + const oldPrompt = (panel as any)._prompt; + (app.shell as any)._fireCurrentChanged(); + const newPrompt = (panel as any)._prompt; + expect(newPrompt).not.toBeNull(); + expect(newPrompt).not.toBe(oldPrompt); + }); + + it('dispose: closes SSE, disposes prompt, disconnects shell signal', async () => { + + mockedCallOpenCodeEdit.mockResolvedValue({ + ok: true, + sessionId: 'ses-1', + notebookPath: 'foo.ipynb' + }); + const fakeSse = { close: jest.fn() }; + mockedSubscribeOpenCodeEvents.mockImplementation( + ((_sid: string, _s: any, _handlers: any) => { + return fakeSse; + }) as any + ); + + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ app, serverSettings: {} as any, providers: null }); + Widget.attach(panel, document.body); + panel.toggle(); + + const prompt = (panel as any)._prompt; + (prompt._textarea as HTMLTextAreaElement).value = 'go'; + (prompt._sendBtn as HTMLButtonElement).click(); + await new Promise(r => setTimeout(r, 5)); + + panel.dispose(); + expect(fakeSse.close).toHaveBeenCalled(); + expect(prompt.isDisposed).toBe(true); + expect(app.shell.currentChanged.disconnect).toHaveBeenCalled(); + }); + + it('renders provider and model selects when providers are supplied', () => { + const providers = { + providers: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { + 'claude-sonnet-4-20250514': { id: 'claude-sonnet-4-20250514' }, + 'claude-haiku-4-5': { id: 'claude-haiku-4-5' } + } + } + ], + default: { anthropic: 'claude-sonnet-4-20250514' } + }; + + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1')); + const panel = new OpenCodeFloatingPanel({ + app, + serverSettings: {} as any, + providers: providers as any + }); + Widget.attach(panel, document.body); + panel.toggle(); + + const prompt = (panel as any)._prompt; + const providerSel = prompt.node.querySelector( + '.opencode-provider-select' + ) as HTMLSelectElement; + const modelSel = prompt.node.querySelector( + '.opencode-model-select' + ) as HTMLSelectElement; + expect(providerSel).not.toBeNull(); + expect(modelSel).not.toBeNull(); + expect(providerSel.value).toBe('anthropic'); + expect(modelSel.value).toBe('claude-sonnet-4-20250514'); + + }); + + it('renders the session selector when onListSessions is provided', async () => { + mockedListAllSessions.mockResolvedValue({ + ok: true, + sessions: [ + { id: 'sess-1', title: 'old chat' }, + { id: 'sess-2', title: 'current chat' } + ] + }); + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb')); + const panel = new OpenCodeFloatingPanel({ + app, + serverSettings: {} as any, + providers: null + }); + Widget.attach(panel, document.body); + panel.toggle(); + const prompt = (panel as any)._prompt; + + const row = prompt.node.querySelector('.opencode-prompt-sessions'); + expect(row).not.toBeNull(); + const select = prompt.node.querySelector('.opencode-session-select'); + expect(select).not.toBeNull(); + const newBtn = prompt.node.querySelector('.opencode-session-btn-new'); + const refreshBtn = prompt.node.querySelector('.opencode-session-btn-refresh'); + expect(newBtn).not.toBeNull(); + expect(refreshBtn).not.toBeNull(); + }); + + it('initialize() fetches and populates the session list', async () => { + mockedListAllSessions.mockResolvedValue({ + ok: true, + sessions: [ + { id: 'sess-A', title: 'foo' }, + { id: 'sess-B', title: 'bar' } + ] + }); + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb')); + const panel = new OpenCodeFloatingPanel({ + app, + serverSettings: {} as any, + providers: null + }); + Widget.attach(panel, document.body); + panel.toggle(); + await new Promise(r => setTimeout(r, 5)); + expect(mockedListAllSessions).toHaveBeenCalled(); + const prompt = (panel as any)._prompt; + const select = prompt.node.querySelector( + '.opencode-session-select' + ) as HTMLSelectElement; + const labels = Array.from(select.options).map(o => o.textContent); + expect(labels.length).toBe(3); + }); + + it('clicking + 新建 creates a new session, binds, and reloads history', async () => { + mockedListAllSessions.mockResolvedValue({ ok: true, sessions: [] }); + mockedCreateSession.mockResolvedValue({ + ok: true, + notebookPath: 'foo.ipynb', + sessionId: 'fresh-sid', + activeSessionId: 'fresh-sid' + }); + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb')); + const panel = new OpenCodeFloatingPanel({ + app, + serverSettings: {} as any, + providers: null + }); + Widget.attach(panel, document.body); + panel.toggle(); + await new Promise(r => setTimeout(r, 5)); + const prompt = (panel as any)._prompt; + const newBtn = prompt.node.querySelector( + '.opencode-session-btn-new' + ) as HTMLButtonElement; + newBtn.click(); + await new Promise(r => setTimeout(r, 5)); + expect(mockedCreateSession).toHaveBeenCalled(); + expect((prompt as any)._sessionId).toBe('fresh-sid'); + }); + + it('switching session: bind_existing is called, SSE is closed, history is reloaded', async () => { + mockedListAllSessions.mockResolvedValue({ + ok: true, + sessions: [ + { id: 'sess-A', title: 'first' }, + { id: 'sess-B', title: 'second' } + ] + }); + mockedBindExistingSession.mockResolvedValue({ + ok: true, + notebookPath: 'foo.ipynb', + sessionId: 'sess-B' + }); + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb')); + const panel = new OpenCodeFloatingPanel({ + app, + serverSettings: {} as any, + providers: null + }); + Widget.attach(panel, document.body); + panel.toggle(); + await new Promise(r => setTimeout(r, 5)); + const prompt = (panel as any)._prompt; + prompt.setSessionId('sess-A'); + mockedSessionMessages.mockClear(); + mockedSessionMessages.mockResolvedValue({ + messages: [{ role: 'user', content: 'from sess-B' }] + }); + const select = prompt.node.querySelector( + '.opencode-session-select' + ) as HTMLSelectElement; + select.value = 'sess-B'; + select.dispatchEvent(new Event('change')); + await new Promise(r => setTimeout(r, 5)); + expect(mockedBindExistingSession).toHaveBeenCalled(); + expect(mockedSetActiveSession).not.toHaveBeenCalled(); + expect((prompt as any)._sessionId).toBe('sess-B'); + expect(mockedSessionMessages).toHaveBeenCalledWith( + 'foo.ipynb', + expect.anything() + ); + const userMsg = prompt.node.querySelector( + '.opencode-msg-user' + ) as HTMLElement; + expect(userMsg.textContent).toBe('from sess-B'); + }); + + it('switching session rolls back the dropdown + shows a Notification.error when bind fails', async () => { + mockedListAllSessions.mockResolvedValue({ + ok: true, + sessions: [ + { id: 'sess-A', title: 'first' }, + { id: 'sess-B', title: 'second' } + ] + }); + mockedBindExistingSession.mockResolvedValue({ + ok: false, + notebookPath: 'foo.ipynb', + error: 'upstream down' + }); + const app = makeFakeApp('foo.ipynb', makeFakeCell('x = 1', [], 'foo.ipynb')); + const panel = new OpenCodeFloatingPanel({ + app, + serverSettings: {} as any, + providers: null + }); + Widget.attach(panel, document.body); + panel.toggle(); + await new Promise(r => setTimeout(r, 5)); + const prompt = (panel as any)._prompt; + prompt.setSessionId('sess-A'); + const select = prompt.node.querySelector( + '.opencode-session-select' + ) as HTMLSelectElement; + select.value = 'sess-B'; + select.dispatchEvent(new Event('change')); + await new Promise(r => setTimeout(r, 5)); + expect(select.value).toBe('sess-A'); + expect((prompt as any)._sessionId).toBe('sess-A'); + expect(mockedNotification.error).toHaveBeenCalledWith('切换会话失败'); + }); }); describe('OpenCodeInlinePrompt', () => { it('renders a textarea, a send, a cancel, and an insert-cell-source button', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); expect(prompt.node.querySelector('textarea')).not.toBeNull(); // 3 buttons: 发送, 取消, 📋 插入单元格内容 @@ -786,11 +754,13 @@ describe('OpenCodeInlinePrompt', () => { it('"📋 插入单元格内容" appends the cell source as a markdown code block to the textarea', () => { const cell = makeFakeCell('print("hello")\nprint("world")'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); const insertBtn = prompt.node.querySelector( '.opencode-btn-insert-cell' @@ -808,11 +778,13 @@ describe('OpenCodeInlinePrompt', () => { it('"📋 插入单元格内容" appends to existing textarea content (with a leading newline if needed)', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); const textarea = prompt.node.querySelector('textarea') as HTMLTextAreaElement; textarea.value = 'Explain:'; // no trailing newline @@ -832,11 +804,13 @@ describe('OpenCodeInlinePrompt', () => { it('"📋 插入单元格内容" is a no-op when the cell is empty', () => { const cell = makeFakeCell(''); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); const insertBtn = prompt.node.querySelector( '.opencode-btn-insert-cell' @@ -849,11 +823,13 @@ describe('OpenCodeInlinePrompt', () => { it('renders a history area; setMessages renders user text + assistant markdown and scrolls to bottom', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); const history = prompt.node.querySelector( '.opencode-inline-prompt .opencode-inline-history' @@ -884,11 +860,13 @@ describe('OpenCodeInlinePrompt', () => { it('renders Copy/Insert/Replace buttons INSIDE each code block (not above the message)', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); // User message: no toolbar. prompt.setMessages([{ role: 'user', content: 'help' }]); @@ -921,11 +899,13 @@ describe('OpenCodeInlinePrompt', () => { it('assistant with NO code fences has no toolbar', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setMessages([ { role: 'assistant', content: 'no code here, just an explanation' } @@ -939,11 +919,13 @@ describe('OpenCodeInlinePrompt', () => { it('Replace button overwrites the cell source with the code block content', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setMessages([ { role: 'assistant', content: '```py\nprint(1)\n```' } @@ -962,11 +944,13 @@ describe('OpenCodeInlinePrompt', () => { it('Insert button splices the code block at the editor cursor (offset 0)', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setMessages([ { role: 'assistant', content: '```py\nprint(1)\n```' } @@ -984,11 +968,13 @@ describe('OpenCodeInlinePrompt', () => { it('Copy button writes the code block content to the clipboard', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setMessages([ { role: 'assistant', content: '```py\nprint(1)\n```' } @@ -1005,11 +991,13 @@ describe('OpenCodeInlinePrompt', () => { it('Enter in the textarea submits; Shift+Enter does not', () => { const cell = makeFakeCell('x = 1'); const onSubmit = jest.fn(); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit, onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setMessages([ { role: 'user', content: '' }, @@ -1059,11 +1047,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: text deltas accumulate into a single assistant element (real-time streaming)', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); @@ -1086,11 +1076,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: unrecognized event types are logged but never throw', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); prompt.setSessionId('s1'); @@ -1109,168 +1101,11 @@ describe('OpenCodeInlinePrompt', () => { warnSpy.mockRestore(); }); - it('renders the session selector when onListSessions is provided', () => { - mockedListAllSessions.mockResolvedValue({ - ok: true, - sessions: [ - { id: 'sess-1', title: 'old chat' }, - { id: 'sess-2', title: 'current chat' }, - ] - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - (actions.node.querySelector('button') as HTMLButtonElement).click(); - const prompt = (actions as any)._prompt; - - // The session selector row + select are present. - const row = prompt.node.querySelector('.opencode-prompt-sessions'); - expect(row).not.toBeNull(); - const select = prompt.node.querySelector('.opencode-session-select'); - expect(select).not.toBeNull(); - // + 新建 / 🔄 刷新 buttons are present. - const newBtn = prompt.node.querySelector('.opencode-session-btn-new'); - const refreshBtn = prompt.node.querySelector('.opencode-session-btn-refresh'); - expect(newBtn).not.toBeNull(); - expect(refreshBtn).not.toBeNull(); - }); - - it('initialize() fetches and populates the session list', async () => { - mockedListAllSessions.mockResolvedValue({ - ok: true, - sessions: [ - { id: 'sess-A', title: 'foo' }, - { id: 'sess-B', title: 'bar' }, - ] - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - (actions.node.querySelector('button') as HTMLButtonElement).click(); - // initialize() is called inside _showPrompt. - await new Promise(r => setTimeout(r, 5)); - expect(mockedListAllSessions).toHaveBeenCalled(); - const prompt = (actions as any)._prompt; - const select = prompt.node.querySelector( - '.opencode-session-select' - ) as HTMLSelectElement; - // Both sessions appear + the "new" sentinel. - const labels = Array.from(select.options).map(o => o.textContent); - expect(labels.length).toBe(3); // sess-A, sess-B, + 新建会话… - }); - - it('clicking + 新建 creates a new session, binds, and reloads history', async () => { - mockedListAllSessions.mockResolvedValue({ ok: true, sessions: [] }); - mockedCreateSession.mockResolvedValue({ - ok: true, - notebookPath: 'foo.ipynb', - sessionId: 'fresh-sid', - activeSessionId: 'fresh-sid', - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - (actions.node.querySelector('button') as HTMLButtonElement).click(); - await new Promise(r => setTimeout(r, 5)); - const prompt = (actions as any)._prompt; - const newBtn = prompt.node.querySelector( - '.opencode-session-btn-new' - ) as HTMLButtonElement; - newBtn.click(); - await new Promise(r => setTimeout(r, 5)); - expect(mockedCreateSession).toHaveBeenCalled(); - // The prompt's _sessionId is now the new one. - expect((prompt as any)._sessionId).toBe('fresh-sid'); - }); - - it('switching session: bind_existing is called, SSE is closed, history is reloaded', async () => { - mockedListAllSessions.mockResolvedValue({ - ok: true, - sessions: [ - { id: 'sess-A', title: 'first' }, - { id: 'sess-B', title: 'second' }, - ] - }); - mockedBindExistingSession.mockResolvedValue({ - ok: true, notebookPath: 'foo.ipynb', sessionId: 'sess-B', - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - (actions.node.querySelector('button') as HTMLButtonElement).click(); - await new Promise(r => setTimeout(r, 5)); - const prompt = (actions as any)._prompt; - // Mark sess-A as currently active (so we can verify the switch). - prompt.setSessionId('sess-A'); - // Reset session-messages mock so we can assert it's called again. - mockedSessionMessages.mockClear(); - mockedSessionMessages.mockResolvedValue({ - messages: [{ role: 'user', content: 'from sess-B' }] - }); - const select = prompt.node.querySelector( - '.opencode-session-select' - ) as HTMLSelectElement; - select.value = 'sess-B'; - select.dispatchEvent(new Event('change')); - await new Promise(r => setTimeout(r, 5)); - // bind-existing is the single call (it does both bind AND set - // active on the server). Calling set_active first would 400 - // because the session is not yet bound. - expect(mockedBindExistingSession).toHaveBeenCalled(); - expect(mockedSetActiveSession).not.toHaveBeenCalled(); - // Prompt's internal _sessionId is now the new one. - expect((prompt as any)._sessionId).toBe('sess-B'); - // The prompt's history was reloaded for the new session. - expect(mockedSessionMessages).toHaveBeenCalledWith( - 'foo.ipynb', expect.anything() - ); - // The new history is rendered (the user sees sess-B's messages, - // not sess-A's stale DOM). - const userMsg = prompt.node.querySelector( - '.opencode-msg-user' - ) as HTMLElement; - expect(userMsg.textContent).toBe('from sess-B'); - }); - - it('switching session rolls back the dropdown + shows a Notification.error when bind fails', async () => { - mockedListAllSessions.mockResolvedValue({ - ok: true, - sessions: [ - { id: 'sess-A', title: 'first' }, - { id: 'sess-B', title: 'second' }, - ] - }); - // bind returns ok:false -> the prompt's _handleSwitchSession - // must roll back the dropdown to the previous active id. - mockedBindExistingSession.mockResolvedValue({ - ok: false, notebookPath: 'foo.ipynb', error: 'upstream down', - }); - const cell = makeFakeCell('x = 1', [], 'foo.ipynb'); - const actions = new OpenCodeCellActions(cell); - Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); - (actions as any).onAfterAttach({} as any); - (actions.node.querySelector('button') as HTMLButtonElement).click(); - await new Promise(r => setTimeout(r, 5)); - const prompt = (actions as any)._prompt; - prompt.setSessionId('sess-A'); - const select = prompt.node.querySelector( - '.opencode-session-select' - ) as HTMLSelectElement; - select.value = 'sess-B'; - select.dispatchEvent(new Event('change')); - await new Promise(r => setTimeout(r, 5)); - // Dropdown rolled back to the previous active id (sess-A). - expect(select.value).toBe('sess-A'); - // Prompt's _sessionId is unchanged. - expect((prompt as any)._sessionId).toBe('sess-A'); - // User sees a notification. - expect(mockedNotification.error).toHaveBeenCalledWith('切换会话失败'); - }); - + + + + + it('applyEvent: streaming shows raw text in real-time, then renders to HTML on session.idle', () => { // Regression: a previous version ran marked.parse on every text // delta. That produced glitchy partial-render states (e.g. an @@ -1283,11 +1118,13 @@ describe('OpenCodeInlinePrompt', () => { // stream, and a clean rendered view the moment the turn ends — // matching what setMessages would produce from the stored history. const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); const fullSource = @@ -1346,11 +1183,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: message.part.delta with field=text routes to the assistant message', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1365,11 +1204,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: reasoning deltas render into a collapsible green block', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1388,11 +1229,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: tool-call lifecycle creates and updates a tool block', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1422,11 +1265,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: session.idle resets stream pointers (next text delta starts a fresh assistant element)', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1458,11 +1303,13 @@ describe('OpenCodeInlinePrompt', () => { // can't reply to a different session's prompt); content events // (text, reasoning, tool, idle) always pass through. const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1481,11 +1328,13 @@ describe('OpenCodeInlinePrompt', () => { // question.asked so the user can't accidentally reply to another // session's prompt. const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1500,11 +1349,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: permission.asked creates a permission block with 3 buttons', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1528,12 +1379,14 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: clicking a permission button calls onPermissionReply and shows status', async () => { const cell = makeFakeCell('x = 1'); const onPermissionReply = jest.fn().mockResolvedValue(true); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, providers: null, - onPermissionReply + onPermissionReply, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1555,11 +1408,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: question.asked creates a question block with input + submit', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1578,12 +1433,14 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: question submit calls onQuestionReply with the typed answer', async () => { const cell = makeFakeCell('x = 1'); const onQuestionReply = jest.fn().mockResolvedValue(true); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, providers: null, - onQuestionReply + onQuestionReply, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1607,12 +1464,14 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: question submit with empty input is a no-op', async () => { const cell = makeFakeCell('x = 1'); const onQuestionReply = jest.fn(); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, providers: null, - onQuestionReply + onQuestionReply, + getActiveCell: () => cell }); prompt.setSessionId('s1'); prompt.applyEvent({ @@ -1629,11 +1488,13 @@ describe('OpenCodeInlinePrompt', () => { it('applyEvent: system events (server.* etc.) are NOT rendered', () => { const cell = makeFakeCell('x = 1'); - const prompt = new OpenCodeInlinePrompt(cell, { + const prompt = new OpenCodeInlinePrompt({ + onSubmit: jest.fn(), onCancel: jest.fn(), disabled: false, - providers: null + providers: null, + getActiveCell: () => cell }); prompt.setSessionId('s1'); const systemTypes = [ diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts deleted file mode 100644 index 6eeaa8a..0000000 --- a/src/components/opencode_cell_actions.ts +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Per-cell AI action button rendered inside JupyterLab's native Cell toolbar - * (top-right of the active cell). Clicking it toggles an - * OpenCodeInlinePrompt panel attached to the cell's DOM. - * - * Registered in index.ts via IToolbarWidgetRegistry.addFactory('Cell', ...). - * - * v4 message flow (matches demo.html): - * 1. User clicks 🪄 -> panel appears, shows session history (setMessages) - * 2. User submits prompt -> callOpenCodeEdit returns immediately with - * sessionId - * 3. We subscribe to GET /opencode-bridge/events?session= and - * forward each event to the prompt's applyEvent() dispatcher - * 4. On session.idle we close the stream and re-enable the input - * - * The frontend does NO semantic processing of the LLM reply. The chosen - * provider/model comes from the inline picker (no settings-based default). - */ -import type { CodeCell } from '@jupyterlab/cells'; -import { Notification } from '@jupyterlab/apputils'; -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'; - -// Module-level runtime injection (set by index.ts after activation). -let _serverSettings: ServerConnection.ISettings | null = null; -let _providers: OpenCodeProvidersResponse | null = null; - -export function setOpenCodeServerSettings( - serverSettings: ServerConnection.ISettings -): void { - _serverSettings = serverSettings; -} - -export function setOpenCodeProviders( - p: OpenCodeProvidersResponse | null -): void { - _providers = p; -} - -type Status = 'idle' | 'loading'; - -export class OpenCodeCellActions extends Widget { - private _cell: CodeCell; - // The cell's notebookPath is the only context we need for the - // /edit request. The cell source, error traceback, and previous - // cell are no longer auto-injected — the user attaches whatever - // they want via the "📋 插入单元格内容" button. - private _notebookPath: string | null = null; - private _status: Status = 'idle'; - private _prompt: OpenCodeInlinePrompt | null = null; - private _sseSub: OpenCodeEventSubscription | null = null; - - constructor(cell: CodeCell) { - super(); - this._cell = cell; - this.addClass('opencode-cell-actions'); - this._cell.model.contentChanged.connect(this._onModelChange, this); - this._cell.model.stateChanged.connect(this._onModelChange, this); - this._render(); - this._onModelChange(); - } - - dispose(): void { - if (this.isDisposed) { - return; - } - this._closeSse(); - this._cell.model.contentChanged.disconnect(this._onModelChange, this); - this._cell.model.stateChanged.disconnect(this._onModelChange, this); - this._hidePrompt(); - super.dispose(); - } - - private _onModelChange(): void { - this._notebookPath = extractNotebookPathFromCell(this._cell); - if (this._prompt) { - this._prompt.setDisabled(!this._notebookPath || this._status === 'loading'); - } else { - this._render(); - } - } - - private _render(): void { - const node = this.node; - node.textContent = ''; - - const baseDisabled = !this._notebookPath || this._status === 'loading'; - - const btn = document.createElement('button'); - btn.className = 'opencode-btn opencode-btn-ai'; - btn.textContent = 'AI'; - btn.disabled = baseDisabled; - btn.title = baseDisabled - ? 'OpenCode: 等待 cell 上下文…' - : '让 AI 修改这个 cell 的代码'; - btn.addEventListener('click', () => { - this._togglePrompt(); - }); - node.appendChild(btn); - } - - private _togglePrompt(): void { - if (this._prompt) { - this._hidePrompt(); - } else { - this._showPrompt(); - } - } - - private _showPrompt(): void { - if (this._prompt) { - return; - } - const prompt = new OpenCodeInlinePrompt(this._cell, { - disabled: !this._notebookPath || this._status === 'loading', - providers: _providers, - notebookPath: this._notebookPath ?? undefined, - onSubmit: (text: string, providerId?: string, modelId?: string) => { - void this._onSubmit(text, providerId, modelId); - }, - onCancel: () => { - this._hidePrompt(); - }, - onPermissionReply: async (permId, response) => { - if (!_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, - _serverSettings - ); - return r.ok; - } catch (e) { - console.warn('opencode_bridge: permission reply failed', e); - return false; - } - }, - onQuestionReply: async (qId, answer) => { - if (!_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, - _serverSettings - ); - return r.ok; - } catch (e) { - console.warn('opencode_bridge: question reply failed', e); - return false; - } - }, - onStreamEnd: () => { - // Centralized: the prompt detected "idle" (any shape) and tells - // us to close the SSE stream + restore the input. We no longer - // parse event types here. - this._closeSse(); - }, - onListSessions: async () => { - if (!_serverSettings) return []; - try { - const r = await callOpenCodeListAllSessions(_serverSettings); - return r.sessions || []; - } catch (e) { - console.warn('opencode_bridge: list all sessions failed', e); - return []; - } - }, - onCreateSession: async () => { - if (!_serverSettings || !this._notebookPath) { - throw new Error('notebook not ready'); - } - // Tear down any in-flight SSE first — the old session is - // being replaced. - this._closeSse(); - const r = await callOpenCodeCreateNotebookSession( - this._notebookPath, - undefined, - _serverSettings - ); - if (!r.ok || !r.sessionId) { - throw new Error(r.error || 'create_session failed'); - } - // The prompt's _handleCreateSession calls setSessionId - // internally; no need to set it here too. - return r.sessionId; - }, - onSwitchSession: async (sessionId: string) => { - if (!_serverSettings || !this._notebookPath) { - return false; - } - // Closing the SSE first ensures the old session's events stop - // coming in while we transition. - this._closeSse(); - // PUT /sessions/notebook binds AND sets active in one call. - // (The separate PUT /sessions/active is only for switching - // between sessions that are ALREADY bound; for a session the - // user just picked from the global list, we need to bind - // first — calling set_active before bind returns 400.) - const r = await callOpenCodeBindExistingSession( - this._notebookPath, - sessionId, - _serverSettings - ); - if (!r.ok) { - return false; - } - // The prompt's _handleSwitchSession calls setSessionId - // internally; no need to set it here too. - return true; - }, - onReloadHistory: async () => { - if (!this._notebookPath) return; - await this._refreshHistory(this._notebookPath); - } - }); - Widget.attach(prompt, this._cell.node); - this._prompt = prompt; - - // Fetch the current session's static history and render it before - // streaming begins. Empty list is a normal no-op. - const notebookPath = this._notebookPath; - if (notebookPath) { - void this._refreshHistory(notebookPath); - } - - // Populate the session selector dropdown (loads OpenCode's global - // session list async; if the host didn't wire up the callbacks, - // the row is simply not rendered). - prompt.initialize(); - } - - private async _refreshHistory(notebookPath: string): Promise { - if (!_serverSettings || !this._prompt) { - return; - } - try { - const resp = await callOpenCodeSessionMessages( - notebookPath, - _serverSettings - ); - this._prompt.setMessages(resp.messages); - } catch (e) { - console.warn('opencode_bridge: failed to fetch session messages', e); - } - } - - private _hidePrompt(): void { - if (this._prompt) { - this._prompt.dispose(); - this._prompt = null; - } - } - - private async _onSubmit( - text: string, - providerId?: string, - modelId?: string - ): Promise { - if (!this._notebookPath) { - return; - } - if (!_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, _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; - } - - // Async edit accepted. Bind the prompt to the returned session, clear - // the input, then subscribe to the SSE event stream for this session. - this._prompt?.setSessionId(resp.sessionId); - this._prompt?.clearInput(); - this._sseSub = subscribeOpenCodeEvents( - resp.sessionId, - _serverSettings, - { - onEvent: ev => { - this._prompt?.applyEvent(ev); - // Stream-end is detected by the prompt and signalled via - // onStreamEnd (see _showPrompt); no idle parsing here. - }, - onError: err => { - console.warn('opencode_bridge: SSE error', err); - // Don't flip status here: the server may just have hiccuped. - // session.idle (if it comes through) will re-enable the input. - } - } - ); - } - - private _closeSse(): void { - if (this._sseSub) { - this._sseSub.close(); - this._sseSub = null; - } - this._status = 'idle'; - this._prompt?.setDisabled(false); - } -} - -/** - * Walk the parent chain to find the cell's parent NotebookPanel and - * return its content path. Returns null if the cell is detached - * (no notebook parent). - */ -function extractNotebookPathFromCell(cell: CodeCell): string | null { - let node: Widget | null = cell.parent; - while (node) { - const candidate = node as any; - if ( - candidate.context && - typeof candidate.context.path === 'string' && - candidate.content && - Array.isArray(candidate.content.widgets) - ) { - return candidate.context.path; - } - node = node.parent; - } - return null; -} diff --git a/src/components/opencode_floating_panel.ts b/src/components/opencode_floating_panel.ts new file mode 100644 index 0000000..920b721 --- /dev/null +++ b/src/components/opencode_floating_panel.ts @@ -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 { + 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 { + 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); + } +} diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts index 2c7f5b2..8afd43d 100644 --- a/src/components/opencode_inline_prompt.ts +++ b/src/components/opencode_inline_prompt.ts @@ -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; } - 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 = '(未配置 onQuestionReply 回调)'; + inputRow.innerHTML = + '(未配置 onQuestionReply 回调)'; return; } inputRow.innerHTML = '正在提交…'; @@ -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); diff --git a/src/index.ts b/src/index.ts index 3050c04..ae3bb76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,74 +3,56 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { IToolbarWidgetRegistry } from '@jupyterlab/apputils'; -import { Cell, CodeCell } from '@jupyterlab/cells'; import { Widget } from '@lumino/widgets'; -import { - OpenCodeCellActions, - setOpenCodeProviders, - setOpenCodeServerSettings -} from './components/opencode_cell_actions'; +import { OpenCodeFloatingPanel } from './components/opencode_floating_panel'; import { requestAPI } from './request'; import { callOpenCodeProviders } from './api/opencode_client'; /** * Initialization data for the opencode_bridge extension. * - * v3-final: no JupyterLab plugin settings. Connection config is read from - * environment variables by the server extension; the model is picked - * dynamically in the inline prompt. + * v5-floating: the per-cell AI button is gone. A single + * OpenCodeFloatingPanel is attached to document.body and operates + * against the JupyterLab shell's currently-active notebook. The model + * is picked dynamically in the inline prompt. */ const plugin: JupyterFrontEndPlugin = { id: 'opencode_bridge:plugin', description: 'A JupyterLab extension bridging the UI to OpenCode Serve.', autoStart: true, - optional: [IToolbarWidgetRegistry], - activate: ( - app: JupyterFrontEnd, - toolbarRegistry: IToolbarWidgetRegistry | null - ) => { + activate: (app: JupyterFrontEnd) => { console.log('JupyterLab extension opencode_bridge is activated!'); - // Push the Jupyter server settings into the actions module so that - // callOpenCodeEdit can build the right base URL. - setOpenCodeServerSettings(app.serviceManager.serverSettings); + // Mount the floating AI button on the body. The panel itself is + // hidden until the user clicks the button. + const floating = new OpenCodeFloatingPanel({ + app, + serverSettings: app.serviceManager.serverSettings, + providers: null + }); + Widget.attach(floating, document.body); - // Register the per-cell AI actions into the native Cell toolbar - // (top-right of the active cell, next to move up/down). Non-code - // cells get an empty widget so they show no AI buttons. - if (toolbarRegistry) { - toolbarRegistry.addFactory( - 'Cell', - 'opencode-cell-actions', - (cell: Cell) => { - if (cell instanceof CodeCell) { - return new OpenCodeCellActions(cell); - } - return new Widget(); - } - ); - } - - // Fetch available providers once at activation and cache them for the - // inline prompt's model picker. Failure is non-fatal (the picker hides). + // Fetch available providers once at activation and cache them for + // the inline prompt's model picker. Failure is non-fatal (the + // picker hides). Also push the payload into the floating panel so + // the prompt can render selects without an extra round trip. void callOpenCodeProviders(app.serviceManager.serverSettings) .then(data => { - setOpenCodeProviders(data); + floating.setProviders(data); const lines: string[] = [ '[opencode_bridge] Available OpenCode providers:' ]; for (const p of data.providers) { - const models = Object.values(p.models).map(m => m.id).join(', '); + const models = Object.values(p.models) + .map(m => m.id) + .join(', '); lines.push(` - ${p.id}: ${models || '(no models)'}`); } - // eslint-disable-next-line no-console console.log(lines.join('\n')); }) .catch(reason => { - // eslint-disable-next-line no-console -+ console.warn( + console.warn( '[opencode_bridge] Could not fetch providers (is the opencode-bridge server extension enabled and opencode serve running?):', reason ); diff --git a/style/base.css b/style/base.css index dd9adc7..feefe98 100644 --- a/style/base.css +++ b/style/base.css @@ -4,30 +4,72 @@ https://jupyterlab.readthedocs.io/en/stable/developer/css.html */ -/* Single AI action button inside the native Cell toolbar (active cell, top-right). */ -.opencode-cell-actions { +/* Floating AI launcher (replaces the per-cell toolbar button). + Mounted on document.body with position: fixed so it sits above the + JupyterLab layout regardless of which dock is active. The panel + (when open) renders above the button via column flex. */ +.opencode-floating-root { + position: fixed; + bottom: 24px; + right: 24px; + z-index: 1000; /* above JupyterLab's main UI (z-index tops out around 100) */ display: flex; - align-items: center; + flex-direction: column; + align-items: flex-end; + gap: 8px; } -.opencode-cell-actions .opencode-btn { +.opencode-floating-button { + width: 48px; + height: 48px; + border-radius: 50%; border: none; - background: transparent; - padding: 0 6px; - font-size: var(--jp-ui-font-size1); - line-height: 20px; + background: var(--jp-brand-color1, #1976d2); + color: white; + font-size: 20px; cursor: pointer; - white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + transition: transform 0.15s ease, box-shadow 0.15s ease; + padding: 0; + line-height: 1; +} +.opencode-floating-button:hover { + transform: scale(1.05); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25); +} +.opencode-floating-button:focus { + outline: 2px solid var(--jp-brand-color2, #42a5f5); + outline-offset: 2px; } -.opencode-cell-actions .opencode-btn:hover:enabled { - background: var(--jp-layout-color2); - border-radius: 2px; +.opencode-floating-panel { + width: 480px; + max-width: calc(100vw - 48px); + max-height: 60vh; + overflow: hidden; + display: flex; + flex-direction: column; + background: var(--jp-layout-color1, #fff); + border: 1px solid var(--jp-border-color2, #ddd); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} +.opencode-floating-panel[hidden] { + display: none; } -.opencode-cell-actions .opencode-btn:disabled { - opacity: 0.4; - cursor: default; +/* The inline prompt inside the floating panel should fill it: drop + the standalone panel's own margin/border, and let the panel's + scrollable region take over. */ +.opencode-floating-panel .opencode-inline-prompt { + margin: 0; + border: none; + border-radius: 0; + flex: 1; + overflow-y: auto; +} +.opencode-floating-panel .opencode-inline-history { + max-height: 40vh; } /* Inline prompt panel attached to the cell when the AI button is clicked. */