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