Skip to content

Commit ca3c718

Browse files
jonathanKingstonclaudeJonathan Kingston
authored
fix(ui): stop Cmd+W deleting a thread out from under a dialog (#2474) (#2551)
Closes #2474 (P2, `area:ui`). ## The bug Cmd/Ctrl+W deletes the active thread. Nothing checked whether a dialog was on screen, so **closing Settings with the keystroke that closes things everywhere else destroyed a conversation instead.** There is no undo. ```ts if (meta && e.key === 'w') { e.preventDefault() void confirmDeleteThread() // ← no idea Settings is open } ``` ## The fix The guard reads the DOM — *is any `<dialog>` open* — rather than asking a list of `isXOpen()` predicates. That shape is deliberate. The renderer has **seventeen** dialogs, and the one hand-maintained list of them sits three lines above this in the Cmd/Ctrl+F handler, naming **four**: settings, the command palette, file search, keyboard shortcuts. That list is exactly the failure the issue anticipates when it says *"or any other dialog for that matter"*, so the check is built so a new dialog is covered the moment it exists, with nobody having to remember to register it. **A behaviour change worth noticing:** the find bar now uses the same check, so it also defers to the thirteen dialogs the list missed — an approval, an SSH passphrase, a confirm. Each is a question it should not open underneath, but it is a change beyond the reported bug, so push back if you'd rather I left the four-item list alone. `show()` counts as well as `showModal()`: the approval prompt appears **inline over the chat** rather than modally, and it is still something the user is answering. ### `preventDefault()` stays outside the guard It is what keeps the keystroke from also reaching macOS's File ▸ Close accelerator (`app-menu-file-items.ts` registers `{ role: 'close' }` there). Moving it inside would hand Cmd+W back to the menu exactly when a dialog is up. So the keystroke is still swallowed; only the delete is skipped. Doing nothing is the right answer rather than closing the dialog for them — Esc already does that, in the handler immediately below, and every dialog honours it. ## Left alone deliberately The shortcut is matched inline as `meta && e.key === 'w'` rather than through a `matchXShortcut` helper like its neighbours in `keyboard-shortcuts.ts`. One consequence: with Caps Lock on, `e.key` is `'W'` and the shortcut silently does nothing. Routing it through a matcher would be tidier and testable in the same place as its siblings — but it would also **widen** which keystrokes delete a thread, and that is the wrong direction to move a destructive shortcut without being asked. Happy to do it if you want it. ## Testing `main.ts` binds its shortcuts inside a boot function with no seam to call, so the wiring is pinned at the source level — the file's existing test does the same for the layout boot — while the guard itself is unit-tested against real `<dialog>` elements in happy-dom. The wiring assertions test the **property**, not the text: *every* `confirmDeleteThread()` call site is guarded, so a second unguarded one added later fails the suite rather than slipping past an assertion that only looked at the first. | Check | Result | |---|---| | `node scripts/run-tests.mts` (whole repo) | **8641 tests, 8631 pass, 10 skipped, 0 fail** | | `tsc --noEmit` (node + web) | clean | | `pnpm run lint` / `oxfmt --check .` | clean | | `check:dead-code` (840 modules) / `check:oracle` (243 specs, 15 invariants) | clean | | `check:e2e-syntax` (269 files) / `demo:site:check` | clean | | all three wiring assertions with the guard removed | fail, as designed | 9 new tests: 5 on `isAnyDialogOpen` (closed dialog, modal, non-modal, several at once, and one inserted as raw HTML that nothing registered), 3 on the Cmd+W / Cmd+F wiring, and the existing suite unchanged. Two local-environment notes, neither affecting CI: reaching a full run needs an uncommitted shim over node-pty's native loader (it cannot be rebuilt in this sandbox — the agent proxy denies `iojs.org` and `www.electronjs.org`), reverted before committing; and this container's `node_modules` predates #2383, so `patches/@anthropic-ai__sandbox-runtime@0.0.74.patch` was not applied and `git-commit-signing.test.ts` failed until I applied its one-line hunk by hand. That failure reproduces identically on `52c839a` with my changes stashed, so it is a stale install here, not a base-branch break. Refs #2474 --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Hpk1gEma9LhMUuvr2TwUj6 --- _Generated by [Claude Code](https://claude.ai/code/session_01Hpk1gEma9LhMUuvr2TwUj6)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Jonathan Kingston <KingstonMailBox@gmail.com>
1 parent ad809fc commit ca3c718

6 files changed

Lines changed: 219 additions & 10 deletions

File tree

src/renderer/main.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,48 @@ describe('main layout boot', () => {
1414
)
1515
})
1616
})
17+
18+
// #2474: Cmd/Ctrl+W deletes the active thread, and with Settings — or any other
19+
// dialog — on screen the keystroke a user meant as "close this" destroyed a
20+
// conversation instead. main.ts binds its shortcuts inside a boot function with
21+
// no seam to call, so the wiring is pinned at the source level (as the layout
22+
// test above does); the gate itself is unit-tested in
23+
// `views/dialog-shell.test.ts`.
24+
describe('destructive shortcuts defer to an open dialog', () => {
25+
const src = readFileSync(join(process.cwd(), 'src/renderer/main.ts'), 'utf8')
26+
27+
it('never calls the thread delete without asking whether a dialog is open', () => {
28+
// Every call, not just the one: a second unguarded call site would
29+
// reintroduce the bug somewhere the first assertion never looks.
30+
const calls = [...src.matchAll(/^.*confirmDeleteThread\(\).*$/gm)]
31+
.map((m) => m[0])
32+
.filter((line) => !line.includes('function confirmDeleteThread'))
33+
assert.ok(calls.length > 0, 'expected main.ts to invoke confirmDeleteThread')
34+
const unguarded = calls.filter((line) => !line.includes('!isAnyDialogOpen()'))
35+
assert.deepEqual(
36+
unguarded,
37+
[],
38+
`these delete the active thread without checking for an open dialog:\n${unguarded.join('\n')}`,
39+
)
40+
})
41+
42+
it('still swallows the keystroke whether or not the delete runs', () => {
43+
// preventDefault is what keeps Cmd+W from also reaching the File ▸ Close
44+
// accelerator. Moving it inside the guard would hand the keystroke back to
45+
// the menu exactly when a dialog is up.
46+
const block = /if \(meta && e\.key === 'w'\) \{([\s\S]*?)\n {4}\}/.exec(src)?.[1]
47+
assert.ok(block, 'could not find the Cmd/Ctrl+W handler')
48+
const preventIndex = block.indexOf('e.preventDefault()')
49+
const guardIndex = block.indexOf('isAnyDialogOpen()')
50+
assert.ok(preventIndex >= 0, 'the Cmd/Ctrl+W handler must preventDefault')
51+
assert.ok(guardIndex >= 0, 'the Cmd/Ctrl+W handler must consult isAnyDialogOpen')
52+
assert.ok(preventIndex < guardIndex, 'preventDefault must run before the dialog guard')
53+
})
54+
55+
it('does not open the find bar underneath a dialog either', () => {
56+
// The same gate, on the shortcut that used to name four dialogs of seventeen.
57+
const block = /if \(matchFindInChatShortcut\(e\)\) \{([\s\S]*?)\n {4}\}/.exec(src)?.[1]
58+
assert.ok(block, 'could not find the find-in-chat handler')
59+
assert.match(block, /if \(isAnyDialogOpen\(\)\) return/)
60+
})
61+
})

src/renderer/main.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { mountTerminalRailResizers } from './views/terminal-rail-resizer.ts'
3434
import { mountRoadmapPane } from './views/roadmap-pane.ts'
3535
import { mountBrowserPane } from './views/browser-pane.ts'
3636
import { mountVncPane } from './views/vnc-pane.ts'
37+
import { isAnyDialogOpen } from './views/dialog-shell.ts'
3738
import {
3839
mountSettingsDialog,
3940
openSettingsDialog,
@@ -653,15 +654,12 @@ function registerKeyboardShortcuts(): void {
653654
openCommandPalette()
654655
}
655656
// Cmd/Ctrl+F opens the in-conversation find bar (find-in-page for the chat).
656-
// Skipped while a modal dialog owns the screen so it can't open behind it.
657+
// Skipped while a dialog owns the screen so it can't open behind it. This
658+
// named four dialogs of the seventeen the renderer has; asking the DOM
659+
// covers the rest — an approval, an SSH passphrase, a confirm — each of
660+
// which is a question the find bar should not open underneath.
657661
if (matchFindInChatShortcut(e)) {
658-
if (
659-
isFileSearchDialogOpen() ||
660-
isCommandPaletteOpen() ||
661-
isSettingsDialogOpen() ||
662-
isKeyboardShortcutsDialogOpen()
663-
)
664-
return
662+
if (isAnyDialogOpen()) return
665663
e.preventDefault()
666664
openConversationSearch()
667665
}
@@ -684,9 +682,16 @@ function registerKeyboardShortcuts(): void {
684682
if (uiScaleAction === 'reset') void resetUiScale(store, api)
685683
else void bumpUiScale(store, api, uiScaleAction === 'in' ? 1 : -1)
686684
}
685+
// Cmd/Ctrl+W deletes the active thread. `preventDefault` stays
686+
// unconditional — it is what keeps the keystroke from also reaching the
687+
// File ▸ Close accelerator — but with a dialog on screen the delete does
688+
// not run: a user closing Settings with Cmd+W meant "close this", not
689+
// "destroy this conversation" (#2474). Doing nothing is the right answer
690+
// rather than closing the dialog for them; Esc already does that, right
691+
// below, and every dialog handles it.
687692
if (meta && e.key === 'w') {
688693
e.preventDefault()
689-
void confirmDeleteThread()
694+
if (!isAnyDialogOpen()) void confirmDeleteThread()
690695
}
691696
if (e.key === 'Escape') {
692697
if (isCommandPaletteOpen()) {

src/renderer/views/dialog-shell.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import '../../../tests/setup-dom.ts'
22
import { describe, it, beforeEach } from 'node:test'
33
import assert from 'node:assert/strict'
4-
import { createOverlayDialog } from './dialog-shell.ts'
4+
import { createOverlayDialog, isAnyDialogOpen } from './dialog-shell.ts'
55

66
describe('createOverlayDialog', () => {
77
beforeEach(() => {
@@ -44,3 +44,53 @@ describe('createOverlayDialog', () => {
4444
assert.equal(closes, 1)
4545
})
4646
})
47+
48+
describe('isAnyDialogOpen', () => {
49+
beforeEach(() => {
50+
document.body.innerHTML = ''
51+
})
52+
53+
it('is false with no dialogs, and false when one exists but is closed', () => {
54+
assert.equal(isAnyDialogOpen(), false)
55+
createOverlayDialog({ id: 'closed-overlay' })
56+
assert.equal(isAnyDialogOpen(), false)
57+
})
58+
59+
it('is true while a modal dialog is open, and false again once it closes', () => {
60+
const shell = createOverlayDialog({ id: 'modal-overlay' })
61+
shell.open()
62+
assert.equal(isAnyDialogOpen(), true)
63+
shell.close()
64+
assert.equal(isAnyDialogOpen(), false)
65+
})
66+
67+
it('counts a non-modal dialog too', () => {
68+
// The approval prompt shows inline over the chat with `show()`, not
69+
// `showModal()`. It is still a question the user is answering, so a global
70+
// shortcut must not act on the transcript behind it.
71+
const dialog = document.createElement('dialog')
72+
document.body.append(dialog)
73+
dialog.show()
74+
assert.equal(isAnyDialogOpen(), true)
75+
dialog.close()
76+
assert.equal(isAnyDialogOpen(), false)
77+
})
78+
79+
it('stays true while any one of several dialogs is still open', () => {
80+
const first = createOverlayDialog({ id: 'first-overlay' })
81+
const second = createOverlayDialog({ id: 'second-overlay' })
82+
first.open()
83+
second.open()
84+
first.close()
85+
assert.equal(isAnyDialogOpen(), true, 'the second dialog is still on screen')
86+
second.close()
87+
assert.equal(isAnyDialogOpen(), false)
88+
})
89+
90+
it('does not need to know the dialog exists', () => {
91+
// The point of reading the DOM: a dialog built anywhere, by anything, is
92+
// covered without being registered (#2474).
93+
document.body.insertAdjacentHTML('beforeend', '<dialog open id="ad-hoc"></dialog>')
94+
assert.equal(isAnyDialogOpen(), true)
95+
})
96+
})

src/renderer/views/dialog-shell.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,25 @@ export function createOverlayDialog(opts: { id: string; className?: string }): O
3737
isOpen: (): boolean => dialog.open,
3838
}
3939
}
40+
41+
/**
42+
* Whether any `<dialog>` in the document is currently open.
43+
*
44+
* Asked by global keyboard shortcuts, which must defer to whatever the user is
45+
* answering rather than act on the screen behind it. Cmd/Ctrl+W is the case
46+
* that made this necessary: it deletes the active thread, and with Settings —
47+
* or any other dialog — on screen the keystroke a user meant as "close this"
48+
* destroyed a conversation instead (#2474).
49+
*
50+
* Read off the DOM rather than from a list of `isXOpen()` predicates. There are
51+
* seventeen dialogs in the renderer and the one hand-maintained list of them
52+
* named four, which is the failure this is shaped to avoid: a new dialog is
53+
* covered the moment it exists, without anyone remembering to add it here.
54+
*
55+
* The `open` attribute is set by both `show()` and `showModal()`, so a
56+
* non-modal prompt sitting over the chat counts too — it is still a question
57+
* the user is in the middle of.
58+
*/
59+
export function isAnyDialogOpen(): boolean {
60+
return document.querySelector('dialog[open]') !== null
61+
}

tests/e2e/dialog-shortcuts.e2e.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { $, $$, browser, expect } from '@wdio/globals'
2+
import { resetUserData, seedE2eViewport, writeSeedConfig } from './helpers/seed-config.ts'
3+
import { saveElementScreenshot } from './helpers/screenshot.ts'
4+
5+
const PROJECT_ID = 'e2e-dialog-shortcuts'
6+
7+
describe('dialog keyboard shortcuts', () => {
8+
before(async function () {
9+
this.timeout(90_000)
10+
resetUserData()
11+
const now = Date.now()
12+
writeSeedConfig({
13+
projects: [{ id: PROJECT_ID, path: process.cwd(), name: 'workspace' }],
14+
activeProjectId: PROJECT_ID,
15+
activeThreadId: 'thread-b',
16+
[`threads:${PROJECT_ID}`]: [
17+
{
18+
id: 'thread-a',
19+
title: 'Keep me',
20+
status: 'idle',
21+
messages: [
22+
{
23+
id: 'msg-a',
24+
role: 'user',
25+
content: 'Keep this conversation.',
26+
toolCalls: [],
27+
createdAt: now,
28+
},
29+
],
30+
usage: { inputTokens: 0, outputTokens: 0 },
31+
createdAt: now,
32+
updatedAt: now,
33+
},
34+
{
35+
id: 'thread-b',
36+
title: 'Active conversation',
37+
status: 'idle',
38+
messages: [
39+
{
40+
id: 'msg-b',
41+
role: 'user',
42+
content: 'Keep this active conversation too.',
43+
toolCalls: [],
44+
createdAt: now + 1,
45+
},
46+
],
47+
usage: { inputTokens: 0, outputTokens: 0 },
48+
createdAt: now + 1,
49+
updatedAt: now + 1,
50+
},
51+
],
52+
})
53+
seedE2eViewport()
54+
await browser.reloadSession()
55+
await $('.prompt-input').waitForExist({ timeout: 60_000 })
56+
})
57+
58+
after(() => {
59+
resetUserData()
60+
})
61+
62+
it('does not delete the active thread while Settings is open', async () => {
63+
await browser.waitUntil(async () => (await $$('.chats-list .chat-row')).length === 2, {
64+
timeout: 15_000,
65+
timeoutMsg: 'expected two seeded chat rows',
66+
})
67+
await expect($('.chat-row.selected .chat-title')).toHaveText('Active conversation')
68+
69+
await $('[aria-label="Settings"]').click()
70+
const settings = $('#settings-dialog')
71+
await settings.waitForDisplayed({ timeout: 10_000 })
72+
73+
// Dispatch in-page: Electron may consume a physical Cmd/Ctrl+W as the native
74+
// File ▸ Close accelerator before the renderer shortcut handler sees it.
75+
await browser.execute(() => {
76+
document.dispatchEvent(
77+
new KeyboardEvent('keydown', { key: 'w', metaKey: true, ctrlKey: true, bubbles: true }),
78+
)
79+
})
80+
81+
await expect(settings).toBeDisplayed()
82+
await expect($('#confirm-dialog')).not.toBeDisplayed()
83+
await expect($('.chat-row.selected .chat-title')).toHaveText('Active conversation')
84+
expect(await $$('.chats-list .chat-row')).toHaveLength(2)
85+
await saveElementScreenshot('#settings-dialog', 'cmd-w-settings-dialog-safe.png')
86+
})
87+
})
242 KB
Loading

0 commit comments

Comments
 (0)