diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 5f5b036..258d582 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -1191,7 +1191,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * polling while the page is not actually visible). The pane id travels * with the tab so diff tabs can split below their source pane. */ - const renderTab = (tab: SidebarTab, active: boolean, paneId: string, bottom = false) => ( + // `placement` decides the visibility contract handed to the tab component: + // pane tabs are visible while their panel is open and they are active, but + // a free window is its own surface — its tab stays visible no matter what + // the panels do (the AGENTS §7.5 contract; plugin components honor + // `visible` to pause work, so tying floats to panelOpen would blank them + // the moment the sidebar collapses). + const renderTab = (tab: SidebarTab, active: boolean, paneId: string, placement: 'top' | 'bottom' | 'float' = 'top') => ( { subagentJumpRef.current = childSessionId }} onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }} localeRevision={localeRevision} @@ -1469,7 +1481,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })} actions={actions} onNewTab={onNewTab} - renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)} + renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, 'bottom')} getTabIcon={tabIconOf} getTabBadge={tabBadgeOf} /> @@ -1488,7 +1500,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { renderTab(tab, active, paneId, 'float')} getTabIcon={tabIconOf} onRaise={() => { store.reduce(s => raiseFloat(s, float.id)) }} onMove={(x, y) => { store.reduce(s => moveFloat(s, float.id, x, y)) }} diff --git a/tests/free-window.spec.tsx b/tests/free-window.spec.tsx index 694c807..d3fd343 100644 --- a/tests/free-window.spec.tsx +++ b/tests/free-window.spec.tsx @@ -23,7 +23,7 @@ import { act } from 'react-dom/test-utils' ;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true import { Sidebar } from '../src/client/Sidebar.tsx' -import { createSidebarStore, type SidebarStore, floatTab as floatTabReducer } from '../src/client/state.ts' +import { createSidebarStore, togglePanel, type SidebarStore, floatTab as floatTabReducer } from '../src/client/state.ts' import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts' import { serializeDrag } from '../src/client/TabBar.tsx' @@ -73,7 +73,15 @@ function mountSidebar(): Mounted { document.body.append(container) const store = createSidebarStore() const service = createBetterSidebarService(store) - service.registerTab({ id: 'notes', title: 'Notes', single: true, component: () => createElement('div', null, 'notes body') }) + service.registerTab({ + id: 'notes', + title: 'Notes', + single: true, + // Pins the visibility contract into the DOM: plugin components honor + // `visible` to pause work, so the float tests can assert what a + // registering plugin would actually receive. + component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'), + }) store.setPrefs({ ...store.getPrefs(), openByDefault: true }) store.setSession('s1') const localeSnapshot = { active: 'en' } @@ -294,6 +302,26 @@ describe('free windows: the window', () => { } }) + it('a floated plugin tab stays visible while the panel collapses (placement contract)', () => { + fakeConversationColumn() + const { container, store, service, unmount } = mountSidebar() + try { + act(() => { service.openTab({ type: 'notes', title: 'Notes' }) }) + act(() => { store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) + const win = container.querySelector('[data-dsh-float-window]')! + expect(win.querySelector('[data-visible]')!.dataset.visible).toBe('true') + // Collapse the sidebar: the pane's seeded home tab goes invisible + // (panel closed), but the float is its own surface — a registering + // plugin's component must keep receiving visible=true (AGENTS §7.5). + act(() => { store.reduce(togglePanel) }) + expect(win.querySelector('[data-visible]')!.dataset.visible).toBe('true') + // (No pane-side control: the pane's remaining tab is the builtin editor + // home tab, which does not surface `visible` into the DOM.) + } finally { + unmount() + } + }) + it('releasing the header over a pane docks the tab back (live pane highlight)', async () => { fakeConversationColumn() const { container, store, service, unmount } = mountSidebar()