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

@ -269,3 +269,36 @@
min-height: 0;
position: relative;
}
/* Right-click context menu on the pane toolbar. Fixed-positioned popover
floating in the viewport; the LeafPane parent renders it inside its
own DOM tree so clicks within the menu still get the
stop-propagation chain. */
.pane-context-menu {
z-index: 200;
min-width: 180px;
background: #1a1a1a;
color: #e6e6e6;
border: 1px solid #2a5a8c;
border-radius: 4px;
padding: 4px;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 12px;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6);
}
.pane-context-menu-item {
display: block;
width: 100%;
text-align: left;
background: transparent;
color: #e6e6e6;
border: none;
border-radius: 2px;
padding: 6px 10px;
font: inherit;
cursor: pointer;
}
.pane-context-menu-item:hover {
background: #2a5a8c;
color: #fff;
}

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

View file

@ -56,6 +56,17 @@ export interface Orchestration {
// own quiet-state crosses the threshold; App aggregates so the titlebar
// can show an "N idle" count without spamming toast notifications.
reportLeafIdle: (leafId: NodeId, idle: boolean) => void;
// Multi-window pane transfer ---------------------------------------------
/** Pop a pane out of the current workspace into a fresh top-level window.
* The PTY stays alive across the move (the new window's XtermPane
* adopts the existing PaneId; scrollback ring is replayed). */
moveToNewWindow: (leafId: NodeId) => void;
/** Returns a PaneId only for leaves that just arrived via a window
* transfer (so LeafPane can pass `existingPaneId` to XtermPane to skip
* the spawn). One-shot App clears the entry once the pane has
* registered. */
getInitialPaneIdFor: (leafId: NodeId) => PaneId | undefined;
}
const OrchestrationContext = createContext<Orchestration | null>(null);

View file

@ -45,6 +45,16 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
{ keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" },
],
},
{
title: "Multi-window",
items: [
{
keys: "Right-click pane toolbar → Move to new window",
description:
"Pop the active pane into a fresh tiletopia window (PTY survives the move; scrollback ring replays)",
},
],
},
{
title: "Navigation",
items: [