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) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 19:46:30 +01:00
parent 681d15fdc3
commit bea6cf2977
4 changed files with 54 additions and 2 deletions

View file

@ -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<Self>` 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 B2B5: **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:**
- **B2B5 (blank/dead detached windows) = the capability hypothesis, confirmed.** `src-tauri/capabilities/default.json` had `"windows": ["main"]`; detached labels are `pane-window-<micros>` (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 B2B5 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

View file

@ -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",

View file

@ -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};

View file

@ -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<Awaited<ReturnType<typeof takePendingWindowInit>>> | 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;