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

@ -273,8 +273,18 @@ export default function XtermPane({
});
ro.observe(container);
// Focus so typing immediately lands in the terminal.
term?.focus();
// Focus so typing immediately lands in the terminal — but ONLY if the
// host container is actually visible. With multiple tabs (workspaces),
// a pane in a hidden tab still mounts and spawns; we must not yank
// focus into a tab the user can't see. CSS `visibility: hidden` is
// inherited, so the computed style on the container reflects whether
// any ancestor (workspace-layer) is hiding us.
if (
container.isConnected &&
getComputedStyle(container).visibility !== "hidden"
) {
term?.focus();
}
})();
return () => {