tiletopia/src/App.tsx
megaproxy 8ad51787fc 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>
2026-05-28 18:57:31 +01:00

2198 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
listDistros,
loadWorkspace,
saveWorkspace,
listSshHosts,
saveSshHosts,
setHostPassword,
deleteHostPassword,
mcpStart,
mcpStop,
mcpStatus as mcpStatusCmd,
mcpRegenerateToken,
mcpUpdateState,
onMcpRequest,
mcpActionReply,
mcpPolicyLoad,
mcpPolicySave,
writeToPane,
killPane,
markPaneTransferring,
claimPane,
createPaneWindow,
takePendingWindowInit,
pushWindowWorkspaces,
type PaneId,
type SpawnSpec,
type SshHost,
type McpStatus,
type McpMirror,
type McpMirroredLeaf,
type McpMirroredHost,
type McpActionRequest,
type McpAuditEntry,
} from "./ipc";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
const MAIN_WINDOW_LABEL = "main";
/** Current window label, captured once at module load — used to decide
* load path (load_workspace vs take_pending_window_init) and to push
* 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;
import {
type TreeNode,
type NodeId,
type Orientation,
type LeafNode,
type LeafShellSpec,
type Workspace,
newLeaf,
newId,
splitLeaf,
splitLeafWith,
closeLeaf,
findLeaf,
leafCount,
walkLeaves,
setLeafShell,
changeLabel,
toggleBroadcast as toggleBroadcastInTree,
toggleMcpAllow as toggleMcpAllowInTree,
setAllBroadcast,
adjustFontSize,
adjustAllFontSizes,
reshapeToPreset,
flattenLayout,
updateSplitRatio,
swapLeaves,
findNeighborInDirection,
promoteLeaf,
MIN_PANE_PX,
type Direction,
serialize,
serializeWorkspaces,
deserializeWorkspaces,
singletonEnvelope,
presetSingle,
presetTwoColumns,
presetThreeColumns,
presetTwoRows,
presetTwoByTwo,
} from "./lib/layout/tree";
import { OrchestrationProvider, type Orchestration } from "./lib/layout/orchestration";
import LeafPane from "./lib/layout/LeafPane";
import Gutter from "./lib/layout/Gutter";
import Notifications, { type Toast } from "./components/Notifications";
import Palette from "./components/Palette";
import HostManager from "./components/HostManager";
import Help from "./components/Help";
import McpPanel from "./components/McpPanel";
import McpConfirm, { type McpConfirmSpec } from "./components/McpConfirm";
import TabStrip from "./components/TabStrip";
import "./App.css";
import "./lib/layout/Gutter.css";
const LEGACY_STORAGE_KEY = "tiletopia.tree.v1";
/** Picker default for *new* panes. SSH never lives here — SSH connections
* are always explicit, never a default. */
type DefaultShell =
| { shellKind: "wsl"; distro?: string }
| { shellKind: "powershell" };
function isInteractiveDistro(name: string): boolean {
return !name.toLowerCase().startsWith("docker-desktop");
}
/** Map a {@link DefaultShell} onto the props newLeaf expects. */
function defaultShellAsLeafProps(d: DefaultShell): Partial<LeafNode> {
if (d.shellKind === "powershell") return { shellKind: "powershell" };
return { shellKind: "wsl", distro: d.distro };
}
/** Cap a string for display in modals / audit summaries. Single-line, max
* 60 visible chars, control characters escaped so secrets pasted into a
* write_pane call don't print as gibberish in the modal. */
function truncateForSummary(s: string, cap = 60): string {
const oneLine = s.replace(/\r?\n/g, "\\n").replace(/\t/g, "\\t");
return oneLine.length > cap ? oneLine.slice(0, cap) + "…" : oneLine;
}
/** Short human-readable form of a SpawnSpec, used in MCP confirm summaries. */
function describeSpec(spec: SpawnSpec): string {
if (spec.kind === "wsl") return `WSL${spec.distro ? ` (${spec.distro})` : ""}`;
if (spec.kind === "powershell") return "PowerShell";
return `SSH${spec.host ? ` to ${spec.host}` : ""}`;
}
export default function App() {
// ---- top-level state -----------------------------------------------------
// Workspaces (tabs). Each is one independent tile tree. The mount effect
// seeds an initial singleton workspace if the persisted envelope is empty.
const [workspaces, setWorkspaces] = useState<Workspace[]>(() => {
const t = newLeaf();
return [{ id: newId(), name: "Default", tree: t }];
});
const [currentWorkspaceId, setCurrentWorkspaceId] = useState<NodeId>(
() => "", // filled on mount alongside workspaces
);
/** Per-workspace remembered active leaf — each tab keeps its own focus
* cursor so switching back doesn't dump the user into a random pane. */
const [activeLeafByWorkspace, setActiveLeafByWorkspace] = useState<
Map<NodeId, NodeId | null>
>(() => new Map());
// ---- workspace-aware tree wrappers --------------------------------------
// These mirror the v0.3.0 single-tree API (`tree`, `setTree(updater)`,
// `activeLeafId`, `setActiveLeafId`) but operate on whichever workspace is
// current. Keeps the bulk of App.tsx unchanged across the tabs refactor.
//
// Refs first so the setter useCallbacks below capture stable references
// and don't need currentWorkspaceId in their dep arrays.
const currentWorkspaceIdRef = useRef(currentWorkspaceId);
const workspacesRef = useRef(workspaces);
const currentWorkspace = useMemo(
() => workspaces.find((w) => w.id === currentWorkspaceId) ?? null,
[workspaces, currentWorkspaceId],
);
const tree: TreeNode = currentWorkspace?.tree ?? workspaces[0]?.tree ?? newLeaf();
// Identity-stable across renders — both read the live currentWorkspaceId
// from a ref so every useCallback that captures these doesn't need to
// list them in its deps (mirroring the v0.3.0 stable-setter contract).
// The ref is updated in the workspacesRef / currentWorkspaceIdRef effects
// below; it's seeded synchronously by useState in the same render so
// first-render calls see the initial id.
const setTree = useCallback<React.Dispatch<React.SetStateAction<TreeNode>>>(
(updater) => {
setWorkspaces((ws) => {
if (ws.length === 0) return ws;
const targetId = currentWorkspaceIdRef.current || ws[0].id;
return ws.map((w) => {
if (w.id !== targetId) return w;
const next =
typeof updater === "function"
? (updater as (prev: TreeNode) => TreeNode)(w.tree)
: updater;
if (next === w.tree) return w;
return { ...w, tree: next };
});
});
},
[],
);
const activeLeafId: NodeId | null =
(currentWorkspaceId && activeLeafByWorkspace.get(currentWorkspaceId)) ?? null;
const setActiveLeafId = useCallback<
React.Dispatch<React.SetStateAction<NodeId | null>>
>(
(updater) => {
setActiveLeafByWorkspace((prev) => {
const wsId = currentWorkspaceIdRef.current;
if (!wsId) return prev;
const cur = prev.get(wsId) ?? null;
const next =
typeof updater === "function"
? (updater as (prev: NodeId | null) => NodeId | null)(cur)
: updater;
if (next === cur) return prev;
const m = new Map(prev);
m.set(wsId, next);
return m;
});
},
[],
);
const [distros, setDistros] = useState<string[]>([]);
const [defaultShell, setDefaultShell] = useState<DefaultShell>({
shellKind: "wsl",
});
const [hosts, setHosts] = useState<SshHost[]>([]);
const [hostManagerOpen, setHostManagerOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [mcpStatus, setMcpStatus] = useState<McpStatus>({
running: false,
url: null,
token: null,
});
const [mcpPanelOpen, setMcpPanelOpen] = useState(false);
const [ready, setReady] = useState(false);
const [notifications, setNotifications] = useState<Toast[]>([]);
const [paletteOpen, setPaletteOpen] = useState(false);
const [defaultShellMenuOpen, setDefaultShellMenuOpen] = useState(false);
const [layoutMenuOpen, setLayoutMenuOpen] = useState(false);
// ---- non-reactive lookups -----------------------------------------------
const paneIdByLeafRef = useRef<Map<NodeId, PaneId>>(new Map());
const nextNotifIdRef = useRef(1);
/** Leaves that just arrived via a window transfer, mapped to the
* existing PaneId their XtermPane should adopt. One-shot: cleared in
* registerPaneId once the pane registers. */
const transferredPaneIdsRef = useRef<Map<NodeId, PaneId>>(new Map());
const treeRef = useRef(tree);
useEffect(() => {
treeRef.current = tree;
}, [tree]);
// workspacesRef + currentWorkspaceIdRef are declared up by the tree
// wrappers so setTree / setActiveLeafId can capture them; sync here.
useEffect(() => {
workspacesRef.current = workspaces;
}, [workspaces]);
useEffect(() => {
currentWorkspaceIdRef.current = currentWorkspaceId;
}, [currentWorkspaceId]);
// ---- mount: load workspace + distros + hosts ----------------------------
useEffect(() => {
let cancelled = false;
(async () => {
// First: is this a detached window with a pending transfer payload?
// Non-main windows ALWAYS go through this path (they never read
// workspace.json — only main owns it). A detached window with no
// pending init is the dev-reload / edge case; we boot with a blank
// default workspace.
let initialEnvelope: ReturnType<typeof deserializeWorkspaces> = null;
let adoptedLeafId: NodeId | null = null;
if (!IS_MAIN_WINDOW) {
try {
const pending = await takePendingWindowInit(CURRENT_WINDOW_LABEL);
if (pending) {
try {
const adoptedLeaf = JSON.parse(pending.leafJson) as LeafNode;
if (adoptedLeaf && adoptedLeaf.kind === "leaf") {
transferredPaneIdsRef.current.set(adoptedLeaf.id, pending.paneId);
adoptedLeafId = adoptedLeaf.id;
initialEnvelope = {
version: 2,
workspaces: [
{
id: newId(),
name: pending.workspaceName || "Detached",
tree: adoptedLeaf,
},
],
};
}
} catch (e) {
console.warn("invalid pending leafJson:", e);
}
}
} catch (e) {
console.warn("takePendingWindowInit failed:", e);
}
} else {
// Main window: load workspace.json (and legacy fallback).
try {
const json = await loadWorkspace();
if (json) initialEnvelope = deserializeWorkspaces(json);
} catch (e) {
console.warn("loadWorkspace failed:", e);
}
if (!initialEnvelope) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
initialEnvelope = deserializeWorkspaces(legacy);
if (initialEnvelope) {
void saveWorkspace(serializeWorkspaces(initialEnvelope));
}
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
} catch (e) {
console.warn("legacy localStorage migration failed:", e);
}
}
}
let resolvedDistros: string[] = [];
try {
resolvedDistros = await listDistros();
} catch (e) {
console.warn("list_distros failed:", e);
}
let resolvedHosts: SshHost[] = [];
try {
resolvedHosts = await listSshHosts();
} catch (e) {
console.warn("listSshHosts failed:", e);
}
const initialDefault: DefaultShell = (() => {
const wslDefault = resolvedDistros.find(isInteractiveDistro);
if (wslDefault) return { shellKind: "wsl", distro: wslDefault };
if (resolvedDistros.length > 0) return { shellKind: "wsl", distro: resolvedDistros[0] };
// No WSL distros — fall back to PowerShell as default.
return { shellKind: "powershell" };
})();
if (cancelled) return;
let envelope = initialEnvelope;
if (!envelope) {
envelope = singletonEnvelope(
newLeaf(defaultShellAsLeafProps(initialDefault)),
);
}
if (initialDefault.shellKind === "wsl" && initialDefault.distro) {
for (const w of envelope.workspaces) {
backfillWslDistro(w.tree, initialDefault.distro);
}
}
setWorkspaces(envelope.workspaces);
setCurrentWorkspaceId(envelope.workspaces[0].id);
if (adoptedLeafId) {
setActiveLeafByWorkspace((prev) => {
const m = new Map(prev);
m.set(envelope!.workspaces[0].id, adoptedLeafId);
return m;
});
}
setDistros(resolvedDistros);
setHosts(resolvedHosts);
setDefaultShell(initialDefault);
setReady(true);
})();
return () => {
cancelled = true;
};
}, []);
// ---- workspace sync to backend aggregator -------------------------------
// Every window pushes its own workspaces snapshot; the backend merges
// across windows and debounces the actual workspace.json write (500ms
// tokio sleep inside Rust). This replaces the v0.3.0 per-window
// saveWorkspace path which would race when two windows wrote at once.
useEffect(() => {
if (!ready) return;
pushWindowWorkspaces(CURRENT_WINDOW_LABEL, JSON.stringify(workspaces)).catch(
(e) => console.warn("pushWindowWorkspaces failed:", e),
);
}, [workspaces, ready]);
// ---- focus polling → setActive (xterm.js eats pointerdown) --------------
// Scoped to the current workspace layer so a hidden tab's lingering
// textarea focus (visibility:hidden doesn't auto-blur on tab switch)
// doesn't yank activeLeafId into a tab the user can't see.
useEffect(() => {
let lastLeafId: string | null = null;
const interval = window.setInterval(() => {
const el = document.activeElement;
const leafEl = el?.closest("[data-leaf-id]");
if (!leafEl) return;
const wsEl = leafEl.closest("[data-workspace-id]");
const wsId = wsEl?.getAttribute("data-workspace-id") ?? null;
if (wsId !== currentWorkspaceIdRef.current) return;
const id = leafEl.getAttribute("data-leaf-id");
if (id && id !== lastLeafId) {
lastLeafId = id;
setActiveLeafId(id);
}
}, 250);
return () => clearInterval(interval);
}, [setActiveLeafId]);
// notify is defined up here (and not next to dismissNotification) because
// the split callback below uses it to warn when a pane is too small to
// subdivide further. Build-mode tsc balks on use-before-declaration.
const notify = useCallback((message: string) => {
const id = nextNotifIdRef.current++;
setNotifications((ns) => [...ns, { id, message }]);
window.setTimeout(() => {
setNotifications((ns) => ns.filter((n) => n.id !== id));
}, 5000);
}, []);
// ---- orchestration callbacks --------------------------------------------
const split = useCallback(
(leafId: NodeId, orientation: Orientation) => {
// Refuse the split if it would produce a child pane below MIN_PANE_PX
// — otherwise spamming the split buttons shrinks panes into nothing.
const container = paneWrapRef.current;
if (container) {
const slot = flattenLayout(treeRef.current).leaves.find(
(s) => s.leaf.id === leafId,
);
if (slot) {
const rect = container.getBoundingClientRect();
const paneW = slot.box.width * rect.width;
const paneH = slot.box.height * rect.height;
const childW = orientation === "h" ? paneW / 2 : paneW;
const childH = orientation === "v" ? paneH / 2 : paneH;
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
notify(
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
);
return;
}
}
}
setTree((t) => {
const parent = findLeaf(t, leafId);
const inherit = inheritShellFromParent(parent, defaultShell);
return splitLeaf(t, leafId, orientation, inherit);
});
},
[defaultShell, notify],
);
// Titlebar "+" — spawn a fresh pane of the picked shell, regardless of
// what the active pane is running. Splits off the active pane in
// whichever orientation leaves the new sibling closer to square.
const addPane = useCallback(() => {
const container = paneWrapRef.current;
const layout = flattenLayout(treeRef.current);
const targetId = activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
if (!targetId || !container) return;
const slot = layout.leaves.find((s) => s.leaf.id === targetId);
if (!slot) return;
const rect = container.getBoundingClientRect();
const paneW = slot.box.width * rect.width;
const paneH = slot.box.height * rect.height;
// Split along the longer side so both halves stay close to square.
const orientation: Orientation = paneW >= paneH ? "h" : "v";
const childW = orientation === "h" ? paneW / 2 : paneW;
const childH = orientation === "v" ? paneH / 2 : paneH;
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
notify(
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
);
return;
}
setTree((t) =>
splitLeaf(t, targetId, orientation, defaultShellAsLeafProps(defaultShell)),
);
}, [activeLeafId, defaultShell, notify]);
// From the SSH host manager's "Connect" button — splits off the active
// pane and opens an SSH session to the picked host. Same smart-orient as
// the titlebar "+" path.
const connectToHost = useCallback(
(hostId: string) => {
const container = paneWrapRef.current;
const layout = flattenLayout(treeRef.current);
const targetId = activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
if (!targetId || !container) {
notify("No pane to split off — open a pane first");
return;
}
const slot = layout.leaves.find((s) => s.leaf.id === targetId);
if (!slot) return;
const rect = container.getBoundingClientRect();
const paneW = slot.box.width * rect.width;
const paneH = slot.box.height * rect.height;
const orientation: Orientation = paneW >= paneH ? "h" : "v";
const childW = orientation === "h" ? paneW / 2 : paneW;
const childH = orientation === "v" ? paneH / 2 : paneH;
if (childW < MIN_PANE_PX || childH < MIN_PANE_PX) {
notify(
`Pane too small to split — would create ${Math.round(childW)}×${Math.round(childH)}px (min ${MIN_PANE_PX}px)`,
);
return;
}
setTree((t) =>
splitLeaf(t, targetId, orientation, {
shellKind: "ssh",
sshHostId: hostId,
}),
);
},
[activeLeafId, notify],
);
const close = useCallback(
(leafId: NodeId) => {
const paneId = paneIdByLeafRef.current.get(leafId);
if (paneId != null) {
void killPane(paneId).catch((e) => console.warn("killPane failed:", e));
paneIdByLeafRef.current.delete(leafId);
}
setTree(
(t) =>
closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)),
);
setActiveLeafId((cur) => (cur === leafId ? null : cur));
},
[defaultShell],
);
const setShell = useCallback((leafId: NodeId, spec: LeafShellSpec) => {
setTree((t) => setLeafShell(t, leafId, spec));
}, []);
// ---- tab (workspace) operations ----------------------------------------
/** Append a fresh blank workspace using the current default shell, then
* switch to it. */
const createTab = useCallback(() => {
const idx = workspacesRef.current.length + 1;
const w: Workspace = {
id: newId(),
name: `Tab ${idx}`,
tree: newLeaf(defaultShellAsLeafProps(defaultShell)),
};
setWorkspaces((ws) => [...ws, w]);
setCurrentWorkspaceId(w.id);
}, [defaultShell]);
/** Switch the visible workspace. No-op if the id isn't in the list. */
const switchTab = useCallback((id: NodeId) => {
if (!workspacesRef.current.some((w) => w.id === id)) return;
setCurrentWorkspaceId(id);
}, []);
const renameTab = useCallback((id: NodeId, name: string) => {
setWorkspaces((ws) => ws.map((w) => (w.id === id ? { ...w, name } : w)));
}, []);
/** Close a tab. Kills every PTY in its tree first (so the call site
* doesn't need to walk leaves itself). If the closing tab was current,
* switch to the adjacent one. If it was the only tab, replace it with a
* fresh default — tiletopia must always have at least one workspace. */
const closeTab = useCallback(
(id: NodeId) => {
const target = workspacesRef.current.find((w) => w.id === id);
if (!target) return;
for (const leaf of walkLeaves(target.tree)) {
const paneId = paneIdByLeafRef.current.get(leaf.id);
if (paneId != null) {
void killPane(paneId).catch((e) =>
console.warn("killPane failed:", e),
);
paneIdByLeafRef.current.delete(leaf.id);
}
}
const idx = workspacesRef.current.findIndex((w) => w.id === id);
const wasCurrent = currentWorkspaceIdRef.current === id;
setWorkspaces((prev) => {
const next = prev.filter((w) => w.id !== id);
if (next.length === 0) {
return [
{
id: newId(),
name: "Default",
tree: newLeaf(defaultShellAsLeafProps(defaultShell)),
},
];
}
return next;
});
setActiveLeafByWorkspace((prev) => {
if (!prev.has(id)) return prev;
const m = new Map(prev);
m.delete(id);
return m;
});
if (wasCurrent) {
// Use the snapshot of workspaces BEFORE removal to find a neighbor.
const remaining = workspacesRef.current.filter((w) => w.id !== id);
if (remaining.length > 0) {
const nextIdx = Math.min(idx, remaining.length - 1);
setCurrentWorkspaceId(remaining[nextIdx].id);
}
// else: setWorkspaces above will populate a fresh default; the
// workspaces effect runs and updates currentWorkspaceIdRef below.
}
},
[defaultShell],
);
// When the workspaces list changes (load, close last tab, etc.), make sure
// currentWorkspaceId points at something real.
useEffect(() => {
if (workspaces.length === 0) return;
if (!workspaces.some((w) => w.id === currentWorkspaceId)) {
setCurrentWorkspaceId(workspaces[0].id);
}
}, [workspaces, currentWorkspaceId]);
const setLabel = useCallback((leafId: NodeId, label: string | undefined) => {
setTree((t) => changeLabel(t, leafId, label));
}, []);
const toggleBroadcast = useCallback((leafId: NodeId) => {
setTree((t) => toggleBroadcastInTree(t, leafId));
}, []);
const toggleMcpAllow = useCallback((leafId: NodeId) => {
setTree((t) => toggleMcpAllowInTree(t, leafId));
}, []);
// ---- MCP server lifecycle ------------------------------------------------
const refreshMcpStatus = useCallback(async () => {
try {
const st = await mcpStatusCmd();
setMcpStatus(st);
} catch (e) {
console.warn("mcpStatus failed:", e);
}
}, []);
const startMcp = useCallback(async () => {
try {
const st = await mcpStart();
setMcpStatus(st);
notify("MCP server started — see panel for URL + token");
} catch (e) {
notify(`MCP start failed: ${e}`);
}
}, [notify]);
const stopMcp = useCallback(async () => {
try {
const st = await mcpStop();
setMcpStatus(st);
notify("MCP server stopped");
} catch (e) {
notify(`MCP stop failed: ${e}`);
}
}, [notify]);
const regenerateMcpToken = useCallback(async () => {
try {
const st = await mcpRegenerateToken();
setMcpStatus(st);
notify(
st.running
? "MCP token regenerated — update your client config"
: "MCP token regenerated",
);
} catch (e) {
notify(`MCP token regen failed: ${e}`);
}
}, [notify]);
// On mount, sync our local mcpStatus with whatever's already running
// (the backend persists state across HMR reloads).
useEffect(() => {
void refreshMcpStatus();
}, [refreshMcpStatus]);
// Ctrl+Shift+P: pop the active leaf out one level. The keyboard
// replacement for the (removed) drag-past-sibling gesture. No-op with a
// toast if the leaf is at the root or its parent shares orientation
// with the grandparent — no perpendicular promotion available.
const promoteActive = useCallback(
(leafId: NodeId) => {
const next = promoteLeaf(treeRef.current, leafId);
if (next === null) {
notify("Pane can't be promoted (no perpendicular split above it)");
return;
}
setTree(next);
},
[notify],
);
const setActive = useCallback((leafId: NodeId) => {
setActiveLeafId(leafId);
}, []);
const openHostManager = useCallback(() => setHostManagerOpen(true), []);
const closeHostManager = useCallback(() => setHostManagerOpen(false), []);
// Outside-click dismissal for the titlebar dropdowns. Mirrors the
// per-pane shell-picker pattern in LeafPane.tsx.
useEffect(() => {
if (!defaultShellMenuOpen) return;
const onDocClick = () => setDefaultShellMenuOpen(false);
window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick);
}, [defaultShellMenuOpen]);
useEffect(() => {
if (!layoutMenuOpen) return;
const onDocClick = () => setLayoutMenuOpen(false);
window.addEventListener("click", onDocClick);
return () => window.removeEventListener("click", onDocClick);
}, [layoutMenuOpen]);
const saveHosts = useCallback(
(next: SshHost[]) => {
// Preserve hasPassword flags that aren't included in the payload from
// HostManager (the manager strips them — backend recomputes on next
// list_ssh_hosts; we keep them locally so the badge doesn't flicker).
setHosts((prev) =>
next.map((h) => {
const hp = h.hasPassword ?? prev.find((p) => p.id === h.id)?.hasPassword;
return hp === undefined ? h : { ...h, hasPassword: hp };
}),
);
saveSshHosts(next).catch((e) =>
console.warn("saveSshHosts failed:", e),
);
},
[],
);
const savePassword = useCallback((hostId: string, password: string) => {
setHostPassword(hostId, password).then(
() =>
setHosts((prev) =>
prev.map((h) => (h.id === hostId ? { ...h, hasPassword: true } : h)),
),
(e) => console.warn("setHostPassword failed:", e),
);
}, []);
const clearPassword = useCallback((hostId: string) => {
deleteHostPassword(hostId).then(
() =>
setHosts((prev) =>
prev.map((h) => (h.id === hostId ? { ...h, hasPassword: false } : h)),
),
(e) => console.warn("deleteHostPassword failed:", e),
);
}, []);
// ---- global keyboard shortcuts ------------------------------------------
// Capture phase beats xterm.js's own keystroke handlers. We intentionally
// don't intercept when the user is typing into a regular <input> (label
// edits etc.) — but DO intercept when focus is in the xterm textarea,
// which is what makes shortcuts work while a terminal is focused.
const kbdStateRef = useRef({ activeLeafId, tree });
useEffect(() => {
kbdStateRef.current = { activeLeafId, tree };
});
useEffect(() => {
const DIR_MAP: Record<string, Direction | undefined> = {
arrowleft: "left",
arrowright: "right",
arrowup: "up",
arrowdown: "down",
};
function shouldIgnore(): boolean {
const ae = document.activeElement as HTMLElement | null;
if (!ae) return false;
const tag = ae.tagName;
if (tag !== "INPUT" && tag !== "TEXTAREA") return false;
if (ae.classList.contains("xterm-helper-textarea")) return false;
return true;
}
function onKey(e: KeyboardEvent) {
if (shouldIgnore()) return;
const ctrl = e.ctrlKey || e.metaKey;
const shift = e.shiftKey;
const alt = e.altKey;
const key = e.key.toLowerCase();
const { activeLeafId, tree } = kbdStateRef.current;
// F1 — toggle help overlay
if (key === "f1") {
e.preventDefault();
e.stopPropagation();
setHelpOpen((v) => !v);
return;
}
// Ctrl+K — palette
if (ctrl && !shift && !alt && key === "k") {
e.preventDefault();
e.stopPropagation();
setPaletteOpen((v) => !v);
return;
}
// Ctrl+Shift+Alt+B — global broadcast all/none
if (ctrl && shift && alt && key === "b") {
e.preventDefault();
e.stopPropagation();
let anyOn = false;
for (const leaf of walkLeaves(tree)) {
if (leaf.broadcast) {
anyOn = true;
break;
}
}
setTree((t) => setAllBroadcast(t, !anyOn));
return;
}
// Ctrl[+Shift]+= / - / 0 — terminal font size. Browser convention:
// unshifted touches the active pane, Shift escalates to every pane.
// Match on e.code so the bindings work the same across layouts (and
// regardless of whether Shift turns "=" into "+" etc.).
if (ctrl && !alt && (e.code === "Equal" || e.code === "Minus" || e.code === "Digit0")) {
e.preventDefault();
e.stopPropagation();
const delta =
e.code === "Equal" ? 1 : e.code === "Minus" ? -1 : null;
if (shift) {
setTree((t) => adjustAllFontSizes(t, delta));
} else if (activeLeafId) {
setTree((t) => adjustFontSize(t, activeLeafId, delta));
}
return;
}
// Ctrl+T — new tab.
if (ctrl && !shift && !alt && key === "t") {
e.preventDefault();
e.stopPropagation();
createTab();
return;
}
// Ctrl+Shift+T — close current tab. Confirms via window.confirm when
// the tab has live panes (matches the X-button popover's intent
// without re-using its anchored UI from the keyboard path).
if (ctrl && shift && !alt && key === "t") {
e.preventDefault();
e.stopPropagation();
const wsId = currentWorkspaceIdRef.current;
const ws = workspacesRef.current.find((w) => w.id === wsId);
if (!ws) return;
const paneCount = leafCount(ws.tree);
if (paneCount > 0) {
const labels = Array.from(walkLeaves(ws.tree))
.map((l) => l.label ?? l.distro ?? `(${l.shellKind})`)
.join(", ");
const ok = window.confirm(
`Close tab "${ws.name}"? This will kill ${paneCount} pane${paneCount === 1 ? "" : "s"}: ${labels}`,
);
if (!ok) return;
}
closeTab(wsId);
return;
}
// Ctrl+PageDown / Ctrl+PageUp — switch to next / previous tab.
if (ctrl && !shift && !alt && (key === "pagedown" || key === "pageup")) {
e.preventDefault();
e.stopPropagation();
const ws = workspacesRef.current;
if (ws.length < 2) return;
const idx = ws.findIndex((w) => w.id === currentWorkspaceIdRef.current);
if (idx < 0) return;
const delta = key === "pagedown" ? 1 : -1;
const nextIdx = (idx + delta + ws.length) % ws.length;
switchTab(ws[nextIdx].id);
return;
}
// Ctrl+1..9 — switch to tab N (1-indexed, capped at 9 even if more).
if (ctrl && !shift && !alt && e.code.startsWith("Digit")) {
const n = parseInt(e.code.slice(5), 10);
if (n >= 1 && n <= 9) {
const ws = workspacesRef.current;
if (ws[n - 1]) {
e.preventDefault();
e.stopPropagation();
switchTab(ws[n - 1].id);
return;
}
}
}
// All remaining shortcuts require Ctrl+Shift with no Alt.
if (!ctrl || !shift || alt) return;
// Ctrl+Shift+Arrow — pane navigation
const dir = DIR_MAP[key];
if (dir) {
e.preventDefault();
e.stopPropagation();
const layout = flattenLayout(tree);
if (!activeLeafId) {
const first = layout.leaves[0]?.leaf.id;
if (first) setActiveLeafId(first);
return;
}
const nextId = findNeighborInDirection(layout.leaves, activeLeafId, dir);
if (nextId) setActiveLeafId(nextId);
return;
}
if (!activeLeafId) return;
if (key === "e") {
e.preventDefault();
e.stopPropagation();
split(activeLeafId, "h");
} else if (key === "o") {
e.preventDefault();
e.stopPropagation();
split(activeLeafId, "v");
} else if (key === "w") {
e.preventDefault();
e.stopPropagation();
close(activeLeafId);
} else if (key === "b") {
e.preventDefault();
e.stopPropagation();
toggleBroadcast(activeLeafId);
} else if (key === "p") {
e.preventDefault();
e.stopPropagation();
promoteActive(activeLeafId);
}
}
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [split, close, toggleBroadcast, promoteActive, createTab, closeTab, switchTab]);
// Waiters keyed by leaf id — used by the MCP spawn_pane / connect_host
// handlers, which must reply with the new paneId but can only get one
// after the freshly-mounted XtermPane completes its spawn round-trip and
// calls back into registerPaneId.
const pendingPaneRegistrations = useRef<Map<NodeId, (paneId: PaneId) => void>>(
new Map(),
);
const registerPaneId = useCallback(
(leafId: NodeId, paneId: PaneId | null) => {
if (paneId == null) {
paneIdByLeafRef.current.delete(leafId);
return;
}
paneIdByLeafRef.current.set(leafId, paneId);
// One-shot: now that the pane has registered, the transferred-id
// hint is consumed.
transferredPaneIdsRef.current.delete(leafId);
const waiter = pendingPaneRegistrations.current.get(leafId);
if (waiter) {
pendingPaneRegistrations.current.delete(leafId);
waiter(paneId);
}
},
[],
);
const getInitialPaneIdFor = useCallback(
(leafId: NodeId): PaneId | undefined =>
transferredPaneIdsRef.current.get(leafId),
[],
);
/** Pop the given leaf into a fresh top-level window. The source's
* XtermPane will unmount as the leaf leaves this window's tree;
* markPaneTransferring keeps the underlying PTY alive until the new
* window's XtermPane adopts it via existingPaneId. */
const moveToNewWindow = useCallback(
async (leafId: NodeId) => {
const leaf = findLeaf(treeRef.current, leafId);
if (!leaf || leaf.kind !== "leaf") {
notify("Cannot move — pane not found");
return;
}
const paneId = paneIdByLeafRef.current.get(leafId);
if (paneId == null) {
notify("Cannot move — PTY not ready yet");
return;
}
try {
await markPaneTransferring(paneId);
} catch (e) {
notify(`mark_pane_transferring failed: ${e}`);
return;
}
// Snapshot the leaf BEFORE removing — closeLeaf may produce a tree
// where this leaf is no longer present, breaking findLeaf later.
const leafJson = JSON.stringify(leaf);
const workspaceName = leaf.label ?? `Pane ${paneId}`;
// Remove from current tree (sibling promotes naturally via closeLeaf).
// If this leaf was the entire tree, fall back to a fresh default so
// the source workspace never becomes empty (matches close behavior).
setTree(
(t) =>
closeLeaf(t, leafId) ?? newLeaf(defaultShellAsLeafProps(defaultShell)),
);
paneIdByLeafRef.current.delete(leafId);
setActiveLeafByWorkspace((prev) => {
const wsId = currentWorkspaceIdRef.current;
if (!wsId) return prev;
if (prev.get(wsId) !== leafId) return prev;
const m = new Map(prev);
m.set(wsId, null);
return m;
});
try {
await createPaneWindow({ leafJson, paneId, workspaceName });
} catch (e) {
notify(`Failed to open new window: ${e}`);
// The leaf is already gone from our tree and the PTY is orphaned
// in transferring state. Drop the refcount so a manual kill could
// eventually succeed; but the leaf no longer exists in any tree.
void claimPane(paneId).catch(() => {});
}
},
[defaultShell, notify],
);
/** Insert a new leaf into the tree from a SpawnSpec — used by the MCP
* spawn_pane and connect_host handlers. Returns the new leaf's id
* (caller awaits waitForPaneRegistration on it for the paneId).
* - parentLeafId: defaults to active leaf, then first leaf in layout.
* - orientation: "h" / "v" / undefined; undefined picks smart-orient by
* parent pane aspect (matches the titlebar "+" button's behaviour).
* - New leaf is auto-marked mcpAllow=true so Claude can immediately
* interact with the pane it just spawned. */
const spawnNewLeafFromSpec = useCallback(
async (
spec: SpawnSpec,
parentLeafId: string | undefined,
orientationArg: string | undefined,
): Promise<NodeId> => {
const layout = flattenLayout(treeRef.current);
const parentId =
parentLeafId ?? activeLeafId ?? layout.leaves[0]?.leaf.id ?? null;
if (!parentId) throw new Error("no pane available to split off");
const parent = findLeaf(treeRef.current, parentId);
if (!parent || parent.kind !== "leaf") {
throw new Error(`parent leaf not found: ${parentId}`);
}
let orient: Orientation;
if (orientationArg === "h" || orientationArg === "v") {
orient = orientationArg;
} else if (orientationArg == null) {
// Smart-orient: split along the longer side of the parent's pane.
const container = paneWrapRef.current;
const slot = layout.leaves.find((s) => s.leaf.id === parentId);
if (container && slot) {
const rect = container.getBoundingClientRect();
const paneW = slot.box.width * rect.width;
const paneH = slot.box.height * rect.height;
orient = paneW >= paneH ? "h" : "v";
} else {
orient = "h"; // safe fallback
}
} else {
throw new Error(
`invalid orientation: ${orientationArg} (expected "h" or "v")`,
);
}
// For SSH spawns, the auto-allow safeguard decides whether the new
// pane starts MCP-allowed (Claude can interact immediately) or
// mcpAllow=off (user must explicitly toggle 🤖 to grant access).
// Local shells (WSL / PowerShell) are auto-allowed unconditionally.
let mcpAllow = true;
if (spec.kind === "ssh") {
try {
const policy = await mcpPolicyLoad();
mcpAllow = policy.sshSafeguards.autoAllowSpawnedSsh;
} catch (e) {
console.warn("policy load failed during ssh spawn, defaulting mcpAllow=false:", e);
mcpAllow = false;
}
}
const id = newId();
const newLeafNode: LeafNode = {
kind: "leaf",
id,
shellKind: spec.kind,
mcpAllow,
...(spec.kind === "wsl"
? { distro: spec.distro, cwd: spec.cwd }
: {}),
...(spec.kind === "ssh" && spec.hostId
? { sshHostId: spec.hostId }
: {}),
};
setTree((t) => splitLeafWith(t, parentId, orient, newLeafNode));
return id;
},
[activeLeafId],
);
/** Resolves to the paneId once XtermPane finishes mounting and the
* spawn_pane Tauri command returns. Rejects after timeoutMs. */
function waitForPaneRegistration(
leafId: NodeId,
timeoutMs = 5000,
): Promise<PaneId> {
return new Promise((resolve, reject) => {
const existing = paneIdByLeafRef.current.get(leafId);
if (existing != null) {
resolve(existing);
return;
}
pendingPaneRegistrations.current.set(leafId, resolve);
setTimeout(() => {
if (pendingPaneRegistrations.current.has(leafId)) {
pendingPaneRegistrations.current.delete(leafId);
reject(
new Error(
`spawn timed out after ${timeoutMs}ms — pane never registered`,
),
);
}
}, timeoutMs);
});
}
const broadcastFrom = useCallback(
(originLeafId: NodeId, dataB64: string) => {
let peers = 0;
for (const leaf of walkLeaves(treeRef.current)) {
if (leaf.id === originLeafId) continue;
if (!leaf.broadcast) continue;
const paneId = paneIdByLeafRef.current.get(leaf.id);
if (paneId == null) continue;
peers++;
writeToPane(paneId, dataB64).catch((e) =>
console.warn("broadcast write failed:", e),
);
}
if (peers > 0) {
console.log("[tiletopia] broadcastFrom", originLeafId, "→", peers, "peer(s)");
}
},
[],
);
const dismissNotification = useCallback((id: number) => {
setNotifications((ns) => ns.filter((n) => n.id !== id));
}, []);
// ---- per-pane idle aggregation (replaces toast spam) --------------------
const [idleLeafIds, setIdleLeafIds] = useState<Set<NodeId>>(() => new Set());
const reportLeafIdle = useCallback((leafId: NodeId, idle: boolean) => {
setIdleLeafIds((prev) => {
if (idle && prev.has(leafId)) return prev;
if (!idle && !prev.has(leafId)) return prev;
const next = new Set(prev);
if (idle) next.add(leafId);
else next.delete(leafId);
return next;
});
}, []);
// ---- header-drag swap ---------------------------------------------------
const [dragSourceId, setDragSourceId] = useState<NodeId | null>(null);
const [dragOverId, setDragOverId] = useState<NodeId | null>(null);
const beginHeaderDrag = useCallback((leafId: NodeId) => {
setDragSourceId(leafId);
setDragOverId(null);
}, []);
const setHeaderDragOver = useCallback((leafId: NodeId | null) => {
setDragOverId(leafId);
}, []);
const endHeaderDrag = useCallback(
(commitSwap: boolean) => {
if (
commitSwap &&
dragSourceId &&
dragOverId &&
dragSourceId !== dragOverId
) {
setTree((t) => swapLeaves(t, dragSourceId, dragOverId));
}
setDragSourceId(null);
setDragOverId(null);
},
[dragSourceId, dragOverId],
);
const orch = useMemo<Orchestration>(
() => ({
activeLeafId,
distros,
hosts,
split,
close,
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
openHostManager,
setActive,
registerPaneId,
broadcastFrom,
notify,
dragSourceId,
dragOverId,
beginHeaderDrag,
setHeaderDragOver,
endHeaderDrag,
reportLeafIdle,
moveToNewWindow,
getInitialPaneIdFor,
}),
[
activeLeafId,
distros,
hosts,
split,
close,
setShell,
setLabel,
toggleBroadcast,
toggleMcpAllow,
openHostManager,
setActive,
registerPaneId,
broadcastFrom,
notify,
dragSourceId,
dragOverId,
beginHeaderDrag,
setHeaderDragOver,
endHeaderDrag,
reportLeafIdle,
moveToNewWindow,
getInitialPaneIdFor,
],
);
// ---- MCP mirror push -----------------------------------------------------
// Whenever the tree, hosts, or active selection change AND the MCP server
// is running, push a fresh mirror down to the backend. Per-leaf mcpAllow
// gates whether each leaf appears in the mirror (default-deny).
//
// Multi-window scoping: only the MAIN window pushes the mirror. Detached
// windows have their own current-workspace tree but Claude sees ONE
// workspace surface — main's current tab. Otherwise two windows would
// overwrite each other's mirrors on every keystroke and Claude's view
// would flap unpredictably.
const allowedPaneCount = useMemo(
() => Array.from(walkLeaves(tree)).filter((l) => l.mcpAllow).length,
[tree],
);
useEffect(() => {
if (!IS_MAIN_WINDOW) return;
if (!mcpStatus.running) return;
const leaves: Record<string, McpMirroredLeaf> = {};
for (const leaf of walkLeaves(tree)) {
if (!leaf.mcpAllow) continue;
leaves[leaf.id] = {
paneId: paneIdByLeafRef.current.get(leaf.id) ?? null,
label: leaf.label,
shellKind: leaf.shellKind,
distro: leaf.distro,
sshHostId: leaf.sshHostId,
broadcast: !!leaf.broadcast,
active: activeLeafId === leaf.id,
};
}
const mirroredHosts: McpMirroredHost[] = hosts.map((h) => ({
id: h.id,
label: h.label,
hostname: h.hostname,
user: h.user,
port: h.port,
hasPassword: !!h.hasPassword,
}));
const mirror: McpMirror = {
layoutJson: serialize(tree),
leaves,
hosts: mirroredHosts,
};
mcpUpdateState(mirror).catch((e) =>
console.warn("mcpUpdateState failed:", e),
);
}, [mcpStatus.running, tree, hosts, activeLeafId]);
// ---- MCP audit log -------------------------------------------------------
// Subscribed at App level so events received while the panel is closed (or
// while the user is on another tab) still land in the ring buffer. The
// McpPanel consumes the entries via props.
const AUDIT_RING_CAP = 200;
const [auditEntries, setAuditEntries] = useState<McpAuditEntry[]>([]);
const clearAudit = useCallback(() => setAuditEntries([]), []);
useEffect(() => {
// StrictMode double-mounts effects in dev; the listen() Promise may
// resolve AFTER the first cleanup runs. Guard with a cancelled flag so
// a late-resolving subscription gets immediately torn down instead of
// leaking into the second mount (which would double every event).
let cancelled = false;
let unlisten: (() => void) | undefined;
void listen<McpAuditEntry>("mcp://audit", (e) => {
setAuditEntries((prev) => {
const next = [e.payload, ...prev];
return next.length > AUDIT_RING_CAP ? next.slice(0, AUDIT_RING_CAP) : next;
});
}).then((fn) => {
if (cancelled) fn();
else unlisten = fn;
});
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []);
// ---- MCP action dispatcher -----------------------------------------------
// Backend tools that mutate workspace state emit "mcp://request" events with
// a requestId + args. We resolve them via mcp_action_reply(requestId, Ok|Err).
// When needsConfirm is true, a modal queues up first; user Accept = run,
// Reject = reply with Err. Per-tool handlers live in `runMcpHandler` below.
interface ConfirmEntry extends McpConfirmSpec {
resolve: (accept: boolean) => void;
}
const [confirmQueue, setConfirmQueue] = useState<ConfirmEntry[]>([]);
const requestConfirm = useCallback(
(spec: McpConfirmSpec): Promise<boolean> =>
new Promise((resolve) =>
setConfirmQueue((q) => [...q, { ...spec, resolve }]),
),
[],
);
const dismissConfirm = useCallback((accept: boolean) => {
setConfirmQueue((q) => {
const [head, ...rest] = q;
if (head) head.resolve(accept);
return rest;
});
}, []);
// "Always allow" from the confirm modal: appends the bare tool name to
// the policy's allow bucket so future calls of this tool skip the prompt.
// Idempotent — duplicate adds are no-ops.
const alwaysAllowTool = useCallback(async (tool: string) => {
try {
const policy = await mcpPolicyLoad();
if (!policy.permissions.allow.includes(tool)) {
policy.permissions.allow.push(tool);
await mcpPolicySave(policy);
notify(`Added "${tool}" to MCP allow list — future calls won't prompt`);
}
} catch (e) {
notify(`Failed to update MCP policy: ${e}`);
}
}, [notify]);
// Per-tool handler. Each branch validates args, applies the mutation via the
// same setters the UI uses, and returns a JSON-serialisable payload. Throws
// on validation failure — caller converts to Err reply.
const runMcpHandler = useCallback(
async (tool: string, args: unknown): Promise<{ payload: unknown; summary: string }> => {
switch (tool) {
case "set_label": {
const a = args as { leafId?: string; label?: string };
if (typeof a.leafId !== "string") throw new Error("missing leafId");
if (typeof a.label !== "string") throw new Error("missing label");
const leaf = findLeaf(treeRef.current, a.leafId);
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
const before = leaf.label ?? "(unlabelled)";
setLabel(a.leafId, a.label || undefined);
const after = a.label || "(cleared)";
return {
payload: { leafId: a.leafId, label: a.label },
summary: `Rename pane "${before}" → "${after}"`,
};
}
case "write_pane": {
const a = args as { leafId?: string; text?: string };
if (typeof a.leafId !== "string") throw new Error("missing leafId");
if (typeof a.text !== "string") throw new Error("missing text");
const leaf = findLeaf(treeRef.current, a.leafId);
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
const paneId = paneIdByLeafRef.current.get(a.leafId);
if (paneId == null) throw new Error(`no live pane for leaf ${a.leafId}`);
// UTF-8 encode → base64 (matches the existing writeToPane wire shape).
const bytes = new TextEncoder().encode(a.text);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const b64 = btoa(binary);
await writeToPane(paneId, b64);
const labelStr = leaf.label ?? a.leafId.slice(0, 8);
return {
payload: { leafId: a.leafId, bytesWritten: bytes.length },
summary: `Send to pane "${labelStr}": ${truncateForSummary(a.text)}`,
};
}
case "spawn_pane": {
// Backend McpSpawnSpec only contains Wsl / Powershell — SSH is
// routed through connect_host instead. The cast tolerates the
// wider frontend SpawnSpec union; the kind discriminant is what
// matters at runtime.
const a = args as {
spec?: SpawnSpec;
parentLeafId?: string;
orientation?: string;
};
if (!a.spec || typeof a.spec !== "object") {
throw new Error("missing spec");
}
const newLeafId = await spawnNewLeafFromSpec(
a.spec,
a.parentLeafId,
a.orientation,
);
// 15s covers a cold WSL distro start (~3-5s typical, longer if
// the distro hasn't been used recently).
const paneId = await waitForPaneRegistration(newLeafId, 15000);
return {
payload: { leafId: newLeafId, paneId },
summary: `Spawn ${describeSpec(a.spec)} pane`,
};
}
case "connect_host": {
const a = args as {
hostId?: string;
parentLeafId?: string;
orientation?: string;
};
if (typeof a.hostId !== "string") throw new Error("missing hostId");
const host = hosts.find((h) => h.id === a.hostId);
if (!host) throw new Error(`unknown host_id: ${a.hostId}`);
const newLeafId = await spawnNewLeafFromSpec(
{ kind: "ssh", host: host.hostname, hostId: host.id },
a.parentLeafId,
a.orientation,
);
// 30s covers SSH handshake + password auth on a slow or
// first-time connection.
const paneId = await waitForPaneRegistration(newLeafId, 30000);
return {
payload: { leafId: newLeafId, paneId, hostId: host.id },
summary: `Connect SSH to "${host.label}" (${host.hostname})`,
};
}
case "close_pane": {
const a = args as { leafId?: string };
if (typeof a.leafId !== "string") throw new Error("missing leafId");
const leaf = findLeaf(treeRef.current, a.leafId);
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
const labelStr = leaf.label ?? a.leafId.slice(0, 8);
close(a.leafId);
return {
payload: { leafId: a.leafId },
summary: `Close pane "${labelStr}"`,
};
}
case "swap_panes": {
const a = args as { leafA?: string; leafB?: string };
if (typeof a.leafA !== "string") throw new Error("missing leafA");
if (typeof a.leafB !== "string") throw new Error("missing leafB");
if (a.leafA === a.leafB) throw new Error("leafA and leafB are the same");
const lA = findLeaf(treeRef.current, a.leafA);
const lB = findLeaf(treeRef.current, a.leafB);
if (!lA || lA.kind !== "leaf") throw new Error(`leaf not found: ${a.leafA}`);
if (!lB || lB.kind !== "leaf") throw new Error(`leaf not found: ${a.leafB}`);
const labelA = lA.label ?? a.leafA.slice(0, 8);
const labelB = lB.label ?? a.leafB.slice(0, 8);
setTree((t) => swapLeaves(t, a.leafA!, a.leafB!));
return {
payload: { leafA: a.leafA, leafB: a.leafB },
summary: `Swap panes "${labelA}" ↔ "${labelB}"`,
};
}
case "promote_pane": {
const a = args as { leafId?: string };
if (typeof a.leafId !== "string") throw new Error("missing leafId");
const leaf = findLeaf(treeRef.current, a.leafId);
if (!leaf || leaf.kind !== "leaf") throw new Error(`leaf not found: ${a.leafId}`);
const next = promoteLeaf(treeRef.current, a.leafId);
if (next === null) {
throw new Error(
"pane can't be promoted (no perpendicular split above it)",
);
}
setTree(next);
return {
payload: { leafId: a.leafId },
summary: `Promote pane "${leaf.label ?? a.leafId.slice(0, 8)}" up one level`,
};
}
case "add_host": {
const a = args as {
label?: string;
hostname?: string;
user?: string;
port?: number;
identityFile?: string;
jumpHost?: string;
extraArgs?: string[];
};
if (typeof a.hostname !== "string" || !a.hostname.trim()) {
throw new Error("missing hostname");
}
const hostname = a.hostname.trim();
const label = (a.label && a.label.trim()) || hostname;
const id = newId();
const newHost: SshHost = {
id,
label,
hostname,
...(a.user ? { user: a.user } : {}),
...(a.port ? { port: a.port } : {}),
...(a.identityFile ? { identityFile: a.identityFile } : {}),
...(a.jumpHost ? { jumpHost: a.jumpHost } : {}),
...(a.extraArgs && a.extraArgs.length > 0
? { extraArgs: a.extraArgs }
: {}),
};
const next = [...hosts, newHost];
setHosts(next);
await saveSshHosts(next);
return {
payload: { hostId: id, label, hostname },
summary: `Save SSH host "${label}" (${a.user ? `${a.user}@` : ""}${hostname}${a.port ? `:${a.port}` : ""})`,
};
}
case "delete_host": {
const a = args as { hostId?: string };
if (typeof a.hostId !== "string") throw new Error("missing hostId");
const host = hosts.find((h) => h.id === a.hostId);
if (!host) throw new Error(`unknown host_id: ${a.hostId}`);
const next = hosts.filter((h) => h.id !== a.hostId);
setHosts(next);
// save_ssh_hosts on the backend sweeps orphan keyring credentials
// for any id that disappears from the list, so no separate
// delete_host_password call is needed.
await saveSshHosts(next);
return {
payload: { hostId: a.hostId, label: host.label },
summary: `Delete SSH host "${host.label}" (${host.hostname})`,
};
}
case "apply_preset": {
const a = args as { name?: string; allowDrops?: boolean };
const presetMap: Record<string, (d: Partial<LeafNode>) => TreeNode> = {
single: presetSingle,
two_columns: presetTwoColumns,
three_columns: presetThreeColumns,
two_rows: presetTwoRows,
two_by_two: presetTwoByTwo,
};
const make = a.name ? presetMap[a.name] : undefined;
if (!make) {
throw new Error(
`unknown preset: ${a.name} (valid: single, two_columns, three_columns, two_rows, two_by_two)`,
);
}
const { tree: nextTree, dropped } = reshapeToPreset(
treeRef.current,
make,
defaultShellAsLeafProps(defaultShell),
);
if (dropped.length > 0 && !a.allowDrops) {
const labels = dropped
.map((id) => {
const l = findLeaf(treeRef.current, id);
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
})
.join(", ");
throw new Error(
`would drop ${dropped.length} pane(s) (${labels}); pass allow_drops=true to confirm`,
);
}
for (const id of dropped) {
const paneId = paneIdByLeafRef.current.get(id);
if (paneId != null) {
void killPane(paneId).catch((e) =>
console.warn("killPane failed:", e),
);
paneIdByLeafRef.current.delete(id);
}
}
if (activeLeafId && dropped.includes(activeLeafId)) {
setActiveLeafId(null);
}
setTree(nextTree);
return {
payload: { name: a.name, dropped: dropped.length, droppedLeafIds: dropped },
summary:
dropped.length > 0
? `Reshape to ${a.name} (closes ${dropped.length} pane${dropped.length === 1 ? "" : "s"})`
: `Reshape to ${a.name}`,
};
}
default:
throw new Error(`unsupported MCP tool: ${tool}`);
}
},
[setLabel, close, defaultShell, activeLeafId, hosts, spawnNewLeafFromSpec],
);
// The summary string for the confirm modal needs access to the leaf
// metadata, so we compute it up-front by partially running the handler
// logic (without mutating). For now we just rebuild it inline per tool;
// when more tools land this should split out.
const buildConfirmInfo = useCallback(
(tool: string, args: unknown): { summary: string; ssh?: { hostLabel: string } } => {
function leafLabel(id: string | undefined): string {
if (!id) return "(unknown)";
const l = findLeaf(treeRef.current, id);
return l && l.kind === "leaf" ? (l.label ?? id.slice(0, 8)) : id.slice(0, 8);
}
function sshContextForLeaf(id: string | undefined): { hostLabel: string } | undefined {
if (!id) return undefined;
const l = findLeaf(treeRef.current, id);
if (!l || l.kind !== "leaf" || l.shellKind !== "ssh") return undefined;
const host = hosts.find((h) => h.id === l.sshHostId);
return { hostLabel: host ? `${host.label} (${host.hostname})` : (l.sshHostId ?? "?") };
}
switch (tool) {
case "set_label": {
const a = args as { leafId?: string; label?: string };
return {
summary: `Rename pane "${leafLabel(a.leafId)}" → "${a.label || "(cleared)"}"`,
};
}
case "write_pane": {
const a = args as { leafId?: string; text?: string };
return {
summary: `Send to pane "${leafLabel(a.leafId)}": ${truncateForSummary(a.text ?? "")}`,
ssh: sshContextForLeaf(a.leafId),
};
}
case "spawn_pane": {
const a = args as { spec?: SpawnSpec };
const summary = a.spec ? `Spawn ${describeSpec(a.spec)} pane` : "Spawn pane";
// Bind the narrowed SSH variant to a local so the closure inside
// hosts.find() doesn't lose the discriminant. Using a.spec! here
// would compile under tsc --noEmit but fail under tsc -b.
let ssh: { hostLabel: string } | undefined;
if (a.spec && a.spec.kind === "ssh") {
const sshSpec = a.spec;
ssh = {
hostLabel: sshSpec.hostId
? hosts.find((h) => h.id === sshSpec.hostId)?.label ?? sshSpec.host
: sshSpec.host,
};
}
return { summary, ssh };
}
case "connect_host": {
const a = args as { hostId?: string };
const host = a.hostId ? hosts.find((h) => h.id === a.hostId) : null;
const name = host ? `"${host.label}" (${host.hostname})` : a.hostId;
return {
summary: `Connect SSH to ${name}`,
ssh: host ? { hostLabel: `${host.label} (${host.hostname})` } : undefined,
};
}
case "close_pane": {
const a = args as { leafId?: string };
return {
summary: `Close pane "${leafLabel(a.leafId)}"`,
ssh: sshContextForLeaf(a.leafId),
};
}
case "swap_panes": {
const a = args as { leafA?: string; leafB?: string };
return {
summary: `Swap panes "${leafLabel(a.leafA)}" ↔ "${leafLabel(a.leafB)}"`,
};
}
case "promote_pane": {
const a = args as { leafId?: string };
return {
summary: `Promote pane "${leafLabel(a.leafId)}" up one level`,
};
}
case "apply_preset": {
const a = args as { name?: string; allowDrops?: boolean };
const suffix = a.allowDrops ? " (drops allowed)" : "";
return { summary: `Reshape workspace to ${a.name}${suffix}` };
}
case "add_host": {
const a = args as {
label?: string;
hostname?: string;
user?: string;
port?: number;
};
const label = (a.label && a.label.trim()) || a.hostname || "(host)";
const conn = `${a.user ? `${a.user}@` : ""}${a.hostname ?? ""}${a.port ? `:${a.port}` : ""}`;
return { summary: `Save SSH host "${label}" (${conn})` };
}
case "delete_host": {
const a = args as { hostId?: string };
const host = a.hostId ? hosts.find((h) => h.id === a.hostId) : null;
const name = host ? `"${host.label}" (${host.hostname})` : a.hostId;
return { summary: `Delete SSH host ${name}` };
}
default:
return { summary: `Run ${tool}` };
}
},
[hosts],
);
useEffect(() => {
// Only the main window handles MCP requests — paneIdByLeafRef is
// per-window so a request targeting a leaf in another window would
// fail anyway. Keeps responsibility clean: MCP sees main, period.
if (!IS_MAIN_WINDOW) return;
let cancelled = false;
let unlisten: (() => void) | undefined;
void onMcpRequest(async (req: McpActionRequest) => {
try {
if (req.needsConfirm) {
const info = buildConfirmInfo(req.tool, req.args);
const ok = await requestConfirm({
tool: req.tool,
args: req.args,
reason: req.reason,
summary: info.summary,
ssh: info.ssh,
});
if (!ok) {
await mcpActionReply(req.requestId, { Err: "user rejected" });
return;
}
}
const { payload } = await runMcpHandler(req.tool, req.args);
await mcpActionReply(req.requestId, { Ok: payload });
} catch (err) {
await mcpActionReply(req.requestId, { Err: String(err) });
}
}).then((fn) => {
if (cancelled) fn();
else unlisten = fn;
});
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, [runMcpHandler, requestConfirm, buildConfirmInfo]);
const applyPreset = useCallback(
(make: (d: Partial<LeafNode>) => TreeNode) => {
const { tree: nextTree, dropped } = reshapeToPreset(
tree,
make,
defaultShellAsLeafProps(defaultShell),
);
if (dropped.length > 0) {
const ok = window.confirm(
`This preset has fewer slots than your current ${leafCount(tree)} panes. ${dropped.length} pane${dropped.length === 1 ? "" : "s"} will be closed (their shells will be killed). Continue?`,
);
if (!ok) return;
for (const id of dropped) {
const paneId = paneIdByLeafRef.current.get(id);
if (paneId != null) {
void killPane(paneId).catch((e) =>
console.warn("killPane failed:", e),
);
paneIdByLeafRef.current.delete(id);
}
}
if (activeLeafId && dropped.includes(activeLeafId)) {
setActiveLeafId(null);
}
}
setTree(nextTree);
},
[tree, defaultShell, activeLeafId],
);
const paletteLeaves = useMemo<LeafNode[]>(
() => (paletteOpen ? Array.from(walkLeaves(tree)) : []),
[paletteOpen, tree],
);
// ---- flat layout per workspace — leaves as siblings keyed by id ---------
// Each workspace gets its own .workspace-layer (rendered below). Layouts
// are recomputed inline per tab; the per-workspace render preserves
// LeafPane (and its PTY) across any tree reshape and across tab switches
// (since non-active layers are visibility:hidden rather than unmounted).
const paneWrapRef = useRef<HTMLDivElement>(null);
const onGutterRatio = useCallback((splitId: NodeId, ratio: number) => {
setTree((t) => updateSplitRatio(t, splitId, ratio));
}, []);
// ---- global broadcast state (derived from tree) -------------------------
const broadcastStats = useMemo(() => {
let on = 0;
let total = 0;
for (const leaf of walkLeaves(tree)) {
total++;
if (leaf.broadcast) on++;
}
return { on, total };
}, [tree]);
const toggleBroadcastAll = useCallback(() => {
// If any pane is broadcasting, turn them all off. Otherwise turn them all on.
setTree((t) => setAllBroadcast(t, broadcastStats.on === 0));
}, [broadcastStats.on]);
const broadcastBtnLabel =
broadcastStats.on === 0
? "📡 all off"
: broadcastStats.on === broadcastStats.total
? "📡 all on"
: `📡 ${broadcastStats.on}/${broadcastStats.total}`;
const onPalettePick = useCallback((leafId: string) => {
setActiveLeafId(leafId);
setPaletteOpen(false);
}, []);
// Label shown on the default-shell chip — current selection at a glance.
const isDefaultDistro = (d: string) =>
defaultShell.shellKind === "wsl" && defaultShell.distro === d;
const isDefaultPowershell = defaultShell.shellKind === "powershell";
const defaultShellLabel =
defaultShell.shellKind === "powershell"
? "PowerShell"
: (defaultShell.distro ?? "(none)");
return (
<div className="app">
<header className="titlebar">
<span className="label">tiletopia</span>
<span className="distro-wrap">
<button
className="titlebar-chip"
onClick={(e) => {
e.stopPropagation();
setDefaultShellMenuOpen((v) => !v);
}}
title="Shell that '+' will spawn (also the boot default)"
>
{defaultShellLabel}
</button>
{defaultShellMenuOpen && (
<div
className="distro-menu shell-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
{distros.length > 0 ? (
<>
<div className="shell-menu-header">WSL</div>
{distros.map((d) => (
<button
key={d}
className={`distro-menu-item${isDefaultDistro(d) ? " active" : ""}`}
onClick={() => {
setDefaultShell({ shellKind: "wsl", distro: d });
setDefaultShellMenuOpen(false);
}}
>
{d}
</button>
))}
</>
) : (
<>
<div className="shell-menu-header">WSL</div>
<div className="shell-menu-empty">(no distros)</div>
</>
)}
<div className="shell-menu-header">Windows</div>
<button
className={`distro-menu-item${isDefaultPowershell ? " active" : ""}`}
onClick={() => {
setDefaultShell({ shellKind: "powershell" });
setDefaultShellMenuOpen(false);
}}
>
PowerShell
</button>
</div>
)}
</span>
<button
className="titlebar-chip add-pane"
onClick={addPane}
title={`Spawn a new ${defaultShellLabel} pane (splits the active pane)`}
aria-label="Add pane"
>
+
</button>
<button
className="titlebar-chip"
onClick={openHostManager}
title="Add, edit, or remove saved SSH hosts"
aria-label="Manage SSH hosts"
>
🔑
</button>
<span className="distro-wrap">
<button
className="titlebar-chip"
onClick={(e) => {
e.stopPropagation();
setLayoutMenuOpen((v) => !v);
}}
title="Apply a preset layout (replaces current panes)"
>
layout
</button>
{layoutMenuOpen && (
<div
className="distro-menu shell-menu"
role="menu"
onClick={(e) => e.stopPropagation()}
>
<button
className="distro-menu-item"
onClick={() => {
applyPreset(presetSingle);
setLayoutMenuOpen(false);
}}
>
Single pane
</button>
<button
className="distro-menu-item"
onClick={() => {
applyPreset(presetTwoColumns);
setLayoutMenuOpen(false);
}}
>
Two columns
</button>
<button
className="distro-menu-item"
onClick={() => {
applyPreset(presetThreeColumns);
setLayoutMenuOpen(false);
}}
>
Three columns
</button>
<button
className="distro-menu-item"
onClick={() => {
applyPreset(presetTwoRows);
setLayoutMenuOpen(false);
}}
>
Two rows
</button>
<button
className="distro-menu-item"
onClick={() => {
applyPreset(presetTwoByTwo);
setLayoutMenuOpen(false);
}}
>
2 × 2 grid
</button>
</div>
)}
</span>
<button
className={`palette-btn bcast-all${broadcastStats.on > 0 ? " on" : ""}${broadcastStats.on > 0 && broadcastStats.on < broadcastStats.total ? " partial" : ""}`}
onClick={toggleBroadcastAll}
title={
broadcastStats.on === 0
? "Broadcast to ALL panes (Ctrl+Shift+Alt+B)"
: broadcastStats.on === broadcastStats.total
? "All panes broadcasting — click to disable (Ctrl+Shift+Alt+B)"
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all (Ctrl+Shift+Alt+B)`
}
>
{broadcastBtnLabel}
</button>
<button
className="palette-btn"
onClick={() => setPaletteOpen(true)}
title="Jump to pane (Ctrl+K)"
>
K
</button>
<button
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
onClick={() => setMcpPanelOpen(true)}
title={
mcpStatus.running
? `MCP server running (${allowedPaneCount} of ${leafCount(tree)} panes visible) — click for details`
: "MCP server is OFF — click to configure / start"
}
aria-label="MCP server"
aria-pressed={mcpStatus.running ? "true" : "false"}
>
🤖
</button>
<button
className="palette-btn"
onClick={() => setHelpOpen(true)}
title="Show keyboard shortcuts and tips (F1)"
aria-label="Help"
>
?
</button>
<span className="layout-info">
{leafCount(tree)} pane{leafCount(tree) === 1 ? "" : "s"}
{idleLeafIds.size > 0 && (
<span className="idle-info" title="Panes that haven't produced output recently">
{" · "}
{idleLeafIds.size} idle
</span>
)}
</span>
</header>
<TabStrip
workspaces={workspaces}
currentWorkspaceId={currentWorkspaceId}
onSwitch={switchTab}
onCreate={createTab}
onClose={closeTab}
onRename={renameTab}
/>
<div className="pane-wrap" ref={paneWrapRef}>
{ready && (
<OrchestrationProvider value={orch}>
{workspaces.map((ws) => {
const wsLayout = flattenLayout(ws.tree);
const isCurrent = ws.id === currentWorkspaceId;
return (
<div
key={ws.id}
className={`workspace-layer${isCurrent ? " active" : ""}`}
data-workspace-id={ws.id}
style={{
position: "absolute",
inset: 0,
visibility: isCurrent ? "visible" : "hidden",
pointerEvents: isCurrent ? "auto" : "none",
zIndex: isCurrent ? 1 : 0,
}}
aria-hidden={isCurrent ? "false" : "true"}
>
{wsLayout.leaves.map(({ leaf, box }) => (
<div
key={leaf.id}
className="leaf-slot"
style={{
position: "absolute",
top: `${box.top * 100}%`,
left: `${box.left * 100}%`,
width: `${box.width * 100}%`,
height: `${box.height * 100}%`,
}}
>
<LeafPane leaf={leaf} />
</div>
))}
{isCurrent &&
wsLayout.gutters.map((g) => (
<Gutter
key={g.splitId}
info={g}
containerRef={paneWrapRef}
onRatioChange={onGutterRatio}
/>
))}
</div>
);
})}
</OrchestrationProvider>
)}
</div>
<Notifications notifications={notifications} onDismiss={dismissNotification} />
{paletteOpen && (
<Palette
leaves={paletteLeaves}
onPick={onPalettePick}
onClose={() => setPaletteOpen(false)}
/>
)}
{hostManagerOpen && (
<HostManager
hosts={hosts}
onSave={saveHosts}
onSavePassword={savePassword}
onClearPassword={clearPassword}
onConnect={(hostId) => {
connectToHost(hostId);
closeHostManager();
}}
onClose={closeHostManager}
/>
)}
{helpOpen && <Help onClose={() => setHelpOpen(false)} />}
{mcpPanelOpen && (
<McpPanel
status={mcpStatus}
onStart={startMcp}
onStop={stopMcp}
onRegenerateToken={regenerateMcpToken}
onClose={() => setMcpPanelOpen(false)}
allowedPaneCount={allowedPaneCount}
totalPaneCount={leafCount(tree)}
auditEntries={auditEntries}
onClearAudit={clearAudit}
/>
)}
{confirmQueue.length > 0 && (
<McpConfirm
spec={confirmQueue[0]}
onAccept={() => dismissConfirm(true)}
onReject={() => dismissConfirm(false)}
onAlwaysAllow={async () => {
await alwaysAllowTool(confirmQueue[0].tool);
dismissConfirm(true);
}}
/>
)}
</div>
);
}
/** When splitting a leaf, the new sibling defaults to whatever the parent
* is running — so "split right" inside an Ubuntu pane gives you another
* Ubuntu pane, same SSH host gives you another connection to that host,
* etc. If no parent (shouldn't happen with current callers), fall back to
* the app-level default. */
function inheritShellFromParent(
parent: LeafNode | null,
fallback: DefaultShell,
): Partial<LeafNode> {
if (!parent) return defaultShellAsLeafProps(fallback);
if (parent.shellKind === "wsl") {
return {
shellKind: "wsl",
distro: parent.distro ?? (fallback.shellKind === "wsl" ? fallback.distro : undefined),
cwd: parent.cwd,
};
}
if (parent.shellKind === "powershell") {
return { shellKind: "powershell" };
}
return { shellKind: "ssh", sshHostId: parent.sshHostId };
}
/** For previously-saved workspaces written before shellKind existed: any
* WSL leaf without an explicit distro inherits the resolved default. */
function backfillWslDistro(node: TreeNode, fallback: string) {
if (node.kind === "leaf") {
if (node.shellKind === "wsl" && !node.distro) node.distro = fallback;
} else {
backfillWslDistro(node.a, fallback);
backfillWslDistro(node.b, fallback);
}
}