mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(ai-assist): renderer controller + Cmd+K wiring (end-to-end)
createInlineAiController() glues the popover, the streaming bridge,
and the CodeMirror editor together. Each Cmd+K opens a fresh
request; the controller:
- reads the active selection from the CodeMirror view
- builds the rewrite/shorten/expand prompt via inline-assist.js
- clears the selection so chunks can fill back in
- dispatches each chunk as it arrives (typewriter into the editor)
- on done: applies the final result (or restores selection on no-op)
- on error: restores the original selection and shows error state
- on Esc: cancels the in-flight request, restores the selection,
hides the popover
main-side streaming handler now reads the AI Assistant plugin's
provider config from settings.json under plugins.ai-assistant.* —
keys never cross the IPC boundary.
renderer.js:
- new getCreateInlineAiController() lazy loader
- new ensureInlineAiController() factory
- new Cmd+K / Ctrl+K keymap handler in the global keydown listener
that resolves the active tab's CodeMirror view via tabManager
16 new controller tests covering show/handleKey wiring, streaming
chunk application, done/error finalization, stale-request filtering,
and detach cleanup.
Amit Haridas
This commit is contained in:
+14
-7
@@ -4910,19 +4910,26 @@ ipcMain.on('clear-recent-files', (event) => {
|
||||
ipcMain.handle('recent-files:get', () => getRecentFiles());
|
||||
|
||||
// Inline AI assist (v4.13.0): streaming proxy from renderer to provider.
|
||||
// Renderer sends {requestId, request}; main streams chunks back via
|
||||
// 'ai-assist-stream:chunk' events with the same requestId, plus a
|
||||
// 'done' or 'error' terminal event. Renderer can abort via
|
||||
// 'ai-assist-stream:cancel'.
|
||||
// Renderer sends {requestId, request:{system, messages}}; main streams
|
||||
// chunks back via 'ai-assist-stream:chunk' events with the same
|
||||
// requestId, plus a 'done' or 'error' terminal event. Renderer can
|
||||
// abort via 'ai-assist-stream:cancel'. Provider config comes from the
|
||||
// AI Assistant plugin's settings (same path the plugin itself uses),
|
||||
// so the renderer never sees API keys.
|
||||
const aiAssistStreams = new Map(); // requestId -> { abort, sender }
|
||||
ipcMain.on('ai-assist-stream:start', async (event, { requestId, request } = {}) => {
|
||||
if (!requestId || !request) return;
|
||||
const sender = event.sender;
|
||||
let ac;
|
||||
try {
|
||||
ac = new AbortController();
|
||||
const ac = new AbortController();
|
||||
aiAssistStreams.set(requestId, { abort: () => ac.abort(), sender });
|
||||
for await (const chunk of completeStream(request, { signal: ac.signal })) {
|
||||
const aiSettings = getAiAssistantSettings();
|
||||
const fullRequest = {
|
||||
...aiSettings,
|
||||
system: request.system,
|
||||
messages: request.messages,
|
||||
};
|
||||
for await (const chunk of completeStream(fullRequest, { signal: ac.signal })) {
|
||||
if (ac.signal.aborted) break;
|
||||
sender.send('ai-assist-stream:chunk', { requestId, chunk });
|
||||
}
|
||||
|
||||
@@ -220,6 +220,13 @@ function getCreateQuickSwitcherOverlay() {
|
||||
require('./quick-switcher/quick-switcher-overlay').createQuickSwitcherOverlay;
|
||||
return _createQuickSwitcherOverlay;
|
||||
}
|
||||
let _createInlineAiController;
|
||||
function getCreateInlineAiController() {
|
||||
if (!_createInlineAiController)
|
||||
_createInlineAiController = require('./renderer/inline-ai-controller')
|
||||
.createInlineAiController;
|
||||
return _createInlineAiController;
|
||||
}
|
||||
function getPrintPreview() {
|
||||
if (!_PrintPreview) _PrintPreview = require('./print-preview').PrintPreview;
|
||||
return _PrintPreview;
|
||||
@@ -2575,6 +2582,24 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
return quickSwitcherOverlayInstance;
|
||||
}
|
||||
|
||||
// Inline AI assist controller (Cmd+K on selected text) — lazy-instantiated
|
||||
// so its dependency cost is paid only on first use. The active tab's
|
||||
// CodeMirror view is sourced via tabManager each time Cmd+K fires, so
|
||||
// tab switches automatically pick up the right editor.
|
||||
let inlineAiControllerInstance = null;
|
||||
function ensureInlineAiController() {
|
||||
if (inlineAiControllerInstance) return inlineAiControllerInstance;
|
||||
const create = getCreateInlineAiController();
|
||||
inlineAiControllerInstance = create({
|
||||
getEditorView: () => {
|
||||
const active = tabManager.activeTab || (tabManager.activeTabId && tabManager.tabs.get(tabManager.activeTabId));
|
||||
return active ? active.editorView : null;
|
||||
},
|
||||
electronAPI: window.electronAPI,
|
||||
});
|
||||
return inlineAiControllerInstance;
|
||||
}
|
||||
const pluginRegistry = new PluginRegistry({
|
||||
sidebar: sidebarManager,
|
||||
commands: commandPalette,
|
||||
@@ -2987,6 +3012,14 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
e.preventDefault();
|
||||
commandPalette.open();
|
||||
}
|
||||
// Cmd+K / Ctrl+K — Inline AI assist (Rewrite / Shorten / Expand) on
|
||||
// selected text. v4.13.0. Lazy-instantiated; uses the active tab's
|
||||
// CodeMirror view for selection bounds and dispatch.
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
ensureInlineAiController().handleKey(e);
|
||||
return;
|
||||
}
|
||||
// F11 — Zen Mode
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*
|
||||
* Inline AI assist popover — DOM + interaction tests.
|
||||
*
|
||||
* Pure controller. Wires the floating popover to the editor's selection
|
||||
* state and to the IPC streaming bridge. Holds no global state across
|
||||
* invocations: each Cmd+K opens a fresh request with its own requestId.
|
||||
*
|
||||
* Public API:
|
||||
* const ctl = createInlineAiController({ ... });
|
||||
* ctl.attach() // registers Cmd+K keymap
|
||||
* ctl.detach() // tears down
|
||||
*
|
||||
* Or call ctl.showForSelection() directly from a custom shortcut.
|
||||
*
|
||||
* @module inline-ai-controller
|
||||
*/
|
||||
|
||||
const { createInlineAiPopover } = require('../ai-assist/inline-ai-popover');
|
||||
const {
|
||||
buildAssistPrompt,
|
||||
applyAssistResult,
|
||||
MAX_SELECTION_CHARS,
|
||||
} = require('../ai-assist/inline-assist');
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {() => any} deps.getEditorView - returns CodeMirror EditorView
|
||||
* @param {object} deps.electronAPI - window.electronAPI shape
|
||||
* @param {(key:string, e:Event) => boolean} [deps.onShortcut] - returns
|
||||
* true when the keymap handler should claim the event. Used to keep
|
||||
* Esc-cancel from firing when the popover isn't open.
|
||||
*/
|
||||
function createInlineAiController(deps) {
|
||||
const { getEditorView, electronAPI, onShortcut } = deps;
|
||||
if (typeof getEditorView !== 'function') {
|
||||
throw new Error('createInlineAiController: getEditorView is required');
|
||||
}
|
||||
if (!electronAPI || !electronAPI.aiAssist) {
|
||||
throw new Error('createInlineAiController: electronAPI.aiAssist is required');
|
||||
}
|
||||
|
||||
let popover = null;
|
||||
let activeRequestId = null;
|
||||
let activeSelection = null; // { from, to, original }
|
||||
let activeAction = null; // 'rewrite' | 'shorten' | 'expand'
|
||||
let unsubscribers = []; // [{ off }]
|
||||
|
||||
function ensurePopover() {
|
||||
if (popover) return popover;
|
||||
popover = createInlineAiPopover(document.body, {
|
||||
onAction: (action, selectionText) => runAssist(action, selectionText),
|
||||
onCancel: () => cancelActive(),
|
||||
onRetry: () => {
|
||||
if (activeAction && activeSelection) {
|
||||
runAssist(activeAction, activeSelection.original);
|
||||
}
|
||||
},
|
||||
});
|
||||
return popover;
|
||||
}
|
||||
|
||||
function getSelectionFromView() {
|
||||
const view = getEditorView();
|
||||
if (!view) return null;
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return null;
|
||||
const text = view.state.sliceDoc(sel.from, sel.to);
|
||||
return { from: sel.from, to: sel.to, original: text };
|
||||
}
|
||||
|
||||
function rectForSelection(view, from, to) {
|
||||
const start = view.coordsAtPos(from);
|
||||
const end = view.coordsAtPos(to);
|
||||
if (!start || !end) return null;
|
||||
return {
|
||||
top: Math.min(start.top, end.top),
|
||||
bottom: Math.max(start.bottom, end.bottom),
|
||||
left: Math.min(start.left, end.left),
|
||||
width: Math.max(80, Math.abs(end.left - start.left)),
|
||||
right: Math.max(start.right, end.right),
|
||||
};
|
||||
}
|
||||
|
||||
function showForSelection() {
|
||||
const view = getEditorView();
|
||||
if (!view) return false;
|
||||
const sel = getSelectionFromView();
|
||||
if (!sel) return false;
|
||||
if (sel.original.length === 0 || sel.original.length > MAX_SELECTION_CHARS) {
|
||||
return false;
|
||||
}
|
||||
const rect = rectForSelection(view, sel.from, sel.to);
|
||||
if (!rect) return false;
|
||||
|
||||
cancelActive({ silent: true });
|
||||
activeSelection = sel;
|
||||
activeAction = null;
|
||||
ensurePopover().show({ text: sel.original, rect });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runAssist(action, selectionText) {
|
||||
const view = getEditorView();
|
||||
if (!view) return;
|
||||
|
||||
let prompt;
|
||||
try {
|
||||
prompt = buildAssistPrompt(action, selectionText);
|
||||
} catch (err) {
|
||||
ensurePopover().setState('error', { message: err.message || 'Invalid selection.' });
|
||||
return;
|
||||
}
|
||||
|
||||
activeAction = action;
|
||||
ensurePopover().setState('loading');
|
||||
|
||||
// Empty out the selection; the stream will fill it back in.
|
||||
const insertAt = activeSelection ? activeSelection.from : view.state.selection.main.from;
|
||||
view.dispatch({
|
||||
changes: { from: insertAt, to: activeSelection?.to ?? insertAt, insert: '' },
|
||||
selection: { anchor: insertAt },
|
||||
});
|
||||
|
||||
const requestId = `assist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
activeRequestId = requestId;
|
||||
let accumulated = '';
|
||||
|
||||
const offChunk = electronAPI.on('ai-assist-stream:chunk', (_e, payload) => {
|
||||
if (!payload || payload.requestId !== activeRequestId) return;
|
||||
accumulated += payload.chunk || '';
|
||||
view.dispatch({
|
||||
changes: { from: insertAt, to: insertAt, insert: accumulated },
|
||||
selection: { anchor: insertAt + accumulated.length },
|
||||
});
|
||||
});
|
||||
const offDone = electronAPI.on('ai-assist-stream:done', (_e, payload) => {
|
||||
if (!payload || payload.requestId !== activeRequestId) return;
|
||||
finalizeSuccess(view, insertAt, accumulated, selectionText);
|
||||
});
|
||||
const offError = electronAPI.on('ai-assist-stream:error', (_e, payload) => {
|
||||
if (!payload || payload.requestId !== activeRequestId) return;
|
||||
finalizeError(view, insertAt, accumulated, selectionText, payload.message);
|
||||
});
|
||||
unsubscribers.push(offChunk, offDone, offError);
|
||||
|
||||
electronAPI.aiAssist.start(requestId, {
|
||||
system: prompt.system,
|
||||
messages: prompt.messages,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeSuccess(view, insertAt, accumulated, selectionText) {
|
||||
const finalText = applyAssistResult(selectionText, accumulated);
|
||||
cleanupListeners();
|
||||
if (finalText === null) {
|
||||
// No-op (empty / unchanged) — restore the original selection
|
||||
view.dispatch({
|
||||
changes: { from: insertAt, to: insertAt + accumulated.length, insert: selectionText },
|
||||
selection: { anchor: insertAt + selectionText.length },
|
||||
});
|
||||
} else {
|
||||
view.dispatch({
|
||||
changes: { from: insertAt, to: insertAt + accumulated.length, insert: finalText },
|
||||
selection: { anchor: insertAt + finalText.length },
|
||||
});
|
||||
}
|
||||
ensurePopover().hide();
|
||||
activeRequestId = null;
|
||||
activeSelection = null;
|
||||
activeAction = null;
|
||||
}
|
||||
|
||||
function finalizeError(view, insertAt, accumulated, selectionText, message) {
|
||||
cleanupListeners();
|
||||
// Restore the original selection on error
|
||||
if (accumulated.length > 0) {
|
||||
view.dispatch({
|
||||
changes: { from: insertAt, to: insertAt + accumulated.length, insert: selectionText },
|
||||
selection: { anchor: insertAt + selectionText.length },
|
||||
});
|
||||
}
|
||||
ensurePopover().setState('error', { message: message || 'AI request failed.' });
|
||||
activeRequestId = null;
|
||||
}
|
||||
|
||||
function cancelActive({ silent = false } = {}) {
|
||||
if (activeRequestId) {
|
||||
electronAPI.aiAssist.cancel(activeRequestId);
|
||||
}
|
||||
cleanupListeners();
|
||||
const view = getEditorView();
|
||||
if (view && activeSelection) {
|
||||
// Restore the original if we cleared the selection
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) {
|
||||
view.dispatch({
|
||||
changes: { from: activeSelection.from, to: sel.anchor, insert: activeSelection.original },
|
||||
selection: { anchor: activeSelection.from + activeSelection.original.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
if (popover && !silent) popover.hide();
|
||||
activeRequestId = null;
|
||||
activeSelection = null;
|
||||
activeAction = null;
|
||||
}
|
||||
|
||||
function cleanupListeners() {
|
||||
for (const off of unsubscribers) {
|
||||
try {
|
||||
off();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
unsubscribers = [];
|
||||
}
|
||||
|
||||
function detach() {
|
||||
cancelActive({ silent: true });
|
||||
if (popover) {
|
||||
popover.destroy();
|
||||
popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Lightweight keymap helper. Returns true if the event was handled.
|
||||
function handleKey(e) {
|
||||
// Esc while popover is open → cancel
|
||||
if (e.key === 'Escape' && popover && popover.isVisible()) {
|
||||
e.preventDefault();
|
||||
cancelActive();
|
||||
return true;
|
||||
}
|
||||
// Cmd+K / Ctrl+K → open popover for current selection
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
if (meta && (e.key === 'k' || e.key === 'K')) {
|
||||
if (typeof onShortcut === 'function' && !onShortcut('cmd-k', e)) return false;
|
||||
e.preventDefault();
|
||||
return showForSelection();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
showForSelection,
|
||||
cancelActive,
|
||||
handleKey,
|
||||
detach,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createInlineAiController };
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*
|
||||
* Inline AI assist controller — DOM + IPC + editor integration.
|
||||
*
|
||||
* The controller is the glue between the floating popover, the IPC
|
||||
* streaming bridge, and the CodeMirror editor. These tests mock the
|
||||
* editor (a minimal state/dispatch stand-in) and the electronAPI
|
||||
* surface to verify the wiring without bringing up CodeMirror.
|
||||
*/
|
||||
|
||||
const { createInlineAiController } = require('../src/renderer/inline-ai-controller');
|
||||
|
||||
// --- Minimal CodeMirror stand-in ---------------------------------------
|
||||
|
||||
function makeEditor(initialContent = '') {
|
||||
const doc = { text: initialContent, length: initialContent.length };
|
||||
const sel = { from: 0, to: 0, empty: true };
|
||||
const dispatched = [];
|
||||
const view = {
|
||||
state: {
|
||||
get doc() {
|
||||
return doc;
|
||||
},
|
||||
get selection() {
|
||||
return { main: sel };
|
||||
},
|
||||
sliceDoc(from, to) {
|
||||
return doc.text.slice(from, to);
|
||||
},
|
||||
},
|
||||
dispatch(tr) {
|
||||
dispatched.push(tr);
|
||||
if (tr.changes) {
|
||||
const { from, to, insert } = tr.changes;
|
||||
doc.text = doc.text.slice(0, from) + insert + doc.text.slice(to);
|
||||
doc.length = doc.text.length;
|
||||
const anchor = tr.selection ? tr.selection.anchor : from + insert.length;
|
||||
sel.from = anchor;
|
||||
sel.to = anchor;
|
||||
sel.anchor = anchor;
|
||||
sel.head = anchor;
|
||||
sel.empty = true;
|
||||
}
|
||||
},
|
||||
coordsAtPos(_pos) {
|
||||
return { top: 100, bottom: 120, left: 50, right: 80 };
|
||||
},
|
||||
_dispatched: dispatched,
|
||||
_setSelection(from, to) {
|
||||
sel.from = from;
|
||||
sel.to = to;
|
||||
sel.empty = from === to;
|
||||
},
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
// --- Minimal electronAPI stand-in ---------------------------------------
|
||||
|
||||
function makeApi() {
|
||||
const listeners = { chunk: [], done: [], error: [] };
|
||||
const started = [];
|
||||
const cancelled = [];
|
||||
return {
|
||||
api: {
|
||||
aiAssist: {
|
||||
start: (requestId, request) => started.push({ requestId, request }),
|
||||
cancel: (requestId) => cancelled.push(requestId),
|
||||
onChunk: (cb) => listeners.chunk.push(cb),
|
||||
onDone: (cb) => listeners.done.push(cb),
|
||||
onError: (cb) => listeners.error.push(cb),
|
||||
},
|
||||
on: (channel, cb) => {
|
||||
if (channel === 'ai-assist-stream:chunk') listeners.chunk.push(cb);
|
||||
else if (channel === 'ai-assist-stream:done') listeners.done.push(cb);
|
||||
else if (channel === 'ai-assist-stream:error') listeners.error.push(cb);
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
_state: { listeners, started, cancelled },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeKeyEvent(init) {
|
||||
return Object.assign({ preventDefault: () => {}, stopPropagation: () => {} }, init);
|
||||
}
|
||||
|
||||
function mount(opts = {}) {
|
||||
const editor = opts.editor || makeEditor(opts.content || '');
|
||||
const { api, _state } = makeApi();
|
||||
const electronAPI = Object.assign(opts.electronAPI || {}, api);
|
||||
const controller = createInlineAiController({
|
||||
getEditorView: () => editor,
|
||||
electronAPI,
|
||||
});
|
||||
return { controller, editor, electronAPI, _state };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll('.inline-ai-popover').forEach((el) => el.remove());
|
||||
});
|
||||
|
||||
function getPopoverButton(action) {
|
||||
const popovers = document.querySelectorAll('.inline-ai-popover');
|
||||
// Use the LAST (most recent) popover, which belongs to this test
|
||||
const popover = popovers[popovers.length - 1];
|
||||
if (!popover) throw new Error('No popover mounted');
|
||||
return popover.querySelector(`button[data-action="${action}"]`);
|
||||
}
|
||||
|
||||
function emit(state, type, payload) {
|
||||
for (const cb of state.listeners[type]) cb({}, payload);
|
||||
}
|
||||
|
||||
describe('createInlineAiController — showForSelection', () => {
|
||||
test('returns false when no selection', () => {
|
||||
const { controller } = mount({ content: 'hello world' });
|
||||
expect(controller.showForSelection()).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when selection is too large', () => {
|
||||
const editor = makeEditor('a'.repeat(9000));
|
||||
editor.state.selection.main = { from: 0, to: 9000, empty: false };
|
||||
const { controller } = mount({ editor });
|
||||
expect(controller.showForSelection()).toBe(false);
|
||||
});
|
||||
|
||||
test('opens the popover when a valid selection exists', () => {
|
||||
const editor = makeEditor('hello world');
|
||||
editor._setSelection(0, 5);
|
||||
const { controller } = mount({ editor });
|
||||
expect(controller.showForSelection()).toBe(true);
|
||||
expect(document.querySelector('.inline-ai-popover')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInlineAiController — handleKey', () => {
|
||||
test('Cmd+K opens the popover for the current selection', () => {
|
||||
const editor = makeEditor('hello world');
|
||||
editor._setSelection(0, 5);
|
||||
const { controller } = mount({ editor });
|
||||
const handled = controller.handleKey(fakeKeyEvent({ key: 'k', metaKey: true }));
|
||||
expect(handled).toBe(true);
|
||||
expect(document.querySelector('.inline-ai-popover')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('Cmd+K with no selection is a no-op (returns false)', () => {
|
||||
const { controller } = mount({ content: '' });
|
||||
const handled = controller.handleKey(fakeKeyEvent({ key: 'k', metaKey: true }));
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
test('Esc while popover is open cancels and hides', () => {
|
||||
const editor = makeEditor('hello world');
|
||||
editor._setSelection(0, 5);
|
||||
const { controller } = mount({ editor });
|
||||
controller.showForSelection();
|
||||
const handled = controller.handleKey(fakeKeyEvent({ key: 'Escape' }));
|
||||
expect(handled).toBe(true);
|
||||
expect(document.querySelector('.inline-ai-popover.open')).toBeNull();
|
||||
});
|
||||
|
||||
test('Esc when popover is closed does nothing', () => {
|
||||
const { controller } = mount();
|
||||
const handled = controller.handleKey(fakeKeyEvent({ key: 'Escape' }));
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
|
||||
test('non-shortcut keys return false', () => {
|
||||
const { controller } = mount();
|
||||
expect(controller.handleKey(fakeKeyEvent({ key: 'a' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInlineAiController — streaming', () => {
|
||||
function setupWithSelection(content = 'the quick brown fox', from = 0, to = 19) {
|
||||
const editor = makeEditor(content);
|
||||
editor._setSelection(from, to);
|
||||
const m = mount({ editor });
|
||||
m.controller.showForSelection();
|
||||
return m;
|
||||
}
|
||||
|
||||
test('clicking an action starts a stream and clears the selection', () => {
|
||||
const m = setupWithSelection();
|
||||
const rewriteBtn = getPopoverButton('rewrite');
|
||||
rewriteBtn.click();
|
||||
// selection should be replaced with empty insert (changes[0])
|
||||
expect(m.editor._dispatched[0].changes).toEqual({ from: 0, to: 19, insert: '' });
|
||||
// aiAssist.start should have been called
|
||||
expect(m._state.started).toHaveLength(1);
|
||||
expect(m._state.started[0].request.system).toMatch(/Rewrite/i);
|
||||
expect(m._state.started[0].request.messages[0].content).toBe('the quick brown fox');
|
||||
});
|
||||
|
||||
test('chunks progressively replace the selection', () => {
|
||||
const m = setupWithSelection();
|
||||
getPopoverButton('rewrite').click();
|
||||
const requestId = m._state.started[0].requestId;
|
||||
|
||||
emit(m._state, 'chunk', { requestId, chunk: 'A ' });
|
||||
emit(m._state, 'chunk', { requestId, chunk: 'faster ' });
|
||||
emit(m._state, 'chunk', { requestId, chunk: 'fox' });
|
||||
|
||||
// The most recent dispatch should contain the accumulated text
|
||||
const last = m.editor._dispatched[m.editor._dispatched.length - 1];
|
||||
expect(last.changes.insert).toBe('A faster fox');
|
||||
});
|
||||
|
||||
test('done event finalizes and hides the popover', () => {
|
||||
const m = setupWithSelection();
|
||||
getPopoverButton('rewrite').click();
|
||||
const requestId = m._state.started[0].requestId;
|
||||
|
||||
emit(m._state, 'chunk', { requestId, chunk: 'final text' });
|
||||
emit(m._state, 'done', { requestId });
|
||||
|
||||
expect(document.querySelector('.inline-ai-popover.open')).toBeNull();
|
||||
// editor text should now contain 'final text'
|
||||
expect(m.editor.state.doc.text).toContain('final text');
|
||||
});
|
||||
|
||||
test('error event restores the original selection', () => {
|
||||
const m = setupWithSelection('original text');
|
||||
getPopoverButton('rewrite').click();
|
||||
const requestId = m._state.started[0].requestId;
|
||||
|
||||
emit(m._state, 'chunk', { requestId, chunk: 'partial' });
|
||||
emit(m._state, 'error', { requestId, message: 'boom' });
|
||||
|
||||
// popover should show error state
|
||||
const popover = document.querySelector('.inline-ai-popover');
|
||||
expect(popover.dataset.state).toBe('error');
|
||||
// original selection restored (partial replaced with original)
|
||||
expect(m.editor.state.doc.text).toContain('original text');
|
||||
expect(m.editor.state.doc.text).not.toContain('partial');
|
||||
});
|
||||
|
||||
test('stale events for an old requestId are ignored', () => {
|
||||
const m = setupWithSelection();
|
||||
getPopoverButton('rewrite').click();
|
||||
const requestId = m._state.started[0].requestId;
|
||||
|
||||
// Emit a chunk for a different requestId
|
||||
emit(m._state, 'chunk', { requestId: 'old-id', chunk: 'should not apply' });
|
||||
// No additional dispatch beyond the initial clear should have happened
|
||||
// for chunks targeting 'old-id'
|
||||
const chunkDispatches = m.editor._dispatched.filter(
|
||||
(d) => d.changes && d.changes.insert === 'should not apply'
|
||||
);
|
||||
expect(chunkDispatches).toHaveLength(0);
|
||||
|
||||
// Real chunk should still work
|
||||
emit(m._state, 'chunk', { requestId, chunk: 'real' });
|
||||
const last = m.editor._dispatched[m.editor._dispatched.length - 1];
|
||||
expect(last.changes.insert).toBe('real');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInlineAiController — detach', () => {
|
||||
test('detach removes DOM and clears state', () => {
|
||||
const editor = makeEditor('hello world');
|
||||
editor._setSelection(0, 5);
|
||||
const { controller } = mount({ editor });
|
||||
controller.showForSelection();
|
||||
controller.detach();
|
||||
expect(document.querySelector('.inline-ai-popover')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInlineAiController — validation', () => {
|
||||
test('throws when getEditorView is missing', () => {
|
||||
expect(() =>
|
||||
createInlineAiController({
|
||||
electronAPI: { aiAssist: {} },
|
||||
getEditorView: undefined,
|
||||
})
|
||||
).toThrow(/getEditorView/);
|
||||
});
|
||||
|
||||
test('throws when electronAPI.aiAssist is missing', () => {
|
||||
expect(() => createInlineAiController({ getEditorView: () => null, electronAPI: {} })).toThrow(
|
||||
/aiAssist/
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user