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

@ -38,6 +38,15 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
| `Ctrl+Shift+W` | Close active pane |
| `Ctrl+Shift+P` | Promote active pane out one level (turns a nested pane into a full row/column; self-inverse) |
**Tabs**
| Key | Action |
|---|---|
| `Ctrl+T` | New tab (blank workspace, one pane) |
| `Ctrl+Shift+T` | Close current tab (confirms when the tab has live panes) |
| `Ctrl+PageDown / Ctrl+PageUp` | Switch to next / previous tab |
| `Ctrl+1 … Ctrl+9` | Switch to tab 1 … 9 |
**Navigation**
| Key | Action |
@ -79,6 +88,7 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil
- **Clickable links** — http and https URLs in terminal output get underlined and open in your default browser on click.
- **Drag pane headers to swap** — Grab a pane's title bar and drag it onto another pane to swap their tree positions. Useful for reorganizing without keyboard.
- **Workspace persistence** — Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.
- **Tabs (workspaces)** — Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.
- **MCP server (let Claude drive the workspace)** — Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.
<!-- SHORTCUTS:END -->

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>

178
src/components/TabStrip.css Normal file
View file

@ -0,0 +1,178 @@
.tab-strip {
flex: 0 0 auto;
display: flex;
align-items: stretch;
gap: 2px;
padding: 4px 8px 0 8px;
background: #161616;
border-bottom: 1px solid #2a2a2a;
font-size: 12px;
color: #aaa;
user-select: none;
overflow-x: auto;
overflow-y: visible;
min-height: 28px;
box-sizing: border-box;
white-space: nowrap;
/* Allow the inline confirm popover to spill over the bottom edge instead
of being clipped by overflow:hidden. overflow-y:visible alongside
overflow-x:auto only works because confirm popovers position themselves
absolutely. */
}
.tab-strip-item {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 4px 4px 10px;
border: 1px solid #2a2a2a;
border-bottom: none;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
background: #1a1a1a;
color: #999;
cursor: pointer;
max-width: 200px;
min-width: 80px;
flex-shrink: 0;
}
.tab-strip-item:hover {
background: #232323;
color: #ccc;
}
.tab-strip-item.active {
background: #0c0c0c;
color: #e6e6e6;
border-color: #2a5a8c;
/* Pull the active tab visually onto the pane area below it. */
margin-bottom: -1px;
}
.tab-strip-name {
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
min-width: 0;
}
.tab-strip-rename {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #0c0c0c;
color: #e6e6e6;
border: 1px solid #2a5a8c;
border-radius: 2px;
padding: 1px 4px;
width: 100%;
flex: 1 1 auto;
min-width: 0;
outline: none;
}
.tab-strip-close {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 13px;
line-height: 1;
background: transparent;
color: #777;
border: none;
border-radius: 2px;
padding: 0 4px;
cursor: pointer;
flex: 0 0 auto;
}
.tab-strip-close:hover {
background: #c94040;
color: #fff;
}
.tab-strip-add {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 14px;
line-height: 1;
background: #1a1a1a;
color: #aaa;
border: 1px solid #2a2a2a;
border-radius: 4px;
padding: 2px 10px;
cursor: pointer;
align-self: center;
margin-left: 4px;
flex: 0 0 auto;
}
.tab-strip-add:hover {
background: #1a3a5c;
color: #fff;
border-color: #2a5a8c;
}
/* Inline confirm popover anchored to the close button spills below the
strip. Plain matte panel; reuses the existing app palette. */
.tab-strip-confirm {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 50;
min-width: 260px;
max-width: 360px;
background: #1a1a1a;
color: #e6e6e6;
border: 1px solid #c98a1f;
border-radius: 4px;
padding: 10px 12px;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
white-space: normal;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.6);
cursor: default;
}
.tab-strip-confirm-title {
font-weight: 600;
color: #f0c060;
margin-bottom: 6px;
}
.tab-strip-confirm-body {
color: #ccc;
margin-bottom: 10px;
}
.tab-strip-confirm-labels {
color: #e6e6e6;
font-size: 11px;
margin-top: 4px;
word-break: break-word;
}
.tab-strip-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.tab-strip-confirm-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
background: #2a2a2a;
color: #ddd;
border: 1px solid #333;
border-radius: 3px;
padding: 4px 10px;
cursor: pointer;
}
.tab-strip-confirm-btn.cancel:hover {
background: #333;
}
.tab-strip-confirm-btn.destructive {
background: #4a1010;
color: #f8c0c0;
border-color: #c94040;
}
.tab-strip-confirm-btn.destructive:hover {
background: #6a1818;
color: #fff;
}

212
src/components/TabStrip.tsx Normal file
View file

@ -0,0 +1,212 @@
import {
useState,
useRef,
useEffect,
useCallback,
useMemo,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from "react";
import { walkLeaves, leafCount, type Workspace, type NodeId } from "../lib/layout/tree";
import "./TabStrip.css";
interface TabStripProps {
workspaces: Workspace[];
currentWorkspaceId: NodeId | null;
onSwitch: (id: NodeId) => void;
onCreate: () => void;
/** Caller MUST handle PTY teardown for the tab's leaves before removing it
* from the workspaces list. TabStrip just gates the action on user
* confirm. */
onClose: (id: NodeId) => void;
onRename: (id: NodeId, name: string) => void;
}
/** Tab strip displayed above the pane area. One pill per workspace; click to
* switch, double-click name to rename, × to close (with inline confirm if
* the tab has live panes), + at the end to spawn a new blank workspace. */
export default function TabStrip({
workspaces,
currentWorkspaceId,
onSwitch,
onCreate,
onClose,
onRename,
}: TabStripProps) {
const [editingId, setEditingId] = useState<NodeId | null>(null);
const [draft, setDraft] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);
const [confirmingId, setConfirmingId] = useState<NodeId | null>(null);
const startEdit = useCallback(
(id: NodeId, current: string, e: ReactMouseEvent) => {
e.stopPropagation();
setEditingId(id);
setDraft(current);
queueMicrotask(() => editInputRef.current?.select());
},
[],
);
const commitEdit = useCallback(() => {
if (editingId == null) return;
const trimmed = draft.trim();
if (trimmed) onRename(editingId, trimmed);
setEditingId(null);
}, [editingId, draft, onRename]);
const cancelEdit = useCallback(() => setEditingId(null), []);
const onEditKey = useCallback(
(e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
}
},
[commitEdit, cancelEdit],
);
// Outside-click dismissal for the inline confirm popover.
useEffect(() => {
if (confirmingId == null) return;
const onDocClick = () => setConfirmingId(null);
// Run on next tick so the click that opened the confirm doesn't immediately close it.
const id = window.setTimeout(
() => window.addEventListener("click", onDocClick),
0,
);
return () => {
clearTimeout(id);
window.removeEventListener("click", onDocClick);
};
}, [confirmingId]);
const confirmingWorkspace = useMemo(
() => workspaces.find((w) => w.id === confirmingId) ?? null,
[workspaces, confirmingId],
);
const confirmingPaneLabels = useMemo(() => {
if (!confirmingWorkspace) return [] as string[];
return Array.from(walkLeaves(confirmingWorkspace.tree)).map(
(l) => l.label ?? l.distro ?? `(${l.shellKind})`,
);
}, [confirmingWorkspace]);
const requestClose = useCallback(
(id: NodeId, e: ReactMouseEvent) => {
e.stopPropagation();
const w = workspaces.find((ws) => ws.id === id);
if (!w) return;
// Silent close when the tab has no live panes (e.g. empty default leaf
// with no PTY yet — but every leaf has one, so effectively never zero).
// The leafCount check leaves room for a future "empty tab" state.
if (leafCount(w.tree) === 0) {
onClose(id);
return;
}
setConfirmingId(id);
},
[workspaces, onClose],
);
const confirmClose = useCallback(
(e: ReactMouseEvent) => {
e.stopPropagation();
if (confirmingId == null) return;
const id = confirmingId;
setConfirmingId(null);
onClose(id);
},
[confirmingId, onClose],
);
return (
<div className="tab-strip" role="tablist">
{workspaces.map((w) => {
const isActive = w.id === currentWorkspaceId;
const isEditing = editingId === w.id;
const isConfirming = confirmingId === w.id;
return (
<div
key={w.id}
className={`tab-strip-item${isActive ? " active" : ""}`}
role="tab"
aria-selected={isActive ? "true" : "false"}
onClick={() => onSwitch(w.id)}
onDoubleClick={(e) => startEdit(w.id, w.name, e)}
title={`Switch to ${w.name}`}
>
{isEditing ? (
<input
ref={editInputRef}
className="tab-strip-rename"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={onEditKey}
onBlur={commitEdit}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="tab-strip-name">{w.name}</span>
)}
<button
className="tab-strip-close"
onClick={(e) => requestClose(w.id, e)}
title="Close tab"
aria-label={`Close tab ${w.name}`}
tabIndex={-1}
>
×
</button>
{isConfirming && (
<div
className="tab-strip-confirm"
role="dialog"
aria-label="Confirm close tab"
onClick={(e) => e.stopPropagation()}
>
<div className="tab-strip-confirm-title">
Close "{confirmingWorkspace?.name}"?
</div>
<div className="tab-strip-confirm-body">
This will kill {confirmingPaneLabels.length} pane
{confirmingPaneLabels.length === 1 ? "" : "s"}:
<div className="tab-strip-confirm-labels">
{confirmingPaneLabels.join(", ")}
</div>
</div>
<div className="tab-strip-confirm-actions">
<button
className="tab-strip-confirm-btn cancel"
onClick={(e) => {
e.stopPropagation();
setConfirmingId(null);
}}
>
Cancel
</button>
<button
className="tab-strip-confirm-btn destructive"
onClick={confirmClose}
>
Close tab
</button>
</div>
</div>
)}
</div>
);
})}
<button
className="tab-strip-add"
onClick={onCreate}
title="New tab (Ctrl+T)"
aria-label="New tab"
>
+
</button>
</div>
);
}

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 () => {

View file

@ -21,6 +21,10 @@ import {
MAX_FONT_SIZE,
serialize,
deserialize,
serializeWorkspaces,
deserializeWorkspaces,
singletonEnvelope,
WORKSPACES_VERSION,
presetSingle,
presetTwoColumns,
presetThreeColumns,
@ -665,3 +669,82 @@ describe("serialize / deserialize", () => {
expect(back.sshHostId).toBe("h-1");
});
});
describe("workspaces envelope", () => {
it("roundtrips a multi-workspace envelope", () => {
const env = {
version: WORKSPACES_VERSION,
workspaces: [
{ id: "w1", name: "alpha", tree: newLeaf({ distro: "Ubuntu" }) },
{
id: "w2",
name: "beta",
tree: newSplit("h", newLeaf({ label: "left" }), newLeaf()),
},
],
};
const back = deserializeWorkspaces(serializeWorkspaces(env));
expect(back).toEqual(env);
});
it("returns null on invalid JSON", () => {
expect(deserializeWorkspaces("not json")).toBeNull();
});
it("returns null when version is wrong or workspaces is missing", () => {
expect(deserializeWorkspaces('{"version": 99, "workspaces": []}')).toBeNull();
expect(deserializeWorkspaces('{"version": 2}')).toBeNull();
});
it("returns null when an envelope has zero valid workspaces", () => {
expect(
deserializeWorkspaces('{"version": 2, "workspaces": [{"id": 1}]}'),
).toBeNull();
});
it("migrates a legacy v1 bare-tree JSON into a single 'Default' workspace", () => {
const legacy = JSON.stringify({
kind: "split",
id: "s1",
orientation: "h",
ratio: 0.5,
a: { kind: "leaf", id: "a", distro: "Ubuntu" },
b: { kind: "leaf", id: "b", distro: "PowerShell" },
});
const env = deserializeWorkspaces(legacy);
expect(env).not.toBeNull();
expect(env!.version).toBe(WORKSPACES_VERSION);
expect(env!.workspaces.length).toBe(1);
expect(env!.workspaces[0].name).toBe("Default");
// Per-leaf legacy migration also applied — PowerShell sentinel mapped.
const tree = env!.workspaces[0].tree as SplitNode;
expect((tree.a as LeafNode).shellKind).toBe("wsl");
expect((tree.b as LeafNode).shellKind).toBe("powershell");
expect((tree.b as LeafNode).distro).toBeUndefined();
});
it("singletonEnvelope wraps a tree with a fresh workspace id", () => {
const t = newLeaf({ label: "only" });
const env = singletonEnvelope(t, "Main");
expect(env.workspaces.length).toBe(1);
expect(env.workspaces[0].name).toBe("Main");
expect(env.workspaces[0].tree).toBe(t);
expect(typeof env.workspaces[0].id).toBe("string");
expect(env.workspaces[0].id).not.toBe(t.id);
});
it("skips malformed workspaces but keeps the valid ones", () => {
const env = {
version: WORKSPACES_VERSION,
workspaces: [
{ id: "ok", name: "alpha", tree: { kind: "leaf", id: "L" } },
{ id: 42, name: "bad-id", tree: { kind: "leaf", id: "L2" } },
{ id: "no-tree", name: "still-bad" },
],
};
const back = deserializeWorkspaces(JSON.stringify(env));
expect(back).not.toBeNull();
expect(back!.workspaces.length).toBe(1);
expect(back!.workspaces[0].id).toBe("ok");
});
});

View file

@ -703,6 +703,92 @@ export function deserialize(json: string): TreeNode | null {
}
}
// ---- workspaces envelope ---------------------------------------------------
/** One named tab in the tab strip. Each workspace owns its own tile tree;
* leaf NodeIds remain globally unique across workspaces so the app-level
* paneIdByLeaf map continues to work without partitioning. */
export interface Workspace {
id: NodeId;
name: string;
tree: TreeNode;
}
/** Top-level persistence shape. `version` bumps when the envelope schema
* changes; the v1 shape was a bare TreeNode at the JSON root, migrated
* automatically by {@link deserializeWorkspaces}. */
export interface WorkspacesEnvelope {
version: 2;
workspaces: Workspace[];
}
export const WORKSPACES_VERSION = 2 as const;
/** Construct an envelope wrapping a single workspace with the given tree.
* Used for first-launch and as the destination of the v1v2 migration. */
export function singletonEnvelope(tree: TreeNode, name = "Default"): WorkspacesEnvelope {
return {
version: WORKSPACES_VERSION,
workspaces: [{ id: newId(), name, tree }],
};
}
export function serializeWorkspaces(env: WorkspacesEnvelope): string {
return JSON.stringify(env);
}
/** Parse a persisted workspaces envelope. Accepts:
* - Current shape: `{ version: 2, workspaces: [{ id, name, tree }] }`
* - Legacy v1 shape: a bare {@link TreeNode} wrapped as one workspace
* named "Default" with a fresh id.
* Per-leaf legacy migrations ({@link migrateLegacyLeaves}) still apply to
* each workspace's tree. Returns null when the JSON is unrecognisable. */
export function deserializeWorkspaces(json: string): WorkspacesEnvelope | null {
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
// v1: bare TreeNode at the root
if (isTreeNode(parsed)) {
return singletonEnvelope(migrateLegacyLeaves(parsed));
}
// v2: envelope
if (
typeof parsed === "object" &&
parsed !== null &&
(parsed as { version?: unknown }).version === WORKSPACES_VERSION &&
Array.isArray((parsed as { workspaces?: unknown }).workspaces)
) {
const raw = (parsed as { workspaces: unknown[] }).workspaces;
const workspaces: Workspace[] = [];
for (const w of raw) {
if (
typeof w !== "object" ||
w === null ||
typeof (w as { id?: unknown }).id !== "string" ||
typeof (w as { name?: unknown }).name !== "string" ||
!isTreeNode((w as { tree?: unknown }).tree)
) {
continue;
}
const tw = w as { id: string; name: string; tree: TreeNode };
workspaces.push({
id: tw.id,
name: tw.name,
tree: migrateLegacyLeaves(tw.tree),
});
}
if (workspaces.length === 0) return null;
return { version: WORKSPACES_VERSION, workspaces };
}
return null;
}
/** Sentinel used in pre-shellKind workspaces to mark PowerShell panes. */
const LEGACY_POWERSHELL_DISTRO = "PowerShell";

View file

@ -30,6 +30,21 @@ export const SHORTCUT_SECTIONS: ShortcutSection[] = [
},
],
},
{
title: "Tabs",
items: [
{ keys: "Ctrl+T", description: "New tab (blank workspace, one pane)" },
{
keys: "Ctrl+Shift+T",
description: "Close current tab (confirms when the tab has live panes)",
},
{
keys: "Ctrl+PageDown / Ctrl+PageUp",
description: "Switch to next / previous tab",
},
{ keys: "Ctrl+1 … Ctrl+9", description: "Switch to tab 1 … 9" },
],
},
{
title: "Navigation",
items: [
@ -108,6 +123,10 @@ export const TIPS: TipSpec[] = [
title: "Workspace persistence",
body: "Layout, labels, distro choices, and SSH hosts auto-save to %APPDATA%/com.megaproxy.tiletopia (debounced 500ms). Closed panes don't come back — only the structure is restored, shells spawn fresh on next launch.",
},
{
title: "Tabs (workspaces)",
body: "Each tab is an independent tile layout — useful for keeping one tab per project. PTYs in non-active tabs keep running (a Claude session in tab A keeps going while you work in tab B). New tab starts with one default-shell pane; close confirms when the tab has live panes. Tabs auto-save to the same workspace.json.",
},
{
title: "MCP server (let Claude drive the workspace)",
body: "Titlebar 🤖 opens the MCP control panel. Start the server, then for Claude Desktop click 'Download .mcpb' and drag the file into Settings → Extensions — zero-config because the bundle reads your bearer token from %APPDATA% at launch (no copy-paste, survives token rotation). For Claude Code (terminal CLI) use the fallback snippet in the panel: it wires npx mcp-remote as a stdio shim because Claude Code's HTTP-MCP client ignores static bearer auth and tries OAuth instead. URL + token persist across restarts; Regenerate the token in the panel if it leaks. Default-deny per pane: toggle 🤖 on each pane's toolbar to expose it to MCP.",