feat: AI entry button at the cell's bottom-right + clear input after success

AI entry button position:
  - The AI button (entry point to the inline prompt) now renders as a
    floating pill at the bottom-right corner of the cell, not inside
    the native cell toolbar.
  - The widget is still created per cell via the Cell toolbar factory
    (it's the per-cell lifecycle hook), but its own node is
    display:none inside the toolbar (no visible artifact). On
    construction, OpenCodeCellActions creates a real <button> with
    class .opencode-cell-ai-btn and appends it to cell.node. The
    cell.node gets position:relative so the absolute-positioned
    button anchors to the cell.
  - style/base.css: removed the .opencode-cell-actions / .opencode-btn
    rules (the widget's own node is no longer the visible button) and
    added .opencode-cell-ai-btn (absolute, bottom:4px, right:4px,
    z-index:10, small pill, hover/disabled states).

Clear input after success:
  - OpenCodeInlinePrompt gains a public clearInput() method that empties
    the textarea.
  - OpenCodeCellActions._handleResponse on a successful response now
    calls prompt.clearInput() after the history refresh, so the user
    can immediately type a follow-up message.

Tests:
  - All tests that previously queried the AI button on actions.node now
    query cell.node.querySelector('button.opencode-cell-ai-btn') (the
    button lives on the cell, not on the widget's own node). The two
    tests that constructed the widget only for its side effect now use
    the expression statement form to avoid the unused-const lint.
  - The orphan-cell test now sets (cell as any).node since the
    constructor accesses cell.node to position/append.
  - New test: 'clears the input textarea after a successful response'
    (types into the textarea, fires _handleResponse success, asserts
    textarea.value === '').

Verification: pytest 37/37, jest 35/35, jlpm build OK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-23 19:32:45 +08:00
co-authored by Claude Fable 5
parent b5642ec5cd
commit fd30e53e25
4 changed files with 110 additions and 40 deletions
+45 -11
View File
@@ -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', () => {
+37 -11
View File
@@ -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)}`
);
+8
View File
@@ -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 = '';
}
}
+20 -18
View File
@@ -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;
}