diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.test.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.test.tsx index cdb995343f..5efc764c35 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.test.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.test.tsx @@ -3,13 +3,14 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell' import { registry } from '@/contrib/registry' +import { $paneStates, setPaneWidthOverride } from '@/store/panes' import { $connection } from '@/store/session' import { stubResizeObserver } from '@/test/jsdom' import { group, split } from '../model' import { $hiddenTreePanes, $layoutTree, $narrowViewport, declareDefaultTree } from '../store' -import { NarrowOverlays } from './narrow-overlays' +import { NarrowOverlays, narrowOverlayWidth } from './narrow-overlays' // Ground truth for "the Bots tab is still visible when the sessions sidebar // collapses on a narrow window". A collapsible pane DOCKED into the sessions @@ -128,4 +129,44 @@ describe('narrow overlay of a stacked zone', () => { expect(dragSpacer).not.toBeNull() expect(dragSpacer?.style.height).toBe('34px') }) + + it('honors the user drag width, not the declared width, when the zone collapsed', () => { + // The sash writes a widthOverride per shown pane of the zone; the overlay + // must size from the same resolution the docked zone uses (fixedTrackSize + // = declared max() refined by overrides), not from data.width alone. + // jsdom's CSSOM drops the min() wrapper from style.width, so the rendered + // width is unobservable here — assert the overlay's own resolution + // (narrowOverlayWidth, the pure helper the component styles from) against + // the live tree + store the mounted overlay saw. + setPaneWidthOverride('sessions', 170) + setPaneWidthOverride('bots', 170) + + const { container } = render() + + revealPane('bots') + + const overlay = container.querySelector('[data-narrow-overlay]') + expect(overlay).toBeTruthy() + + const tree = $layoutTree.get()! + const bots = registry.getArea('panes').find(p => p.id === 'bots')! + + const width = narrowOverlayWidth( + { + paneFor: id => registry.getArea('panes').find(p => p.id === id), + paneGone: () => false, + overrides: $paneStates.get() + }, + tree, + bots + ) + + // The user dragged the zone to 170px; the declared 260px must lose. + expect(width).toBe('170px') + expect(width).not.toBe('260px') + + // And the seamless fallback: without a tree the declared width still + // sizes the overlay. + expect(narrowOverlayWidth({ paneFor: () => undefined, paneGone: () => false, overrides: {} }, null, bots)).toBe('260px') + }) }) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx index d2a190e2de..91ab3d5b90 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx @@ -16,21 +16,45 @@ import { useContributions } from '@/contrib/react/use-contributions' import type { Contribution } from '@/contrib/types' import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' import { cn } from '@/lib/utils' +import { $paneStates } from '@/store/panes' import { PANE_TOGGLE_REVEAL_EVENT } from '../..' import { useWindowControlsOverlap } from '../../geometry' import { NO_PANE_GROUP } from '../../pane-visibility' -import { allPaneIds, findGroupOfPane } from '../model' +import { allPaneIds, findGroupOfPane, type LayoutNode } from '../model' import { $hiddenTreePanes, $layoutTree, $narrowViewport } from '../store' import { KeepAlivePaneSlot, useStablePaneHosts } from './keep-alive-panes' -import { paneChrome } from './track-model' +import { fixedTrackSize, paneChrome, type TrackContext } from './track-model' + +/** The width a revealed narrow overlay sizes itself to: the SAME resolution + * the pane's zone uses while docked — declared max() refined by the live + * widthOverride of the zone's shown panes (fixedTrackSize) — so the overlay + * and the docked zone can never disagree. Falls back to the pane's declared + * `data.width` (then 18rem) when no zone claims the pane. Pure, so the + * regression test asserts the resolution itself (jsdom's CSSOM drops the + * `min()` wrapper from style.width, hiding the rendered result). */ +export function narrowOverlayWidth( + ctx: TrackContext, + tree: LayoutNode | null, + revealed: Contribution +): string { + if (!tree) { + return paneChrome(revealed).width ?? '18rem' + } + + const zone = findGroupOfPane(tree, revealed.id) + const track = zone ? fixedTrackSize(zone, 'row', ctx) : null + + return track ?? paneChrome(revealed).width ?? '18rem' +} export function NarrowOverlays() { const narrow = useStore($narrowViewport) const solo = useStore($chatOnboardingSolo) const tree = useStore($layoutTree) const panes = useContributions('panes') + const paneStates = useStore($paneStates) const stableHosts = useStablePaneHosts() const hiddenPanes = useStore($hiddenTreePanes) const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null) @@ -137,6 +161,14 @@ export function NarrowOverlays() { const revealed = reveal ? collapsibles.find(p => p.id === reveal.id) : undefined const sides = [...new Set(collapsibles.map(sideOf))] + // Size the overlay the way the pane's zone is sized while docked: declared + // width refined by the user's drag override (fixedTrackSize), so a pane the + // user narrowed stays narrowed here too — reading only data.width would + // reset the overlay to the declared size on every reveal. + const overlayWidth = revealed + ? narrowOverlayWidth({ paneFor: id => panes.find(p => p.id === id), paneGone: () => false, overrides: paneStates }, tree, revealed) + : null + // The revealed pane's ZONE-mates that also left the grid (the sessions zone // stacks SESSIONS | BOTS): the overlay mirrors the zone's tab strip so a // pane docked into a collapsed zone stays reachable on narrow viewports — @@ -192,7 +224,7 @@ export function NarrowOverlays() { // draggable, mirroring TreeGroup's reservation. style={{ paddingTop: wcOverlap ? wcOverlap.y + wcOverlap.height : undefined, - width: `min(${(revealed.data as { width?: string } | undefined)?.width ?? '18rem'}, 85vw)` + width: `min(${overlayWidth}, 85vw)` }} > {wcOverlap && (