Tidy titlebar: dropdowns for shell + layout, '+' button to spawn

- Collapse the inline distro buttons + PowerShell + 🔑 SSH hosts into
  a single 'Ubuntu ▾' dropdown (WSL distros + PowerShell sections),
  with 🔑 as a separate icon-only button.
- Collapse the 5 preset buttons into a 'layout ▾' dropdown.
- Add a '+' button next to the shell picker that spawns a new pane of
  the picked shell by splitting the active pane (smart orientation:
  splits right if wide, down if tall). Per-pane ⇥/⇣ arrows still
  inherit from parent — only '+' uses the titlebar selection.
- Drop the 🔔 test-toast button.
- Drop overflow:hidden from titlebar + pane toolbar so dropdowns
  aren't clipped; height lock + nowrap still prevent the reflow bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-25 22:26:41 +01:00
parent e46446444e
commit fa18307fd9
3 changed files with 197 additions and 66 deletions

View file

@ -20,12 +20,11 @@
text would otherwise wrap (e.g. "📡 all off") would grow the
titlebar, shrink .pane-wrap, and reflow every xterm. nowrap stops
text-wrap inside buttons, flex-shrink:0 stops children from being
squeezed, height locks the row height, overflow:hidden clips items
that don't fit (widen the window to see them). */
squeezed, height locks the row height. Overflow is left visible
so dropdown menus below the chips aren't clipped by the bar. */
white-space: nowrap;
height: 34px;
box-sizing: border-box;
overflow: hidden;
}
.titlebar > * {
flex-shrink: 0;
@ -35,12 +34,7 @@
color: #ddd;
}
.distros, .presets {
display: flex;
gap: 4px;
align-items: center;
}
.distro-btn, .preset-btn, .palette-btn {
.titlebar-chip, .palette-btn {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
@ -51,15 +45,21 @@
padding: 2px 8px;
cursor: pointer;
}
.distro-btn:hover, .preset-btn:hover, .palette-btn:hover {
.titlebar-chip:hover, .palette-btn:hover {
background: #2a2a2a;
color: #ddd;
}
.distro-btn.active {
background: #1a3a5c;
.titlebar-chip.add-pane {
font-size: 14px;
line-height: 1;
padding: 2px 8px;
color: #cce6ff;
border-color: #2a5a8c;
}
.titlebar-chip.add-pane:hover {
background: #1a3a5c;
color: #fff;
}
.palette-btn.bcast-all.on {
background: #4a3010;
color: #f0c060;
@ -74,14 +74,6 @@
color: #80e080;
border-color: #2a6a2a;
}
.preset-btn {
min-width: 28px;
text-align: center;
}
.muted {
color: #666;
font-style: italic;
}
.layout-info {
margin-left: auto;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;

View file

@ -105,6 +105,8 @@ export default function App() {
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());
@ -249,6 +251,36 @@ export default function App() {
[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]);
const close = useCallback(
(leafId: NodeId) => {
const paneId = paneIdByLeafRef.current.get(leafId);
@ -339,6 +371,21 @@ export default function App() {
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
@ -742,56 +789,155 @@ export default function App() {
setPaletteOpen(false);
}, []);
// Titlebar default-shell picker: WSL distros + a single PowerShell button.
// SSH never lives here — connections are always per-pane and explicit.
// 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="distros">
<span className="muted">default:</span>
{distros.length === 0 ? (
<span className="muted">no WSL distros</span>
) : (
distros.map((d) => (
<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
key={d}
className={`distro-btn${isDefaultDistro(d) ? " active" : ""}`}
onClick={() => setDefaultShell({ shellKind: "wsl", distro: d })}
title="Set default shell for new panes"
className={`distro-menu-item${isDefaultPowershell ? " active" : ""}`}
onClick={() => {
setDefaultShell({ shellKind: "powershell" });
setDefaultShellMenuOpen(false);
}}
>
{d}
PowerShell
</button>
))
</div>
)}
<button
className={`distro-btn${isDefaultPowershell ? " active" : ""}`}
onClick={() => setDefaultShell({ shellKind: "powershell" })}
title="Default new panes to PowerShell"
>
PowerShell
</button>
<button
className="distro-btn"
onClick={openHostManager}
title="Add, edit, or remove saved SSH hosts"
>
🔑 SSH hosts
</button>
</span>
<span className="presets">
<span className="muted">layout:</span>
<button className="preset-btn" title="Single pane" onClick={() => applyPreset(presetSingle)}>1</button>
<button className="preset-btn" title="Two columns" onClick={() => applyPreset(presetTwoColumns)}>2H</button>
<button className="preset-btn" title="Three columns" onClick={() => applyPreset(presetThreeColumns)}>3H</button>
<button className="preset-btn" title="Two rows" onClick={() => applyPreset(presetTwoRows)}>2V</button>
<button className="preset-btn" title="2 × 2 grid" onClick={() => applyPreset(presetTwoByTwo)}>2×2</button>
<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
@ -815,13 +961,6 @@ export default function App() {
>
K
</button>
<button
className="palette-btn"
onClick={() => notify("test toast at " + new Date().toLocaleTimeString())}
title="Fire a test toast"
>
🔔
</button>
<button
className={`palette-btn mcp-btn${mcpStatus.running ? " on" : ""}`}
onClick={() => setMcpPanelOpen(true)}

View file

@ -61,11 +61,11 @@
height: 24px;
box-sizing: border-box;
/* Lock height: a narrow pane used to wrap toolbar items to 2+ rows,
which shrank the xterm beneath and reflowed the terminal. Clip
overflow instead of showing a scrollbar (which would itself eat
into the 24px and crush the buttons). */
which shrank the xterm beneath and reflowed the terminal. nowrap +
flex-shrink:0 keeps items at natural width on one row; overflow is
left visible so the shell-picker dropdown (rendered below the
toolbar) isn't clipped. */
white-space: nowrap;
overflow: hidden;
}
.pane-toolbar > * {
flex-shrink: 0;