From 8ceaefcfc771bf97406c4dfb6be102c30d440450 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:32:54 +0800 Subject: [PATCH 1/7] fix: keep conversation composer in the viewport under layout push Long unbreakable tokens (OAuth URLs) grew the AppFrame center column past the viewport because the layout-push rule used the grid default min-height:auto. Cap --dsh-sidebar-height the same way setBottomHeight does (viewport minus PANEL_MIN) and let the column shrink and wrap. --- src/client/Sidebar.tsx | 31 +++++++++------- src/client/layout-push.ts | 32 +++++++++++++++++ src/client/layout.css | 8 +++++ tests/layout-css.spec.ts | 22 ++++++++++++ tests/layout-push.spec.ts | 75 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 src/client/layout-push.ts create mode 100644 tests/layout-css.spec.ts create mode 100644 tests/layout-push.spec.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 5684db7..eddb857 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -43,6 +43,7 @@ import { import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { useNarrowViewport } from './breakpoints.ts' +import { layoutPushSize } from './layout-push.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' import { getShellPreset } from './shell-presets.ts' @@ -804,12 +805,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { dragCommitted.current = true stopDragScheduling() const last = lastDragSize.current - const adoptedWidth = !narrow && state?.panelOpen === true - ? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth) - : 0 - const adoptedHeight = !narrow && state?.bottomOpen === true - ? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight) - : 0 + const { width: adoptedWidth, height: adoptedHeight } = layoutPushSize({ + narrow, + panelOpen: state?.panelOpen === true, + bottomOpen: state?.bottomOpen === true, + width: last?.width ?? state?.width ?? 0, + bottomHeight: last?.height ?? state?.bottomHeight ?? 0, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }) applyDrag(adoptedWidth, adoptedHeight) draggingRef.current = false measureCenter() @@ -826,12 +830,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // On NARROW viewports the drawer FLOATS over the app shell — no push, the // conversation keeps the full width behind the drawer. useEffect(() => { - const width = !narrow && snapshot.state?.panelOpen === true - ? Math.min(snapshot.state.width, window.innerWidth) - : 0 - const height = !narrow && snapshot.state?.bottomOpen === true - ? Math.min(snapshot.state.bottomHeight, window.innerHeight) - : 0 + const { width, height } = layoutPushSize({ + narrow, + panelOpen: snapshot.state?.panelOpen === true, + bottomOpen: snapshot.state?.bottomOpen === true, + width: snapshot.state?.width ?? 0, + bottomHeight: snapshot.state?.bottomHeight ?? 0, + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }) writeGeometry(width, height) }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) // Unmount must release the push (issue #31): when the boundary swaps the diff --git a/src/client/layout-push.ts b/src/client/layout-push.ts new file mode 100644 index 0000000..cf235d6 --- /dev/null +++ b/src/client/layout-push.ts @@ -0,0 +1,32 @@ +/** + * Size written to `--dsh-sidebar-width` / `--dsh-sidebar-height`. + * The conversation column (output + composer) must keep at least + * {@link PANEL_MIN} of the viewport after the bottom panel claims height. + */ +import { BOTTOM_MIN, PANEL_MIN } from './state.ts' + +export interface LayoutPushInput { + narrow: boolean + panelOpen: boolean + bottomOpen: boolean + width: number + bottomHeight: number + viewportWidth: number + viewportHeight: number +} + +export interface LayoutPushSize { + width: number + height: number +} + +/** Compute the live layout-push size. Narrow drawers float and push 0. */ +export function layoutPushSize(input: LayoutPushInput): LayoutPushSize { + if (input.narrow) return { width: 0, height: 0 } + const maxWidth = Math.max(PANEL_MIN, input.viewportWidth) + const maxHeight = Math.max(BOTTOM_MIN, input.viewportHeight - PANEL_MIN) + return { + width: input.panelOpen ? Math.min(Math.max(0, input.width), maxWidth) : 0, + height: input.bottomOpen ? Math.min(Math.max(0, input.bottomHeight), maxHeight) : 0, + } +} diff --git a/src/client/layout.css b/src/client/layout.css index 333f9b0..e634d79 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -44,6 +44,14 @@ #root [data-dsh-frame] > [data-pane="conversation"], #root :has(> [data-slot="conversation"]) { margin-bottom: var(--dsh-sidebar-height, 0px); + /* Grid/flex items default to min-height:auto. A long unbreakable token + (OAuth URL) then grows this column past the viewport and clips the + composer + left-rail Settings row. min-height:0 lets the cell shrink; + overflow:hidden keeps paint inside the cell; overflow-wrap wraps the + token so it cannot force the cell's intrinsic size. */ + min-height: 0; + overflow: hidden; + overflow-wrap: anywhere; transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); } diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts new file mode 100644 index 0000000..3b42b9a --- /dev/null +++ b/tests/layout-css.spec.ts @@ -0,0 +1,22 @@ +/** + * layout.css must let the DSH conversation column shrink below its content + * size. Without min-height:0 a long unbreakable URL grows the grid item + * past the viewport and clips the composer. + */ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const css = readFileSync('src/client/layout.css', 'utf8') + +describe('layout.css conversation column', () => { + it('targets the AppFrame center column', () => { + expect(css).toContain('[data-pane="conversation"]') + expect(css).toContain('[data-slot="conversation"]') + }) + + it('allows the center column to shrink and wrap long tokens', () => { + expect(css).toMatch(/min-height:\s*0/) + expect(css).toMatch(/overflow:\s*hidden/) + expect(css).toMatch(/overflow-wrap:\s*anywhere/) + }) +}) diff --git a/tests/layout-push.spec.ts b/tests/layout-push.spec.ts new file mode 100644 index 0000000..35b3fa9 --- /dev/null +++ b/tests/layout-push.spec.ts @@ -0,0 +1,75 @@ +/** + * Layout-push size contract: the conversation column (output + composer) + * must keep at least PANEL_MIN of the viewport after the bottom panel + * claims its height. A stale persisted bottomHeight (or a cap at 100vh) + * used to crush the composer off-screen. + */ +import { describe, expect, it } from 'vitest' +import { BOTTOM_MIN, PANEL_MIN } from '../src/client/state.ts' +import { layoutPushSize } from '../src/client/layout-push.ts' + +describe('layoutPushSize', () => { + it('pushes nothing on a narrow drawer viewport', () => { + expect(layoutPushSize({ + narrow: true, + panelOpen: true, + bottomOpen: true, + width: 400, + bottomHeight: 400, + viewportWidth: 390, + viewportHeight: 700, + })).toEqual({ width: 0, height: 0 }) + }) + + it('pushes nothing when both panels are collapsed', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: false, + bottomOpen: false, + width: 400, + bottomHeight: 400, + viewportWidth: 1280, + viewportHeight: 800, + })).toEqual({ width: 0, height: 0 }) + }) + + it('caps an oversize bottom panel so the conversation keeps PANEL_MIN', () => { + const viewportHeight = 800 + const size = layoutPushSize({ + narrow: false, + panelOpen: false, + bottomOpen: true, + width: 0, + bottomHeight: 10_000, + viewportWidth: 1280, + viewportHeight, + }) + expect(size.height).toBe(viewportHeight - PANEL_MIN) + expect(size.height).toBeGreaterThanOrEqual(BOTTOM_MIN) + expect(viewportHeight - size.height).toBeGreaterThanOrEqual(PANEL_MIN) + }) + + it('caps an oversize right panel at the viewport width', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: false, + width: 10_000, + bottomHeight: 0, + viewportWidth: 1280, + viewportHeight: 800, + })).toEqual({ width: 1280, height: 0 }) + }) + + it('passes through in-range open sizes', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: 400, + bottomHeight: 220, + viewportWidth: 1280, + viewportHeight: 800, + })).toEqual({ width: 400, height: 220 }) + }) +}) From 965ec49c163ebb29df3cecb885f1fa0081d63ae7 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:41:33 +0800 Subject: [PATCH 2/7] fix: harden layout push bounds --- src/client/layout-push.ts | 15 ++++++++++----- src/client/layout.css | 5 ++--- tests/layout-css.spec.ts | 9 ++++++++- tests/layout-push.spec.ts | 24 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/client/layout-push.ts b/src/client/layout-push.ts index cf235d6..8b53cb2 100644 --- a/src/client/layout-push.ts +++ b/src/client/layout-push.ts @@ -3,7 +3,7 @@ * The conversation column (output + composer) must keep at least * {@link PANEL_MIN} of the viewport after the bottom panel claims height. */ -import { BOTTOM_MIN, PANEL_MIN } from './state.ts' +import { PANEL_MIN } from './state.ts' export interface LayoutPushInput { narrow: boolean @@ -20,13 +20,18 @@ export interface LayoutPushSize { height: number } +function finiteNonNegative(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0 +} + /** Compute the live layout-push size. Narrow drawers float and push 0. */ export function layoutPushSize(input: LayoutPushInput): LayoutPushSize { if (input.narrow) return { width: 0, height: 0 } - const maxWidth = Math.max(PANEL_MIN, input.viewportWidth) - const maxHeight = Math.max(BOTTOM_MIN, input.viewportHeight - PANEL_MIN) + const viewportWidth = finiteNonNegative(input.viewportWidth) + const viewportHeight = finiteNonNegative(input.viewportHeight) + const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight)) return { - width: input.panelOpen ? Math.min(Math.max(0, input.width), maxWidth) : 0, - height: input.bottomOpen ? Math.min(Math.max(0, input.bottomHeight), maxHeight) : 0, + width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0, + height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0, } } diff --git a/src/client/layout.css b/src/client/layout.css index e634d79..17075c6 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -47,10 +47,9 @@ /* Grid/flex items default to min-height:auto. A long unbreakable token (OAuth URL) then grows this column past the viewport and clips the composer + left-rail Settings row. min-height:0 lets the cell shrink; - overflow:hidden keeps paint inside the cell; overflow-wrap wraps the - token so it cannot force the cell's intrinsic size. */ + overflow-wrap lets the token wrap instead of forcing its intrinsic + size. The host's own descendants remain responsible for scrolling. */ min-height: 0; - overflow: hidden; overflow-wrap: anywhere; transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); } diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts index 3b42b9a..c90d889 100644 --- a/tests/layout-css.spec.ts +++ b/tests/layout-css.spec.ts @@ -16,7 +16,14 @@ describe('layout.css conversation column', () => { it('allows the center column to shrink and wrap long tokens', () => { expect(css).toMatch(/min-height:\s*0/) - expect(css).toMatch(/overflow:\s*hidden/) expect(css).toMatch(/overflow-wrap:\s*anywhere/) }) + + it('leaves overflow ownership to the host conversation descendants', () => { + const conversationRule = css.match( + /#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/, + )?.[1] + expect(conversationRule).toBeDefined() + expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/) + }) }) diff --git a/tests/layout-push.spec.ts b/tests/layout-push.spec.ts index 35b3fa9..b9052d2 100644 --- a/tests/layout-push.spec.ts +++ b/tests/layout-push.spec.ts @@ -72,4 +72,28 @@ describe('layoutPushSize', () => { viewportHeight: 800, })).toEqual({ width: 400, height: 220 }) }) + + it('never pushes beyond a viewport smaller than the normal panel minima', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: 400, + bottomHeight: 220, + viewportWidth: 200, + viewportHeight: 200, + })).toEqual({ width: 200, height: 0 }) + }) + + it('turns non-finite geometry into a safe zero push', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: Number.NaN, + bottomHeight: Number.POSITIVE_INFINITY, + viewportWidth: Number.NaN, + viewportHeight: Number.POSITIVE_INFINITY, + })).toEqual({ width: 0, height: 0 }) + }) }) From c426431e8bbdd8030dee522753f278251e28f164 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:49:13 +0800 Subject: [PATCH 3/7] fix: recalculate panel geometry on resize --- src/client/Sidebar.tsx | 25 ++++++++++++++----- src/client/breakpoints.ts | 22 ++++++++++++----- tests/breakpoints-react.spec.tsx | 42 ++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) create mode 100644 tests/breakpoints-react.spec.tsx diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index eddb857..552dd62 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -42,7 +42,7 @@ import { } from './state.ts' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx' -import { useNarrowViewport } from './breakpoints.ts' +import { isNarrowWidth, useViewportSize } from './breakpoints.ts' import { layoutPushSize } from './layout-push.ts' import { parseDesktopEnv } from './desktop-env.ts' import { getWcoSnapshot, subscribeWco } from './wco.ts' @@ -184,7 +184,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // — the merged display is the right sidebar alone, the bottom tabs thrown // into its strips. Widening never rewrites the migrated state: the tabs // keep living in the right tree. - const narrow = useNarrowViewport() + const viewport = useViewportSize() + const narrow = isNarrowWidth(viewport.width) // On-screen keyboard / visual-viewport inset (mobile, split-screen, …): // when the visual viewport shrinks below the layout viewport, bottom- @@ -836,11 +837,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { bottomOpen: snapshot.state?.bottomOpen === true, width: snapshot.state?.width ?? 0, bottomHeight: snapshot.state?.bottomHeight ?? 0, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, }) writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, viewport.height]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / // HMR), the CSS variables would otherwise stay on and layout.css @@ -955,6 +956,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { ) } + const bottomPanelHeight = layoutPushSize({ + narrow, + panelOpen: state.panelOpen, + // Keep the hidden panel's geometry ready for its slide-in transition; + // only the layout push itself becomes zero while it is closed. + bottomOpen: true, + width: state.width, + bottomHeight: state.bottomHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }).height + const onNewTab = (optionId: string): void => { const service = ctx.betterSidebar const descriptor = service?.getTab(optionId) @@ -1205,7 +1218,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { data-dsh-panel data-dsh-bottom-panel style={{ - height: Math.min(state.bottomHeight, window.innerHeight), + height: bottomPanelHeight, left: centerRect.left, // Keep the panel above the on-screen keyboard when the visual // viewport shrinks (see the keyboardInset effect). diff --git a/src/client/breakpoints.ts b/src/client/breakpoints.ts index 3901cff..2535861 100644 --- a/src/client/breakpoints.ts +++ b/src/client/breakpoints.ts @@ -20,6 +20,11 @@ export function isNarrowWidth(width: number): boolean { return width < NARROW_MAX_WIDTH } +export interface ViewportSize { + width: number + height: number +} + /** * Live narrow-viewport flag for components. Reads `window.innerWidth` and * re-measures on resize (rAF-throttled, the repo's existing drag pattern). @@ -27,16 +32,17 @@ export function isNarrowWidth(width: number): boolean { * resize listener is equally exact for a breakpoint that never changes * while the page is open. */ -export function useNarrowViewport(): boolean { - const [narrow, setNarrow] = useState( - () => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth), - ) +export function useViewportSize(): ViewportSize { + const [size, setSize] = useState(() => ({ + width: typeof window === 'undefined' ? 0 : window.innerWidth, + height: typeof window === 'undefined' ? 0 : window.innerHeight, + })) useEffect(() => { if (typeof window === 'undefined') return let frame: number | null = null const measure = (): void => { frame = null - setNarrow(isNarrowWidth(window.innerWidth)) + setSize({ width: window.innerWidth, height: window.innerHeight }) } const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) @@ -47,5 +53,9 @@ export function useNarrowViewport(): boolean { if (frame !== null) cancelAnimationFrame(frame) } }, []) - return narrow + return size +} + +export function useNarrowViewport(): boolean { + return isNarrowWidth(useViewportSize().width) } diff --git a/tests/breakpoints-react.spec.tsx b/tests/breakpoints-react.spec.tsx new file mode 100644 index 0000000..a9ce885 --- /dev/null +++ b/tests/breakpoints-react.spec.tsx @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useViewportSize } from '../src/client/breakpoints.ts' + +describe('useViewportSize', () => { + afterEach(() => { + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('publishes height-only resizes without requiring a breakpoint change', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }) + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }) + + const observed: string[] = [] + function Probe(): null { + const size = useViewportSize() + observed.push(`${size.width}x${size.height}`) + return null + } + + const host = document.createElement('div') + document.body.append(host) + const root = createRoot(host) + await act(async () => { root.render() }) + + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 600 }) + window.dispatchEvent(new Event('resize')) + await act(async () => { frames.shift()?.(0) }) + + expect(observed.at(-1)).toBe('1280x600') + await act(async () => { root.unmount() }) + }) +}) From 04686dcc7dd7f04bffa6face71b9d155f39195b8 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:54:49 +0800 Subject: [PATCH 4/7] fix: preserve bottom cap during width drags --- src/client/Sidebar.tsx | 15 ++++++++++++--- tests/sidebar-layout-push.spec.ts | 11 +++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 tests/sidebar-layout-push.spec.ts diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 552dd62..914d4fb 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -235,6 +235,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const state = snapshot.state const sessionId = snapshot.sessionId const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd + const pushedBottomHeight = (bottomOpen: boolean, bottomHeight: number): number => layoutPushSize({ + narrow, + panelOpen: false, + bottomOpen, + width: 0, + bottomHeight, + viewportWidth: viewport.width, + viewportHeight: viewport.height, + }).height // The collapsed toggle cluster reclaims the top-right corner, so the DSH // session header's right-aligned utilities (the "Session log" download @@ -776,7 +785,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // (clamped) instead of rolling back the flick. if (draggingWidth) { width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX)) - height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0 + height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0) } else if (draggingBottom) { width = Math.min(state?.width ?? 0, window.innerWidth) height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)) @@ -1110,7 +1119,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startWidth } = widthDrag.current const width = clampWidth(startWidth + (startX - event.clientX)) - const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0 + const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight) scheduleDrag(width, height) }} onPointerUp={(event) => { @@ -1124,7 +1133,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // pointermove (the rAF pending value) can be stale. Commit // from the up position (v0.13.1 semantics; issue #247). const width = clampWidth(startWidth + (startX - event.clientX)) - const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0 + const height = pushedBottomHeight(state.bottomOpen, state.bottomHeight) commitDrag(width, height, s => setWidth(s, width)) setDraggingWidth(false) }} diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts new file mode 100644 index 0000000..8f355fb --- /dev/null +++ b/tests/sidebar-layout-push.spec.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const source = readFileSync('src/client/Sidebar.tsx', 'utf8') + +describe('Sidebar layout-push integration', () => { + it('does not bypass the shared bottom-height cap during width drags', () => { + expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') + expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) + }) +}) From 1c330ba44fdb6430d583ad425cd808cc29f3ebd7 Mon Sep 17 00:00:00 2001 From: Zzy Date: Sat, 22 Aug 2026 00:02:22 +0800 Subject: [PATCH 5/7] fix: cap bottom panel above virtual keyboard --- src/client/Sidebar.tsx | 16 ++++++++++------ tests/sidebar-layout-push.spec.ts | 6 ++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 914d4fb..ede7acd 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -217,6 +217,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (frame !== null) cancelAnimationFrame(frame) } }, []) + // The bottom panel is offset above the on-screen keyboard. Cap its height + // against that same visible area, not the taller layout viewport, so the + // conversation keeps PANEL_MIN even on wide touch devices. + const layoutViewportHeight = Math.max(0, viewport.height - keyboardInset) // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( @@ -242,7 +246,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { width: 0, bottomHeight, viewportWidth: viewport.width, - viewportHeight: viewport.height, + viewportHeight: layoutViewportHeight, }).height // The collapsed toggle cluster reclaims the top-right corner, so the DSH @@ -821,8 +825,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { bottomOpen: state?.bottomOpen === true, width: last?.width ?? state?.width ?? 0, bottomHeight: last?.height ?? state?.bottomHeight ?? 0, - viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, + viewportWidth: viewport.width, + viewportHeight: layoutViewportHeight, }) applyDrag(adoptedWidth, adoptedHeight) draggingRef.current = false @@ -847,10 +851,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { width: snapshot.state?.width ?? 0, bottomHeight: snapshot.state?.bottomHeight ?? 0, viewportWidth: viewport.width, - viewportHeight: viewport.height, + viewportHeight: layoutViewportHeight, }) writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, viewport.height]) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / // HMR), the CSS variables would otherwise stay on and layout.css @@ -974,7 +978,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { width: state.width, bottomHeight: state.bottomHeight, viewportWidth: viewport.width, - viewportHeight: viewport.height, + viewportHeight: layoutViewportHeight, }).height const onNewTab = (optionId: string): void => { diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index 8f355fb..0574ba1 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -8,4 +8,10 @@ describe('Sidebar layout-push integration', () => { expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) }) + + it('caps panel geometry against the viewport visible above the keyboard', () => { + expect(source).toContain('viewport.height - keyboardInset') + expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) + expect(source).toContain('viewport.width, layoutViewportHeight]') + }) }) From 27cd211bb3407777a6492ecc368eaeccd959bb40 Mon Sep 17 00:00:00 2001 From: Zzy Date: Sat, 22 Aug 2026 00:08:06 +0800 Subject: [PATCH 6/7] fix: cap drag height to visual viewport --- src/client/Sidebar.tsx | 16 +++++++++------- tests/sidebar-layout-push.spec.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index ede7acd..cd58a9c 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -198,6 +198,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Guarded: browsers without visualViewport (older WebViews, jsdom) stay // at 0. rAF-throttled, same pattern as useNarrowViewport. const [keyboardInset, setKeyboardInset] = useState(0) + const [visualViewportHeight, setVisualViewportHeight] = useState(null) useEffect(() => { const vv = window.visualViewport if (vv === null || vv === undefined) return @@ -206,6 +207,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { frame = null const inset = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop)) setKeyboardInset(inset > 1 ? Math.round(inset) : 0) + setVisualViewportHeight(Math.max(0, Math.round(vv.height))) } const onResize = (): void => { if (frame === null) frame = requestAnimationFrame(measure) } vv.addEventListener('resize', onResize) @@ -220,7 +222,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // The bottom panel is offset above the on-screen keyboard. Cap its height // against that same visible area, not the taller layout viewport, so the // conversation keeps PANEL_MIN even on wide touch devices. - const layoutViewportHeight = Math.max(0, viewport.height - keyboardInset) + const layoutViewportHeight = visualViewportHeight ?? viewport.height // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( @@ -792,10 +794,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { height = pushedBottomHeight(state?.bottomOpen === true, state?.bottomHeight ?? 0) } else if (draggingBottom) { width = Math.min(state?.width ?? 0, window.innerWidth) - height = clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY)) + height = pushedBottomHeight(true, clampHeight(bottomDrag.current.startHeight + (bottomDrag.current.startY - event.clientY))) } else if (draggingCorner) { width = clampWidth(cornerDrag.current.startWidth + (cornerDrag.current.startX - event.clientX)) - height = clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY)) + height = pushedBottomHeight(true, clampHeight(cornerDrag.current.startHeight + (cornerDrag.current.startY - event.clientY))) } } if (width !== undefined && height !== undefined) { @@ -1187,7 +1189,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startY, startWidth, startHeight } = cornerDrag.current const width = clampWidth(startWidth + (startX - event.clientX)) - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) scheduleDrag(width, height) }} onPointerUp={(event) => { @@ -1199,7 +1201,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // Up position wins over the rAF pending value (see the width // strip handler — issue #247). const width = clampWidth(startWidth + (startX - event.clientX)) - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height)) setDraggingCorner(false) }} @@ -1266,7 +1268,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startY, startHeight } = bottomDrag.current - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) scheduleDrag(Math.min(state.width, window.innerWidth), height) }} onPointerUp={(event) => { @@ -1277,7 +1279,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { const { startY, startHeight } = bottomDrag.current // Up position wins over the rAF pending value (see the width // strip handler — issue #247). - const height = clampHeight(startHeight + (startY - event.clientY)) + const height = pushedBottomHeight(true, clampHeight(startHeight + (startY - event.clientY))) commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height)) setDraggingBottom(false) }} diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index 0574ba1..b94de12 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -6,12 +6,19 @@ const source = readFileSync('src/client/Sidebar.tsx', 'utf8') describe('Sidebar layout-push integration', () => { it('does not bypass the shared bottom-height cap during width drags', () => { expect(source).not.toContain('Math.min(state.bottomHeight, window.innerHeight)') - expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(3) + expect(source.match(/pushedBottomHeight\(/g)).toHaveLength(9) }) it('caps panel geometry against the viewport visible above the keyboard', () => { - expect(source).toContain('viewport.height - keyboardInset') + expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))') + expect(source).toContain('visualViewportHeight ?? viewport.height') expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) expect(source).toContain('viewport.width, layoutViewportHeight]') }) + + it('reapplies the visible-height cap to every vertical drag result', () => { + expect(source).not.toMatch(/const height = clampHeight\(/) + expect(source).not.toMatch(/height = clampHeight\(/) + expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6) + }) }) From f52cb8779e166e20568199cbb6482ccdd8e837b0 Mon Sep 17 00:00:00 2001 From: Zzy Date: Sat, 22 Aug 2026 00:15:06 +0800 Subject: [PATCH 7/7] fix: reserve keyboard inset in layout push --- src/client/Sidebar.tsx | 10 +++++++--- tests/sidebar-layout-push.spec.ts | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index cd58a9c..a9e39fe 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -694,7 +694,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { // `width + detailsWidth` — derived from the measured column, keeping the // drag write-only (no React re-render mid-drag). bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`) - writeGeometry(width, height) + const bottomPush = !narrow && state?.bottomOpen === true ? height + keyboardInset : 0 + writeGeometry(width, bottomPush) } // Drags write at most once per frame: pointer events fire several times @@ -855,8 +856,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { viewportWidth: viewport.width, viewportHeight: layoutViewportHeight, }) - writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight]) + const bottomPush = !narrow && snapshot.state?.bottomOpen === true + ? height + keyboardInset + : 0 + writeGeometry(width, bottomPush) + }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight, viewport.width, layoutViewportHeight, keyboardInset]) // Unmount must release the push (issue #31): when the boundary swaps the // whole sidebar after a render crash (or the plugin fiber is disposed / // HMR), the CSS variables would otherwise stay on and layout.css diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts index b94de12..1f0ea1b 100644 --- a/tests/sidebar-layout-push.spec.ts +++ b/tests/sidebar-layout-push.spec.ts @@ -13,7 +13,13 @@ describe('Sidebar layout-push integration', () => { expect(source).toContain('setVisualViewportHeight(Math.max(0, Math.round(vv.height)))') expect(source).toContain('visualViewportHeight ?? viewport.height') expect(source.match(/viewportHeight: layoutViewportHeight/g)).toHaveLength(4) - expect(source).toContain('viewport.width, layoutViewportHeight]') + expect(source).toContain('viewport.width, layoutViewportHeight, keyboardInset]') + }) + + it('adds the keyboard inset to the conversation push, not the panel height', () => { + expect(source.match(/height \+ keyboardInset/g)).toHaveLength(2) + expect(source).toContain('height: bottomPanelHeight') + expect(source).not.toContain('height: bottomPanelHeight + keyboardInset') }) it('reapplies the visible-height cap to every vertical drag result', () => {