Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .ai/contexts/app-dialogs.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 12 additions & 4 deletions .ai/contexts/window-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions e2e/app-dialogs.spec.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
9 changes: 9 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions public/activity-trace-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
return new Date(iso).toLocaleString();
}

async function openActivityTraceFile(file) {

Check warning on line 60 in public/activity-trace-panel.js

View workflow job for this annotation

GitHub Actions / lint

'openActivityTraceFile' is already defined as a built-in global variable
const panel = getActivityTracePanel();
if (!panel) return;
let result;
Expand All @@ -67,7 +67,7 @@
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();
Expand Down Expand Up @@ -117,7 +117,7 @@
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 {
Expand All @@ -130,7 +130,7 @@
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'}` });
}
});

Expand All @@ -141,7 +141,7 @@
return row;
}

async function renderActivityTraceFiles(listEl) {

Check warning on line 144 in public/activity-trace-panel.js

View workflow job for this annotation

GitHub Actions / lint

'renderActivityTraceFiles' is already defined as a built-in global variable
if (!listEl) return;
activityTraceListEl = listEl;
let files = [];
Expand All @@ -157,7 +157,7 @@
for (const file of files) listEl.appendChild(buildActivityTraceRow(file, listEl));
}

function wireActivityTraceToggle(inputEl, listEl) {

Check warning on line 160 in public/activity-trace-panel.js

View workflow job for this annotation

GitHub Actions / lint

'wireActivityTraceToggle' is already defined as a built-in global variable
if (!inputEl) return;
inputEl.addEventListener('change', async () => {
inputEl.disabled = true;
Expand Down
2 changes: 1 addition & 1 deletion public/agents-view.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// see .ai/contexts/bg-agents.md

const AGENTS_RECONCILE_MS = 30000;
const bgAgentSessionIds = new Set();

Check warning on line 4 in public/agents-view.js

View workflow job for this annotation

GitHub Actions / lint

'bgAgentSessionIds' is already defined as a built-in global variable
let agentsViewActive = false;
let agentsRoster = [];
let agentsDaemonReachable = true;
Expand Down Expand Up @@ -418,7 +418,7 @@
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();
Expand Down
14 changes: 14 additions & 0 deletions public/app-dialogs.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading