diff --git a/.ai/contexts/app-dialogs.md b/.ai/contexts/app-dialogs.md new file mode 100644 index 00000000..232d1292 --- /dev/null +++ b/.ai/contexts/app-dialogs.md @@ -0,0 +1,23 @@ +# Renderer modal stack and questions + +`public/modal-stack.js` loads before every renderer consumer. It owns the modal stack and the only modal `keydown` listener, installed on `document` in capture phase. Each modal calls `registerModal({ overlay, cancel, enter, bodyEnter })` after mounting and calls the returned unregister function on every close path. The stack's last registration owns the keys; neither DOM class order nor the current event target chooses another modal. + +Registered surfaces are the choice dialog (including `appConfirm` and `appAlert`), New Session, Resume, remote launch, Send a prompt, Add Project, dispatch-agent, the unsaved-edits guard, session/worktree deletion, Touched info, What's new, and fullscreen transcript images. The New session popover is a menu and retains its own arrow-key navigation. + +Escape cancels only the top modal from anywhere, including the body and a terminal underneath. Tab and Shift+Tab cycle through that modal's enabled, visible controls; hidden controls and descendants of hidden or inert containers are excluded. A modal without controls focuses its overlay. The router stops Enter, Escape and Tab with `stopImmediatePropagation` at document capture: handlers inside the modal and document-bubble handlers do not receive them. Only window-capture listeners see those keys before the router. A focused button activates once, with default activation prevented. Each modal may supply its own Enter action. A default-prevented Enter remains consumed without an action. + +Choice questions keep the existing rules: Enter on a checkbox does nothing, and Enter on the container accepts an ordinary question. A dangerous question requires a focused button; container, body, or outside Enter cannot accept it. New Session retains #586: the selected permission mode receives initial focus, Enter on a mode or checkbox starts, Cancel cancels, and Enter in a text input does not start. Its body Enter also starts when it is the top modal. Resume opens with its selected permission mode focused, and Enter on that mode resumes with the selection. Resume, Add Project and dispatch retain their body Enter actions through the same router's explicit `bodyEnter` option. Enter in Add Project's path submits; remote launch initially focuses its path, where Enter validates and starts; Send a prompt uses Ctrl/Cmd+Enter. Other text-field keys retain their normal browser behavior inside the top modal. Keys outside the top modal have propagation blocked, but only Enter, Escape, Tab and matching configurable or fixed app shortcuts have their defaults prevented. Other keys keep their native defaults, including Electron menu accelerators for DevTools, Undo and Select All. + +`hasOpenModal()` suppresses `app.js`'s `handleGlobalShortcut` and the xterm custom key handler before tool/session-navigation/search/fullscreen/newline shortcuts, including direct handler calls when focus drifts underneath a modal. Registration records the current opener and moves focus into the dialog (with `tabIndex=-1`) when it is outside the overlay. Dialogs register before focusing their initial control. A document-capture `focusin` listener returns outside focus to the top modal’s last focused connected control, or its dialog when that control is gone. Fullscreen transitions and single-view `showSession` skip terminal focus while a modal is open. Document-capture listeners cancel terminal-textarea `paste`, `beforeinput` and `input` events, including non-cancelable input propagation, while leaving modal fields editable. The xterm `onData` route stays active: terminal protocol replies such as DA, cursor-position reports, DECRQM and OSC colour replies must reach the PTY even for background terminals and panel shells while a modal is open. Unregistering returns focus to the remaining top modal, or restores the last modal’s connected opener. A missing opener, body opener or disabled opener falls back to the active session’s terminal. Unregister is idempotent, so restoration happens once. Dialogs with their own explicit restoration register with `restoreFocus: false`: choice questions, the unsaved guard, New Session, Touched info and fullscreen screenshots retain their existing opener and fallback behavior without a second restoration from the stack. Closing the last modal enables keyboard paths again. + +## Question queue and the unsaved guard + +`showChoiceDialog({ replace: true })` is the one way to jump the queue: it cancels the open choice dialog (resolving it as Cancel) and enqueues its question at the front (`enqueueModalQuestion(open, { front: true })`). `closeWith`, a promise, closes the dialog with the value it resolves to; both paths close through the same function that unregisters from the modal stack. The window-close question (#478, [window-frame](window-frame.md), "Closing the window") uses both. + +Use async `appConfirm({ title, message, confirmLabel, danger })` and `appAlert({ title, message })` from `public/app-dialogs.js`. They adapt `showChoiceDialog`, which shares `enqueueModalQuestion` with session/worktree deletion and the Save/Discard/Cancel guard. Busy questions stay pending rather than resolving as Cancel. Regular questions use FIFO order; only one queued question is mounted at a time. A question may sit on top of a configuration modal. + +The quit/close/reload guard ACKs immediately, then enqueues an idle question rather than waiting on an idle snapshot and opening separately. Idle questions yield to regular questions and wait only for the question queue to settle. Configuration and informational modals do not delay the guard: it registers last and opens on top of them. A zero-delay task checks question idleness after the current promise reactions, allowing a caller's next continuation question (including `resolveResumeSession`'s candidate loop and fallback) to enter the queue first. Buffers are recollected when the guard actually opens. The guard holds the question queue during saving; an alert requested meanwhile opens after the guard closes. Save failures stay inline. Save receives initial focus, Enter really saves, Escape cancels, and Tab/Shift+Tab are handled by the shared router. Disabled controls cannot act during an in-flight save. The beforeunload veto and main-process protocol are unchanged. + +Every decision caller awaits its answer before performing its side effect. Changes and Touched discard paths remain async. `returnToPanelList()` returns a boolean for an immediate transition or a promise while a discard decision is pending; the diff's Escape listener consumes only a return that already succeeded. Destructive confirmations and the live-elsewhere warning focus Cancel; continuation prompts focus Open session. The scheduled-task notice is an OK-only alert and refuses resume. `guardResume` defaults its notice adapter to `appAlert` in the renderer; outside the renderer, a missing adapter produces an explicit contract error. + +Native `confirm`, `alert`, and `prompt` (bare or on `window`, `globalThis`, or `self`) remain forbidden by ESLint throughout `public/**`. The property rule matches named objects and cannot express nested `document.defaultView`; that spelling is not covered. Main-process Electron dialogs remain outside this renderer rule. diff --git a/.ai/contexts/window-frame.md b/.ai/contexts/window-frame.md index 99343d5d..2b87846c 100644 --- a/.ai/contexts/window-frame.md +++ b/.ai/contexts/window-frame.md @@ -246,10 +246,18 @@ session, so there the question reads "Close the window?" and says the app stays in the Dock. The question is the in-page choice dialog (`confirmWindowClose` in `file-panel.js`), not a native `confirm()`: Cancel has the focus, so `Enter` keeps the app, and the page keeps running while it -is shown, which the probe below relies on. Only one choice dialog shows at a -time, so the close question is asked with `replace: true`: another one that is -open (the Archive folder dialog, say) is cancelled first, and a close is never -answered with a silent Cancel because something else was on screen. +is shown, which the probe below relies on. Choice dialogs wait in the modal +stack's question queue ([app-dialogs](app-dialogs.md)), so the close question +is asked with `replace: true`: an open choice dialog (the Archive folder dialog, +say) is cancelled first and the question goes to the front of the queue; any +other open question (Delete session, Delete worktree) is left alone and the +close question opens on top of it, like the unsaved guard, and Escape returns +to it. Queued questions never go ahead of it. `closeWith` also settles a close +question that has not opened yet, so a quit that arrives first is never held +behind another question. +It registers with the modal stack like any choice dialog: `Escape` cancels, +`Tab` stays inside it, and every close path (answer, `replace`, `closeWith`) +unregisters it. It rides on the unsaved-edits guard (`unsaved-guard.js`, see [viewer-panel](viewer-panel.md), "Unsaved edits on quit, reload and close"): diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f5be70c..c0f233bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### Changed +- Confirmations and error messages now use Switchboard’s own dialog, matching the rest of the app. Only the top dialog receives keys and keeps focus, with terminal typing, paste and app shortcuts paused while it is open; closing it gives focus back to the control that opened it when that control is still there. The unsaved-edits guard waits for pending questions, opens over other dialogs, and keeps its Save/Discard/Cancel layout: Enter saves, Escape cancels, and Tab or Shift+Tab moves between its buttons. (#585) - **Archive Project** now keeps your settings and suspends schedules until you restore it from Settings → **Archived projects**. A separate **Delete Project** confirms the removal of settings, cache and schedule registration, while keeping your folders and files. (#568) - Markdown previews now show a file's frontmatter as a readable header above its content, including its name, description and metadata. (#593) - Closing the window (the close button, `Alt`+`F4`) asks for confirmation first, with Cancel selected; a window that is busy at that moment asks once it is free. Quitting from the ☰ menu (Quit, or Exit on Windows) does not ask again, and a quit from outside (a logout, an update install) is not stopped by the question. On macOS it asks "Close the window?": Switchboard stays in the Dock, but its sessions stop. If the window has stopped responding, a single close waits, and closing it again a few seconds later ends it; never while one of its own confirmation boxes waits for an answer. A window closed while it is still starting asks once it has loaded. (#478) diff --git a/e2e/app-dialogs.spec.js b/e2e/app-dialogs.spec.js new file mode 100644 index 00000000..47ef968e --- /dev/null +++ b/e2e/app-dialogs.spec.js @@ -0,0 +1,36 @@ +const { test, expect, makeRepo } = require('./fixtures'); + +test('background Delete is a designed confirmation: Escape cancels and Delete proceeds', async ({ home, env, launch }) => { + makeRepo(home, env, { 'dialog.txt': 'fixture\n' }); + const { app, page } = await launch(); + const native = []; + page.on('dialog', dialog => { native.push(dialog.type()); dialog.dismiss(); }); + await app.evaluate(({ ipcMain }) => { + globalThis.dialogDeletes = []; + ipcMain.removeHandler('get-bg-agents'); + ipcMain.handle('get-bg-agents', () => ({ daemonReachable: true, roster: [ + { id: 'aaaaaaaa', sessionId: 'dialog-session', name: 'Dialog fixture', cwd: '/fixture', kind: 'background', state: 'done', attachedHere: false }, + ] })); + ipcMain.removeHandler('bg-agent-verb'); + ipcMain.handle('bg-agent-verb', (_event, verb, id) => { + globalThis.dialogDeletes.push([verb, id]); + return { ok: true }; + }); + }); + await page.locator('#agents-toggle-btn').click(); + await page.locator('.agents-row[data-key="bg:aaaaaaaa"]').click(); + const opener = page.locator('#agents-detail [data-verb="rm"]'); + await opener.focus(); + await page.keyboard.press('Enter'); + const dialog = page.locator('.choice-dialog-overlay'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('.choice-dialog-cancel')).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); + expect(await app.evaluate(() => globalThis.dialogDeletes)).toEqual([]); + await opener.click(); + await dialog.getByRole('button', { name: 'Delete', exact: true }).click(); + await expect.poll(() => app.evaluate(() => globalThis.dialogDeletes)).toEqual([['rm', 'aaaaaaaa']]); + expect(native).toEqual([]); +}); diff --git a/eslint.config.js b/eslint.config.js index 199fe494..b1994113 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,10 @@ const ACTIVITY_CLASS_MESSAGE = 'Only public/session-activity-dom.js may write .c // public/sidebar.js (and equivalents). Kept readonly so reassignment // from another file gets flagged. const rendererCrossFileGlobals = { + registerModal: 'readonly', + hasOpenModal: 'readonly', + enqueueModalQuestion: 'readonly', + hasActiveModalQuestion: 'readonly', TOOL_BAR: 'readonly', filePanelState: 'readonly', currentPanelSessionId: 'readonly', @@ -124,6 +128,8 @@ const rendererCrossFileGlobals = { shortProjectPath: 'readonly', // public/choice-dialog.js and public/worktree-nesting.js showChoiceDialog: 'readonly', + appConfirm: 'readonly', + appAlert: 'readonly', SIDEBAR_WORKTREE_RE: 'readonly', worktreeParentPath: 'readonly', worktreeName: 'readonly', @@ -353,6 +359,7 @@ const rendererCrossFileGlobals = { // Configurable keyboard shortcuts (public/shortcuts.js + grid-view.js) DEFAULT_SHORTCUTS: 'readonly', SHORTCUT_DEFS: 'readonly', + FIXED_SHORTCUTS: 'readonly', normalizeShortcuts: 'readonly', keyFamily: 'readonly', matchShortcut: 'readonly', @@ -415,6 +422,8 @@ module.exports = [ rules: { 'no-undef': 'error', 'no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }], + 'no-restricted-globals': ['error', ...['confirm', 'alert', 'prompt'].map(name => ({ name, message: 'Use public/app-dialogs.js (appConfirm/appAlert).' }))], + 'no-restricted-properties': ['error', ...['window', 'globalThis', 'self'].flatMap(object => ['confirm', 'alert', 'prompt'].map(property => ({ object, property, message: 'Use public/app-dialogs.js (appConfirm/appAlert).' })))], 'no-implicit-globals': 'off', // renderer relies on script-scope globals by design 'no-empty': ['warn', { allowEmptyCatch: true }], 'no-unreachable': 'warn', diff --git a/public/activity-trace-panel.js b/public/activity-trace-panel.js index 5eddd9ca..f1ab1cfe 100644 --- a/public/activity-trace-panel.js +++ b/public/activity-trace-panel.js @@ -67,7 +67,7 @@ async function openActivityTraceFile(file) { result = { ok: false, error: (err && err.message) || String(err) }; } if (!result || !result.ok) { - window.alert(`Could not read ${file.name}: ${(result && result.error) || 'unknown error'}`); + await appAlert({ title: 'Switchboard', message: `Could not read ${file.name}: ${(result && result.error) || 'unknown error'}` }); return; } if (typeof hideAllViewers === 'function') hideAllViewers(); @@ -117,7 +117,7 @@ function buildActivityTraceRow(file, listEl) { deleteBtn.className = 'settings-check-updates-btn activity-trace-delete-btn'; deleteBtn.textContent = 'Delete'; deleteBtn.addEventListener('click', async () => { - if (!window.confirm(`Delete "${file.name}"?\n\nThis cannot be undone.`)) return; + if (!await appConfirm({ title: 'Delete?', message: `Delete "${file.name}"?\n\nThis cannot be undone.`, confirmLabel: 'Delete', danger: true })) return; deleteBtn.disabled = true; let result; try { @@ -130,7 +130,7 @@ function buildActivityTraceRow(file, listEl) { if (result && result.ok) { renderActivityTraceFiles(listEl); } else { - window.alert(`Delete failed: ${(result && result.error) || 'unknown error'}`); + await appAlert({ title: 'Switchboard', message: `Delete failed: ${(result && result.error) || 'unknown error'}` }); } }); diff --git a/public/agents-view.js b/public/agents-view.js index 7f917054..5bf7f83d 100644 --- a/public/agents-view.js +++ b/public/agents-view.js @@ -418,7 +418,7 @@ async function runAgentVerb(verb, entry) { attachBgAgent(entry); return; } - if (verb === 'rm' && !window.confirm('Delete this background session? Its conversation goes, and its worktree when that is safe.')) return; + if (verb === 'rm' && !await appConfirm({ title: 'Delete?', message: 'Delete this background session? Its conversation goes, and its worktree when that is safe.', confirmLabel: 'Delete', danger: true })) return; agentsPendingVerbs.add(key); agentsVerbErrors.delete(key); if (agentsViewActive) renderAgentsView(); diff --git a/public/app-dialogs.js b/public/app-dialogs.js new file mode 100644 index 00000000..a69efefe --- /dev/null +++ b/public/app-dialogs.js @@ -0,0 +1,14 @@ +// see .ai/contexts/app-dialogs.md +async function appConfirm({ title = 'Confirm', message, confirmLabel = 'OK', danger = false } = {}) { + return (await showChoiceDialog({ + title, message: appDialogMessage(message), confirmLabel, danger, + initialFocus: danger ? 'cancel' : 'confirm', + })) !== null; +} +async function appAlert({ title = 'Switchboard', message } = {}) { + await showChoiceDialog({ title, message: appDialogMessage(message), confirmLabel: 'OK', cancelLabel: null }); +} + +function appDialogMessage(message) { + return typeof message === 'string' ? message.split('\n') : message; +} diff --git a/public/app.js b/public/app.js index 54544631..1e1f096c 100644 --- a/public/app.js +++ b/public/app.js @@ -306,7 +306,7 @@ async function runRestore(list, { retryAfterIndexing = !restoreIndexingDone } = const originalId = item.sessionId; const originalSession = s; let waitingForIndex = false; - s = await resolveResumeSession({ ...s, projectPath: item.projectPath, mergedMemberIds: [] }, { automatic: true, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions, onHold: reason => { waitingForIndex = reason.waitingForIndex; } }); + s = await resolveResumeSession({ ...s, projectPath: item.projectPath, mergedMemberIds: [] }, { automatic: true, api: window.api, confirm: message => appConfirm({ title: 'Open session?', message, confirmLabel: 'Open session' }), resolutions: window._startupResumeResolutions, onHold: reason => { waitingForIndex = reason.waitingForIndex; } }); if (!s) { const index = restoreSavedIndex.has(originalId) ? restoreSavedIndex.get(originalId) : position; skippedWorkingSetEntries.set(originalId, { item, index, retryContinuation: retryAfterIndexing }); @@ -1054,7 +1054,7 @@ async function triggerRebuildAndSearch() { async function confirmAndStopSession(sessionId, btn, rowSession) { const openEntry = openSessions.get(sessionId); const plan = resolveSessionStop(sessionMap.get(sessionId) || openEntry?.session || rowSession, { attach: !!(openEntry && openEntry.attach) }); - if (!confirm(plan.confirmText)) return; + if (!await appConfirm({ title: plan.attach ? 'Detach session?' : 'Stop session?', message: plan.confirmText, confirmLabel: plan.attach ? 'Detach' : 'Stop', danger: true })) return; const result = plan.remote ? await window.api.remoteStopSession(plan.alias, sessionId) : await window.api.stopSession(sessionId); @@ -1520,7 +1520,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, } if (!continuationResolved && customOptions?.type !== 'attach' && (!openSessions.has(session.sessionId) || openSessions.get(session.sessionId).closed)) { const originalId = session.sessionId; - session = await resolveResumeSession(session, { automatic, api: window.api, confirm: msg => window.confirm(msg), resolutions: window._startupResumeResolutions }); + session = await resolveResumeSession(session, { automatic, api: window.api, confirm: message => appConfirm({ title: 'Open session?', message, confirmLabel: 'Open session' }), resolutions: window._startupResumeResolutions }); if (!session) return false; if (session.sessionId !== originalId) { live = undefined; @@ -1552,7 +1552,7 @@ async function openSessionNow(session, customOptions, { automatic = false, live, } // see .ai/contexts/cli-session-state.md ("Live elsewhere") and .ai/contexts/bg-agents.md ("Attach") - const verdict = customOptions?.type === 'attach' ? true : await guardResume(session, { automatic, live, allowBgAttach, api: window.api, confirm: (msg) => window.confirm(msg) }); + const verdict = customOptions?.type === 'attach' ? true : await guardResume(session, { automatic, live, allowBgAttach, api: window.api, confirm: message => appConfirm({ title: 'Open session?', message, confirmLabel: 'Open session', danger: true }), alert: message => appAlert({ title: 'Scheduled task already running', message }) }); if (verdict === false) return false; if (verdict && typeof verdict === 'object' && verdict.attach) { customOptions = { type: 'attach', jobId: verdict.attach, cwd: verdict.cwd || projectPath }; @@ -1760,6 +1760,7 @@ initAgentsView(); function handleGlobalShortcut(e) { if (e._handled || e.type !== 'keydown') return; + if (hasOpenModal()) return; if (typeof handleFocusShortcut === 'function' && handleFocusShortcut(e)) return; if (matchShortcut('agentsToggle', e, isMac, appShortcuts)) { e.preventDefault(); @@ -1952,7 +1953,7 @@ document.getElementById('indexing-banner-dismiss').addEventListener('click', dis window.api.onFullScreenChanged(() => { requestAnimationFrame(() => { const entry = activeSessionId && openSessions.get(activeSessionId); - if (entry && !entry.closed) entry.terminal.focus(); + if (entry && !entry.closed && !hasOpenModal()) entry.terminal.focus(); }); }); diff --git a/public/choice-dialog.js b/public/choice-dialog.js index dc694ce9..48fbb1fe 100644 --- a/public/choice-dialog.js +++ b/public/choice-dialog.js @@ -8,15 +8,26 @@ function writeRememberedChoice(rememberKey, checked) { try { localStorage.setItem(rememberKey, checked ? '1' : '0'); } catch {} } +// see .ai/contexts/app-dialogs.md +let choiceDialogReturnFocus = null; let closeOpenChoiceDialog = null; -/** - * A modal with optional checkboxes. Resolves to `{ [choice.id]: boolean }` on - * confirm, null on cancel, and null at once while another one is open, unless - * `replace` cancels the open one and shows this one. `closeWith`, a promise, - * closes the dialog with the value it resolves to. - */ -function showChoiceDialog({ +function showChoiceDialog(options = {}) { + const active = document.activeElement; + const returnFocus = options.returnFocus || (active?.closest('.choice-dialog-overlay') ? choiceDialogReturnFocus : active); + if (options.replace && closeOpenChoiceDialog) closeOpenChoiceDialog(null); + else if (options.replace && hasActiveModalQuestion()) { + choiceDialogReturnFocus = returnFocus; + return openChoiceDialog({ ...options, returnFocus }); + } + return enqueueModalQuestion(() => { + const restore = options.replace && returnFocus && returnFocus.isConnected === false ? document.activeElement : returnFocus; + choiceDialogReturnFocus = restore; + return openChoiceDialog({ ...options, returnFocus: restore }); + }, { front: !!options.replace, settleWith: options.closeWith || null }); +} + +function openChoiceDialog({ title, message, choices = [], @@ -25,15 +36,8 @@ function showChoiceDialog({ initialFocus = 'confirm', danger = false, returnFocus, - replace = false, closeWith = null, } = {}) { - if (document.querySelector('.choice-dialog-overlay')) { - if (!replace || !closeOpenChoiceDialog) return Promise.resolve(null); - closeOpenChoiceDialog(null); - if (returnFocus && returnFocus.isConnected === false) returnFocus = document.activeElement; - } - return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay choice-dialog-overlay'; @@ -86,6 +90,7 @@ function showChoiceDialog({ cancelBtn.type = 'button'; cancelBtn.className = 'new-session-cancel-btn choice-dialog-cancel'; cancelBtn.textContent = cancelLabel; + cancelBtn.hidden = cancelLabel === null; const confirmBtn = document.createElement('button'); confirmBtn.type = 'button'; confirmBtn.className = (danger ? 'delete-worktree-confirm-btn' : 'new-session-start-btn') + ' choice-dialog-confirm'; @@ -101,9 +106,10 @@ function showChoiceDialog({ function close(value) { if (closed) return; closed = true; + if (closeOpenChoiceDialog === close) closeOpenChoiceDialog = null; overlay.remove(); - document.removeEventListener('keydown', onKey, true); - if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); + unregister(); + if (returnFocus?.isConnected && typeof returnFocus.focus === 'function') returnFocus.focus(); resolve(value); } @@ -116,24 +122,10 @@ function showChoiceDialog({ close(result); } - function onKey(event) { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - close(null); - } else if (event.key === 'Tab') { - event.preventDefault(); - const controls = [...dialog.querySelectorAll('button, input')]; - const at = controls.indexOf(document.activeElement); - const step = event.shiftKey ? -1 : 1; - const next = at === -1 ? (event.shiftKey ? controls.length - 1 : 0) : (at + step + controls.length) % controls.length; - controls[next].focus(); - } else if (event.key === 'Enter') { - if (!overlay.contains(event.target)) return; - event.preventDefault(); - if (buttons.includes(event.target)) event.target.click(); - else if (!(event.target.matches && event.target.matches('input[type="checkbox"]'))) accept(); - } + function enter(event) { + if (buttons.includes(event.target)) event.target.click(); + else if (!danger && !event.target.matches('input[type="checkbox"]')) accept(); + return true; } closeBtn.addEventListener('click', () => close(null)); @@ -142,9 +134,9 @@ function showChoiceDialog({ overlay.addEventListener('click', (event) => { if (event.target === overlay) close(null); }); document.body.appendChild(overlay); + const unregister = registerModal({ overlay, cancel: () => close(null), enter, restoreFocus: false }); closeOpenChoiceDialog = close; if (closeWith) closeWith.then(close, () => {}); - document.addEventListener('keydown', onKey, true); (initialFocus === 'cancel' ? cancelBtn : confirmBtn).focus(); }); } diff --git a/public/dialogs.js b/public/dialogs.js index 060a997f..b031cb1b 100644 --- a/public/dialogs.js +++ b/public/dialogs.js @@ -332,7 +332,7 @@ async function showNewSessionDialog(project, opener = document.activeElement) { function close(returnFocus = true) { overlay.remove(); - document.removeEventListener('keydown', onKey); + unregister(); if (!returnFocus) return; const targets = [ opener, @@ -377,17 +377,14 @@ async function showNewSessionDialog(project, opener = document.activeElement) { dialog.querySelector('.new-session-start-btn').onclick = start; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - // Keyboard support - function onKey(e) { - if (e.defaultPrevented || (!overlay.contains(e.target) && e.target !== document.body)) return; - if (e.key === 'Escape') close(); - if (e.key === 'Enter' && !e.target.matches('input:not([type="checkbox"])')) { - e.preventDefault(); + const unregister = registerModal({ overlay, cancel: () => close(), bodyEnter: true, restoreFocus: false, enter: e => { + if (!e.target.matches('input:not([type="checkbox"])')) { if (e.target.closest('.new-session-cancel-btn')) close(); else start(); + return true; } - } - document.addEventListener('keydown', onKey); + return false; + } }); focusSelectedMode(); } @@ -465,7 +462,7 @@ function showRemoteLaunchDialog(project) { function close() { overlay.remove(); - document.removeEventListener('keydown', onKey); + unregister(); } function start() { @@ -485,11 +482,12 @@ function showRemoteLaunchDialog(project) { dialog.querySelector('.new-session-start-btn').onclick = start; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - function onKey(e) { - if (e.key === 'Escape') close(); - if (e.key === 'Enter' && e.target.matches('input')) start(); - } - document.addEventListener('keydown', onKey); + const unregister = registerModal({ overlay, cancel: close, enter: e => { + if (!e.target.matches('input')) return false; + start(); + return true; + } }); + dialog.querySelector('#rld-path').focus(); } async function showResumeSessionDialog(session) { @@ -587,7 +585,7 @@ async function showResumeSessionDialog(session) { function close() { overlay.remove(); - document.removeEventListener('keydown', onKey); + unregister(); } function resume() { @@ -616,11 +614,13 @@ async function showResumeSessionDialog(session) { dialog.querySelector('.new-session-start-btn').onclick = resume; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - function onKey(e) { - if (e.key === 'Escape') close(); - if (e.key === 'Enter' && !e.target.matches('input')) resume(); - } - document.addEventListener('keydown', onKey); + const unregister = registerModal({ overlay, cancel: close, bodyEnter: true, enter: e => { + if (e.target.matches('input, button:not(.permission-option)')) return false; + resume(); + return true; + } }); + (modeGrid.querySelector(dangerousSkip ? '[data-mode="dangerous-skip"]' : '.permission-option.selected') || + modeGrid.querySelector('button')).focus(); } // see .ai/contexts/session-cache.md ("Remote hosts — sending a prompt") @@ -653,11 +653,11 @@ function showSendPromptDialog(session) { overlay.appendChild(dialog); document.body.appendChild(overlay); overlay.tabIndex = -1; - textarea.focus(); dialog.addEventListener('click', (e) => { if (e.target !== textarea) textarea.focus(); }); function close() { overlay.remove(); + unregister(); } async function send() { @@ -684,10 +684,12 @@ function showSendPromptDialog(session) { sendBtn.onclick = send; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - overlay.addEventListener('keydown', (e) => { - if (e.key === 'Escape') close(); - if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) send(); - }); + const unregister = registerModal({ overlay, cancel: close, enter: e => { + if (!e.ctrlKey && !e.metaKey) return false; + send(); + return true; + } }); + textarea.focus(); } // Settings viewer is in settings-panel.js (openSettingsViewer / closeSettingsViewer) @@ -719,11 +721,10 @@ function showAddProjectDialog() { const pathInput = dialog.querySelector('#add-project-path'); const errorEl = dialog.querySelector('#add-project-error'); - pathInput.focus(); function close() { overlay.remove(); - document.removeEventListener('keydown', onKey); + unregister(); } async function addProject() { @@ -754,11 +755,12 @@ function showAddProjectDialog() { dialog.querySelector('.add-project-add-btn').onclick = addProject; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - function onKey(e) { - if (e.key === 'Escape') close(); - if (e.key === 'Enter') addProject(); - } - document.addEventListener('keydown', onKey); + const unregister = registerModal({ overlay, cancel: close, bodyEnter: true, enter: e => { + if (e.target.matches('button')) return false; + addProject(); + return true; + } }); + pathInput.focus(); } // see .ai/contexts/bg-agents.md @@ -926,7 +928,7 @@ async function showDispatchAgentDialog(project) { function close() { overlay.remove(); - document.removeEventListener('keydown', onKey); + unregister(); } async function start() { @@ -962,10 +964,10 @@ async function showDispatchAgentDialog(project) { startBtn.onclick = start; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); - function onKey(e) { - if (e.key === 'Escape') close(); - if (e.key === 'Enter' && !e.target.matches('input, textarea, select, button')) start(); - } - document.addEventListener('keydown', onKey); + const unregister = registerModal({ overlay, cancel: close, bodyEnter: true, enter: e => { + if (e.target.matches('input, textarea, select, button')) return false; + start(); + return true; + } }); dialog.querySelector('#dad-prompt').focus(); } diff --git a/public/file-panel.js b/public/file-panel.js index 29ab885b..6392c4b2 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -138,7 +138,7 @@ function initFilePanel() { if (event.defaultPrevented || event.isComposing) return; if (event.target.closest('.cm-panels, .cm-search, .cm-tooltip')) return; if (event.target.closest('input, textarea') && !event.target.closest('.cm-content')) return; - if (returnToPanelList()) { + if (returnToPanelList() === true) { event.preventDefault(); event.stopPropagation(); } @@ -337,16 +337,17 @@ function initFilePanel() { // ── Handlers ──────────────────────────────────────────────────────── -function handleClose() { - if (!currentPanelSessionId) return; - const state = getSessionState(currentPanelSessionId); +async function handleClose() { + const sessionId = currentPanelSessionId; + if (!sessionId) return; + const state = getSessionState(sessionId); const tab = state.currentTab; let pending = null; if (tab) { - if (!confirmDiscardChangesEdits(tab)) return; + if ((hasUnsavedChangesEdits(tab) && !await confirmDiscardChangesEdits(tab))) return; if (tab.type === 'diff' && !tab.resolved) { - window.api.mcpDiffResponse(currentPanelSessionId, tab.diffId, 'reject', null); + window.api.mcpDiffResponse(sessionId, tab.diffId, 'reject', null); } if (tab.type === 'diff') { pending = tab.pendingTouchedOpen || null; @@ -357,30 +358,34 @@ function handleClose() { tab.editorView = null; } if (tab.type === 'changes') { - unwatchChangesFile(currentPanelSessionId, tab); + unwatchChangesFile(sessionId, tab); destroyChangesEditor(tab); } state.currentTab = null; } - endCurrentTab(currentPanelSessionId, state, { pending }); + endCurrentTab(sessionId, state, { pending }); } function returnToPanelList() { - if (!currentPanelSessionId) return false; - const state = getSessionState(currentPanelSessionId); + const sessionId = currentPanelSessionId; + if (!sessionId) return false; + const state = getSessionState(sessionId); const tab = state.currentTab; if (tab?.type === 'changes' && tab.selectedFile && !tab.returnList) { - closeChangesDiff(currentPanelSessionId); - return !tab.selectedFile; + const closing = closeChangesDiff(sessionId); + return !tab.selectedFile || closing.then(() => !tab.selectedFile); } if (!tab?.returnList) return false; - if (!confirmDiscardChangesEdits(tab)) return false; - const previous = tab.returnList; - destroyCurrentTab(state, { stash: false }); - state.currentTab = previous; - renderPanel(currentPanelSessionId); - return true; + const restore = () => { + const previous = tab.returnList; + destroyCurrentTab(state, { stash: false }); + state.currentTab = previous; + if (currentPanelSessionId === sessionId) renderPanel(sessionId); + return true; + }; + if (hasUnsavedChangesEdits(tab)) return confirmDiscardChangesEdits(tab).then(confirmed => confirmed && restore()); + return restore(); } function snapshotPanelList(list, tab) { @@ -454,8 +459,7 @@ async function saveDiffTab(tab) { try { let result = await window.api.saveFileForPanel(tab.filePath, content, tab.diskBaseline); while (result && result.reason === 'stale' && typeof result.disk === 'string' - && typeof window.confirm === 'function' - && window.confirm('This file changed on disk since the diff opened. Overwrite it with your edits?')) { + && await appConfirm({ title: 'Overwrite?', message: 'This file changed on disk since the diff opened. Overwrite it with your edits?', confirmLabel: 'Overwrite', danger: true })) { tab.diskBaseline = result.disk; result = await window.api.saveFileForPanel(tab.filePath, content, tab.diskBaseline); } @@ -464,11 +468,11 @@ async function saveDiffTab(tab) { tab.diskBaseline = content.replace(/\r\n?/g, '\n'); const btn = diffToolbarEl.querySelector('.fp-save-btn'); if (btn) flashButtonText(btn, 'Saved!'); - } else if (result && result.reason !== 'stale' && typeof window.alert === 'function') { - window.alert(`Save failed: ${result.error || 'unknown error'}`); + } else if (result && result.reason !== 'stale') { + await appAlert({ title: 'Switchboard', message: `Save failed: ${result.error || 'unknown error'}` }); } } catch (err) { - if (typeof window.alert === 'function') window.alert(`Save failed: ${(err && err.message) || 'unknown error'}`); + await appAlert({ title: 'Switchboard', message: `Save failed: ${(err && err.message) || 'unknown error'}` }); } finally { tab.saving = false; const queued = tab.saveQueued; @@ -705,18 +709,17 @@ function showUnsavedEditsDialog(tabs) { dialog.appendChild(actions); overlay.appendChild(dialog); document.body.appendChild(overlay); - saveBtn.focus(); function finish(proceed) { overlay.remove(); - document.removeEventListener('keydown', onKey); - if (!proceed && returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus(); + unregister(); + if (returnFocus?.isConnected) returnFocus.focus(); resolve(proceed); } - function onKey(e) { - if (e.key === 'Escape') finish(false); - } - document.addEventListener('keydown', onKey); + const unregister = registerModal({ overlay, restoreFocus: false, cancel: () => { + if (!cancelBtn.disabled) finish(false); + } }); + saveBtn.focus(); cancelBtn.onclick = () => finish(false); discardBtn.onclick = () => finish(true); @@ -776,7 +779,10 @@ function askAboutUnsavedEdits() { if (unsavedPrompt) return unsavedPrompt; const tabs = collectUnsavedFileTabs(); if (!tabs.length) return Promise.resolve(true); - unsavedPrompt = showUnsavedEditsDialog(tabs).then((proceed) => { + unsavedPrompt = enqueueModalQuestion(() => { + const currentTabs = collectUnsavedFileTabs(); + return currentTabs.length ? showUnsavedEditsDialog(currentTabs) : true; + }, { whenIdle: true }).then((proceed) => { unsavedPrompt = null; if (proceed) { unloadApproved = true; @@ -1147,10 +1153,10 @@ function handleDiffAction(sessionId, tab, action) { // ── Changes Mode — see .ai/contexts/changes-view.md ────────────────── -function toggleChangesTab(sessionId) { +async function toggleChangesTab(sessionId) { const state = getSessionState(sessionId); if (state.currentTab && state.currentTab.type === 'changes' && !state.currentTab.returnList) { - if (!confirmDiscardChangesEdits(state.currentTab)) return; + if ((hasUnsavedChangesEdits(state.currentTab) && !await confirmDiscardChangesEdits(state.currentTab))) return; destroyCurrentTab(state, { stash: false }); state.currentTab = null; endCurrentTab(sessionId, state); @@ -1366,7 +1372,7 @@ async function openChangesDiff(sessionId, file) { if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; const tab = state.currentTab; - if (tab.selectedFile && !isSelectedChangesRow(tab, file) && !confirmDiscardChangesEdits(tab)) return; + if (tab.selectedFile && !isSelectedChangesRow(tab, file) && (hasUnsavedChangesEdits(tab) && !await confirmDiscardChangesEdits(tab))) return; if (!tab.selectedFile && currentPanelSessionId === sessionId) snapshotPanelList(changesListEl, tab); tab.selectedFile = file; @@ -1471,15 +1477,15 @@ function applyUntrackedCounts(tab, expectedData, filePath, added, deleted, count tab.listRevision = (tab.listRevision || 0) + 1; } -function closeChangesDiff(sessionId) { +async function closeChangesDiff(sessionId) { const state = filePanelState.get(sessionId); if (!state || !state.currentTab || state.currentTab.type !== 'changes') return; const tab = state.currentTab; if (tab.returnList) { - if (currentPanelSessionId === sessionId) returnToPanelList(); + if (currentPanelSessionId === sessionId) await returnToPanelList(); return; } - if (!confirmDiscardChangesEdits(tab)) return; + if ((hasUnsavedChangesEdits(tab) && !await confirmDiscardChangesEdits(tab))) return; unwatchChangesFile(sessionId, tab); destroyChangesEditor(tab); tab.selectedFile = null; @@ -1503,11 +1509,13 @@ function hasUnsavedChangesEdits(tab) { return !!tab && tab.type === 'changes' && isChangesBufferDirty(tab); } -// Asks, and does nothing else. -function confirmDiscardChangesEdits(tab) { +// see .ai/contexts/app-dialogs.md +async function confirmDiscardChangesEdits(tab) { if (!hasUnsavedChangesEdits(tab)) return true; - if (typeof window.confirm !== 'function') return true; - return window.confirm('This file has unsaved edits. Discard them?'); + const state = [...filePanelState.values()].find(state => state.currentTab === tab); + const content = readChangesEditorContent(tab); + const confirmed = await appConfirm({ title: 'Discard edits?', message: 'This file has unsaved edits. Discard them?', confirmLabel: 'Discard', danger: true }); + return confirmed && (!state || state.currentTab === tab) && readChangesEditorContent(tab) === content; } function renderChangesContent(sessionId, tab) { @@ -2158,7 +2166,7 @@ async function reloadChangesFile(sessionId) { const state = filePanelState.get(sessionId); const tab = state && state.currentTab; if (!tab || tab.type !== 'changes' || !tab.editable || !tab.selectedFile) return; - if (!confirmDiscardChangesEdits(tab)) return; + if ((hasUnsavedChangesEdits(tab) && !await confirmDiscardChangesEdits(tab))) return; const file = tab.selectedFile; const result = await readChangesPair(sessionId, tab, file); diff --git a/public/index.html b/public/index.html index 6cb7c3f2..0ba1324c 100644 --- a/public/index.html +++ b/public/index.html @@ -141,6 +141,7 @@ + @@ -181,6 +182,7 @@ + diff --git a/public/jsonl-viewer.js b/public/jsonl-viewer.js index 7cb7f03e..d9c59c67 100644 --- a/public/jsonl-viewer.js +++ b/public/jsonl-viewer.js @@ -522,7 +522,26 @@ function mergeLocalCommandBlocks(blocks) { return result; } -// Render a tool result into a container, handling images, text, and mixed content +// see .ai/contexts/app-dialogs.md +function showJsonlScreenshot(src) { + const returnFocus = document.activeElement; + const overlay = document.createElement('div'); + overlay.className = 'jsonl-screenshot-fullscreen'; + overlay.tabIndex = -1; + const image = document.createElement('img'); + image.src = src; + overlay.appendChild(image); + function close() { + overlay.remove(); + unregister(); + if (returnFocus?.isConnected) returnFocus.focus(); + } + overlay.onclick = close; + document.body.appendChild(overlay); + const unregister = registerModal({ overlay, cancel: close, restoreFocus: false }); + overlay.focus(); +} + function renderToolResult(resultData, container) { // Try to extract image data from the result const images = extractImages(resultData); @@ -537,13 +556,7 @@ function renderToolResult(resultData, container) { imgEl.src = img.src; if (img.alt) imgEl.alt = img.alt; imgEl.onclick = () => { - const overlay = document.createElement('div'); - overlay.className = 'jsonl-screenshot-fullscreen'; - const fullImg = document.createElement('img'); - fullImg.src = img.src; - overlay.appendChild(fullImg); - overlay.onclick = () => overlay.remove(); - document.body.appendChild(overlay); + showJsonlScreenshot(img.src); }; container.appendChild(imgEl); } @@ -792,13 +805,7 @@ async function showJsonlViewer(session) { // Click-to-fullscreen for inline images jsonlViewerBody.querySelectorAll('.jsonl-clickable-img').forEach(img => { img.onclick = () => { - const overlay = document.createElement('div'); - overlay.className = 'jsonl-screenshot-fullscreen'; - const fullImg = document.createElement('img'); - fullImg.src = img.src; - overlay.appendChild(fullImg); - overlay.onclick = () => overlay.remove(); - document.body.appendChild(overlay); + showJsonlScreenshot(img.src); }; }); @@ -883,13 +890,7 @@ async function showSubagentTranscript(session) { // Click-to-fullscreen for inline images jsonlViewerBody.querySelectorAll('.jsonl-clickable-img').forEach(img => { img.onclick = () => { - const overlay = document.createElement('div'); - overlay.className = 'jsonl-screenshot-fullscreen'; - const fullImg = document.createElement('img'); - fullImg.src = img.src; - overlay.appendChild(fullImg); - overlay.onclick = () => overlay.remove(); - document.body.appendChild(overlay); + showJsonlScreenshot(img.src); }; }); diff --git a/public/modal-stack.js b/public/modal-stack.js new file mode 100644 index 00000000..99aa5524 --- /dev/null +++ b/public/modal-stack.js @@ -0,0 +1,163 @@ +// see .ai/contexts/app-dialogs.md +const modalStack = []; +const modalQuestions = []; +const modalIdleQuestions = []; +let modalQuestionActive = false; +let modalIdleTimer = null; + +function hasOpenModal() { + return modalStack.length > 0; +} + +function focusModal(modal) { + const focused = modal.lastFocused; + const target = focused?.isConnected && modal.overlay.contains(focused) && !focused.matches(':disabled') + ? focused : modal.overlay.querySelector('[role="dialog"], .modal-dialog, .new-session-dialog') || modal.overlay; + if (target !== focused) target.tabIndex = -1; + target.focus(); +} + +function registerModal({ overlay, cancel, enter, bodyEnter = false, restoreFocus = true }) { + const opener = document.activeElement; + const previousZIndex = overlay.style.zIndex; + const top = modalStack[modalStack.length - 1]; + overlay.style.zIndex = String(Math.max(Number(getComputedStyle(overlay).zIndex) || 0, + Number(top?.overlay.style.zIndex) || 0) + 1); + const modal = { overlay, cancel, enter, bodyEnter, lastFocused: overlay.contains(opener) ? opener : null }; + modalStack.push(modal); + if (!overlay.contains(opener)) focusModal(modal); + return () => { + const index = modalStack.indexOf(modal); + if (index === -1) return; + modalStack.splice(index, 1); + overlay.style.zIndex = previousZIndex; + const remaining = modalStack[modalStack.length - 1]; + if (remaining && restoreFocus) { + if (!remaining.overlay.contains(document.activeElement)) focusModal(remaining); + } else if (!remaining && restoreFocus) { + let restored = false; + if (opener?.isConnected && opener !== document.body && opener !== document.documentElement && !opener.matches(':disabled')) { + opener.focus(); + restored = document.activeElement === opener; + } + if (!restored && typeof activeSessionId !== 'undefined' && typeof openSessions !== 'undefined') { + const entry = activeSessionId && openSessions.get(activeSessionId); + if (entry && !entry.closed) entry.terminal.focus(); + } + } + drainModalQuestions(); + }; +} + +function modalControls(overlay) { + return [...overlay.querySelectorAll('button, input, select, textarea, a[href], [tabindex]')] + .filter(control => control.tabIndex >= 0 && !control.matches(':disabled') && !control.closest('[hidden], [inert]')) + .filter(control => { + for (let element = control; element; element = element.parentElement) { + const style = getComputedStyle(element); + if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false; + } + return true; + }); +} + +function routeModalKey(event) { + const modal = modalStack[modalStack.length - 1]; + if (!modal) return; + const inside = modal.overlay.contains(event.target); + if (event.key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + modal.cancel(); + } else if (event.key === 'Tab') { + event.preventDefault(); + event.stopImmediatePropagation(); + const controls = modalControls(modal.overlay); + if (!controls.length) { modal.overlay.focus(); return; } + const at = controls.indexOf(document.activeElement); + const next = at === -1 ? (event.shiftKey ? controls.length - 1 : 0) + : (at + (event.shiftKey ? -1 : 1) + controls.length) % controls.length; + controls[next].focus(); + } else if (event.key === 'Enter') { + event.stopImmediatePropagation(); + if (event.defaultPrevented) return; + if (!inside && !(modal.bodyEnter && event.target === document.body)) { event.preventDefault(); return; } + if (modal.enter?.(event)) event.preventDefault(); + else if (event.target.matches?.('button:not(:disabled)')) { + event.preventDefault(); + event.target.click(); + } + } else if (!inside) { + if (typeof matchShortcut === 'function' && typeof SHORTCUT_DEFS !== 'undefined' && + [...SHORTCUT_DEFS.map(def => def.id), ...Object.keys(typeof FIXED_SHORTCUTS === 'undefined' ? {} : FIXED_SHORTCUTS)] + .some(id => matchShortcut(id, event, typeof isMac === 'undefined' ? false : isMac, + typeof appShortcuts === 'undefined' ? {} : appShortcuts))) event.preventDefault(); + event.stopImmediatePropagation(); + } +} + +document.addEventListener('keydown', routeModalKey, true); +document.addEventListener('focusin', event => { + const modal = modalStack[modalStack.length - 1]; + if (!modal) return; + if (modal.overlay.contains(event.target)) modal.lastFocused = event.target; + else focusModal(modal); +}, true); + +for (const type of ['paste', 'beforeinput', 'input']) { + document.addEventListener(type, event => { + if (!hasOpenModal() || !event.target.matches?.('.xterm-helper-textarea')) return; + event.preventDefault(); + event.stopImmediatePropagation(); + }, true); +} + +function hasActiveModalQuestion() { + return modalQuestionActive; +} + +function enqueueModalQuestion(open, { whenIdle = false, front = false, settleWith = null } = {}) { + return new Promise((resolve, reject) => { + const queue = whenIdle ? modalIdleQuestions : modalQuestions; + const question = { open, resolve, reject }; + queue[front ? 'unshift' : 'push'](question); + if (settleWith) { + settleWith.then((value) => { + const index = queue.indexOf(question); + if (index === -1) return; + queue.splice(index, 1); + resolve(value); + }, () => {}); + } + drainModalQuestions(); + }); +} + +function openModalQuestion(question) { + modalQuestionActive = true; + let result; + try { result = question.open(); } catch (error) { result = Promise.reject(error); } + Promise.resolve(result).then(value => finish(null, value), error => finish(error)); + function finish(error, value) { + modalQuestionActive = false; + if (error) question.reject(error); + else question.resolve(value); + drainModalQuestions(); + } +} + +function drainModalQuestions() { + if (modalQuestionActive) return; + if (modalQuestions.length) { + openModalQuestion(modalQuestions.shift()); + return; + } + if (modalIdleTimer !== null) return; + modalIdleTimer = setTimeout(() => { + modalIdleTimer = null; + if (modalQuestionActive || modalQuestions.length) { drainModalQuestions(); return; } + if (modalIdleQuestions.length) { + openModalQuestion(modalIdleQuestions.shift()); + } + }, 0); +} diff --git a/public/resume-guard.js b/public/resume-guard.js index 745669e8..3b337b0b 100644 --- a/public/resume-guard.js +++ b/public/resume-guard.js @@ -17,7 +17,7 @@ async function liveElsewhereMany(sessionIds, { api } = {}) { } } -async function guardResume(session, { automatic = false, api, confirm, live, allowBgAttach = false } = {}) { +async function guardResume(session, { automatic = false, api, confirm, alert = typeof appAlert === 'function' ? message => appAlert({ message }) : undefined, live, allowBgAttach = false } = {}) { if (!session || session.type === 'terminal') return true; if (live === undefined) { try { @@ -29,8 +29,9 @@ async function guardResume(session, { automatic = false, api, confirm, live, all if (!live) return true; if (automatic && !(allowBgAttach && live.kind === 'bg')) return false; if (live.kind === 'schedule') { + if (typeof alert !== 'function') throw new TypeError('guardResume requires an alert adapter for a scheduled task notice'); const where = live.cwd ? ` in ${live.cwd}` : ''; - confirm(`This session's scheduled task is already running (pid ${live.pid}${where}).\n\n` + await alert(`This session's scheduled task is already running (pid ${live.pid}${where}).\n\n` + 'Wait for the scheduled run to finish before opening it here.'); return false; } @@ -39,7 +40,7 @@ async function guardResume(session, { automatic = false, api, confirm, live, all if (typeof live.jobId === 'string' && live.jobId) return { attach: live.jobId, cwd: live.cwd || null }; return false; } - return !!confirm(liveElsewhereMessage(live)); + return !!(await confirm(liveElsewhereMessage(live))); } // see .ai/contexts/cli-session-state.md ("Conversation continuations") @@ -53,23 +54,23 @@ async function resolveResumeSession(session, { automatic = false, api, confirm, } else result = await api.getSessionContinuations(session.sessionId); } catch { if (automatic) return null; - return confirm('Could not check this conversation for continuations.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; + return await confirm('Could not check this conversation for continuations.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; } const candidates = result?.candidates || []; const listing = candidates.map(c => c.sessionId + ' (last activity: ' + (c.modified || 'unknown') + ')').join('\n'); if (!result || result.unresolved) { if (automatic) { onHold?.({ waitingForIndex: !!result?.waitingForIndex }); return null; } - return confirm('This conversation has an unresolved continuation (cycle, missing transcript or malformed continuation record).\n' + return await confirm('This conversation has an unresolved continuation (cycle, missing transcript or malformed continuation record).\n' + listing + '\nYou can retry after indexing or open a candidate from the sidebar.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; } if (!candidates.length) return session; if (automatic) return candidates.length === 1 ? { ...session, sessionId: candidates[0].sessionId } : null; for (const candidate of candidates) { - if (confirm('This conversation continued under another id:\n' + listing + '\n\nOpen ' + candidate.sessionId + '?')) { + if (await confirm('This conversation continued under another id:\n' + listing + '\n\nOpen ' + candidate.sessionId + '?')) { return { ...session, sessionId: candidate.sessionId }; } } - return confirm('No continuation selected.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; + return await confirm('No continuation selected.\n\nOpen the original session ' + session.sessionId + '?') ? session : null; } if (typeof module !== 'undefined' && module.exports) { diff --git a/public/sidebar.js b/public/sidebar.js index 60d203e8..6faec65c 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -1166,7 +1166,7 @@ async function archiveProjectFolder(project, button) { const plan = await window.api.getProjectArchivePlan(groupsArg); if (!plan || plan.indexing) { - alert(ARCHIVE_INDEXING_MESSAGE); + await appAlert({ title: 'Switchboard', message: ARCHIVE_INDEXING_MESSAGE }); return; } const schedules = plan.schedules || []; @@ -1233,13 +1233,13 @@ async function archiveProjectFolder(project, button) { disableSchedules: result.disableSchedules ? disableable.map(s => s.filePath) : [], }); if (res && res.error === 'indexing') { - alert(ARCHIVE_INDEXING_MESSAGE); + await appAlert({ title: 'Switchboard', message: ARCHIVE_INDEXING_MESSAGE }); } else if (res && res.error) { - alert('The folder could not be archived: ' + res.error - + (Array.isArray(res.disabled) && res.disabled.length > 0 ? `. These schedules were turned off: ${res.disabled.join(', ')}` : '')); + await appAlert({ title: 'Switchboard', message: 'The folder could not be archived: ' + res.error + + (Array.isArray(res.disabled) && res.disabled.length > 0 ? `. These schedules were turned off: ${res.disabled.join(', ')}` : '') }); } else if (res && Array.isArray(res.failed) && res.failed.length > 0) { - alert('The folder is archived, but these schedules could not be disabled:\n' - + res.failed.map(f => `${f.name}: ${f.error}`).join('\n')); + await appAlert({ title: 'Switchboard', message: 'The folder is archived, but these schedules could not be disabled:\n' + + res.failed.map(f => `${f.name}: ${f.error}`).join('\n') }); } pollActiveSessions(); loadProjects(); @@ -1310,10 +1310,10 @@ function rebindSidebarEvents(projects) { const newPath = await window.api.browseFolder(); if (!newPath) return; const projectShortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/'); - if (!confirm(`Remap ${projectShortName} to:\n${newPath}?`)) return; + if (!await appConfirm({ title: 'Remap?', message: `Remap ${projectShortName} to:\n${newPath}?`, confirmLabel: 'Remap', danger: true })) return; const result = await window.api.remapProject(project.projectPath, newPath); if (result.error) { - alert('Failed to remap: ' + result.error); + await appAlert({ title: 'Switchboard', message: 'Failed to remap: ' + result.error }); } else { loadProjects(); } @@ -1370,7 +1370,7 @@ function rebindSidebarEvents(projects) { loadProjects(); } else { const msg = (result && result.error) ? result.error : 'Unknown error'; - alert(`Failed to delete worktree: ${msg}`); + await appAlert({ title: 'Switchboard', message: `Failed to delete worktree: ${msg}` }); } }; } @@ -1905,7 +1905,7 @@ async function showDeleteSessionDialog(session) { ? window.api.deleteSessionPreview(session.sessionId) : Promise.resolve(null); - return new Promise((resolve) => { + return enqueueModalQuestion(() => new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'new-session-overlay'; @@ -1953,7 +1953,7 @@ async function showDeleteSessionDialog(session) { function close(confirmed) { overlay.remove(); - document.removeEventListener('keydown', onKey); + unregister(); resolve(confirmed); } @@ -1961,9 +1961,9 @@ async function showDeleteSessionDialog(session) { dialog.querySelector('#dss-confirm').onclick = () => close(true); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); }); - function onKey(e) { if (e.key === 'Escape') close(false); } - document.addEventListener('keydown', onKey); - }); + const unregister = registerModal({ overlay, cancel: () => close(false) }); + dialog.querySelector('#dss-cancel').focus(); + })); } // --- Delete worktree confirmation dialog --- @@ -1972,7 +1972,7 @@ async function showDeleteWorktreeDialog(name, worktreePath) { // Fetch worktree status (dirty files) while the dialog is shown const statusPromise = window.api.worktreeStatus(worktreePath); - return new Promise((resolve) => { + return enqueueModalQuestion(() => new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'new-session-overlay'; @@ -2023,7 +2023,7 @@ async function showDeleteWorktreeDialog(name, worktreePath) { function close(confirmed) { overlay.remove(); - document.removeEventListener('keydown', onKey); + unregister(); resolve(confirmed); } @@ -2031,9 +2031,7 @@ async function showDeleteWorktreeDialog(name, worktreePath) { dialog.querySelector('#dwt-confirm').onclick = () => close(true); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); }); - function onKey(e) { - if (e.key === 'Escape') close(false); - } - document.addEventListener('keydown', onKey); - }); + const unregister = registerModal({ overlay, cancel: () => close(false) }); + dialog.querySelector('#dwt-cancel').focus(); + })); } diff --git a/public/terminal-manager.js b/public/terminal-manager.js index 776985ff..ae013353 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -79,8 +79,7 @@ function handleFocusShortcut(e) { const toSidebar = matchShortcut('focusSidebar', e, isMac, appShortcuts); if (!toSidebar && !matchShortcut('focusTerminal', e, isMac, appShortcuts)) return false; if (e.type !== 'keydown' || e._handled) return true; - const overlays = document.querySelectorAll('.modal-overlay, .new-session-overlay, .add-project-overlay, .whats-new-overlay, .jsonl-screenshot-fullscreen, [aria-modal="true"]'); - if ([...overlays].some(isFocusTargetVisible)) return false; + if (hasOpenModal()) return false; const focused = document.activeElement; if (toSidebar ? !focused?.closest('.terminal-container') : focused !== document.body && !focused?.closest('#sidebar')) return false; e.preventDefault(); @@ -106,6 +105,7 @@ function handleFocusShortcut(e) { function setupTerminalKeyBindings(terminal, container, getSessionId, { onFind } = {}) { terminal.attachCustomKeyEventHandler((e) => { + if (hasOpenModal()) return false; if (handleFocusShortcut(e)) return false; // Cmd/Ctrl+F → open terminal search bar if (e.key === 'f' && (isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey) { @@ -1474,7 +1474,7 @@ function showSession(sessionId) { if (tr) revealLap(tr, 'restoreMs'); entry.element.classList.add('visible'); if (tr) revealLap(tr, 'visibleMs'); - entry.terminal.focus(); + if (!hasOpenModal()) entry.terminal.focus(); fitAndScroll(entry, tr, entry.webglWanted); if (tr) revealLap(tr, 'focusMs'); } diff --git a/public/touched-files-view.js b/public/touched-files-view.js index 64b236b1..c78f50d5 100644 --- a/public/touched-files-view.js +++ b/public/touched-files-view.js @@ -139,23 +139,13 @@ function showTouchedInfo(button) { const closeButton = overlay.querySelector('button'); const close = () => { overlay.remove(); - document.removeEventListener('keydown', onKey, true); + unregister(); button.focus(); }; - function onKey(event) { - if (event.key === 'Escape') { - event.preventDefault(); - event.stopImmediatePropagation(); - close(); - } else if (event.key === 'Tab') { - event.preventDefault(); - closeButton.focus(); - } - } closeButton.addEventListener('click', close); overlay.addEventListener('click', event => { if (event.target === overlay) close(); }); document.body.appendChild(overlay); - document.addEventListener('keydown', onKey, true); + const unregister = registerModal({ overlay, cancel: close, restoreFocus: false }); closeButton.focus(); } @@ -181,10 +171,10 @@ function renderTouchedTab(sessionId, tab) { } } -function toggleTouchedTab(sessionId) { +async function toggleTouchedTab(sessionId) { const state = getSessionState(sessionId); if (state.currentTab && (state.currentTab.type === 'touched' || state.currentTab.returnList?.type === 'touched')) { - if (!confirmDiscardChangesEdits(state.currentTab)) return; + if ((hasUnsavedChangesEdits(state.currentTab) && !await confirmDiscardChangesEdits(state.currentTab))) return; destroyCurrentTab(state, { stash: false }); state.currentTab = null; endCurrentTab(sessionId, state); @@ -320,14 +310,14 @@ async function openTouchedFile(sessionId, tab, filePath, { line = null, origin = if (currentPanelSessionId === sessionId) renderPanel(sessionId); return; } - if (!confirmDiscardChangesEdits(current)) return; + if ((hasUnsavedChangesEdits(current) && !await confirmDiscardChangesEdits(current))) return; tab.selection = filePath; if (currentPanelSessionId === sessionId) window.snapshotPanelList(touchedListEl, tab); destroyCurrentTab(state, { stash: false }); restoreTouchedStash(sessionId, state, { key: stashKey, returnList: tab, line }); return; } - if (asks && !confirmDiscardChangesEdits(current)) return; + if (asks && (hasUnsavedChangesEdits(current) && !await confirmDiscardChangesEdits(current))) return; const agreedContent = readChangesEditorContent(current); tab.opening = true; let result; @@ -346,7 +336,7 @@ async function openTouchedFile(sessionId, tab, filePath, { line = null, origin = } if (origin !== 'row') addTouchedOpened(state, filePath); const keep = asks - ? readChangesEditorContent(current) !== agreedContent && !confirmDiscardChangesEdits(current) + ? readChangesEditorContent(current) !== agreedContent && (hasUnsavedChangesEdits(current) && !await confirmDiscardChangesEdits(current)) : hasUnsavedChangesEdits(current); if (keep) { if (currentPanelSessionId === sessionId) renderPanel(sessionId); diff --git a/public/viewer-panel.js b/public/viewer-panel.js index 91f65fb2..f09be654 100644 --- a/public/viewer-panel.js +++ b/public/viewer-panel.js @@ -278,19 +278,19 @@ class ViewerPanel { async _delete() { if (!this.opts.onDelete || !this.filePath) return; - // Confirm via native-ish browser confirm - const name = this.filePath.split('/').pop(); - if (!window.confirm(`Delete "${name}"?\n\nThis cannot be undone.`)) return; + const filePath = this.filePath; + const myGen = this._openGen; + const name = filePath.split('/').pop(); + if (!await appConfirm({ title: 'Delete?', message: `Delete "${name}"?\n\nThis cannot be undone.`, confirmLabel: 'Delete', danger: true }) || this._openGen !== myGen) return; try { - const result = await this.opts.onDelete(this.filePath); + const result = await this.opts.onDelete(filePath); if (result && result.ok !== false) { - // Close panel and trigger refresh through onClose - if (this.opts.onClose) this.opts.onClose(); + if (this._openGen === myGen && this.opts.onClose) this.opts.onClose(); } else { - window.alert(`Delete failed: ${result?.error || 'unknown error'}`); + await appAlert({ title: 'Switchboard', message: `Delete failed: ${result?.error || 'unknown error'}` }); } } catch (err) { - window.alert(`Delete failed: ${err.message}`); + await appAlert({ title: 'Switchboard', message: `Delete failed: ${err.message}` }); } } @@ -467,12 +467,12 @@ class ViewerPanel { try { let result = await this.opts.onSave(this.filePath, content, this._agreedBase); while (this._openGen === myGen && result && result.reason === 'stale' && typeof result.disk === 'string') { - this._lastSeenDisk = asEditorText(result.disk); + const overwriteBase = asEditorText(result.disk); + this._lastSeenDisk = overwriteBase; this._setNotice('stale'); - if (typeof window.confirm !== 'function' - || !window.confirm('This file changed on disk since you opened it. Overwrite it with your edits?') + if (!await appConfirm({ title: 'Overwrite?', message: 'This file changed on disk since you opened it. Overwrite it with your edits?', confirmLabel: 'Overwrite', danger: true }) || this._openGen !== myGen) break; - this._agreedBase = this._lastSeenDisk; + this._agreedBase = overwriteBase; result = await this.opts.onSave(this.filePath, content, this._agreedBase); } if (this._token !== token) { @@ -590,10 +590,10 @@ class ViewerPanel { } async _reloadDiscardingEdits() { - if (this._isDirty() && typeof window.confirm === 'function' - && !window.confirm('This file has unsaved edits. Discard them?')) return; - if (!this.filePath || !window.api.readFileForPanel) return; const myGen = this._openGen; + if (this._isDirty() + && !await appConfirm({ title: 'Discard?', message: 'This file has unsaved edits. Discard them?', confirmLabel: 'Discard', danger: true })) return; + if (this._openGen !== myGen || !this.filePath || !window.api.readFileForPanel) return; const result = await window.api.readFileForPanel(this.filePath); if (this._openGen !== myGen || !this.editorView || !result) return; if (!result.ok) { diff --git a/public/whats-new.js b/public/whats-new.js index 2d30e17d..6550f546 100644 --- a/public/whats-new.js +++ b/public/whats-new.js @@ -68,16 +68,9 @@ function showWhatsNew(doc, api, escape, payload) { function close() { overlay.remove(); - doc.removeEventListener('keydown', onKey, true); + unregister(); api.whatsNewDismissed(); } - function onKey(e) { - if (e.key === 'Escape') { - e.preventDefault(); - e.stopPropagation(); - close(); - } - } overlay.querySelector('.whats-new-close').addEventListener('click', close); overlay.addEventListener('click', (e) => { @@ -89,7 +82,7 @@ function showWhatsNew(doc, api, escape, payload) { close(); } }); - doc.addEventListener('keydown', onKey, true); + const unregister = doc.defaultView.registerModal({ overlay, cancel: close }); overlay.querySelector('.whats-new-close').focus(); return overlay; } diff --git a/test/app-dialogs-lint.test.js b/test/app-dialogs-lint.test.js new file mode 100644 index 00000000..850794ed --- /dev/null +++ b/test/app-dialogs-lint.test.js @@ -0,0 +1,16 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { ESLint } = require('eslint'); + +for (const name of ['confirm', 'alert', 'prompt']) { + for (const prefix of ['', 'window.', 'globalThis.', 'self.']) { + test('renderer lint rejects ' + prefix + name, async () => { + const lint = new ESLint({ cwd: path.join(__dirname, '..') }); + const [result] = await lint.lintText(prefix + name + '("native");', { filePath: 'public/dialog-ban-fixture.js' }); + assert.equal(result.errorCount, 1); + assert.match(result.messages[0].message, /public\/app-dialogs\.js/); + assert.equal(result.messages[0].ruleId, prefix ? 'no-restricted-properties' : 'no-restricted-globals'); + }); + } +} diff --git a/test/confirm-and-stop-session.test.js b/test/confirm-and-stop-session.test.js index 5ce09e6d..01c5fb59 100644 --- a/test/confirm-and-stop-session.test.js +++ b/test/confirm-and-stop-session.test.js @@ -29,7 +29,7 @@ function withHarness(overrides, fn) { window.refreshSidebar = () => { calls.refreshed++; }; window.applyRemoteStopped = (id) => calls.remoteStopped.push(id); window.flashButtonText = (b, text, ms) => calls.flashed.push({ b, text, ms }); - window.confirm = () => true; + window.appConfirm = async () => true; window.setTimeout = (cb) => cb(); window.console = { error: (...args) => calls.logged.push(args) }; window.api = { @@ -111,7 +111,7 @@ test('a local session is stopped through stopSession, not the remote channel', a }); test('declining the confirm() dialog calls neither IPC', async () => { - const declining = () => ({ confirm: () => false }); + const declining = () => ({ appConfirm: async () => false }); await withHarness(declining, async ({ confirmAndStopSession, window, calls }) => { await confirmAndStopSession('s1', null); @@ -119,3 +119,18 @@ test('declining the confirm() dialog calls neither IPC', async () => { assert.ok(window.activePtyIds.has('s1')); }); }); + +test('an attached background session names its confirmation Detach', async () => { + let options; + const attached = () => ({ + sessionMap: new Map(), + openSessions: new Map([['s1', { attach: true }]]), + appConfirm: async value => { options = value; return false; }, + }); + await withHarness(attached, async ({ confirmAndStopSession, calls }) => { + await confirmAndStopSession('s1', null); + assert.equal(options.confirmLabel, 'Detach'); + assert.match(options.message, /keeps running/); + assert.deepEqual(calls.ipc, []); + }); +}); diff --git a/test/continuations-harness.js b/test/continuations-harness.js index 9368438f..0cefa79f 100644 --- a/test/continuations-harness.js +++ b/test/continuations-harness.js @@ -40,7 +40,8 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } const saved = savedEntries || [{ sessionId: 'old', projectPath: root, active: true }]; fs.writeFileSync(settingsFile, JSON.stringify({ openWorkingSet: saved })); const spawned = [], prompts = []; - dom.window.confirm = message => { prompts.push(message); return typeof answer === 'function' ? answer(message) : answer; }; + dom.window.appConfirm = async ({ message: message }) => { prompts.push(message); return typeof answer === 'function' ? answer(message) : answer; }; + dom.window.appAlert = async ({ message }) => { prompts.push(message); }; dom.window.api = { getSetting: async () => JSON.parse(fs.readFileSync(settingsFile, 'utf8')), setSetting: async (_, value) => fs.writeFileSync(settingsFile, JSON.stringify(value)), @@ -50,7 +51,7 @@ function setup(t, graph, { live = {}, answer = false, savedEntries, chunkBytes } getSessionsLiveElsewhere: async ids => Object.fromEntries(ids.filter(id => live[id]).map(id => [id, live[id]])), openTerminal: async (id, project, isNew, options) => { spawned.push({ id, project, options }); return { ok: true }; }, }; - vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/resume-guard.js'), 'utf8'), ctx); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../public/resume-guard.js'), 'utf8'), ctx, { filename: path.join(__dirname, '../public/resume-guard.js') }); vm.runInContext(` var openSessions = new Map(), sessionMap = new Map(), pendingSessions = new Map(); var activeSessionId = null, restoringWorkingSet = true, restorePlanner = null; diff --git a/test/delete-session.test.js b/test/delete-session.test.js index 6ca1caf2..a422b51c 100644 --- a/test/delete-session.test.js +++ b/test/delete-session.test.js @@ -132,7 +132,6 @@ test('delete button: uses the styled dialog, not a renderer-blocking native prom const dlg = sidebar.slice(start, sidebar.indexOf('// --- Delete worktree', start)); assert.match(dlg, /deleteSessionPreview/, 'it must state what will be lost'); assert.match(dlg, /Archive/, 'and point at the non-destructive alternative'); - assert.match(dlg, /Escape/, 'Esc must cancel, like the worktree dialog'); }); test('delete button: closes the tab so it cannot point at a deleted transcript', () => { diff --git a/test/dom-activity-trace-panel.test.js b/test/dom-activity-trace-panel.test.js index abf8d92a..3439ec29 100644 --- a/test/dom-activity-trace-panel.test.js +++ b/test/dom-activity-trace-panel.test.js @@ -79,8 +79,8 @@ function setup({ traceState, files = [], readResult, deleteResult = { ok: true } const confirms = []; const alerts = []; - window.confirm = (msg) => { confirms.push(msg); return window.__confirmAnswer !== false; }; - window.alert = (msg) => { alerts.push(String(msg)); }; + window.appConfirm = async ({ message: msg }) => { confirms.push(msg); return window.__confirmAnswer !== false; }; + window.appAlert = async ({ message: msg }) => { alerts.push(String(msg)); }; evalInWindow(dom, path.join(PUBLIC_DIR, 'setting-defaults.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'utils.js')); @@ -301,3 +301,13 @@ test('an IPC rejection is reported instead of leaving a dead button', async () = assert.match(ctx.alerts[0], /handler exploded/); } finally { ctx.destroy(); } }); + +test('a failed trace read reports its message through appAlert and opens no content', async () => { + const ctx = setup({ files: SAMPLE_FILES, readResult: { ok: false, error: 'gone' } }); + try { + await ctx.window.openActivityTraceFile(SAMPLE_FILES[0]); + assert.equal(ctx.opened.length, 0); + assert.equal(ctx.alerts.length, 1); + assert.match(ctx.alerts[0], /Could not read.*gone/); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-agents-view.test.js b/test/dom-agents-view.test.js index e0692171..03a390af 100644 --- a/test/dom-agents-view.test.js +++ b/test/dom-agents-view.test.js @@ -77,7 +77,7 @@ function setup({ storage = { agentsGroupBy: 'none' }, settingsPanel = false } = showJsonlViewer: (session) => calls.jsonl.push(session), refreshSidebar: () => { calls.sidebarRefreshes++; }, fitAndScroll: () => {}, - confirm: () => true, + appConfirm: async () => true, }; for (const [k, v] of Object.entries(g)) Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'node_modules', 'morphdom', 'dist', 'morphdom-umd.js'), 'utf8'), dom.getInternalVMContext()); @@ -899,3 +899,19 @@ test('the header counts the External rows it displays, and omits the segment whe ctx.emitChanged({ roster: [ROSTER[0], ROSTER[1]], daemonReachable: true }); assert.equal(count(), '1 running · 1 finished'); }); + +test('background Delete waits for its async confirmation and cancellation sends no verb', async t => { + const ctx = setup(); t.after(() => ctx.destroy()); + ctx.setSnapshot({ roster: ROSTER, daemonReachable: true }); + await ctx.window.showAgentsView(); + let answer; + ctx.window.appConfirm = () => new Promise(resolve => { answer = resolve; }); + const pending = ctx.window.runAgentVerb('rm', ROSTER[1]); + assert.deepEqual(ctx.calls.verbs, []); + answer(false); + await pending; + assert.deepEqual(ctx.calls.verbs, []); + ctx.window.appConfirm = async () => true; + await ctx.window.runAgentVerb('rm', ROSTER[1]); + assert.deepEqual(ctx.calls.verbs, [['rm', ROSTER[1].id]]); +}); diff --git a/test/dom-app-dialogs.test.js b/test/dom-app-dialogs.test.js new file mode 100644 index 00000000..30f519e5 --- /dev/null +++ b/test/dom-app-dialogs.test.js @@ -0,0 +1,106 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { setupSidebarDom } = require('./dom-setup'); +function setup() { + const ctx = setupSidebarDom(); + const filename = path.join(__dirname, '../public/app-dialogs.js'); + vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx.context, { filename }); + return ctx; +} +function key(ctx, name) { + ctx.document.activeElement.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true })); +} +test('app dialogs queue confirmations and alerts without treating busy as cancel', async () => { + const ctx = setup(); + try { + const first = ctx.window.appConfirm({ title: 'First', message: 'One' }); + const second = ctx.window.appConfirm({ title: 'Second', message: 'Two' }); + const third = ctx.window.appAlert({ title: 'Third', message: 'Three' }); + assert.equal(ctx.document.querySelectorAll('.choice-dialog-overlay').length, 1); + key(ctx, 'Enter'); + assert.equal(await first, true); + assert.equal(ctx.document.querySelector('h2').textContent, 'Second'); + key(ctx, 'Escape'); + assert.equal(await second, false); + assert.equal(ctx.document.querySelector('h2').textContent, 'Third'); + assert.equal(ctx.document.querySelector('.choice-dialog-cancel').hidden, true); + key(ctx, 'Enter'); + await third; + assert.equal(ctx.document.querySelector('.choice-dialog-overlay'), null); + } finally { ctx.destroy(); } +}); +test('destructive confirm focuses Cancel, styles danger, and restores its connected opener', async () => { + const ctx = setup(); + try { + const opener = ctx.document.createElement('button'); + ctx.document.body.append(opener); + opener.focus(); + const pending = ctx.window.appConfirm({ title: 'Delete', message: 'Really?', confirmLabel: 'Delete', danger: true }); + assert.equal(ctx.document.activeElement, ctx.document.querySelector('.choice-dialog-cancel')); + assert.ok(ctx.document.querySelector('.choice-dialog-confirm').classList.contains('delete-worktree-confirm-btn')); + key(ctx, 'Enter'); + assert.equal(await pending, false); + assert.equal(ctx.document.activeElement, opener); + const again = ctx.window.appConfirm({ message: 'Again' }); + assert.equal(ctx.document.activeElement, ctx.document.querySelector('.choice-dialog-confirm')); + key(ctx, 'Escape'); + assert.equal(await again, false); + assert.equal(ctx.document.activeElement, opener); + } finally { ctx.destroy(); } +}); +test('app confirmation waits behind an existing choice dialog', async () => { + const ctx = setup(); + try { + const existing = ctx.window.showChoiceDialog({ title: 'Choices' }); + const pending = ctx.window.appConfirm({ title: 'Queued' }); + key(ctx, 'Enter'); + await existing; + assert.equal(ctx.document.querySelector('h2').textContent, 'Queued'); + key(ctx, 'Enter'); + assert.equal(await pending, true); + } finally { ctx.destroy(); } +}); +test('closing does not focus a disconnected opener', async () => { + const ctx = setup(); + try { + const opener = ctx.document.createElement('button'); + ctx.document.body.append(opener); + opener.focus(); + const pending = ctx.window.appConfirm({ message: 'Really?' }); + opener.remove(); + let focused = false; + opener.focus = () => { focused = true; }; + key(ctx, 'Escape'); + assert.equal(await pending, false); + assert.equal(focused, false); + } finally { ctx.destroy(); } +}); + +test('queued dialogs restore the external opener after the entire queue closes', async () => { + const ctx = setup(); + try { + const opener = ctx.document.createElement('button'); + ctx.document.body.append(opener); + opener.focus(); + const first = ctx.window.appConfirm({ title: 'First' }); + const second = ctx.window.appConfirm({ title: 'Second' }); + key(ctx, 'Enter'); + await first; + key(ctx, 'Escape'); + await second; + assert.equal(ctx.document.activeElement, opener); + } finally { ctx.destroy(); } +}); + +test('messages preserve the line breaks used by resume and error details', async () => { + const ctx = setup(); + try { + const pending = ctx.window.appAlert({ message: 'First line\nSecond line' }); + assert.deepEqual([...ctx.document.querySelectorAll('.choice-dialog-body p')].map(p => p.textContent), ['First line', 'Second line']); + key(ctx, 'Enter'); + await pending; + } finally { ctx.destroy(); } +}); diff --git a/test/dom-choice-dialog.test.js b/test/dom-choice-dialog.test.js index a6061913..ad32ee1b 100644 --- a/test/dom-choice-dialog.test.js +++ b/test/dom-choice-dialog.test.js @@ -187,14 +187,73 @@ test('choice dialog: one paragraph per message line', async () => { } finally { ctx.destroy(); } }); -test('choice dialog: a second call while one is open resolves null and leaves the first', async () => { +test('choice dialog: a second call waits until the first closes', async () => { const ctx = setupSidebarDom(); try { const first = open(ctx); - assert.equal(await open(ctx), null); + let secondSettled = false; + const second = open(ctx, { title: 'Second dialog' }).then(value => { secondSettled = true; return value; }); + await Promise.resolve(); + assert.equal(secondSettled, false, 'the queued call must remain pending'); assert.equal(ctx.document.querySelectorAll('.modal-overlay').length, 1); confirmBtn(ctx).click(); assert.deepEqual({ ...(await first) }, { a: true, b: true }); + assert.equal(shown(ctx).querySelector('h2').textContent, 'Second dialog'); + assert.equal(secondSettled, false, 'opening the queued dialog must not settle it'); + cancelBtn(ctx).click(); + assert.equal(await second, null); + assert.equal(overlay(ctx), null); + } finally { ctx.destroy(); } +}); + +for (const keyName of ['Escape', 'Tab']) { + test(`choice dialog: ${keyName} works after focus drifts to the body`, async () => { + const ctx = setupSidebarDom(); + try { + const pending = open(ctx); + ctx.document.activeElement.blur(); + assert.equal(ctx.document.activeElement, ctx.document.body); + const event = key(ctx, ctx.document.body, keyName); + assert.equal(event.defaultPrevented, true); + if (keyName === 'Escape') assert.equal(await pending, null); + else { + assert.equal(ctx.document.activeElement, shown(ctx).querySelector('button')); + cancelBtn(ctx).click(); + assert.equal(await pending, null); + } + } finally { ctx.destroy(); } + }); +} + +test('choice dialog: only the topmost overlay handles keys from the body', async () => { + const ctx = setupSidebarDom(); + try { + const pending = open(ctx); + const foreground = ctx.document.createElement('div'); + foreground.className = 'choice-dialog-overlay'; + ctx.document.body.append(foreground); + const unregister = ctx.window.registerModal({ overlay: foreground, cancel: () => { foreground.remove(); unregister(); } }); + key(ctx, ctx.document.body, 'Escape'); + assert.equal(ctx.document.querySelectorAll('.choice-dialog-overlay').length, 1); + assert.ok(overlay(ctx), 'only the registered topmost modal closes'); + key(ctx, ctx.document.body, 'Escape'); + assert.equal(ctx.document.querySelector('.choice-dialog-overlay'), null); + assert.equal(await pending, null); + } finally { ctx.destroy(); } +}); + +test('choice dialog: Enter on a danger dialog container waits for a focused button', async () => { + const ctx = setupSidebarDom(); + try { + const pending = open(ctx, { danger: true, initialFocus: 'cancel' }); + const dialog = shown(ctx).querySelector('.choice-dialog'); + dialog.focus(); + key(ctx, dialog, 'Enter'); + assert.ok(overlay(ctx), 'container Enter must not accept a dangerous action'); + assert.equal(ctx.window.localStorage.length, 0); + confirmBtn(ctx).focus(); + key(ctx, ctx.document.activeElement, 'Enter'); + assert.deepEqual({ ...(await pending) }, { a: true, b: true }); } finally { ctx.destroy(); } }); @@ -248,7 +307,7 @@ test('choice dialog: Enter outside the dialog does nothing', async () => { const pending = open(ctx); const event = key(ctx, outside, 'Enter'); assert.ok(overlay(ctx), 'the dialog must stay open'); - assert.equal(event.defaultPrevented, false); + assert.equal(event.defaultPrevented, true); cancelBtn(ctx).click(); assert.equal(await pending, null); } finally { ctx.destroy(); } @@ -278,3 +337,40 @@ test('choice dialog: closeWith closes it with its value, and does nothing once t assert.equal(ctx.document.activeElement, elsewhere, 'the focus is not taken back a second time'); } finally { ctx.destroy(); } }); + +test('choice dialog: replace cancels the open one and goes ahead of the queue; Escape on it gives the focus back', async () => { + const ctx = setupSidebarDom(); + try { + const origin = ctx.document.createElement('button'); + ctx.document.body.appendChild(origin); + origin.focus(); + const first = open(ctx, { title: 'First' }); + const queued = open(ctx, { title: 'Queued' }); + const closing = open(ctx, { title: 'Close?', replace: true, initialFocus: 'cancel', returnFocus: origin }); + assert.equal(await first, null, 'the open dialog is cancelled'); + await new Promise((r) => setTimeout(r)); + assert.equal(ctx.document.querySelectorAll('.choice-dialog-overlay').length, 1); + assert.equal(shown(ctx).querySelector('#choice-dialog-title').textContent, 'Close?', 'it is not behind the queued one'); + assert.equal(ctx.document.activeElement, cancelBtn(ctx)); + key(ctx, ctx.document.activeElement, 'Escape'); + assert.equal(await closing, null); + await new Promise((r) => setTimeout(r)); + assert.equal(shown(ctx).querySelector('#choice-dialog-title').textContent, 'Queued', 'the queue resumes'); + key(ctx, ctx.document.activeElement, 'Escape'); + assert.equal(await queued, null); + assert.equal(ctx.window.hasOpenModal(), false, 'every path unregistered from the modal stack'); + assert.equal(ctx.document.activeElement, origin); + } finally { ctx.destroy(); } +}); + +test('choice dialog: closeWith closes a replaced-in dialog and leaves the modal stack empty', async () => { + const ctx = setupSidebarDom(); + try { + let settle; + const pending = open(ctx, { replace: true, closeWith: new Promise((r) => { settle = r; }) }); + assert.equal(ctx.window.hasOpenModal(), true); + settle({}); + assert.deepEqual(await pending, {}); + assert.equal(ctx.window.hasOpenModal(), false); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-dispatch-dialog.test.js b/test/dom-dispatch-dialog.test.js index 3d151c2e..75c9f85c 100644 --- a/test/dom-dispatch-dialog.test.js +++ b/test/dom-dispatch-dialog.test.js @@ -27,7 +27,7 @@ function setup({ dispatchResult = { ok: true, id: 'cccccccc' }, projects, effect openSessions: new Map(), activePtyIds: new Set(), refreshSidebar: () => {}, pollActiveSessions: () => {}, }; for (const [k, v] of Object.entries(g)) Object.defineProperty(window, k, { value: v, writable: true, configurable: true }); - for (const f of ['setting-defaults.js', 'utils.js', 'icons.js', 'dialogs.js']) { + for (const f of ['modal-stack.js', 'setting-defaults.js', 'utils.js', 'icons.js', 'dialogs.js']) { vm.runInContext(fs.readFileSync(path.join(PUBLIC, f), 'utf8'), dom.getInternalVMContext(), { filename: path.join(PUBLIC, f) }); } return { window, document: window.document, calls, destroy: () => window.close() }; @@ -80,15 +80,20 @@ test('Enter inside the prompt does not start; Escape closes', async (t) => { assert.equal(d.querySelector('.new-session-overlay'), null); }); -test('Enter on a focused button is left to the button: Enter on Cancel never starts; Enter elsewhere does', async (t) => { +test('Enter on Cancel closes without dispatching; Enter on the dialog starts', async (t) => { const ctx = setup(); t.after(ctx.destroy); await ctx.window.showDispatchAgentDialog(null); const d = ctx.document; d.querySelector('#dad-prompt').value = 'go'; - d.querySelector('.new-session-cancel-btn').dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + const cancel = d.querySelector('.new-session-cancel-btn'); + cancel.focus(); + cancel.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); await tick(); await tick(); assert.equal(ctx.calls.dispatched.length, 0); - d.body.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + assert.equal(d.querySelector('.new-session-overlay'), null); + await ctx.window.showDispatchAgentDialog(null); + d.querySelector('#dad-prompt').value = 'go'; + d.querySelector('.new-session-dialog').dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); await tick(); await tick(); assert.equal(ctx.calls.dispatched.length, 1); }); diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index 720b5f31..8bd0ddab 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -169,7 +169,7 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm // jsdom's own window.confirm throws "not implemented"; the panel asks before // discarding unsaved edits, so the suite answers for the user. - window.confirm = (message) => { + window.appConfirm = async ({ message: message }) => { calls.confirm.push(message); return confirmImpl ? confirmImpl(message) : true; }; @@ -201,6 +201,7 @@ function setupFilePanelDom({ statusImpl, diffImpl, fileImpl, saveImpl, confirmIm Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); + evalInWindow(dom, path.join(PUBLIC_DIR, 'modal-stack.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'splitter.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'session-state.js')); evalInWindow(dom, path.join(PUBLIC_DIR, 'session-activity-dom.js')); @@ -302,6 +303,54 @@ async function openFile(ctx, sessionId, filePath) { await flush(); } +test('Escape in a Changes diff with no return action reaches other listeners', async () => { + const ctx = setupFilePanelDom(); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + const diff = ctx.document.getElementById('changes-diff-view'); + let reached = 0; + ctx.document.addEventListener('keydown', event => { if (event.key === 'Escape') reached++; }); + const event = new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + diff.dispatchEvent(event); + await flush(); + assert.equal(event.defaultPrevented, false); + assert.equal(reached, 1); + assert.equal(ctx.stateOf('s1').currentTab.selectedFile, null); + } finally { ctx.destroy(); } +}); + +test('Escape that returns a clean Changes diff to its list is swallowed immediately', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + let reached = 0; + ctx.document.addEventListener('keydown', () => { reached++; }); + const event = new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + ctx.document.getElementById('changes-diff-view').dispatchEvent(event); + assert.equal(event.defaultPrevented, true); + assert.equal(reached, 0); + assert.equal(ctx.stateOf('s1').currentTab.selectedFile, null); + } finally { ctx.destroy(); } +}); + +test('Escape leaves a dirty Changes diff open and propagates when discard is cancelled', async () => { + const ctx = setupFilePanelDom({ confirmImpl: () => false }); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'unsaved edits'; + let reached = 0; + ctx.document.addEventListener('keydown', () => { reached++; }); + const event = new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + ctx.document.getElementById('changes-diff-view').dispatchEvent(event); + await flush(); + assert.equal(event.defaultPrevented, false); + assert.equal(reached, 1); + assert.ok(ctx.stateOf('s1').currentTab.selectedFile); + assert.equal(ctx.editors[0].box.text, 'unsaved edits'); + } finally { ctx.destroy(); } +}); + // --- Rendering --------------------------------------------------------- test('openChangesTab loads status and renders the summary, branch info, and one row per file', async () => { @@ -2509,3 +2558,19 @@ test('a diff failure on a subagent row is shown, not thrown', async () => { assert.match(ctx.document.getElementById('changes-diff-view').textContent, /no longer exists/); } finally { ctx.destroy(); } }); + +test('delayed discard consent cannot close a replacement MCP tab', async () => { + const ctx = setupFilePanelDom(); + try { + await openFile(ctx, 's1', 'src/a.js'); + ctx.editors[0].box.text = 'unsaved'; + let answer; + ctx.window.appConfirm = () => new Promise(resolve => { answer = resolve; }); + const closing = ctx.window.handleClose(); + ctx.window.openDiffTab('s1', 'replacement', { oldFilePath: '/repo/other.js', oldContent: 'a', newContent: 'b' }); + await flush(); + answer(true); + await closing; + assert.equal(ctx.stateOf('s1').currentTab?.diffId, 'replacement'); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-file-panel-diff-save.test.js b/test/dom-file-panel-diff-save.test.js index d77bed8b..0ed81a1d 100644 --- a/test/dom-file-panel-diff-save.test.js +++ b/test/dom-file-panel-diff-save.test.js @@ -41,8 +41,8 @@ function setup({ saveImpl, confirmAnswer = true } = {}) { return () => Promise.resolve({ ok: true }); }, }); - window.confirm = (msg) => { calls.confirms.push(msg); return confirmAnswer; }; - window.alert = (msg) => { calls.alerts.push(msg); }; + window.appConfirm = async ({ message: msg }) => { calls.confirms.push(msg); return confirmAnswer; }; + window.appAlert = async ({ message: msg }) => { calls.alerts.push(msg); }; window.loadCodeMirrorBundle = () => Promise.resolve(); window.localStorage.setItem('filePanelDiffMode', 'side-by-side'); @@ -60,7 +60,7 @@ function setup({ saveImpl, confirmAnswer = true } = {}) { }); Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); - for (const f of ['viewer-toolbar.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js']) { + for (const f of ['modal-stack.js', 'viewer-toolbar.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js']) { vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: path.join(PUBLIC_DIR, f) }); } window.openSessions = new Map(); @@ -261,7 +261,7 @@ test('the diff tab asks again when the disk moves during its confirm, and never const disk = fakeDisk('session\n'); const ctx = setup({ saveImpl: disk.save }); let asked = 0; - ctx.window.confirm = (msg) => { + ctx.window.appConfirm = async ({ message: msg }) => { ctx.calls.confirms.push(msg); asked += 1; if (asked === 1) { disk.text = 'second session write\n'; return true; } diff --git a/test/dom-file-panel-goto-line.test.js b/test/dom-file-panel-goto-line.test.js index 9501e46f..74de95ce 100644 --- a/test/dom-file-panel-goto-line.test.js +++ b/test/dom-file-panel-goto-line.test.js @@ -38,7 +38,7 @@ function setup({ pair } = {}) { unwatchFile: () => Promise.resolve({ ok: true }), saveFileForPanel: () => Promise.resolve({ ok: true }), }; - window.confirm = () => true; + window.appConfirm = async () => true; window.loadCodeMirrorBundle = () => Promise.resolve(); window.cmRevealLine = (view, line) => calls.revealed.push({ view, line }); @@ -53,7 +53,7 @@ function setup({ pair } = {}) { window.createEditableViewer = (parent) => makeView(parent, 'plain'); Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); - for (const f of ['splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'viewer-toolbar.js', 'file-panel.js', 'touched-files-view.js']) { + for (const f of ['modal-stack.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'viewer-toolbar.js', 'file-panel.js', 'touched-files-view.js']) { vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: f }); } window.openSessions = new Map(); diff --git a/test/dom-file-panel-touched.test.js b/test/dom-file-panel-touched.test.js index 0126cc61..289a7c62 100644 --- a/test/dom-file-panel-touched.test.js +++ b/test/dom-file-panel-touched.test.js @@ -307,7 +307,7 @@ function setupDom({ touchedImpl, readImpl, confirmImpl, storedRatio, storageThro calls.save.push({ filePath, content, expected, options }); return Promise.resolve(saveImpl ? saveImpl(calls.save.length - 1) : { ok: true, version: 'v2' }); }; - window.confirm = (...args) => { calls.confirm++; return (confirmImpl || (() => true))(...args); }; + window.appConfirm = async ({ message }) => { calls.confirm++; return (confirmImpl || (() => true))(message); }; window.cmRevealLine = (_view, line) => calls.revealed.push(line); if (ownerOf) window.panelTerminalOwnerOf = ownerOf; const editors = []; @@ -332,7 +332,7 @@ function setupDom({ touchedImpl, readImpl, confirmImpl, storedRatio, storageThro const bundledMarked = window.marked; delete window.marked; window.loadCodeMirrorBundle = () => { window.marked = bundledMarked; return Promise.resolve(); }; - for (const file of ['viewer-toolbar.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js', 'touched-files-view.js']) { + for (const file of ['modal-stack.js', 'viewer-toolbar.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js', 'touched-files-view.js']) { evalInWindow(dom, path.join(PUBLIC_DIR, file)); } window.openSessions = new Map(); @@ -369,6 +369,14 @@ function flush() { return p; } +async function waitForUnsavedDialog(ctx) { + for (let i = 0; i < 100; i++) { + if (ctx.document.getElementById('unsaved-edits-dialog')) return; + await new Promise(resolve => setTimeout(resolve, 0)); + } + assert.fail('the queued unsaved dialog did not open'); +} + async function openTab(ctx, sessionId = 's1') { ctx.window.switchPanel(sessionId); ctx.document.getElementById('touched-toggle-btn').click(); @@ -732,6 +740,8 @@ test('close and Escape restore the same Touched rows, scroll and selection witho const back = ctx.document.getElementById('changes-diff-close-btn'); assert.ok(back, 'a shared panel back button'); back.click(); + await flush(); + await flush(); assert.equal(ctx.stateOf('s1').currentTab?.type, 'touched'); assert.equal(rows(ctx)[0], first); assert.equal(list.scrollTop, 123); @@ -942,9 +952,12 @@ test('Close keeps a dirty file on refusal and discards it without stashing it af ctx.editors.at(-1).setText('unsaved edit'); const back = ctx.document.getElementById('changes-diff-close-btn'); back.click(); + await flush(); + await flush(); assert.equal(ctx.stateOf('s1').currentTab?.type, 'changes'); agree = true; back.click(); + await flush(); assert.equal(ctx.stateOf('s1').currentTab?.type, 'touched'); assert.equal(ctx.stateOf('s1').touchedStashes?.size ?? 0, 0); } finally { ctx.destroy(); } @@ -1214,6 +1227,7 @@ test('round 3: a symlink Touched pair uses the shared chrome read-only and canno await ctx.window.handleChangesSave('s1'); assert.equal(ctx.calls.save.length, 0); ctx.document.getElementById('changes-diff-close-btn').click(); + await flush(); assert.equal(ctx.stateOf('s1').currentTab, list); assert.equal(rows(ctx)[0], row); assert.equal(ctx.calls.touched.length, 1); @@ -1342,6 +1356,7 @@ test('Touched edits participate in the existing unsaved-file close prompt', asyn await flush(); ctx.editors.at(-1).setText('unsaved'); const pending = ctx.window.askAboutUnsavedEdits(); + await waitForUnsavedDialog(ctx); const dialog = ctx.document.getElementById('unsaved-save'); assert.ok(dialog, 'the unsaved-file prompt includes the Touched editor'); dialog.click(); @@ -1370,6 +1385,7 @@ for (const [dirty, git] of [[true, true], [false, true], [true, false], [false, assert.equal(ctx.editors.at(-1).current, dirty ? 'typing' : 'external'); assert.equal(ctx.stateOf('s1').currentTab.externalChange, dirty); ctx.document.getElementById('changes-diff-close-btn').click(); + await flush(); assert.deepEqual(ctx.watchCalls.at(-1), ['unwatch', '/work/a.txt']); assert.equal(ctx.calls.touched.length, 1); } finally { ctx.destroy(); } @@ -2152,6 +2168,7 @@ test('the quit check lists a stashed Touched file and Save writes it with its ve await dirtyTouched(ctx, '/work/a.txt', 'unsaved'); ctx.window.openDiffTab('s1', 'd1', DIFF); const pending = ctx.window.askAboutUnsavedEdits(); + await waitForUnsavedDialog(ctx); const items = [...ctx.document.querySelectorAll('#unsaved-edits-dialog li')]; assert.deepEqual(items.map(li => [li.textContent, li.title]), [['a.txt', '/work/a.txt']]); ctx.document.getElementById('unsaved-save').click(); @@ -2283,6 +2300,7 @@ test('the quit check keeps a stash whose save is refused, and forgets only the s await dirtyTouched(ctx, '/work/a.txt', 'two'); ctx.window.openDiffTab('s2', 'd2', DIFF); const pending = ctx.window.askAboutUnsavedEdits(); + await waitForUnsavedDialog(ctx); ctx.document.getElementById('unsaved-save').click(); await flush(); assert.equal(ctx.calls.save.length, 2); diff --git a/test/dom-file-panel-unsaved-guard.test.js b/test/dom-file-panel-unsaved-guard.test.js index a6e86edc..af1e3ce5 100644 --- a/test/dom-file-panel-unsaved-guard.test.js +++ b/test/dom-file-panel-unsaved-guard.test.js @@ -12,6 +12,7 @@ const { JSDOM } = require('jsdom'); const { registerPanelTerminals } = require('./terminal-manager-harness'); const { EventEmitter } = require('node:events'); const { createUnsavedGuard } = require('../unsaved-guard'); +const { loadAppFunctions } = require('./app-source'); const PUBLIC_DIR = path.join(__dirname, '..', 'public'); @@ -72,6 +73,7 @@ function setup() { }, }); window.confirm = (msg) => { calls.confirms.push(msg); return calls.onConfirm ? calls.onConfirm(msg) : false; }; + window.appConfirm = async ({ message: msg }) => { calls.confirms.push(msg); return false; }; window.createEditableViewer = (parent, content) => { editor = fakeEditor(content); editor.dom = window.document.createElement('div'); @@ -88,7 +90,7 @@ function setup() { return el; }; - for (const f of ['viewer-toolbar.js', 'viewer-panel.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'choice-dialog.js', 'file-panel.js', 'touched-files-view.js']) { + for (const f of ['modal-stack.js', 'viewer-toolbar.js', 'viewer-panel.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'choice-dialog.js', 'file-panel.js', 'touched-files-view.js']) { vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: path.join(PUBLIC_DIR, f) }); } window.openSessions = new Map(); @@ -98,7 +100,7 @@ function setup() { window.appShortcuts = {}; window.initFilePanel(); registerPanelTerminals(dom, ['s1', 's3']); - return { window, disk, calls, editor: () => editor, destroy: () => window.close() }; + return { window, context: dom.getInternalVMContext(), disk, calls, editor: () => editor, destroy: () => window.close() }; } const flush = () => new Promise((r) => setTimeout(r, 5)); @@ -148,6 +150,25 @@ const A = '/repo/a.md'; const B = '/repo/b.md'; const C = '/repo/c.md'; +test('the unsaved guard keeps its explicit opener restoration to one focus call', async () => { + const ctx = setup(); + try { + const opener = ctx.window.document.createElement('button'); + ctx.window.document.body.append(opener); + opener.focus(); + let restored = 0; + const focus = opener.focus.bind(opener); + opener.focus = () => { restored++; focus(); }; + const pending = ctx.window.showUnsavedEditsDialog([]); + ctx.window.document.activeElement.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { + key: 'Escape', bubbles: true, cancelable: true, + })); + assert.equal(await pending, false); + assert.equal(ctx.window.document.activeElement, opener); + assert.equal(restored, 1); + } finally { ctx.destroy(); } +}); + async function dirtyTab(ctx, sessionId, file, text = 'mine') { ctx.disk.set(file, 'x0\n'); ctx.window.switchPanel(sessionId); @@ -165,6 +186,23 @@ function beforeUnload(ctx) { return event; } +test('quit focuses Save so Enter saves the edits before approving quit', async () => { + const ctx = setup(); + try { + await dirtyTab(ctx, 's1', A); + const checking = ctx.calls.check(60, 'quit'); + await flush(); + const save = button(ctx, 'unsaved-save'); + assert.equal(ctx.window.document.activeElement, save); + dialogKey(ctx, 'Enter'); + await flush(); + assert.equal(ctx.disk.get(A), 'x0\nmine', 'Enter itself must save'); + await checking; + assert.equal(ctx.disk.get(A), 'x0\nmine'); + assert.deepEqual(ctx.calls.answers, [{ id: 60, proceed: true }]); + } finally { ctx.destroy(); } +}); + test('with nothing unsaved the check is answered at once and no dialog shows', async () => { const ctx = setup(); try { @@ -505,13 +543,23 @@ test('a window close while another choice dialog is open cancels that one and as } finally { ctx.destroy(); } }); -test('a choice dialog that does not ask to replace still yields to one already open', async () => { +test('a choice dialog that does not ask to replace waits behind the open one, and shows once it closes', async () => { const ctx = setup(); try { - ctx.window.showChoiceDialog({ title: 'first' }); - const second = await ctx.window.showChoiceDialog({ title: 'second' }); + const { document } = ctx.window; + const first = ctx.window.showChoiceDialog({ title: 'first' }); + let second; + ctx.window.showChoiceDialog({ title: 'second' }).then((v) => { second = v; }); + await flush(); + assert.equal(second, undefined, 'the second question is not answered as a silent Cancel'); + assert.equal(document.querySelector('#choice-dialog-title').textContent, 'first'); + document.activeElement.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.equal(await first, null); + await flush(); + assert.equal(document.querySelector('#choice-dialog-title').textContent, 'second'); + document.querySelector('.choice-dialog-cancel').click(); + await flush(); assert.equal(second, null); - assert.equal(ctx.window.document.querySelector('#choice-dialog-title').textContent, 'first'); } finally { ctx.destroy(); } }); @@ -555,7 +603,7 @@ test('a native dialog the page shows is reported around it, and the shipped guar }; ctx.window.confirm('Delete "x"?'); assert.deepEqual(during, { pings: 0, destroys: 0 }, 'a close that arrives behind a native dialog waits for the user to answer it'); - await flush(); + for (let i = 0; i < 40 && !dialog(ctx); i++) await flush(); assert.ok(dialog(ctx), 'the page answers once the dialog is gone'); ctx.calls.onConfirm = () => { @@ -653,3 +701,226 @@ test('a check delivered twice, before and after the page said it is ready, is an assert.deepEqual(ctx.calls.answers, [{ id: 9, proceed: true }]); } finally { ctx.destroy(); } }); +function loadAppDialogs(ctx) { + for (const f of ['app-dialogs.js']) { + const filename = path.join(PUBLIC_DIR, f); + vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx.context, { filename }); + } +} +function dialogKey(ctx, key, init = {}) { + ctx.window.document.activeElement.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })); +} + +for (const reason of ['quit', 'close', 'reload']) { + test(`${reason} opens the unsaved guard on top of New Session without closing it first`, async () => { + const ctx = setup(); + try { + loadAppDialogs(ctx); + for (const f of ['setting-defaults.js', 'dialogs.js']) { + const filename = path.join(PUBLIC_DIR, f); + vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx.context, { filename }); + } + await dirtyTab(ctx, 's1', A); + ctx.window.api.getEffectiveSettings = async () => ({}); + await ctx.window.showNewSessionDialog({ projectPath: '/repo' }); + const underneath = ctx.window.document.querySelector('.new-session-overlay'); + const checking = ctx.calls.check(73, reason); + assert.deepEqual(ctx.calls.acks.map(x => x.id), [73]); + await flush(); + assert.ok(dialog(ctx), 'a configuration modal must not delay the unsaved guard'); + assert.equal(underneath.isConnected, true); + assert.ok(Number(dialog(ctx).parentElement.style.zIndex) > Number(underneath.style.zIndex)); + assert.equal(ctx.window.document.activeElement, button(ctx, 'unsaved-save')); + dialogKey(ctx, 'Escape'); + await checking; + assert.deepEqual(ctx.calls.answers, [{ id: 73, proceed: false }]); + assert.equal(underneath.isConnected, true); + dialogKey(ctx, 'Escape'); + assert.equal(underneath.isConnected, false); + } finally { ctx.destroy(); } + }); +} + +test('a session deletion question waits for the unsaved guard to close', async () => { + const ctx = setup(); + try { + loadAppDialogs(ctx); + ctx.window.cleanDisplayName = value => value; + ctx.window.escapeHtml = value => value; + const { showDeleteSessionDialog } = loadAppFunctions(ctx.context, { + sourcePath: path.join(PUBLIC_DIR, 'sidebar.js'), functions: ['showDeleteSessionDialog'], + }); + await dirtyTab(ctx, 's1', A); + const checking = ctx.calls.check(72, 'quit'); + await flush(); + const deleting = showDeleteSessionDialog({ sessionId: 's1' }); + assert.equal(ctx.window.document.querySelector('.new-session-overlay'), null); + dialogKey(ctx, 'Escape'); + await checking; + assert.equal(dialog(ctx), null); + assert.ok(ctx.window.document.querySelector('#dss-cancel')); + dialogKey(ctx, 'Escape'); + assert.equal(await deleting, false); + } finally { ctx.destroy(); } +}); +test('N2: unsaved guard traps Tab and cancels Escape from the body', async () => { + const ctx = setup(); + try { + loadAppDialogs(ctx); + await dirtyTab(ctx, 's1', A); + const checking = ctx.calls.check(71, 'quit'); + await flush(); + ctx.window.document.activeElement.blur(); + dialogKey(ctx, 'Tab'); + assert.equal(ctx.window.document.activeElement, button(ctx, 'unsaved-cancel')); + ctx.window.document.activeElement.blur(); + dialogKey(ctx, 'Escape'); + await flush(); + assert.equal(dialog(ctx), null); + await checking; + assert.deepEqual(ctx.calls.answers, [{ id: 71, proceed: false }]); + } finally { ctx.destroy(); } +}); +test('N4: unsaved guard waits for every continuation question, including the final fallback', async () => { + const ctx = setup(); + try { + loadAppDialogs(ctx); + const filename = path.join(PUBLIC_DIR, 'resume-guard.js'); + vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx.context, { filename }); + await dirtyTab(ctx, 's1', A); + const chain = ctx.window.resolveResumeSession({ sessionId: 'original' }, { + api: { getSessionContinuations: async () => ({ candidates: [{ sessionId: 'first' }, { sessionId: 'second' }] }) }, + confirm: message => ctx.window.appConfirm({ message }), + }); + await flush(); + const checking = ctx.calls.check(70, 'quit'); + for (let i = 0; i < 3; i++) { + await flush(); + assert.equal(dialog(ctx), null, `guard must wait for question ${i + 1}`); + assert.equal(ctx.window.document.querySelectorAll('.choice-dialog-overlay').length, 1); + dialogKey(ctx, 'Escape'); + } + assert.equal(await chain, null); + await flush(); + assert.ok(dialog(ctx)); + assert.equal(ctx.window.document.querySelector('.choice-dialog-overlay'), null); + dialogKey(ctx, 'Escape'); + await checking; + assert.deepEqual(ctx.calls.answers, [{ id: 70, proceed: false }]); + } finally { ctx.destroy(); } +}); +test('reload ACK is immediate while confirmations queue; quit upgrade waits for one focused unsaved guard', async () => { + const ctx = setup(); + try { + loadAppDialogs(ctx); + await dirtyTab(ctx, 's1', A); + const opener = ctx.window.document.createElement('button'); + ctx.window.document.body.append(opener); + opener.focus(); + let flushed = 0; + ctx.window.flushStateForExit = async () => { flushed++; }; + const confirming = ctx.window.appConfirm({ title: 'Delete?', danger: true }); + const alerting = ctx.window.appAlert({ message: 'Queued message' }); + const checking = ctx.calls.check(44, 'reload'); + ctx.calls.reason(44, 'quit'); + assert.deepEqual(ctx.calls.acks.map(x => x.id), [44]); + await flush(); + assert.equal(dialog(ctx), null, 'the unsaved guard must wait for the dialog queue'); + dialogKey(ctx, 'Escape'); + assert.equal(await confirming, false); + dialogKey(ctx, 'Enter'); + await alerting; + await flush(); + assert.ok(dialog(ctx)); + assert.equal(ctx.window.document.querySelector('.choice-dialog-overlay'), null); + assert.equal(ctx.window.document.activeElement, button(ctx, 'unsaved-save')); + dialogKey(ctx, 'Tab', { shiftKey: true }); + assert.equal(ctx.window.document.activeElement, button(ctx, 'unsaved-discard')); + dialogKey(ctx, 'Tab'); + assert.equal(ctx.window.document.activeElement, button(ctx, 'unsaved-save')); + button(ctx, 'unsaved-discard').click(); + await checking; + assert.deepEqual(ctx.calls.answers, [{ id: 44, proceed: true }]); + assert.equal(flushed, 1); + assert.equal(ctx.window.document.activeElement, opener); + } finally { ctx.destroy(); } +}); +test('an alert waits for the unsaved guard, then owns focus and Escape', async () => { + const ctx = setup(); + try { + loadAppDialogs(ctx); + await dirtyTab(ctx, 's1', A); + const checking = ctx.calls.check(45, 'reload'); + await flush(); + const alerting = ctx.window.appAlert({ message: 'Foreground' }); + dialogKey(ctx, 'Tab'); + assert.ok(dialog(ctx).contains(ctx.window.document.activeElement)); + assert.equal(ctx.window.document.querySelector('.choice-dialog-overlay'), null); + assert.deepEqual(ctx.calls.answers, []); + dialogKey(ctx, 'Escape'); + await checking; + assert.deepEqual(ctx.calls.answers, [{ id: 45, proceed: false }]); + assert.equal(dialog(ctx), null); + assert.ok(ctx.window.document.activeElement.closest('.choice-dialog-overlay')); + dialogKey(ctx, 'Escape'); + await alerting; + } finally { ctx.destroy(); } +}); + +function openOtherQuestion(ctx) { + const { document } = ctx.window; + const overlay = document.createElement('div'); + overlay.className = 'new-session-overlay'; + const dialog = document.createElement('div'); + dialog.className = 'new-session-dialog'; + const ok = document.createElement('button'); + ok.textContent = 'Delete'; + dialog.append(ok); + overlay.append(dialog); + return ctx.window.enqueueModalQuestion(() => new Promise((resolve) => { + document.body.append(overlay); + const unregister = ctx.window.registerModal({ overlay, cancel: () => { overlay.remove(); unregister(); resolve(false); }, restoreFocus: false }); + })); +} + +test('a quit that arrives while the close question waits behind another question answers yes without that question', async () => { + const ctx = setup(); + const { document } = ctx.window; + try { + let answered = null; + openOtherQuestion(ctx).then((v) => { answered = v; }); + await flush(); + ctx.calls.check(1, 'close'); + await flush(); + ctx.calls.reason(1, 'quit'); + await flush(); + assert.deepEqual(ctx.calls.answers, [{ id: 1, proceed: true }]); + assert.equal(answered, null, 'the other question is still the user\'s to answer'); + assert.ok(document.querySelector('.new-session-overlay')); + } finally { ctx.destroy(); } +}); + +test('the close question opens on top of another question and Escape returns to it', async () => { + const ctx = setup(); + const { document } = ctx.window; + try { + let answered = null; + openOtherQuestion(ctx).then((v) => { answered = v; }); + await flush(); + const underneath = document.querySelector('.new-session-overlay'); + ctx.calls.check(1, 'close'); + await flush(); + assert.equal(document.querySelectorAll('.choice-dialog').length, 1, 'the close question is shown, not held behind'); + assert.ok(document.querySelector('.new-session-overlay'), 'the other question is not cancelled'); + document.activeElement.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + await flush(); + assert.deepEqual(ctx.calls.answers, [{ id: 1, proceed: false }]); + assert.equal(answered, null); + assert.equal(document.querySelector('.choice-dialog'), null); + assert.ok(underneath.isConnected); + assert.ok(underneath.contains(document.activeElement), 'focus returns to the other question'); + document.activeElement.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + await flush(); + assert.equal(answered, false, 'Escape now reaches the other question'); + } finally { ctx.destroy(); } +}); diff --git a/test/dom-ide-emulation-indicator.test.js b/test/dom-ide-emulation-indicator.test.js index 6d8c67b6..45b0149c 100644 --- a/test/dom-ide-emulation-indicator.test.js +++ b/test/dom-ide-emulation-indicator.test.js @@ -39,7 +39,7 @@ function setup() { }, }); Object.defineProperty(window, 'activeSessionId', { value: null, writable: true, configurable: true }); - for (const f of ['viewer-toolbar.js', 'viewer-panel.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js']) { + for (const f of ['modal-stack.js', 'viewer-toolbar.js', 'viewer-panel.js', 'splitter.js', 'session-state.js', 'session-activity-dom.js', 'session-activity.js', 'shortcuts.js', 'header-controls.js', 'tool-bar.js', 'file-panel.js']) { vm.runInContext(fs.readFileSync(path.join(PUBLIC_DIR, f), 'utf8'), dom.getInternalVMContext(), { filename: path.join(PUBLIC_DIR, f) }); } window.openSessions = new Map(); diff --git a/test/dom-modal-routing.test.js b/test/dom-modal-routing.test.js new file mode 100644 index 00000000..3c47357f --- /dev/null +++ b/test/dom-modal-routing.test.js @@ -0,0 +1,539 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { setupTerminalDom } = require('./terminal-manager-harness'); +const { loadAppFunctions } = require('./app-source'); +const { setupSidebarDom } = require('./dom-setup'); +const { JSDOM } = require('jsdom'); +const { createCanvas } = require('@napi-rs/canvas'); + +function setup(api = {}) { + const writes = []; + const ctx = setupTerminalDom({ api: { sendInput: (...args) => writes.push(args), getEffectiveSettings: async () => ({}), ...api } }); + const { window } = ctx; + window.cachedAllProjects = []; + for (const name of ['choice-dialog.js', 'app-dialogs.js', 'setting-defaults.js', 'dialogs.js']) { + const filename = path.join(__dirname, '../public', name); + vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx.context, { filename }); + } + const doc = window.document; + const container = doc.createElement('div'); + container.className = 'terminal-container visible'; + container.innerHTML = ''; + doc.getElementById('terminals').append(container); + const textarea = container.firstElementChild; + const terminal = { attachCustomKeyEventHandler(handler) { this.handler = handler; } }; + window.setupTerminalKeyBindings(terminal, container, () => 's1'); + textarea.addEventListener('keydown', e => { if (terminal.handler(e)) window.api.sendInput('s1', e.key); }); + const { handleGlobalShortcut } = loadAppFunctions(ctx.context, { functions: ['handleGlobalShortcut'] }); + doc.addEventListener('keydown', handleGlobalShortcut); + const press = (target, key, init = {}) => { + const event = new window.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init }); + target.dispatchEvent(event); + return event; + }; + return { ...ctx, doc, writes, textarea, terminal, press, handleGlobalShortcut }; +} + +for (const kind of ['Touched info', "What's new"]) { + test(`the topmost ${kind} modal owns Escape over a choice question`, async () => { + const ctx = setupSidebarDom(); + try { + const doc = ctx.document; + const pending = ctx.window.appConfirm({ title: 'Underneath' }); + const origin = doc.createElement('button'); + doc.body.append(origin); + if (kind === 'Touched info') { + const filename = path.join(__dirname, '../public/touched-files-view.js'); + vm.runInContext(fs.readFileSync(filename, 'utf8'), ctx.context, { filename }); + ctx.window.showTouchedInfo(origin); + } else { + require('../public/whats-new').showWhatsNew(doc, { whatsNewDismissed() {} }, ctx.window.escapeHtml, + { sections: [] }); + } + doc.body.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.ok(doc.querySelector('.choice-dialog-overlay'), 'the underlying question must stay open'); + assert.equal(doc.querySelectorAll('.choice-dialog-overlay, .touched-info-overlay, .new-session-overlay, .whats-new-overlay').length, 1); + doc.querySelector('.choice-dialog-cancel').click(); + await pending; + } finally { ctx.destroy(); } + }); +} + +for (const kind of ['session', 'worktree']) { + test(`a ${kind} deletion question waits for the active choice question`, async () => { + const ctx = setupSidebarDom(); + try { + const first = ctx.window.appConfirm({ title: 'First' }); + const deleting = kind === 'session' ? ctx.window.showDeleteSessionDialog({ sessionId: 's1' }) + : ctx.window.showDeleteWorktreeDialog('fixture', '/fixture'); + assert.equal(ctx.document.querySelector('.new-session-overlay'), null); + ctx.document.querySelector('.choice-dialog-confirm').click(); + assert.equal(await first, true); + assert.ok(ctx.document.querySelector('.new-session-overlay')); + ctx.document.body.dispatchEvent(new ctx.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); + assert.equal(await deleting, false); + assert.equal(ctx.window.hasOpenModal(), false); + } finally { ctx.destroy(); } + }); +} + +for (const kind of ['New Session', 'Resume', 'Add Project', 'Remote launch', 'Send prompt', 'Dispatch agent']) { + test(`${kind} traps body Tab, blocks background shortcuts and cancels from outside`, async () => { + const ctx = setup(); + try { + if (kind === 'New Session') await ctx.window.showNewSessionDialog({ projectPath: '/fixture' }); + else if (kind === 'Resume') await ctx.window.showResumeSessionDialog({ sessionId: 's1', projectPath: '/fixture' }); + else if (kind === 'Add Project') ctx.window.showAddProjectDialog(); + else if (kind === 'Remote launch') ctx.window.showRemoteLaunchDialog({ projectPath: '/fixture', remoteAlias: 'box' }); + else if (kind === 'Send prompt') ctx.window.showSendPromptDialog({ sessionId: 's1', remoteAlias: 'box' }); + else await ctx.window.showDispatchAgentDialog({ projectPath: '/fixture' }); + const overlay = ctx.doc.querySelector('.new-session-overlay, .add-project-overlay'); + const tools = []; + ctx.window.handleToolShortcut = e => { tools.push(e.key); return false; }; + ctx.textarea.focus(); + ctx.press(ctx.textarea, 'y'); + ctx.handleGlobalShortcut(new ctx.window.KeyboardEvent('keydown', { key: 'X' })); + assert.equal(ctx.terminal.handler(new ctx.window.KeyboardEvent('keydown', { key: 'y' })), false); + assert.deepEqual(tools, []); + assert.deepEqual(ctx.writes, []); + ctx.textarea.blur(); + ctx.press(ctx.doc.body, 'Tab'); + assert.ok(overlay.contains(ctx.doc.activeElement)); + ctx.press(ctx.doc.body, 'Tab', { shiftKey: true }); + assert.ok(overlay.contains(ctx.doc.activeElement)); + const field = overlay.querySelector('input[type="text"], textarea'); + if (field) { + if (kind === 'Remote launch') field.value = ''; + field.focus(); + assert.equal(ctx.press(field, 'y').defaultPrevented, false); + assert.equal(ctx.press(field, 'Enter').defaultPrevented, kind === 'Add Project' || kind === 'Remote launch'); + } + ctx.press(ctx.textarea, 'Escape'); + assert.equal(overlay.isConnected, false); + assert.equal(ctx.window.hasOpenModal(), false); + ctx.handleGlobalShortcut(new ctx.window.KeyboardEvent('keydown', { key: 'X' })); + assert.deepEqual(tools, ['X']); + } finally { ctx.destroy(); } + }); +} + +test('N1: terminal and document shortcuts and PTY input stop under an open question', async () => { + const ctx = setup(); + try { + const tools = [], navigation = []; + ctx.window.handleToolShortcut = e => { tools.push(e.key); return false; }; + ctx.window.isSessionNavKey = e => e.key === 'ArrowRight'; + ctx.window.handleSessionNavKey = e => navigation.push(e.key); + const pending = ctx.window.appConfirm({ danger: true }); + ctx.textarea.focus(); + for (const key of ['X', 'ArrowRight', 'Enter', 'y']) ctx.press(ctx.textarea, key); + assert.deepEqual(tools, []); + assert.deepEqual(navigation, []); + assert.deepEqual(ctx.writes, []); + for (const key of ['X', 'ArrowRight', 'Enter', 'y']) { + assert.equal(ctx.terminal.handler(new ctx.window.KeyboardEvent('keydown', { key })), false); + ctx.handleGlobalShortcut(new ctx.window.KeyboardEvent('keydown', { key })); + } + assert.deepEqual(tools, []); + assert.deepEqual(navigation, []); + assert.deepEqual(ctx.writes, []); + ctx.doc.querySelector('.choice-dialog-cancel').click(); + assert.equal(await pending, false); + assert.equal(ctx.terminal.handler(new ctx.window.KeyboardEvent('keydown', { key: 'y' })), true); + } finally { ctx.destroy(); } +}); + +test('N1: xterm protocol replies reach every PTY while a modal is open', async () => { + const ctx = setup(); + const entries = []; + try { + vm.runInContext(fs.readFileSync(require.resolve('@xterm/xterm'), 'utf8'), ctx.context); + const Xterm = ctx.window.Terminal; + ctx.window.Terminal = class extends Xterm { + loadAddon() {} + get unicode() { return { activeVersion: '' }; } + open() {} + }; + for (const [sessionId, options] of [['s1', {}], ['background', {}], ['shell', { panel: true }]]) { + entries.push(ctx.window.createTerminalEntry({ sessionId }, options)); + } + const pending = ctx.window.appConfirm({ danger: true }); + for (const entry of entries) { + await new Promise(resolve => entry.terminal.write('\x1b[6n\x1b[c\x1b[?1$p', resolve)); + } + assert.deepEqual(ctx.writes, ['s1', 'background', 'shell'].flatMap(id => [ + [id, '\x1b[1;1R'], [id, '\x1b[?1;2c'], [id, '\x1b[?1;2$y'], + ])); + ctx.doc.querySelector('.choice-dialog-cancel').click(); + await pending; + await new Promise(resolve => entries[0].terminal.write('\x1b[6n', resolve)); + assert.deepEqual(ctx.writes.at(-1), ['s1', '\x1b[1;1R']); + } finally { + for (const entry of entries) entry.terminal.dispose(); + ctx.destroy(); + } +}); + +test('registering a modal removes focus from the terminal textarea', () => { + const ctx = setup(); + try { + ctx.textarea.focus(); + const overlay = ctx.doc.createElement('div'); + overlay.innerHTML = '