Skip to content

Commit 3c1b0dc

Browse files
committed
MCP: ack dialog-close on mcp-dialog-closed, not pane gen
Real-MCP testing of the ack contract caught two false negatives the unit + Playwright coverage missed: - `dialog close <confirmation>` (mkdir / mkfile / transfer / delete): the close path waited on `GenerationAdvanced`, but cancelling a confirmation dialog doesn't touch pane state, so the bump never arrives. Every cancel returned `ActionNotAcknowledged` even though the dialog actually closed. - `dialog close settings`: the Rust side emitted `mcp-settings-close` to the settings webview but `routes/settings/+page.svelte` had no listener. Tool returned `ActionNotAcknowledged` AND the window stayed open — straight-up broken, not a false negative. Add a new `AckSignal::SoftDialogDisappeared(id)` that waits for the `SoftDialogTracker` membership to drop (the `notifyDialogClosed` IPC from `ModalDialog.svelte` is the trustworthy signal). Wire the soft-dialog-close path in `dialogs.rs` to it. Add the missing `mcp-settings-close` listener with the same two-rAF defer as the production Escape handler, so the close behaves consistently regardless of trigger. Verified end-to-end via the real MCP transport: settings open/close in ~150 ms; mkdir/mkfile/transfer/delete confirmation cancels in ~130–170 ms; all 176 MCP unit tests + clippy + rustfmt + oxfmt + svelte-check green.
1 parent df11cae commit 3c1b0dc

6 files changed

Lines changed: 75 additions & 10 deletions

File tree

apps/desktop/src-tauri/src/mcp/CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,14 @@ The mechanism lives in `executor/ack.rs`. Each tool:
6767
| ------------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
6868
| `GenerationAdvanced` | `PaneStateStore.generation` is strictly greater than the captured value | Anything that mutates pane state (`select`, `set_view_mode`, `sort`, `toggle_hidden`, `tab`, `nav_*`, auto-confirmed `copy`/`move`/`delete`, `dialog confirm`). NOT `refresh` — see TODO note below. |
6969
| `SoftDialogAppeared(id)` | A soft dialog with that ID is in `SoftDialogTracker` | Confirmation dialogs from `copy`/`move`/`delete` (autoConfirm: false), `mkdir`, `mkfile`; `dialog open about` |
70+
| `SoftDialogDisappeared(id)` | A soft dialog with that ID is no longer in `SoftDialogTracker` | `dialog close <confirmation>` — the FE `ModalDialog` fires `notifyDialogClosed` on unmount, so the tracker reflects the close even when cancel doesn't bump pane generation |
7071
| `WindowAppeared(label)` | A `webview_windows()` entry matches the label (exact, or `viewer-*`) | `dialog open settings|file-viewer`, `dialog focus` |
7172
| `WindowDisappeared` | The matching `webview_windows()` entry is gone | `dialog close settings|file-viewer|about` |
7273
| `Any([...])` | Logical OR — any inner signal fires | `open_under_cursor` (directory case bumps generation, file case opens a viewer window) |
7374

74-
The polling cadence is 250 ms for state-driven signals (matching the existing `await` tool) and 100 ms for window/dialog appearance signals (windows show up faster than full pane state pushes).
75+
The polling cadence is 250 ms for state-driven signals (matching the existing `await` tool) and 100 ms for window/soft-dialog signals (both react faster than a full pane state push).
76+
77+
**Gotcha/Why**: `dialog close <settings>` requires the settings window to listen for the `mcp-settings-close` event and close itself (`apps/desktop/src/routes/settings/+page.svelte`). Without that listener the backend keeps polling for `WindowDisappeared("settings")` and times out at 1500 ms while the window stays put. Same shape applies if you add new window-based dialogs: the FE side has to opt in.
7578

7679
The 1500 ms budget is a backend-side latency budget, not a client-facing knob: MCP clients shouldn't have to tune ack timeouts. Bump it per-call via the `Duration` argument to `wait_for_ack` if a specific operation has a known higher latency floor; don't expose it as a tool parameter.
7780

apps/desktop/src-tauri/src/mcp/executor/ack.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ pub enum AckSignal {
6161
GenerationAdvanced { from: u64 },
6262
/// A soft dialog with this ID appeared in `SoftDialogTracker`.
6363
SoftDialogAppeared(&'static str),
64+
/// A soft dialog with this ID is no longer present in `SoftDialogTracker`.
65+
/// Use this when an MCP tool dispatches a close to a soft (overlay) dialog:
66+
/// the FE's `ModalDialog` runs `notifyDialogClosed` on destroy, so the
67+
/// tracker reflects the close even when the surrounding pane state didn't
68+
/// change (e.g. cancelling a confirmation dialog doesn't bump generation).
69+
SoftDialogDisappeared(&'static str),
6470
/// A Tauri webview window whose label equals (or starts with, for viewers)
6571
/// the given pattern appeared.
6672
WindowAppeared(&'static str),
@@ -79,6 +85,7 @@ impl AckSignal {
7985
format!("pane state generation > {from}")
8086
}
8187
AckSignal::SoftDialogAppeared(id) => format!("soft dialog '{id}' opened"),
88+
AckSignal::SoftDialogDisappeared(id) => format!("soft dialog '{id}' closed"),
8289
AckSignal::WindowAppeared(label) => format!("window '{label}' opened"),
8390
AckSignal::WindowDisappeared(label) => format!("window '{label}' closed"),
8491
AckSignal::Any(signals) => {
@@ -140,6 +147,12 @@ fn check_signal<R: Runtime>(app: &AppHandle<R>, signal: &AckSignal) -> bool {
140147
.try_state::<SoftDialogTracker>()
141148
.map(|tracker| tracker.get_open_types().iter().any(|d| d == id))
142149
.unwrap_or(false),
150+
AckSignal::SoftDialogDisappeared(id) => app
151+
.try_state::<SoftDialogTracker>()
152+
.map(|tracker| !tracker.get_open_types().iter().any(|d| d == id))
153+
// If the tracker isn't registered (test contexts), treat the dialog as
154+
// gone — there's nothing to wait for.
155+
.unwrap_or(true),
143156
AckSignal::WindowAppeared(pattern) => window_matches(app, pattern),
144157
AckSignal::WindowDisappeared(pattern) => !window_matches(app, pattern),
145158
AckSignal::Any(signals) => signals.iter().any(|s| check_signal(app, s)),
@@ -159,10 +172,15 @@ fn window_matches<R: Runtime>(app: &AppHandle<R>, pattern: &str) -> bool {
159172
}
160173
}
161174

162-
/// Whether any leaf in the signal tree references windows. Drives poll cadence.
175+
/// Whether any leaf in the signal tree references windows or soft dialogs.
176+
/// Both are FE-side mutations that don't require a full pane-state push, so
177+
/// they should react with the tighter cadence.
163178
fn signal_uses_windows(signal: &AckSignal) -> bool {
164179
match signal {
165-
AckSignal::WindowAppeared(_) | AckSignal::WindowDisappeared(_) => true,
180+
AckSignal::WindowAppeared(_)
181+
| AckSignal::WindowDisappeared(_)
182+
| AckSignal::SoftDialogAppeared(_)
183+
| AckSignal::SoftDialogDisappeared(_) => true,
166184
AckSignal::Any(signals) => signals.iter().any(signal_uses_windows),
167185
_ => false,
168186
}
@@ -200,6 +218,9 @@ mod tests {
200218
.describe()
201219
.contains("delete-confirmation")
202220
);
221+
let closed = AckSignal::SoftDialogDisappeared("mkdir-confirmation").describe();
222+
assert!(closed.contains("mkdir-confirmation"));
223+
assert!(closed.contains("closed"));
203224
assert!(AckSignal::WindowAppeared("settings").describe().contains("settings"));
204225
assert!(AckSignal::WindowDisappeared("settings").describe().contains("settings"));
205226
let any = AckSignal::Any(vec![
@@ -215,6 +236,9 @@ mod tests {
215236
fn signal_uses_windows_picks_tighter_cadence() {
216237
assert!(!signal_uses_windows(&AckSignal::GenerationAdvanced { from: 0 }));
217238
assert!(signal_uses_windows(&AckSignal::WindowAppeared("settings")));
239+
assert!(signal_uses_windows(&AckSignal::SoftDialogDisappeared(
240+
"mkdir-confirmation"
241+
)));
218242
assert!(signal_uses_windows(&AckSignal::Any(vec![
219243
AckSignal::GenerationAdvanced { from: 0 },
220244
AckSignal::WindowAppeared("viewer"),

apps/desktop/src-tauri/src/mcp/executor/dialogs.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
//! - `open settings|file-viewer|about` → child window appears in `webview_windows()`.
55
//! - `open` confirmation dialogs → not allowed (use copy/move/delete/mkdir/mkfile instead).
66
//! - `close settings|file-viewer|about` → matching window disappears.
7-
//! - `close <confirmation>` → soft dialog is no longer in the tracker, ack via dialog gone.
7+
//! - `close <confirmation>` → soft dialog is no longer in `SoftDialogTracker`. Cancel
8+
//! doesn't reliably bump pane generation, so we wait for the tracker entry to vanish.
89
//! - `focus settings|file-viewer|about` → window is present (no-op fast path; if the
910
//! window isn't there, the wait_for_ack times out, which is the correct contract for
1011
//! focusing a non-existent dialog).
@@ -197,14 +198,13 @@ async fn execute_dialog_close<R: Runtime>(app: &AppHandle<R>, dialog_type: &str,
197198
}
198199
"transfer-confirmation" | "mkdir-confirmation" | "new-file-confirmation" | "delete-confirmation" => {
199200
app.emit("close-confirmation", ())?;
200-
// For soft confirmation dialogs we can't directly probe a "disappeared"
201-
// signal — we'd need a NotSoftDialog variant. Instead, ack on a generation
202-
// bump: when the dialog closes, the FE typically re-pushes pane state.
203-
// Pragmatic and matches the current FE behavior.
204-
let pre_gen = snapshot_generation(app);
201+
// Soft confirmation dialogs unmount their `ModalDialog`, which fires
202+
// `notifyDialogClosed` and updates the `SoftDialogTracker`. Wait for the
203+
// tracker to lose the dialog ID. Cancel doesn't reliably bump generation
204+
// (that's what we used to wait for, and it broke on every cancel).
205205
wait_for_ack(
206206
app,
207-
AckSignal::GenerationAdvanced { from: pre_gen },
207+
AckSignal::SoftDialogDisappeared(soft_dialog_id(dialog_type)),
208208
DEFAULT_ACK_TIMEOUT,
209209
)
210210
.await?;

apps/desktop/src-tauri/src/mcp/tests/ack_system_tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,26 @@ fn soft_dialog_tracker_membership_matches_ack_check() {
178178
assert!(!tracker.get_open_types().iter().any(|d| d == id));
179179
}
180180

181+
#[test]
182+
fn soft_dialog_disappeared_signal_flips_after_close() {
183+
// The ack contract for `dialog close <confirmation>` relies on the tracker
184+
// losing the dialog ID. Pins the semantic: after `close()` the tracker reports
185+
// the id as absent, which is what `AckSignal::SoftDialogDisappeared` checks.
186+
let tracker = SoftDialogTracker::new();
187+
let id = "mkdir-confirmation";
188+
189+
tracker.open(id.to_string());
190+
let after_open_absent = !tracker.get_open_types().iter().any(|d| d == id);
191+
assert!(!after_open_absent, "dialog must be present right after open");
192+
193+
tracker.close(id);
194+
let after_close_absent = !tracker.get_open_types().iter().any(|d| d == id);
195+
assert!(
196+
after_close_absent,
197+
"tracker must report the dialog as gone after close — this is what `SoftDialogDisappeared` polls"
198+
);
199+
}
200+
181201
#[test]
182202
fn soft_dialog_tracker_distinguishes_dialog_ids() {
183203
// Important for the ack contract: copy/move both open "transfer-confirmation",

apps/desktop/src/routes/settings/+page.svelte

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
let contentElement: HTMLElement | null = $state(null)
2323
let unlistenFocusSelf: UnlistenFn | undefined
2424
let unlistenNavigate: UnlistenFn | undefined
25+
let unlistenMcpClose: UnlistenFn | undefined
2526
2627
function safeParseSectionParam(raw: string): string[] | null {
2728
try {
@@ -217,6 +218,19 @@
217218
handleSectionSelect(event.payload.section)
218219
})
219220
221+
// MCP can request that this window close (used by `dialog close settings`).
222+
// Mirror the Escape-key handler: defer the close() by two rAFs so the in-flight
223+
// IPC ack can settle before webkit2gtk begins destroying the webview.
224+
unlistenMcpClose = await listen('mcp-settings-close', () => {
225+
log.debug('Received mcp-settings-close, closing window')
226+
const win = getCurrentWindow()
227+
requestAnimationFrame(() => {
228+
requestAnimationFrame(() => {
229+
void win.close()
230+
})
231+
})
232+
})
233+
220234
log.debug('Settings page ready')
221235
} catch (error) {
222236
log.error('Failed to initialize settings: {error}', { error })
@@ -231,6 +245,7 @@
231245
// Clean up event listeners
232246
unlistenFocusSelf?.()
233247
unlistenNavigate?.()
248+
unlistenMcpClose?.()
234249
cleanupAccentColor()
235250
cleanupTextSize()
236251
})

docs/tooling/mcp.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ What this means for automation:
5252
- For long-running operations, poll completion via the `await` tool just like before.
5353
- Timeouts surface as JSON-RPC errors with a clear message ("Action not acknowledged by backend within 1500 ms (waiting
5454
for: …)"). Treat them as real failures — don't retry blindly; check `cmdr://state` to triage.
55+
- One legit timeout source: navigating into a remote share (`open_under_cursor` on an SMB host, `nav_to_path` into
56+
`/Volumes/<share>` right after a mount) can take a few seconds end-to-end. The tool will time out at 1500 ms even
57+
though the navigation eventually succeeds. Use `await` with `path` or `path_contains` after dispatching to confirm.
5558

5659
Architecture details: `apps/desktop/src-tauri/src/mcp/CLAUDE.md` § "Action-tool ack contract".
5760

0 commit comments

Comments
 (0)