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

@ -185,6 +185,38 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
setStatusOk(ok);
}, []);
// ---- right-click context menu ------------------------------------------
// Single entry in v1: "Move to new window" (pops the pane out into a
// fresh top-level tiletopia window without losing the PTY).
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
const openContextMenu = useCallback(
(e: MouseEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setMenuPos({ x: e.clientX, y: e.clientY });
},
[],
);
const closeContextMenu = useCallback(() => setMenuPos(null), []);
useEffect(() => {
if (!menuPos) return;
const onDocClick = () => setMenuPos(null);
const onEsc = (e: globalThis.KeyboardEvent) => {
if (e.key === "Escape") setMenuPos(null);
};
// Defer attaching the click listener so the click that opened the menu
// doesn't immediately close it.
const t = window.setTimeout(() => {
window.addEventListener("click", onDocClick);
window.addEventListener("keydown", onEsc, true);
}, 0);
return () => {
clearTimeout(t);
window.removeEventListener("click", onDocClick);
window.removeEventListener("keydown", onEsc, true);
};
}, [menuPos]);
// ---- header-drag swap ---------------------------------------------------
// Drag the toolbar onto another pane's toolbar/body to swap their tree
// positions. Uses a movement threshold so accidental tiny moves while
@ -306,6 +338,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
onPointerMove={onToolbarPointerMove}
onPointerUp={onToolbarPointerUp}
onPointerCancel={onToolbarPointerCancel}
onContextMenu={openContextMenu}
>
{editingLabel ? (
<input
@ -482,6 +515,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
{spec ? (
<XtermPane
spec={spec}
existingPaneId={orch.getInitialPaneIdFor(leaf.id)}
onStatus={onStatus}
onSpawn={onPaneSpawned}
onInput={onTerminalInput}
@ -500,6 +534,31 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</div>
)}
</div>
{menuPos && (
<div
className="pane-context-menu"
style={{
position: "fixed",
top: menuPos.y,
left: menuPos.x,
}}
role="menu"
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<button
type="button"
className="pane-context-menu-item"
role="menuitem"
onClick={() => {
closeContextMenu();
orch.moveToNewWindow(leaf.id);
}}
>
Move to new window
</button>
</div>
)}
</div>
);
}