Phase 1: tabbed workspaces

Each tab is an independent tile tree; PTYs in non-active tabs keep
running (render-all-panes with visibility:hidden on inactive layers
so xterm.js's fit() still sees valid dimensions and the existing
per-pane resize dedupe absorbs no-op SIGWINCHes).

workspace.json shape goes from a bare TreeNode to
`{ version: 2, workspaces: [{ id, name, tree }] }` with a legacy v1
auto-wrap migration (the old single tree becomes one tab named
"Default").

App.tsx wraps the old single-tree state in workspace-aware state
but keeps `tree` / `setTree` / `activeLeafId` / `setActiveLeafId` as
identity-stable derived wrappers (reading currentWorkspaceId from a
ref), so the bulk of App.tsx stays unchanged.

XtermPane's initial term.focus() now checks `visibility !== "hidden"`
on the container so a pane mounting inside a hidden tab on app boot
doesn't yank focus away from the active tab. The focus poller is
scoped to the active workspace layer for the same reason.

Shortcuts: Ctrl+T new tab, Ctrl+Shift+T close current (window.confirm
when there are live panes), Ctrl+PageDown/PageUp navigate, Ctrl+1..9
switch to tab N. README + help overlay auto-generated from
shortcuts.ts.

79/79 vitest pass (7 new envelope-migration cases). tsc -b clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-28 18:43:32 +01:00
parent c92847413b
commit 1a035ad0a6
8 changed files with 933 additions and 52 deletions

View file

@ -35,6 +35,7 @@ import {
type Orientation,
type LeafNode,
type LeafShellSpec,
type Workspace,
newLeaf,
newId,
splitLeaf,
@ -59,7 +60,9 @@ import {
MIN_PANE_PX,
type Direction,
serialize,
deserialize,
serializeWorkspaces,
deserializeWorkspaces,
singletonEnvelope,
presetSingle,
presetTwoColumns,
presetThreeColumns,
@ -75,6 +78,7 @@ 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";
@ -114,8 +118,86 @@ function describeSpec(spec: SpawnSpec): string {
export default function App() {
// ---- top-level state -----------------------------------------------------
const [tree, setTree] = useState<TreeNode>(() => newLeaf());
const [activeLeafId, setActiveLeafId] = useState<NodeId | null>(null);
// 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",
@ -142,24 +224,34 @@ export default function App() {
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 () => {
let loaded: TreeNode | null = null;
let loadedEnvelope: ReturnType<typeof deserializeWorkspaces> = null;
try {
const json = await loadWorkspace();
if (json) loaded = deserialize(json);
if (json) loadedEnvelope = deserializeWorkspaces(json);
} catch (e) {
console.warn("loadWorkspace failed:", e);
}
if (!loaded) {
if (!loadedEnvelope) {
try {
const legacy = localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
loaded = deserialize(legacy);
if (loaded) void saveWorkspace(legacy);
loadedEnvelope = deserializeWorkspaces(legacy);
if (loadedEnvelope) {
void saveWorkspace(serializeWorkspaces(loadedEnvelope));
}
localStorage.removeItem(LEGACY_STORAGE_KEY);
}
} catch (e) {
@ -190,14 +282,20 @@ export default function App() {
})();
if (cancelled) return;
if (loaded) {
if (initialDefault.shellKind === "wsl" && initialDefault.distro) {
backfillWslDistro(loaded, initialDefault.distro);
}
setTree(loaded);
} else {
setTree(newLeaf(defaultShellAsLeafProps(initialDefault)));
let envelope = loadedEnvelope;
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);
setDistros(resolvedDistros);
setHosts(resolvedHosts);
setDefaultShell(initialDefault);
@ -212,27 +310,34 @@ export default function App() {
useEffect(() => {
if (!ready) return;
const id = window.setTimeout(() => {
saveWorkspace(serialize(tree)).catch((e) =>
console.warn("saveWorkspace failed:", e),
);
saveWorkspace(
serializeWorkspaces({ version: 2, workspaces }),
).catch((e) => console.warn("saveWorkspace failed:", e));
}, SAVE_DEBOUNCE_MS);
return () => clearTimeout(id);
}, [tree, ready]);
}, [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]");
const id = leafEl?.getAttribute("data-leaf-id") ?? null;
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
@ -364,6 +469,95 @@ export default function App() {
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));
}, []);
@ -583,6 +777,65 @@ export default function App() {
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;
@ -629,7 +882,7 @@ export default function App() {
window.addEventListener("keydown", onKey, true);
return () => window.removeEventListener("keydown", onKey, true);
}, [split, close, toggleBroadcast, promoteActive]);
}, [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
@ -1408,11 +1661,11 @@ export default function App() {
[paletteOpen, tree],
);
// ---- flat layout — leaves as siblings keyed by id; gutters separate -----
// This lets React preserve LeafPane (and its PTY) across any tree reshape
// — split, close, preset application, etc. The tree changes, the boxes
// change, the leaves re-position but DON'T unmount.
const layout = useMemo(() => flattenLayout(tree), [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));
@ -1651,32 +1904,62 @@ export default function App() {
</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}>
{layout.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>
))}
{layout.gutters.map((g) => (
<Gutter
key={g.splitId}
info={g}
containerRef={paneWrapRef}
onRatioChange={onGutterRatio}
/>
))}
{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>