Phase 2: drag-/right-click-a-pane-to-new-window

Right-click any pane's title bar → "Move to new window" pops it into a
fresh tiletopia window with its PTY intact. Same Tauri process; the
PtyManager is shared, so the existing PaneId stays valid and Tauri 2's
process-wide event routing keeps pane://{id}/data flowing into the new
window's XtermPane.

Mechanism (Rust-side, plan-agent's main correction over my draft):
- pty.rs: PtyManager.transferring is a per-pane refcount; kill_pane
  becomes a no-op while it's >0. Source window's React unmount calls
  kill_pane → silently dropped while in flight; target window's
  claim_pane decrements after it has subscribed.
- window_state.rs: per-window workspaces snapshot map +
  debounced-by-tokio aggregate save. Each window pushes its tabs via
  push_window_workspaces; backend writes the merged
  { version: 2, workspaces: [...] } envelope. Non-main windows have
  their entries dropped on CloseRequested so closing a detached window
  discards its tabs (Chrome-style).
- commands: mark_pane_transferring, claim_pane, get_pane_ring (base64
  scrollback ring snapshot), create_pane_window, take_pending_window_init,
  push_window_workspaces.

Frontend:
- XtermPane gets `existingPaneId?: PaneId`: skip spawn, replay ring
  snapshot via term.write before attaching the live data listener,
  resize PTY to this window's grid, claim_pane. Scrollback replay was
  the plan agent's other ship-in-v1 call — without it a transferred
  Claude session looks blank until next prompt repaint.
- LeafPane: onContextMenu opens a fixed-positioned "Move to new
  window" popover. Esc / outside-click dismiss.
- orchestration adds moveToNewWindow + getInitialPaneIdFor; App owns a
  one-shot transferredPaneIdsRef cleared in registerPaneId.
- App mount branches on getCurrentWebviewWindow().label: main loads
  workspace.json as before; non-main calls take_pending_window_init
  and builds a singleton workspace around the adopted leaf.
- MCP mirror + onMcpRequest only run in main (paneIdByLeafRef is per-
  window; Claude sees the main window's current tab as the single
  workspace surface).

pnpm check (tsc -b) clean. 79/79 vitest pass. Rust side authored in
WSL; cargo build needs verification on Windows host before this is
runnable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 18:57:31 +01:00
parent 1a035ad0a6
commit 8ad51787fc
12 changed files with 797 additions and 48 deletions

View file

@ -53,6 +53,53 @@ export const resizePane = (id: PaneId, cols: number, rows: number): Promise<void
export const killPane = (id: PaneId): Promise<void> => invoke("kill_pane", { id });
/** Increment the "do not kill" transfer refcount for a pane. Source window
* calls this BEFORE removing the leaf from its tree so the unmount-driven
* kill_pane on the source becomes a no-op until the target window's
* XtermPane has claimed it. */
export const markPaneTransferring = (id: PaneId): Promise<void> =>
invoke("mark_pane_transferring", { id });
/** Decrement the transfer refcount. Target window's XtermPane calls this
* after subscribing to pane://{id}/data and replaying the ring snapshot. */
export const claimPane = (id: PaneId): Promise<void> =>
invoke("claim_pane", { id });
/** Snapshot of the per-pane scrollback ring as base64. Target window's
* XtermPane writes it into xterm.js before attaching the live data
* listener so a transferred pane doesn't open blank. */
export const getPaneRing = (id: PaneId): Promise<string> =>
invoke("get_pane_ring", { id });
// ---- multi-window pane transfer -------------------------------------------
export interface PendingInit {
leafJson: string;
paneId: PaneId;
workspaceName: string;
}
/** Open a new window and stash the pending-init payload keyed by the new
* window's label. Returns the new label. */
export const createPaneWindow = (payload: PendingInit): Promise<string> =>
invoke("create_pane_window", { payload });
/** Read and remove the pending-init for the current window. Null when there
* is no pending payload (main window startup, or this call already
* consumed it). */
export const takePendingWindowInit = (
label: string,
): Promise<PendingInit | null> =>
invoke("take_pending_window_init", { label });
/** Push this window's workspaces snapshot to the backend aggregator. The
* backend debounces and writes the merged envelope to workspace.json. */
export const pushWindowWorkspaces = (
label: string,
workspacesJson: string,
): Promise<void> =>
invoke("push_window_workspaces", { label, workspacesJson });
export const onPaneData = (
id: PaneId,
cb: (b64: string) => void,