feat: Copy / Insert / Replace buttons on assistant messages
Each assistant message in the inline prompt history now renders a small toolbar with three action buttons: 📋 复制 — copy the rendered assistant content (the markdown source, not the rendered HTML) to the clipboard via navigator.clipboard.writeText, with a fallback to a hidden textarea + execCommand for non-secure contexts. ↪ 插入 — insert the content at the current editor cursor. Uses cell.editor.getCursorPosition() + the cell source to compute the offset, then sharedModel.setSource to splice the text in. Falls back to appending if the cursor can't be read. ⟳ 替换 — replace the entire cell source with the assistant content via sharedModel.setSource. User messages and the textarea/send/cancel UI are unchanged; the toolbar only appears on rendered assistant (markdown) messages. Implementation: - OpenCodeInlinePrompt now stores the cell (private _cell) so the button handlers can act on it. - New private methods: _copyToClipboard, _insertAtCursor, _replaceCell. Each shows a Notification on success/failure. - Added import { Notification } from '@jupyterlab/apputils' (the prompt file didn't import it before; cell_actions does, so the test mock already provides the static methods). - style/base.css: .opencode-msg-toolbar (flex row) and .opencode-msg-btn (small bordered button) styles. - Test fake cell now has an editor stub returning cursor (0,0) so the Insert test produces a deterministic offset. jsdom lacks navigator.clipboard; the test stubs it globally. Tests: - jest 33/33 (was 29; +4 new: toolbar renders 3 buttons only on assistant, Replace sets source, Insert splices at offset 0, Copy calls clipboard.writeText with the content). - pytest 37/37 (no server changes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
ce59502e97
commit
cb3576c83a
@@ -52,6 +52,15 @@ jest.mock('marked', () => ({
|
||||
marked: { parse: (md: string) => `<mock>${md}</mock>` }
|
||||
}));
|
||||
|
||||
// jsdom doesn't implement navigator.clipboard; stub it so the copy
|
||||
// button's happy path can be exercised in tests.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
value: { writeText: jest.fn().mockResolvedValue(undefined) },
|
||||
configurable: true
|
||||
});
|
||||
});
|
||||
|
||||
// Mock the network-touching API module so jsdom tests don't try to hit
|
||||
// a real notebook server (and to keep the refresh-history logic decoupled
|
||||
// from the network). The cell_actions tests focus on widget behavior.
|
||||
@@ -101,6 +110,11 @@ function makeFakeCell(
|
||||
const cell = new (CodeCell as unknown as new () => CodeCell)();
|
||||
(cell as any).model = model;
|
||||
(cell as any).node = document.createElement('div');
|
||||
// Minimal editor stub: cursor always at (line 0, column 0) so the
|
||||
// "insert at cursor" tests produce a deterministic offset.
|
||||
(cell as any).editor = {
|
||||
getCursorPosition: () => ({ line: 0, column: 0 })
|
||||
};
|
||||
|
||||
const notebook: any = { widgets: [] as CodeCell[] };
|
||||
const panel: any = {
|
||||
@@ -417,4 +431,97 @@ describe('OpenCodeInlinePrompt', () => {
|
||||
expect(asstMsg.innerHTML).toContain('<mock>');
|
||||
expect(asstMsg.innerHTML).toContain('# Here you go');
|
||||
});
|
||||
|
||||
it('renders a Copy / Insert / Replace toolbar on assistant messages', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setMessages([
|
||||
{ role: 'user', content: 'help' },
|
||||
{ role: 'assistant', content: '```py\nprint(1)\n```' }
|
||||
]);
|
||||
|
||||
const asst = prompt.node.querySelector(
|
||||
'.opencode-msg-assistant'
|
||||
) as HTMLElement;
|
||||
const toolbar = asst.querySelector(
|
||||
'.opencode-msg-toolbar'
|
||||
) as HTMLElement;
|
||||
expect(toolbar).not.toBeNull();
|
||||
const buttons = toolbar.querySelectorAll('button');
|
||||
expect(buttons.length).toBe(3);
|
||||
expect(buttons[0].textContent).toContain('复制');
|
||||
expect(buttons[1].textContent).toContain('插入');
|
||||
expect(buttons[2].textContent).toContain('替换');
|
||||
// User messages must NOT get a toolbar.
|
||||
const user = prompt.node.querySelector('.opencode-msg-user');
|
||||
expect(user!.querySelector('.opencode-msg-toolbar')).toBeNull();
|
||||
});
|
||||
|
||||
it('Replace button overwrites the cell source with the assistant content', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setMessages([
|
||||
{ role: 'assistant', content: 'print("replaced")' }
|
||||
]);
|
||||
const replaceBtn = prompt.node.querySelector(
|
||||
'.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(3)'
|
||||
) as HTMLButtonElement;
|
||||
replaceBtn.click();
|
||||
|
||||
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
||||
expect(setSource).toHaveBeenCalledTimes(1);
|
||||
expect(setSource).toHaveBeenCalledWith('print("replaced")');
|
||||
});
|
||||
|
||||
it('Insert button splices the content at the editor cursor (offset 0)', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setMessages([
|
||||
{ role: 'assistant', content: 'inserted()\n' }
|
||||
]);
|
||||
const insertBtn = prompt.node.querySelector(
|
||||
'.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(2)'
|
||||
) as HTMLButtonElement;
|
||||
insertBtn.click();
|
||||
|
||||
const setSource = (cell as any).model.sharedModel.setSource as jest.Mock;
|
||||
expect(setSource).toHaveBeenCalledTimes(1);
|
||||
// cursor at (0,0) with source "x = 1" -> content is inserted at offset 0
|
||||
expect(setSource).toHaveBeenCalledWith('inserted()\nx = 1');
|
||||
});
|
||||
|
||||
it('Copy button writes the assistant content to the clipboard', () => {
|
||||
const cell = makeFakeCell('x = 1');
|
||||
const prompt = new OpenCodeInlinePrompt(cell, {
|
||||
onSubmit: jest.fn(),
|
||||
onCancel: jest.fn(),
|
||||
disabled: false,
|
||||
providers: null
|
||||
});
|
||||
prompt.setMessages([
|
||||
{ role: 'assistant', content: 'copy me' }
|
||||
]);
|
||||
const copyBtn = prompt.node.querySelector(
|
||||
'.opencode-msg-assistant .opencode-msg-toolbar button:nth-child(1)'
|
||||
) as HTMLButtonElement;
|
||||
copyBtn.click();
|
||||
|
||||
const writeText = (navigator.clipboard.writeText as unknown as jest.Mock);
|
||||
expect(writeText).toHaveBeenCalledWith('copy me');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user