From bea6cf2977654bb2e68c1e2438ad3bbc19c9a1f0 Mon Sep 17 00:00:00 2001 From: megaproxy Date: Thu, 28 May 2026 19:46:30 +0100 Subject: [PATCH] Fix detached-window IPC scoping and pane-transfer session loss - capabilities/default.json: extend window scope to "pane-window-*" so detached windows can invoke/listen (fixes blank panes B2-B5). - App.tsx: memoize the destructive take_pending_window_init read at module scope so React StrictMode's double mount-effect doesn't consume the transfer payload twice and lose the adopted PTY session. - lib.rs: add `use tauri::Manager;` for Window::app_handle() in on_window_event. Co-Authored-By: Claude Opus 4.8 (1M context) --- memory.md | 31 +++++++++++++++++++++++++++++ src-tauri/capabilities/default.json | 2 +- src-tauri/src/lib.rs | 5 +++++ src/App.tsx | 18 ++++++++++++++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/memory.md b/memory.md index 6b6561e..5761c25 100644 --- a/memory.md +++ b/memory.md @@ -87,6 +87,37 @@ Two big features the user asked for in one session. Three commits on `main`: `1a **Phase 2 verification needed** (user, on Windows host): 1. `cd D:\dev\tiletopia\src-tauri && cargo check` — the Rust changes have to compile. **Note: `Cargo.toml` lives in `src-tauri/`, NOT the project root** (Tauri layout). I got this wrong in the original verification steps; user had to point it out. Added a preflight-checks rule to global `~/claude/CLAUDE.md`. Watch in the check output for: tauri 2 `WebviewWindowBuilder::new` signature, `on_window_event` handler closure types, my `Arc` method receiver style on WindowsState. + +**Uncommitted local fix (as of 2026-05-28 wrap-up):** + +`src-tauri/src/lib.rs` has an added `use tauri::Manager;` import — needed because `Window::app_handle()` is a trait method (Manager trait) used in the new `on_window_event` handler. Same pattern as the `Emitter` trait stumble in v0.3.0. Cargo check went clean after this. **Not committed yet** — user wanted to smoke-test the feature first, then found the bug list below. Commit this fix at the same time as the bug-fix commit. + +**Detached-window bug list (deferred — user will resume):** + +Smoke test on Windows revealed bugs specific to detached (non-main) windows. Main window is unaffected. + +- **B1** — Drag-out has no ghost image during drag (cosmetic, user OK with deferring). +- **B2** — Detached window: transferred pane is blank, "idle" within 5s. No input, no output. +- **B3** — Detached window: shell-picker swap (Ubuntu → PowerShell → Ubuntu) doesn't spawn a working terminal. Fresh `spawn_pane` call from the detached window — toolbar updates but no PTY output. +- **B4** — Detached window: new tab (Ctrl+T or + button) creates the tab but no terminal. Same blank/idle symptom. +- **B5** — Right-click "Move to new window" produces the same broken detached window as drag-out. Confirms the bug is detached-window-scoped, not gesture-scoped. +- **B6** (control) — Main window: new tab, new pane, normal ops all work. + +**Strongest single hypothesis** for B2–B5: **Tauri 2's capability system gates `invoke` and `listen` per window-label.** Default capability config in `src-tauri/capabilities/default.json` (or similar) usually scopes to `"windows": ["main"]`. Newly-built `pane-window-*` labels match nothing → all IPC and events silently fail. One config fix (add wildcard window pattern, or programmatically attach a capability to each new window before `.build()`) would explain ALL of B2-B5 in one go. + +**Where to look first when resuming:** +1. `src-tauri/capabilities/*.json` — read the existing capability config to confirm scoping. +2. Try `"windows": ["main", "pane-window-*"]` (Tauri 2 supports glob patterns in capability window targets). +3. If that doesn't work: `AppHandle::add_capability(...)` on the new window before `.build()` in `commands.rs::create_pane_window`. +4. Verify by re-testing B4 first (simplest: fresh new tab in a detached window — needs only `invoke("spawn_pane")` and `listen("pane://...")` to work). + +**RESOLVED 2026-05-28 (resume session) — two root causes, both fixed:** + +- **B2–B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-` (commands.rs:122) → matched nothing → every `invoke`/`listen` silently denied. Fix: `"windows": ["main", "pane-window-*"]`. Tauri 2 glob pattern works; one line cleared all four. (App-defined commands aren't individually permission-gated — they're available to any window the capability is *applied* to, i.e. listed in `windows`.) +- **Session-loss-on-adopt (surfaced after B2–B5 cleared) = destructive read × StrictMode.** Once IPC worked, drag-out still spawned a FRESH pty (new id, tab named "Default", status `alive` not `adopted`) instead of adopting. Cause: `take_pending_window_init` is a **destructive** backend read (`by_label.remove`); React StrictMode runs the mount effect twice in dev — pass 1 consumed the payload then bailed on the `cancelled` flag, pass 2 got `null` → fell back to `singletonEnvelope` (fresh "Default" + fresh spawn). The `cancelled`-flag pattern guards against *using* stale async results but cannot un-consume a destructive backend call. Fix: module-level memoized `consumePendingWindowInit()` in App.tsx so the take fires **exactly once per window** and both StrictMode passes share the payload. Dev-only symptom (prod StrictMode doesn't double-invoke effects) but fixed for robustness. **Lesson: any destructive/once-only backend read called from a mount effect must be memoized at module scope, not just guarded by `cancelled`.** +- **Verified:** user confirmed adopt works (scrollback intact, same pane id, live input). `tsc -b` clean. B1 (drag ghost image) still deferred — cosmetic. +- Committed together with the carried-over `use tauri::Manager;` lib.rs import. + 2. `pnpm tauri dev` — smoke test: - Existing workspace loads as one tab named "Default" ✓ migrate - Ctrl+T spawns new tab with default-shell pane diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 0b5585b..144512e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capability set for wsl-mux spike", - "windows": ["main"], + "windows": ["main", "pane-window-*"], "permissions": [ "core:default", "core:event:default", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 51e3053..3a88bac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,11 @@ mod window_state; use std::sync::Arc; +// `Manager` trait must be in scope to call `.app_handle()` on the `&Window` +// passed to the `on_window_event` closure below. Same pattern as the +// `Emitter` trait needed for `.emit()` (see 2026-05-26 PR-1 session log). +use tauri::Manager; + use crate::mcp::{McpServerHandle, McpState, PendingActions}; use crate::pty::PtyManager; use crate::window_state::{PendingInits, WindowsState, MAIN_WINDOW_LABEL}; diff --git a/src/App.tsx b/src/App.tsx index 69a3113..484c02a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,22 @@ const MAIN_WINDOW_LABEL = "main"; * this window's state to the cross-window aggregator. */ const CURRENT_WINDOW_LABEL = getCurrentWebviewWindow().label; const IS_MAIN_WINDOW = CURRENT_WINDOW_LABEL === MAIN_WINDOW_LABEL; + +/** `take_pending_window_init` is a DESTRUCTIVE backend read (it removes the + * entry). React StrictMode runs the mount effect twice in dev, so a plain + * call would consume the payload on the first (cancelled) pass and hand the + * second pass `null` — booting a fresh "Default" workspace and spawning a new + * PTY instead of adopting the transferred one (session lost). Memoize the + * promise at module scope so the backend take happens exactly once per window + * and every effect pass awaits the same result. */ +let pendingInitOnce: Promise>> | null = + null; +const consumePendingWindowInit = () => { + if (!pendingInitOnce) { + pendingInitOnce = takePendingWindowInit(CURRENT_WINDOW_LABEL); + } + return pendingInitOnce; +}; import { type TreeNode, type NodeId, @@ -263,7 +279,7 @@ export default function App() { if (!IS_MAIN_WINDOW) { try { - const pending = await takePendingWindowInit(CURRENT_WINDOW_LABEL); + const pending = await consumePendingWindowInit(); if (pending) { try { const adoptedLeaf = JSON.parse(pending.leafJson) as LeafNode;