1+ import { useEffect } from 'react' ;
12import type { Meta , StoryObj } from '@storybook/react' ;
23import {
34 TerminalPaneHeader ,
@@ -10,6 +11,7 @@ import {
1011} from '../components/Wall' ;
1112import type { ActivityNotification } from '../lib/alert-manager' ;
1213import type { SetTerminalUserTitleResult } from '../lib/terminal-registry' ;
14+ import { removeMouseSelectionState , setMouseReporting , setOverride } from '../lib/mouse-selection' ;
1315
1416const 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+
135192const 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