Skip to content

fix: prevent ESC from closing settings when a nested modal is open#1512

Merged
arnestrickmann merged 2 commits intogeneralaction:mainfrom
janburzinski:fix/settings-modal-esc-propagation
Mar 17, 2026
Merged

fix: prevent ESC from closing settings when a nested modal is open#1512
arnestrickmann merged 2 commits intogeneralaction:mainfrom
janburzinski:fix/settings-modal-esc-propagation

Conversation

@janburzinski
Copy link
Contributor

Summary

right now if you are in the settings and open another modal and then press escape the expected behavior would be that only the newly opened modal would be closed and not the entire settings. this was not the case. if you were in the settings and then opened another modal and then pressed escape it would close the modal and the entire settings with it.

Snapshot

Screen_Recording_2026-03-17_at_20.22.56.mov

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Chore (refactoring code, technical debt, workflow improvements)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Refactor (does not change functionality, e.g. code style improvements, linting)
  • This change requires a documentation update

Mandatory Tasks

  • I have self-reviewed the code
  • A decent size PR without self-review might be rejected

Checklist

  • I have read the contributing guide
  • My code follows the style guidelines of this project (pnpm run format)
  • I have commented my code, particularly in hard-to-understand areas
  • I have checked if my PR needs changes to the documentation
  • I have checked if my changes generate no new warnings (pnpm run lint)
  • I have added tests that prove my fix is effective or that my feature works
  • I haven't checked if new and existing unit tests pass locally with my changes

The global keyboard shortcut handler (useKeyboardShortcuts) runs in the
capture phase on window and processes CLOSE_MODAL (Escape) before any
nested dialog can handle it. When settings are open and a nested modal
(e.g. agent execution settings) is also open, pressing Escape would
close the entire settings page instead of just the modal.

Fix:
- useKeyboardShortcuts: skip modal-priority shortcuts when a nested
  dialog ([role=dialog/alertdialog]) is present in the DOM
- CustomCommandModal: use capture phase + stopImmediatePropagation to
  prevent the SettingsPage ESC handler from also firing
- SettingsPage: check defaultPrevented before closing as a safety net
@vercel
Copy link

vercel bot commented Mar 17, 2026

@janburzinski is attempting to deploy a commit to the General Action Team on Vercel.

A member of the Team first needs to authorize it.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 17, 2026

Greptile Summary

This PR fixes a bug where pressing Escape while a nested modal (e.g. the agent execution settings dialog) was open inside the settings page would close both the nested modal and the entire settings page simultaneously. The fix uses a three-layer defence:

  • Nested modals move to capture phase (CustomCommandModal, FeedbackModal): their keydown listeners are re-registered with the capture: true flag and call event.preventDefault() before onClose(). Because the capture phase runs before the bubble phase, this marks the event as handled before SettingsPage's listener can see it.
  • SettingsPage checks defaultPrevented: the bubble-phase handler now guards on !e.defaultPrevented, so it is a no-op whenever a nested modal already consumed the event.
  • useKeyboardShortcuts skips onCloseModal when a dialog is in the DOM: a document.querySelector('[role="dialog"], [role="alertdialog"]') check short-circuits the global Escape handler if any dialog element is present, preventing the onCloseModal callback from also firing.

The three layers are complementary and together correctly handle the primary use case. The main open concern (raised in a prior thread) is that the document-wide selector in useKeyboardShortcuts is not scoped to the settings context and could match dialogs elsewhere in the app or elements still animating out via AnimatePresence, which would silently swallow Escape in unrelated views.

Confidence Score: 3/5

  • The fix is functionally correct for the described bug but introduces a document-wide DOM query that can suppress Escape handling in unrelated contexts — this concern was raised in a prior review thread and remains unaddressed.
  • The capture-phase migration for the nested modals and the defaultPrevented guard on SettingsPage are sound and correctly fix the immediate bug. However, the document.querySelector check in useKeyboardShortcuts is document-scoped rather than settings-scoped, meaning any [role="dialog"] element anywhere in the DOM (including elements animating out) can block the global Escape handler for all tracked closable views. This was flagged in a prior review thread and has not been changed.
  • src/renderer/hooks/useKeyboardShortcuts.ts — the document-wide dialog selector (line 568) deserves extra scrutiny before merge.

Important Files Changed

Filename Overview
src/renderer/hooks/useKeyboardShortcuts.ts Adds a document.querySelector('[role="dialog"], [role="alertdialog"]') guard before executing modal-priority shortcuts (Escape). Effectively prevents onCloseModal from firing when any dialog is present in the DOM anywhere on the page, including elements that are in the process of animating out via AnimatePresence — this scope is wider than the settings nesting context and was flagged in a prior review thread.
src/renderer/components/SettingsPage.tsx Adds !e.defaultPrevented guard to the Escape keydown handler. Because nested modals now run in capture phase and call event.preventDefault() before this bubble-phase handler fires, the guard correctly prevents settings from closing when a nested modal has already consumed the event.
src/renderer/components/CustomCommandModal.tsx Moves the keydown listener from bubble phase to capture phase (adds true flag). Escape now calls event.preventDefault() in capture phase, which both stops SettingsPage's bubble-phase handler from firing and satisfies the defaultPrevented check. Change is correct and intentional.
src/renderer/components/FeedbackModal.tsx Same capture-phase migration as CustomCommandModal. FeedbackModal is not imported by SettingsPage, so if the two views can be open simultaneously the change is necessary; if they are mutually exclusive it is harmless. The change is safe either way.

Sequence Diagram

sequenceDiagram
    participant User
    participant useKeyboardShortcuts as useKeyboardShortcuts<br/>(window, capture, init-time)
    participant NestedModal as NestedModal<br/>(CustomCommandModal / FeedbackModal)<br/>(window, capture, on-open)
    participant SettingsPage as SettingsPage<br/>(window, bubble)

    Note over User,SettingsPage: Scenario A — Escape with nested modal open (fixed case)

    User->>useKeyboardShortcuts: keydown Escape (capture phase)
    useKeyboardShortcuts->>useKeyboardShortcuts: document.querySelector('[role="dialog"]') → found
    useKeyboardShortcuts-->>User: continue (skip onCloseModal, no preventDefault)

    User->>NestedModal: keydown Escape (capture phase)
    NestedModal->>NestedModal: event.preventDefault() + onClose()
    NestedModal-->>User: modal closed

    User->>SettingsPage: keydown Escape (bubble phase)
    SettingsPage->>SettingsPage: e.defaultPrevented === true → skip onClose()
    Note over SettingsPage: ✅ Settings stays open

    Note over User,SettingsPage: Scenario B — Escape with NO nested modal open

    User->>useKeyboardShortcuts: keydown Escape (capture phase)
    useKeyboardShortcuts->>useKeyboardShortcuts: document.querySelector('[role="dialog"]') → null
    useKeyboardShortcuts->>useKeyboardShortcuts: hasClosableView === true (settings open)
    useKeyboardShortcuts->>useKeyboardShortcuts: event.preventDefault() + onCloseModal()
    Note over useKeyboardShortcuts: Settings closed via onCloseModal

    User->>SettingsPage: keydown Escape (bubble phase)
    SettingsPage->>SettingsPage: e.defaultPrevented === true → skip onClose()
    Note over SettingsPage: ✅ No double-close
Loading

Last reviewed commit: 5b21585

Comment on lines 57 to 60
if (event.key === 'Escape') {
event.preventDefault();
event.stopImmediatePropagation();
onClose();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 stopImmediatePropagation() does not block useKeyboardShortcuts

The comment on line 51–52 states that stopImmediatePropagation() prevents the SettingsPage ESC handler from firing, but this reasoning is incorrect. useKeyboardShortcuts is registered on window in the capture phase at app initialization — long before CustomCommandModal opens and adds its own capture-phase listener. Because stopImmediatePropagation() only blocks listeners registered after the current one, it cannot stop useKeyboardShortcuts, which runs first.

The actual protection for useKeyboardShortcuts comes from the document.querySelector('[role="dialog"]') check added in useKeyboardShortcuts.ts. And the protection for SettingsPage.tsx comes from its new !e.defaultPrevented guard (which does work, because event.preventDefault() is called here before the bubble phase).

stopImmediatePropagation() as used here is misleading and unnecessary for the stated purpose. If left in, the comment should be corrected to avoid future confusion. If removed, the fix still works via the other two mechanisms.

Comment on lines +568 to +569
const nestedDialog = document.querySelector('[role="dialog"], [role="alertdialog"]');
if (nestedDialog) continue;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Document-wide dialog selector may suppress Escape in unrelated views

document.querySelector('[role="dialog"], [role="alertdialog"]') searches the entire document, not just the settings context. This means that any role="dialog" element present anywhere in the UI — including during an AnimatePresence exit animation (where the element remains in the DOM while fading out) — will silently swallow the Escape key for ALL priority: 'modal' shortcuts.

Concretely, if the user has a non-settings closable view open (e.g. browser preview, diff viewer, editor, kanban) and some unrelated role="dialog" element happens to be in the DOM, pressing Escape will not trigger onCloseModal through this path. The user loses keyboard control.

A safer approach would be to scope the check: for example, only skip if the dialog is a descendant of the settings container, or use a dedicated state flag rather than querying the DOM:

// Instead of a document-wide DOM query, thread an explicit flag:
if (shortcut.priority === 'modal') {
  if (handlers.isNestedModalOpen) continue;
  // ...
}

This makes the intent explicit, avoids timing issues with animations, and prevents interference with unrelated views.

@janburzinski
Copy link
Contributor Author

@greptile do your thing again hehe

@arnestrickmann arnestrickmann merged commit e02e3ba into generalaction:main Mar 17, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants