diff --git a/src/__tests__/opencode_cell_actions.spec.ts b/src/__tests__/opencode_cell_actions.spec.ts index 371c9db..7482a58 100644 --- a/src/__tests__/opencode_cell_actions.spec.ts +++ b/src/__tests__/opencode_cell_actions.spec.ts @@ -194,8 +194,11 @@ describe('OpenCodeCellActions', () => { it('renders a single AI button', () => { const cell = makeFakeCell('x = 1'); - const actions = new OpenCodeCellActions(cell); - const btns = actions.node.querySelectorAll('button'); + // Constructing the actions widget has the side effect of appending + // the floating AI button to cell.node; the widget reference itself + // is not needed in this test. + new OpenCodeCellActions(cell); + const btns = cell.node.querySelectorAll('button.opencode-cell-ai-btn'); expect(btns.length).toBe(1); expect(btns[0].textContent).toContain('AI'); }); @@ -210,8 +213,11 @@ describe('OpenCodeCellActions', () => { 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; + (cell as any).node = document.createElement('div'); + new OpenCodeCellActions(cell); + const btn = cell.node.querySelector( + 'button.opencode-cell-ai-btn' + ) as HTMLButtonElement; expect(btn.disabled).toBe(true); }); @@ -221,7 +227,7 @@ describe('OpenCodeCellActions', () => { Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); - const btn = actions.node.querySelector('button') as HTMLButtonElement; + const btn = cell.node.querySelector('button.opencode-cell-ai-btn') as HTMLButtonElement; btn.click(); const prompt = cell.node.querySelector( @@ -236,7 +242,7 @@ describe('OpenCodeCellActions', () => { Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); - const btn = actions.node.querySelector('button') as HTMLButtonElement; + const btn = cell.node.querySelector('button.opencode-cell-ai-btn') as HTMLButtonElement; btn.click(); btn.click(); expect(cell.node.querySelector('.opencode-inline-prompt')).toBeNull(); @@ -274,7 +280,7 @@ describe('OpenCodeCellActions', () => { Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); - const btn = actions.node.querySelector('button') as HTMLButtonElement; + const btn = cell.node.querySelector('button.opencode-cell-ai-btn') as HTMLButtonElement; btn.click(); const prompt = cell.node.querySelector( @@ -319,7 +325,7 @@ describe('OpenCodeCellActions', () => { Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); - const btn = actions.node.querySelector('button') as HTMLButtonElement; + const btn = cell.node.querySelector('button.opencode-cell-ai-btn') as HTMLButtonElement; btn.click(); const modelSel = cell.node.querySelector( @@ -348,7 +354,7 @@ describe('OpenCodeCellActions', () => { Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); - const btn = actions.node.querySelector('button') as HTMLButtonElement; + const btn = cell.node.querySelector('button.opencode-cell-ai-btn') as HTMLButtonElement; btn.click(); const prompt = cell.node.querySelector( @@ -384,7 +390,7 @@ describe('OpenCodeCellActions', () => { Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); - const btn = actions.node.querySelector('button') as HTMLButtonElement; + const btn = cell.node.querySelector('button.opencode-cell-ai-btn') as HTMLButtonElement; btn.click(); expect( @@ -403,7 +409,7 @@ describe('OpenCodeCellActions', () => { // has a target and the cell source check has a baseline). Object.defineProperty(actions, 'parent', { value: cell, configurable: true }); (actions as any).onAfterAttach({} as any); - const btn = actions.node.querySelector('button') as HTMLButtonElement; + const btn = cell.node.querySelector('button.opencode-cell-ai-btn') as HTMLButtonElement; btn.click(); // The history refresh on show is async; spy on _refreshHistory to @@ -422,6 +428,34 @@ describe('OpenCodeCellActions', () => { // A history refresh was triggered (to pick up the new assistant msg). expect(refreshSpy).toHaveBeenCalledWith('foo.ipynb'); }); + + it('clears the input textarea after a successful response', () => { + 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); + + // Open the prompt and type something into the textarea. + const aiBtn = cell.node.querySelector( + 'button.opencode-cell-ai-btn' + ) as HTMLButtonElement; + aiBtn.click(); + const prompt = (actions as any)._prompt; + expect(prompt).not.toBeNull(); + const textarea = prompt._textarea as HTMLTextAreaElement; + textarea.value = 'make it faster'; + + // A successful response clears the input so the user can type a + // follow-up message. + (actions as any)._handleResponse({ + ok: true, + markdown: 'ok', + sessionId: 'ses-1', + notebookPath: 'foo.ipynb' + }); + expect(textarea.value).toBe(''); + }); }); describe('OpenCodeInlinePrompt', () => { diff --git a/src/components/opencode_cell_actions.ts b/src/components/opencode_cell_actions.ts index d64bfaf..b70085c 100644 --- a/src/components/opencode_cell_actions.ts +++ b/src/components/opencode_cell_actions.ts @@ -50,11 +50,22 @@ export class OpenCodeCellActions extends Widget { private _context: CellContext | null = null; private _status: Status = 'idle'; private _prompt: OpenCodeInlinePrompt | null = null; + // Floating "AI" entry button that lives at the cell's bottom-right + // corner (overlaid on the cell, absolutely positioned). The widget's + // own node stays inside the native cell toolbar (as a hidden lifecycle + // holder) and is NOT where the user-visible button renders. + private _floatingBtn: HTMLButtonElement | null = null; constructor(cell: CodeCell) { super(); this._cell = cell; this.addClass('opencode-cell-actions'); + // Hide the widget's own node inside the native cell toolbar — the + // actual UI is the floating button below. + this.node.style.display = 'none'; + // Anchor the absolutely-positioned floating button to the cell. + this._cell.node.style.position = 'relative'; + this._createFloatingButton(); this._cell.model.contentChanged.connect(this._onModelChange, this); this._cell.model.stateChanged.connect(this._onModelChange, this); this._render(); @@ -68,9 +79,26 @@ export class OpenCodeCellActions extends Widget { this._cell.model.contentChanged.disconnect(this._onModelChange, this); this._cell.model.stateChanged.disconnect(this._onModelChange, this); this._hidePrompt(); + if (this._floatingBtn && this._floatingBtn.parentNode) { + this._floatingBtn.parentNode.removeChild(this._floatingBtn); + } + this._floatingBtn = null; super.dispose(); } + private _createFloatingButton(): void { + const btn = document.createElement('button'); + btn.className = 'opencode-cell-ai-btn'; + btn.type = 'button'; + btn.textContent = 'AI'; + btn.title = '让 AI 修改这个 cell 的代码'; + btn.addEventListener('click', () => { + this._togglePrompt(); + }); + this._cell.node.appendChild(btn); + this._floatingBtn = btn; + } + private _onModelChange(): void { this._context = extractCellContextFromCell(this._cell); if (this._prompt) { @@ -81,22 +109,18 @@ export class OpenCodeCellActions extends Widget { } private _render(): void { - const node = this.node; - node.textContent = ''; - + // The widget's own node no longer hosts the button (it lives at the + // cell's bottom-right). Just update the floating button's label and + // disabled state. + const btn = this._floatingBtn; + if (!btn) { + return; + } const baseDisabled = !this._context || 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 { @@ -204,6 +228,8 @@ export class OpenCodeCellActions extends Widget { if (notebookPath) { void this._refreshHistory(notebookPath); } + // Clear the input so the user can type a follow-up message. + this._prompt?.clearInput(); Notification.info( `OpenCode 完成 (${resp.markdown.length} chars). Session: ${resp.sessionId.slice(0, 8)}…` ); diff --git a/src/components/opencode_inline_prompt.ts b/src/components/opencode_inline_prompt.ts index 9d692c8..c987551 100644 --- a/src/components/opencode_inline_prompt.ts +++ b/src/components/opencode_inline_prompt.ts @@ -367,4 +367,12 @@ export class OpenCodeInlinePrompt extends Widget { cell.model.sharedModel.setSource(text); Notification.info('已替换单元格内容'); } + + /** + * Clear the user input textarea (called by the cell action after a + * successful response, so the user can type a follow-up message). + */ + clearInput(): void { + this._textarea.value = ''; + } } diff --git a/style/base.css b/style/base.css index 6c23365..a6347bc 100644 --- a/style/base.css +++ b/style/base.css @@ -4,29 +4,31 @@ 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 { - display: flex; - align-items: center; -} - -.opencode-cell-actions .opencode-btn { - border: none; - background: transparent; - padding: 0 6px; - font-size: var(--jp-ui-font-size1); - line-height: 20px; +/* AI entry button: rendered as a floating pill at the bottom-right + corner of the cell. The widget that owns it (opencode-cell-actions) + is registered in the native cell toolbar but its own node is + display:none — only this floating button is visible. */ +.opencode-cell-ai-btn { + position: absolute; + bottom: 4px; + right: 4px; + z-index: 10; + border: 1px solid var(--jp-border-color1, #999); + background: var(--jp-layout-color1, #fff); + color: var(--jp-ui-font-color1, #333); + padding: 1px 8px; + border-radius: 10px; + font-size: var(--jp-ui-font-size0, 11px); + line-height: 1.3; cursor: pointer; - white-space: nowrap; } -.opencode-cell-actions .opencode-btn:hover:enabled { - background: var(--jp-layout-color2); - border-radius: 2px; +.opencode-cell-ai-btn:hover { + background: var(--jp-layout-color2, #f0f0f0); } -.opencode-cell-actions .opencode-btn:disabled { - opacity: 0.4; +.opencode-cell-ai-btn:disabled { + opacity: 0.5; cursor: default; }