Skip to content

Commit 960112f

Browse files
authored
TerminalPaneHeader: keep minimize/close visible as the header narrows (#148)
2 parents d1f406e + 95d99bc commit 960112f

2 files changed

Lines changed: 122 additions & 2 deletions

File tree

lib/src/components/wall/TerminalPaneHeader.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
209209
className={tabVariant({ state: isActiveHeader ? 'active' : 'inactive' })}
210210
onMouseDown={() => actions.onClickPanel(api.id)}
211211
>
212-
<div className="flex flex-1 min-w-0 items-center gap-1.5">
212+
<div className="flex flex-1 min-w-0 items-center gap-1.5 overflow-hidden">
213213
{isRenaming ? (
214214
<input
215215
data-renaming-input-for={api.id}
@@ -315,7 +315,7 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
315315
</div>
316316
{!isRenaming && (
317317
<>
318-
{showMouseIcon && (
318+
{showMouseIcon && tier !== 'minimal' && (
319319
<div className="ml-1 shrink-0">
320320
<HeaderActionButton
321321
className="flex h-5 min-w-5 items-center justify-center rounded transition-colors shrink-0 hover:bg-current/10"
@@ -359,6 +359,14 @@ export function TerminalPaneHeader({ api }: IDockviewPanelHeaderProps) {
359359
>{zoomed ? <ArrowsInIcon size={14} /> : <ArrowsOutIcon size={14} />}</HeaderActionButton>
360360
</div>
361361
)}
362+
{/*
363+
Minimize + close are the highest-priority controls: they must stay
364+
visible no matter how narrow the header gets. They sit last (so
365+
nothing fixed-width is to their right to push them off) and every
366+
other element yields first — the title/bell region clips via
367+
`overflow-hidden`, split/zoom drop below the `full` tier, and the
368+
mouse icon drops at the `minimal` tier.
369+
*/}
362370
<div className="ml-1 flex shrink-0 items-center gap-0.5">
363371
<HeaderActionButton
364372
className="flex h-5 min-w-5 items-center justify-center rounded transition-colors hover:bg-current/10"

lib/src/stories/TerminalPaneHeader.stories.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from 'react';
12
import type { Meta, StoryObj } from '@storybook/react';
23
import {
34
TerminalPaneHeader,
@@ -10,6 +11,7 @@ import {
1011
} from '../components/Wall';
1112
import type { ActivityNotification } from '../lib/alert-manager';
1213
import type { SetTerminalUserTitleResult } from '../lib/terminal-registry';
14+
import { removeMouseSelectionState, setMouseReporting, setOverride } from '../lib/mouse-selection';
1315

1416
const SESSION_ID = 'tab-story';
1517

@@ -58,6 +60,7 @@ function TabStory({
5860
isRenaming = false,
5961
width = 360,
6062
reducedMotion = false,
63+
mouseCaptured = false,
6164
actions = noopActions,
6265
}: {
6366
title?: string;
@@ -66,10 +69,19 @@ function TabStory({
6669
isRenaming?: boolean;
6770
width?: number;
6871
reducedMotion?: boolean;
72+
/** Simulate a TUI capturing the mouse, which surfaces the mouse-override icon. */
73+
mouseCaptured?: boolean;
6974
actions?: WallActions;
7075
}) {
7176
const mockApi = { id: SESSION_ID, title } as any;
7277

78+
useEffect(() => {
79+
if (!mouseCaptured) return;
80+
setMouseReporting(SESSION_ID, 'any');
81+
setOverride(SESSION_ID, 'temporary');
82+
return () => removeMouseSelectionState(SESSION_ID);
83+
}, [mouseCaptured]);
84+
7385
return (
7486
<ModeContext.Provider value={mode}>
7587
<SelectedIdContext.Provider value={isSelected ? SESSION_ID : null}>
@@ -132,6 +144,51 @@ async function submitReservedRename() {
132144
await wait(50);
133145
}
134146

147+
/**
148+
* Confirms the minimize + close controls are the top-priority elements of the
149+
* header: they must render and stay fully inside the header bounds (never
150+
* clipped or pushed out) no matter how narrow it gets. Throws — so the failure
151+
* surfaces in Storybook's Interactions panel — if either control is missing,
152+
* collapsed to zero size, or sticking outside the header's horizontal extent.
153+
*/
154+
async function assertControlsVisible({ canvasElement }: { canvasElement: HTMLElement }) {
155+
const CONTROLS = [
156+
['Minimize', '[aria-label="Minimize"]'],
157+
['Kill', '[aria-label="Kill"]'],
158+
] as const;
159+
const EPS = 0.5;
160+
161+
// Returns a human-readable reason the controls aren't fully visible yet, or
162+
// null once every control is rendered and sits inside the header bounds.
163+
const violation = (): string | null => {
164+
const header = canvasElement.querySelector<HTMLElement>('.bg-app-bg');
165+
if (!header) return 'header container not found';
166+
const bounds = header.getBoundingClientRect();
167+
for (const [name, selector] of CONTROLS) {
168+
const el = canvasElement.querySelector<HTMLElement>(selector);
169+
if (!el) return `${name} button is not rendered`;
170+
const r = el.getBoundingClientRect();
171+
if (r.width <= 0 || r.height <= 0) return `${name} button collapsed to zero size (hidden)`;
172+
if (r.left < bounds.left - EPS || r.right > bounds.right + EPS) {
173+
return `${name} button is clipped: button x=[${r.left.toFixed(1)}, ${r.right.toFixed(1)}] `
174+
+ `exceeds header x=[${bounds.left.toFixed(1)}, ${bounds.right.toFixed(1)}]`;
175+
}
176+
}
177+
return null;
178+
};
179+
180+
// Poll until the primed state (two rAFs) and the ResizeObserver-driven tier
181+
// have settled, instead of guessing a fixed delay. Surface the last reason if
182+
// it never settles within the timeout.
183+
const start = performance.now();
184+
let reason = violation();
185+
while (reason && performance.now() - start < 1000) {
186+
await wait(16);
187+
reason = violation();
188+
}
189+
if (reason) throw new Error(reason);
190+
}
191+
135192
const NOTIFICATIONS = {
136193
osc9BodyOnly: {
137194
source: 'OSC 9',
@@ -175,6 +232,7 @@ const meta: Meta<typeof TabStory> = {
175232
title: { control: 'text' },
176233
width: { control: 'number' },
177234
reducedMotion: { control: 'boolean' },
235+
mouseCaptured: { control: 'boolean' },
178236
},
179237
args: {
180238
title: 'build-server',
@@ -183,6 +241,7 @@ const meta: Meta<typeof TabStory> = {
183241
isRenaming: false,
184242
width: 360,
185243
reducedMotion: false,
244+
mouseCaptured: false,
186245
},
187246
};
188247

@@ -371,3 +430,56 @@ export const RenameRejectedReserved: Story = {
371430
}),
372431
play: submitReservedRename,
373432
};
433+
434+
// --- Minimize + close stay visible as the header shrinks -------------------
435+
//
436+
// These stories drive the header down to (and below) the `minimal` tier and
437+
// assert in their play function that the minimize and close controls remain
438+
// rendered and fully inside the header bounds. The assertion uses live layout
439+
// geometry, so it confirms the controls in the real Storybook browser.
440+
441+
export const NarrowControlsVisible: Story = {
442+
args: {
443+
width: 110,
444+
},
445+
parameters: primedState({
446+
status: 'NOTHING_TO_SHOW',
447+
todo: false,
448+
}),
449+
play: assertControlsVisible,
450+
};
451+
452+
export const ExtremelyNarrowControlsVisible: Story = {
453+
args: {
454+
width: 76,
455+
},
456+
parameters: primedState({
457+
status: 'ALERT_RINGING',
458+
todo: true,
459+
}),
460+
play: assertControlsVisible,
461+
};
462+
463+
export const NarrowWithMouseCaptureControlsVisible: Story = {
464+
args: {
465+
width: 120,
466+
mouseCaptured: true,
467+
},
468+
parameters: primedState({
469+
status: 'NOTHING_TO_SHOW',
470+
todo: false,
471+
}),
472+
play: assertControlsVisible,
473+
};
474+
475+
export const NarrowLongTitleControlsVisible: Story = {
476+
args: {
477+
title: 'my-extremely-long-running-background-process-with-a-very-descriptive-name',
478+
width: 130,
479+
},
480+
parameters: primedState({
481+
status: 'ALERT_RINGING',
482+
todo: true,
483+
}),
484+
play: assertControlsVisible,
485+
};

0 commit comments

Comments
 (0)