From 95d99bc6ad94668706711f3753c1a56da9d9f519 Mon Sep 17 00:00:00 2001 From: Edgar Twigg Date: Thu, 18 Jun 2026 07:19:52 -0700 Subject: [PATCH] TerminalPaneHeader: keep minimize/close visible as the header narrows Make the minimize and close controls the highest-priority elements of the terminal pane header so they stay visible no matter how narrow it gets. - Add `overflow-hidden` to the leading title/bell region so it clips cleanly instead of overflowing onto the controls. - Hide the mouse-override icon at the `minimal` tier, matching how split/zoom drop below `full` and the TODO pill drops at `minimal`. Add Storybook stories that confirm the invariant with a self-checking play function (`assertControlsVisible`) that reads live layout geometry and throws if either control is missing, zero-size, or clipped outside the header bounds. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/wall/TerminalPaneHeader.tsx | 12 +- .../stories/TerminalPaneHeader.stories.tsx | 112 ++++++++++++++++++ 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/lib/src/components/wall/TerminalPaneHeader.tsx b/lib/src/components/wall/TerminalPaneHeader.tsx index 554404e0..a67e1473 100644 --- a/lib/src/components/wall/TerminalPaneHeader.tsx +++ b/lib/src/components/wall/TerminalPaneHeader.tsx @@ -209,7 +209,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) { className={tabVariant({ state: isActiveHeader ? 'active' : 'inactive' })} onMouseDown={() => actions.onClickPanel(api.id)} > -
+
{isRenaming ? ( {!isRenaming && ( <> - {showMouseIcon && ( + {showMouseIcon && tier !== 'minimal' && (
{zoomed ? : }
)} + {/* + Minimize + close are the highest-priority controls: they must stay + visible no matter how narrow the header gets. They sit last (so + nothing fixed-width is to their right to push them off) and every + other element yields first — the title/bell region clips via + `overflow-hidden`, split/zoom drop below the `full` tier, and the + mouse icon drops at the `minimal` tier. + */}
{ + if (!mouseCaptured) return; + setMouseReporting(SESSION_ID, 'any'); + setOverride(SESSION_ID, 'temporary'); + return () => removeMouseSelectionState(SESSION_ID); + }, [mouseCaptured]); + return ( @@ -132,6 +144,51 @@ async function submitReservedRename() { await wait(50); } +/** + * Confirms the minimize + close controls are the top-priority elements of the + * header: they must render and stay fully inside the header bounds (never + * clipped or pushed out) no matter how narrow it gets. Throws — so the failure + * surfaces in Storybook's Interactions panel — if either control is missing, + * collapsed to zero size, or sticking outside the header's horizontal extent. + */ +async function assertControlsVisible({ canvasElement }: { canvasElement: HTMLElement }) { + const CONTROLS = [ + ['Minimize', '[aria-label="Minimize"]'], + ['Kill', '[aria-label="Kill"]'], + ] as const; + const EPS = 0.5; + + // Returns a human-readable reason the controls aren't fully visible yet, or + // null once every control is rendered and sits inside the header bounds. + const violation = (): string | null => { + const header = canvasElement.querySelector('.bg-app-bg'); + if (!header) return 'header container not found'; + const bounds = header.getBoundingClientRect(); + for (const [name, selector] of CONTROLS) { + const el = canvasElement.querySelector(selector); + if (!el) return `${name} button is not rendered`; + const r = el.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return `${name} button collapsed to zero size (hidden)`; + if (r.left < bounds.left - EPS || r.right > bounds.right + EPS) { + return `${name} button is clipped: button x=[${r.left.toFixed(1)}, ${r.right.toFixed(1)}] ` + + `exceeds header x=[${bounds.left.toFixed(1)}, ${bounds.right.toFixed(1)}]`; + } + } + return null; + }; + + // Poll until the primed state (two rAFs) and the ResizeObserver-driven tier + // have settled, instead of guessing a fixed delay. Surface the last reason if + // it never settles within the timeout. + const start = performance.now(); + let reason = violation(); + while (reason && performance.now() - start < 1000) { + await wait(16); + reason = violation(); + } + if (reason) throw new Error(reason); +} + const NOTIFICATIONS = { osc9BodyOnly: { source: 'OSC 9', @@ -175,6 +232,7 @@ const meta: Meta = { title: { control: 'text' }, width: { control: 'number' }, reducedMotion: { control: 'boolean' }, + mouseCaptured: { control: 'boolean' }, }, args: { title: 'build-server', @@ -183,6 +241,7 @@ const meta: Meta = { isRenaming: false, width: 360, reducedMotion: false, + mouseCaptured: false, }, }; @@ -371,3 +430,56 @@ export const RenameRejectedReserved: Story = { }), play: submitReservedRename, }; + +// --- Minimize + close stay visible as the header shrinks ------------------- +// +// These stories drive the header down to (and below) the `minimal` tier and +// assert in their play function that the minimize and close controls remain +// rendered and fully inside the header bounds. The assertion uses live layout +// geometry, so it confirms the controls in the real Storybook browser. + +export const NarrowControlsVisible: Story = { + args: { + width: 110, + }, + parameters: primedState({ + status: 'NOTHING_TO_SHOW', + todo: false, + }), + play: assertControlsVisible, +}; + +export const ExtremelyNarrowControlsVisible: Story = { + args: { + width: 76, + }, + parameters: primedState({ + status: 'ALERT_RINGING', + todo: true, + }), + play: assertControlsVisible, +}; + +export const NarrowWithMouseCaptureControlsVisible: Story = { + args: { + width: 120, + mouseCaptured: true, + }, + parameters: primedState({ + status: 'NOTHING_TO_SHOW', + todo: false, + }), + play: assertControlsVisible, +}; + +export const NarrowLongTitleControlsVisible: Story = { + args: { + title: 'my-extremely-long-running-background-process-with-a-very-descriptive-name', + width: 130, + }, + parameters: primedState({ + status: 'ALERT_RINGING', + todo: true, + }), + play: assertControlsVisible, +};