feat(ai-assist): first-use confirmation + wire Check-for-Updates to updater

Inline AI first-use confirmation (v4.13.0):
  - inline-ai-controller accepts optional confirmFirstUse + getProviderLabel
    deps. On the first action of the session, calls confirmFirstUse with
    {providerLabel, selectionLength, action}; if it returns false, aborts
    and shows error state. Once confirmed, never re-prompts for the
    session.
  - new 'ai-assistant:confirm-info' invoke handler returns
    {provider, model} (no key material) so the renderer can label the
    confirm dialog.
  - electronAPI.aiAssistant.confirmInfo() exposed via preload.
  - renderer.js wires window.confirm() with the provider label as the
    v1 prompt UX; can be upgraded to a custom modal later without
    touching the controller.

Check-for-Updates menu (v4.13.0):
  - Help > Check for Updates now calls ensureAutoUpdater().check()
    instead of opening the Releases page in the browser.
  - Falls back to the browser page if the check throws (dev runs with
    no app-update.yml, network failures, etc.) so the user always has
    a way to update manually.

4 new controller tests cover: confirm called once with provider label
and selection length; declining aborts + surfaces error state; confirm
not asked twice in a session; missing confirmFirstUse dep skips the
prompt and streams normally.

Amit Haridas
This commit is contained in:
Amit Haridas
2026-09-30 21:02:28 +05:30
parent 47315add80
commit e826c0e624
5 changed files with 145 additions and 2 deletions
+22 -1
View File
@@ -1475,8 +1475,19 @@ function createMenu() {
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/issues'),
},
{
// v4.13.0 — trigger the in-app updater (electron-updater).
// If the check throws (no app-update.yml in dev, no network),
// fall back to opening the Releases page in the browser so the
// user still has a way to check manually.
label: 'Check for Updates',
click: () => shell.openExternal('https://github.com/amitwh/markdown-converter/releases'),
click: async () => {
try {
await ensureAutoUpdater().check();
} catch (err) {
console.warn('Check-for-updates failed; falling back to releases page:', err.message);
shell.openExternal('https://github.com/amitwh/markdown-converter/releases');
}
},
},
],
},
@@ -5153,6 +5164,16 @@ ipcMain.handle('ai-assistant:status', async () => {
};
});
// v4.13.0 — Inline AI first-use confirmation. Returns a small payload
// (provider + model name; never the key) for the renderer's confirm
// dialog. The renderer is responsible for asking the user.
ipcMain.handle('ai-assistant:confirm-info', async () => {
const settings = getAiAssistantSettings();
const provider = settings.provider || 'unknown';
const model = settings.model || AiProviders.PROVIDER_DEFAULTS[provider]?.defaultModel || '';
return { provider, model };
});
/**
* Run a chat completion. The payload is {system?, messages:[{role,content}]}
* with provider settings taken from the store, never from the caller — a
+10
View File
@@ -44,6 +44,9 @@ const ALLOWED_SEND_CHANNELS = [
'ai-assistant:complete',
'ai-assistant:status',
// Inline AI assist confirm helpers
'ai-assistant:confirm-info',
// v4.13.0 — Inline AI assist streaming (Cmd+K in editor)
'ai-assist-stream:start',
'ai-assist-stream:cancel',
@@ -597,6 +600,13 @@ contextBridge.exposeInMainWorld('electronAPI', {
onStatus: (cb) => ipcRenderer.on('updates:status', (_e, p) => cb(p)),
},
// v4.13.0 — Inline AI first-use confirmation. Returns a small
// user-safe payload (no keys) describing what the AI request will do,
// for the renderer to surface in a confirm dialog.
aiAssistant: {
confirmInfo: () => ipcRenderer.invoke('ai-assistant:confirm-info'),
},
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
});
+23
View File
@@ -2604,9 +2604,20 @@ document.addEventListener('DOMContentLoaded', async () => {
// CodeMirror view is sourced via tabManager each time Cmd+K fires, so
// tab switches automatically pick up the right editor.
let inlineAiControllerInstance = null;
let inlineAiProviderLabel = 'your AI provider';
function ensureInlineAiController() {
if (inlineAiControllerInstance) return inlineAiControllerInstance;
const create = getCreateInlineAiController();
// Fetch provider label once (used in the first-use confirm dialog).
window.electronAPI.aiAssistant
.confirmInfo()
.then((info) => {
if (info && info.provider) {
inlineAiProviderLabel = `${info.provider}${info.model ? ` (${info.model})` : ''}`;
}
})
.catch(() => {});
inlineAiControllerInstance = create({
getEditorView: () => {
const active =
@@ -2615,6 +2626,18 @@ document.addEventListener('DOMContentLoaded', async () => {
return active ? active.editorView : null;
},
electronAPI: window.electronAPI,
// v4.13.0 — first-use confirmation. Once per session, then never
// re-prompts. Uses native window.confirm for v1; can be upgraded
// to a custom modal later without changing the controller.
getProviderLabel: () => inlineAiProviderLabel,
confirmFirstUse: ({ providerLabel, selectionLength, action }) => {
const verb = action === 'rewrite' ? 'Rewrite' : action === 'shorten' ? 'Shorten' : 'Expand';
return window.confirm(
`${verb} ${selectionLength} characters using ${providerLabel}?\n\n` +
'This sends your selected text to the AI provider. API charges may apply ' +
'according to your provider plan. You will not be prompted again this session.'
);
},
});
return inlineAiControllerInstance;
}
+21 -1
View File
@@ -33,7 +33,7 @@ const {
* Esc-cancel from firing when the popover isn't open.
*/
function createInlineAiController(deps) {
const { getEditorView, electronAPI, onShortcut } = deps;
const { getEditorView, electronAPI, onShortcut, confirmFirstUse, getProviderLabel } = deps;
if (typeof getEditorView !== 'function') {
throw new Error('createInlineAiController: getEditorView is required');
}
@@ -46,6 +46,7 @@ function createInlineAiController(deps) {
let activeSelection = null; // { from, to, original }
let activeAction = null; // 'rewrite' | 'shorten' | 'expand'
let unsubscribers = []; // [{ off }]
let firstUseConfirmed = false; // session-scoped — never re-prompts after OK
function ensurePopover() {
if (popover) return popover;
@@ -113,6 +114,25 @@ function createInlineAiController(deps) {
return;
}
// First-use confirmation — only on the first action of the session,
// and only when the renderer supplied a confirmFirstUse callback.
if (!firstUseConfirmed && typeof confirmFirstUse === 'function') {
const providerLabel =
typeof getProviderLabel === 'function' ? getProviderLabel() : 'your configured AI provider';
const ok = confirmFirstUse({
providerLabel,
selectionLength: selectionText.length,
action,
});
if (!ok) {
ensurePopover().setState('error', {
message: 'Cancelled — first-use confirmation declined.',
});
return;
}
firstUseConfirmed = true;
}
activeAction = action;
ensurePopover().setState('loading');
+69
View File
@@ -269,6 +269,75 @@ describe('createInlineAiController — detach', () => {
});
});
describe('createInlineAiController — first-use confirmation', () => {
test('confirmFirstUse is called with provider label and selection length', () => {
const confirmFirstUse = jest.fn().mockReturnValue(true);
const editor = makeEditor('hello world longer');
editor._setSelection(0, 5);
const { api } = makeApi();
const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({
getEditorView: () => editor,
electronAPI: api,
getProviderLabel: () => 'openai (gpt-4o)',
confirmFirstUse,
});
controller.showForSelection();
getPopoverButton('rewrite').click();
expect(confirmFirstUse).toHaveBeenCalledTimes(1);
expect(confirmFirstUse).toHaveBeenCalledWith(
expect.objectContaining({
providerLabel: 'openai (gpt-4o)',
selectionLength: 5, // selection is 'hello' (5 chars)
action: 'rewrite',
})
);
});
test('declining the confirm aborts and surfaces an error state', () => {
const confirmFirstUse = jest.fn().mockReturnValue(false);
const editor = makeEditor('hello world');
editor._setSelection(0, 5);
const { api, _state } = makeApi();
const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({
getEditorView: () => editor,
electronAPI: api,
confirmFirstUse,
});
controller.showForSelection();
getPopoverButton('rewrite').click();
expect(_state.started).toHaveLength(0);
const popovers = document.querySelectorAll('.inline-ai-popover');
expect(popovers[popovers.length - 1].dataset.state).toBe('error');
});
test('confirm is only asked once per session', () => {
const confirmFirstUse = jest.fn().mockReturnValue(true);
const editor = makeEditor('hello world');
editor._setSelection(0, 5);
const { api } = makeApi();
const controller = require('../src/renderer/inline-ai-controller').createInlineAiController({
getEditorView: () => editor,
electronAPI: api,
confirmFirstUse,
});
controller.showForSelection();
getPopoverButton('rewrite').click();
// Reset selection for a second action — confirm should NOT be asked again
editor._setSelection(0, 5);
getPopoverButton('shorten').click();
expect(confirmFirstUse).toHaveBeenCalledTimes(1);
});
test('no confirmFirstUse dep means no prompt (still streams)', () => {
const editor = makeEditor('hello world');
editor._setSelection(0, 5);
const { controller, _state } = mount({ editor });
controller.showForSelection();
getPopoverButton('rewrite').click();
expect(_state.started).toHaveLength(1);
});
});
describe('createInlineAiController — validation', () => {
test('throws when getEditorView is missing', () => {
expect(() =>