fix(desktop): narrow edge overlay honours the dragged pane width

The narrow edge overlay sized itself from the pane's declared data.width
(260px for bots), discarding the persisted widthOverride the sash drag
wrote, so every chat-switch reveal snapped the panel back to its declared
width. Size from the same fixedTrackSize resolution the pane's docked
zone uses, extracted as a pure narrowOverlayWidth helper so the
resolution itself is testable under jsdom.

Co-authored-by: kokhlo <47825603+kokhlo@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson
2026-09-27 06:40:47 -05:00
committed by brooklyn!
parent a1dcbc20ad
commit 351d59be8f
2 changed files with 77 additions and 4 deletions

View File

@@ -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(<NarrowOverlays />)
revealPane('bots')
const overlay = container.querySelector<HTMLElement>('[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')
})
})

View File

@@ -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 && (