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 }) + }) +})