diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index d7c8c07..ab56e55 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -42,7 +42,8 @@ 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' import { getShellPreset } from './shell-presets.ts' @@ -242,7 +243,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- @@ -255,6 +257,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 @@ -263,6 +266,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) @@ -274,6 +278,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 = visualViewportHeight ?? viewport.height // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( @@ -292,6 +300,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: layoutViewportHeight, + }).height // The collapsed toggle cluster reclaims the top-right corner, so the DSH // session header's right-aligned utilities (the "Session log" download @@ -779,7 +796,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 - centerRectRef.current.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 @@ -876,13 +894,13 @@ 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)) + 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) { @@ -906,12 +924,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: viewport.width, + viewportHeight: layoutViewportHeight, + }) applyDrag(adoptedWidth, adoptedHeight) draggingRef.current = false measureCenter() @@ -928,14 +949,20 @@ 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) + 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: viewport.width, + viewportHeight: layoutViewportHeight, + }) + const bottomPush = !narrow && snapshot.state?.bottomOpen === true + ? height + keyboardInset : 0 - const height = !narrow && snapshot.state?.bottomOpen === true - ? Math.min(snapshot.state.bottomHeight, window.innerHeight) - : 0 - writeGeometry(width, height) - }, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight]) + 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 @@ -1050,6 +1077,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: layoutViewportHeight, + }).height + const onNewTab = (optionId: string): void => { const service = ctx.betterSidebar const descriptor = service?.getTab(optionId) @@ -1195,7 +1234,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) => { @@ -1209,7 +1248,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) }} @@ -1259,7 +1298,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) => { @@ -1271,7 +1310,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) }} @@ -1303,7 +1342,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: centerRectRef.current.left, // Keep the panel above the on-screen keyboard when the visual // viewport shrinks (see the keyboardInset effect). @@ -1338,7 +1377,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) => { @@ -1349,7 +1388,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/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/src/client/layout-push.ts b/src/client/layout-push.ts new file mode 100644 index 0000000..8b53cb2 --- /dev/null +++ b/src/client/layout-push.ts @@ -0,0 +1,37 @@ +/** + * 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 { 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 +} + +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 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(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 333f9b0..17075c6 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -44,6 +44,13 @@ #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-wrap lets the token wrap instead of forcing its intrinsic + size. The host's own descendants remain responsible for scrolling. */ + min-height: 0; + overflow-wrap: anywhere; transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); } 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() }) + }) +}) diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts new file mode 100644 index 0000000..c90d889 --- /dev/null +++ b/tests/layout-css.spec.ts @@ -0,0 +1,29 @@ +/** + * 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-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 new file mode 100644 index 0000000..b9052d2 --- /dev/null +++ b/tests/layout-push.spec.ts @@ -0,0 +1,99 @@ +/** + * 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 }) + }) + + 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 }) + }) +}) diff --git a/tests/sidebar-layout-push.spec.ts b/tests/sidebar-layout-push.spec.ts new file mode 100644 index 0000000..1f0ea1b --- /dev/null +++ b/tests/sidebar-layout-push.spec.ts @@ -0,0 +1,30 @@ +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(9) + }) + + it('caps panel geometry against the viewport visible above the keyboard', () => { + 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, 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', () => { + expect(source).not.toMatch(/const height = clampHeight\(/) + expect(source).not.toMatch(/height = clampHeight\(/) + expect(source.match(/pushedBottomHeight\(true, clampHeight\(/g)).toHaveLength(6) + }) +})