diff --git a/apps/desktop-tauri/src-tauri/src/proof_harness.rs b/apps/desktop-tauri/src-tauri/src/proof_harness.rs index 04464c050d..39d8e1168f 100644 --- a/apps/desktop-tauri/src-tauri/src/proof_harness.rs +++ b/apps/desktop-tauri/src-tauri/src/proof_harness.rs @@ -129,12 +129,78 @@ pub fn activate(app: &AppHandle) { } } +/// Bottom inset (physical px) kept between the proof panel's bottom edge and +/// the monitor work-area bottom (#265). +const PROOF_BOTTOM_INSET_PX: i32 = 8; + +/// Mirror of the frontend auto-fit ceiling (`TRAY_MAX_MEASURE_HEIGHT`, +/// logical px in `useTrayPanelLayout.ts`): the proof panel can settle +/// anywhere up to this height. +const PROOF_MAX_SETTLE_HEIGHT_LOGICAL: f64 = 920.0; + +/// Justified proof-panel anchor (#265). The harness anchors `main` once and +/// never re-anchors (the flyout-only `reanchor_tray_panel` path is a no-op +/// here, and Win32 ignores `set_max_size` for programmatic resizes), so an +/// anchor computed from the DEFAULT initial size lets a tall auto-fit settle +/// (up to the frontend's 920px cap) push the bottom edge under the taskbar. +/// When `anchor_y + max settle` would exceed `work_bottom - inset`, move the +/// anchor up just enough that even the tallest settle stays fully on screen; +/// otherwise keep the historical anchor unchanged. +fn proof_anchor_y(anchor_y: i32, work_bottom: i32, max_settle_height_px: i32) -> i32 { + let justified = work_bottom - PROOF_BOTTOM_INSET_PX - max_settle_height_px; + anchor_y.min(justified) +} + +/// Bottom edge of a settled panel anchored via [`proof_anchor_y`]. +#[cfg(test)] +fn proof_settled_bottom( + anchor_y: i32, + settled_height: i32, + work_bottom: i32, + max_settle_height_px: i32, +) -> i32 { + proof_anchor_y(anchor_y, work_bottom, max_settle_height_px) + settled_height +} + /// Calculate a predictable window position for proof captures. /// /// Proof mode needs a reliable on-screen position. We skip /// `inferred_tray_panel_position` because its DPI-scaled maths can /// produce off-screen coords on high-DPI setups. fn proof_window_position(app: &AppHandle) -> Option<(i32, i32)> { + let (x, y) = proof_window_position_unjustified(app)?; + + // #265: justify above the taskbar when the tallest legitimate auto-fit + // settle (the frontend's 920px cap) would otherwise push the bottom edge + // under it — the harness anchors `main` once and never re-anchors, so + // the anchor itself must reserve the settle room. + let Some(window) = app.get_webview_window("main") else { + return Some((x, y)); + }; + let Some(m) = window + .primary_monitor() + .ok() + .flatten() + .or_else(|| window.available_monitors().ok()?.into_iter().next()) + else { + return Some((x, y)); + }; + let work_area = m.work_area(); + let work_bottom = work_area.position.y + work_area.size.height as i32; + let scale = m.scale_factor().max(1.0); + let max_settle = (PROOF_MAX_SETTLE_HEIGHT_LOGICAL * scale) as i32; + let justified_y = proof_anchor_y(y, work_bottom, max_settle); + if justified_y != y { + tracing::info!( + "proof-pos: #265 justified anchor y={y} → {justified_y} \ + (work_bottom={work_bottom} max_settle={max_settle})" + ); + } + Some((x, justified_y)) +} + +/// Raw proof-capture anchor, before the #265 above-taskbar justification. +fn proof_window_position_unjustified(app: &AppHandle) -> Option<(i32, i32)> { if let Some(pos) = shell::tray_panel_position(app) { return Some(pos); } @@ -204,6 +270,28 @@ mod tests { static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + #[test] + fn tall_panel_bottom_edge_never_passes_work_bottom_minus_inset() { + // 1920x1080 proof rig at 100% display scale: historical anchor y=252 + // (1040 work bottom - 776 default height - 12 margin); the tallest + // legitimate settle is the frontend auto-fit cap, 920px. + let max_settle = 920; + // y is justified up to 112 so the capped settle bottoms out exactly + // on the inset boundary... + assert_eq!(proof_anchor_y(252, 1040, max_settle), 112); + let bottom = proof_settled_bottom(252, max_settle, 1040, max_settle); + assert!(bottom <= 1040 - PROOF_BOTTOM_INSET_PX); + assert_eq!(bottom, 1032); + // ...and any shorter settle keeps its bottom edge on screen too. + assert!(proof_settled_bottom(252, 780, 1040, max_settle) <= 1040 - PROOF_BOTTOM_INSET_PX); + assert!(proof_settled_bottom(252, 568, 1040, max_settle) <= 1040 - PROOF_BOTTOM_INSET_PX); + // Tall displays where even the capped settle already fits keep the + // historical anchor unchanged. + assert_eq!(proof_anchor_y(252, 2000, max_settle), 252); + // Degenerate anchors below the work bottom move fully above it. + assert_eq!(proof_anchor_y(1500, 1040, max_settle), 112); + } + fn with_proof_mode_env(value: Option<&str>, test: impl FnOnce()) { let _guard = ENV_LOCK.lock().unwrap(); let prev = std::env::var("CODEXBAR_PROOF_MODE").ok(); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts index c5eacbb73c..c47a506e32 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelController.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelController.ts @@ -273,6 +273,7 @@ export function useTrayPanelController(state: BootstrapState) { autoFit: flyoutSize === null && !autoFitKilled, fixedSize: fixedFlyoutSize, isOpen: isFlyoutOpen, + zoom: trayScale, onUserResize: handleUserResize, }); diff --git a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts index aeb19f8a43..65f265e206 100644 --- a/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts +++ b/apps/desktop-tauri/src/hooks/useTrayPanelLayout.ts @@ -16,6 +16,18 @@ const TRAY_OVERVIEW_MIN_HEIGHT = 200; const TRAY_DETAIL_MIN_HEIGHT = 420; const TRAY_DENSE_OVERVIEW_HEIGHT = 776; +/** Scale a measured content height into the window's pixel space. TrayPanel + * renders the surface with `zoom: trayScale` (CSS zoom), but every DOM metric + * the layout pass reads — scrollHeight AND bounding rects — is reported in + * the surface's LOCAL, pre-zoom px (measured identical at 100/150/200%). The + * WIN32 window must be sized in POST-zoom px or tall cards clip below the + * fold (and the zoomed width overflows into a horizontal scrollbar), so the + * raw measure is multiplied by the active zoom before clamping (#265). */ +export function scaledContentHeight(rawHeight: number, zoom: number): number { + if (!Number.isFinite(zoom) || zoom <= 0 || zoom === 1) return rawHeight; + return Math.round(rawHeight * zoom); +} + export interface TrayPanelLayoutOptions { canMeasure: boolean; denseOverview: boolean; @@ -29,6 +41,10 @@ export interface TrayPanelLayoutOptions { /** Whether the flyout is currently open (surface mode === trayPanel). Used as * the "just opened" trigger for the fixed-size restore + re-anchor. */ isOpen?: boolean; + /** Active tray-panel zoom factor (the CSS `zoom` TrayPanel applies to the + * surface). Auto-fit scales its measured content height by this before + * clamping — see `scaledContentHeight`. Defaults to 1 (no zoom). */ + zoom?: number; /** Called with the new logical size on a genuine user drag-resize. */ onUserResize?: (width: number, height: number) => void; } @@ -46,6 +62,7 @@ export function useTrayPanelLayout({ autoFit = true, fixedSize = null, isOpen = false, + zoom = 1, onUserResize, }: TrayPanelLayoutOptions): TrayPanelLayout { const [layoutReady, setLayoutReady] = useState(false); @@ -261,19 +278,32 @@ export function useTrayPanelLayout({ if (run !== resizeRunRef.current) return; const surfaceRect = surface.getBoundingClientRect(); - let contentHeight = Math.max( - surface.scrollHeight, - Math.ceil(surfaceRect.height), + // Everything measured here is in the surface's LOCAL, pre-zoom px; + // scale into post-zoom window px via `scaledContentHeight` at each + // derivation point so the clamped window fits the rendered content. + let contentHeight = scaledContentHeight( + Math.max(surface.scrollHeight, Math.ceil(surfaceRect.height)), + zoom, ); let maxBottom = surfaceRect.top + contentHeight; const bodyRect = body?.getBoundingClientRect(); - if (bodyRect && bodyRect.height > 0 && bodyRect.bottom > maxBottom) { - maxBottom = bodyRect.bottom; + if (bodyRect && bodyRect.height > 0) { + const scaledBottom = + surfaceRect.top + + scaledContentHeight(bodyRect.bottom - surfaceRect.top, zoom); + if (scaledBottom > maxBottom) { + maxBottom = scaledBottom; + } } const footer = surface.querySelector(".menu-surface__footer"); const footerRect = footer?.getBoundingClientRect(); - if (footerRect && footerRect.height > 0 && footerRect.bottom > maxBottom) { - maxBottom = footerRect.bottom; + if (footerRect && footerRect.height > 0) { + const scaledBottom = + surfaceRect.top + + scaledContentHeight(footerRect.bottom - surfaceRect.top, zoom); + if (scaledBottom > maxBottom) { + maxBottom = scaledBottom; + } } contentHeight = Math.ceil(maxBottom - surfaceRect.top) + 4; @@ -331,7 +361,7 @@ export function useTrayPanelLayout({ window.clearTimeout(timer); resizeRunRef.current += 1; }; - }, [autoFit, canMeasure, denseOverview, detailMode, layoutRevision, applySize]); + }, [autoFit, canMeasure, denseOverview, detailMode, layoutRevision, applySize, zoom]); return { layoutReady, requestLayout }; } diff --git a/apps/desktop-tauri/src/styles.css b/apps/desktop-tauri/src/styles.css index 9b2b4096d2..e70ea32721 100644 --- a/apps/desktop-tauri/src/styles.css +++ b/apps/desktop-tauri/src/styles.css @@ -4501,6 +4501,12 @@ html:has(.menu-surface--tray) { border: 1px solid rgba(255, 255, 255, 0.10); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); overflow: hidden; + /* #265: TrayPanel applies CSS `zoom` on this surface, so it lays out in + windowWidth/zoom local px; the 260px `min-width` floor from .menu-surface + would be multiplied by the zoom on render (→ 390px at 150%) and force a + horizontal scrollbar in the 328px tray window. The window's own 300px + minimum already covers the floor — let the zoomed surface wrap instead. */ + min-width: 0; } .menu-surface--popout { width: 100%; diff --git a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx index dcb47e0f04..5b6d1ee05f 100644 --- a/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx +++ b/apps/desktop-tauri/src/surfaces/TrayPanel.test.tsx @@ -850,4 +850,48 @@ describe("TrayPanel provider grid", () => { ); }); }); + + it("scales the auto-fit measure by the active tray zoom before clamping (#265)", async () => { + const setSize = vi.fn().mockResolvedValue(undefined); + windowMocks.getCurrentWindow.mockReturnValue({ + setSize, + close: vi.fn().mockResolvedValue(undefined), + scaleFactor: vi.fn().mockResolvedValue(1), + onResized: vi.fn().mockResolvedValue(() => {}), + innerSize: vi.fn().mockResolvedValue({ width: 328, height: 200 }), + }); + // jsdom has no layout engine (scrollHeight always reads 0), so pin it + // globally to a deterministic PRE-zoom content height: TrayPanel applies + // `zoom: trayScale` via CSS and the hook must size the window in POST-zoom + // px or tall cards clip below the fold (#265). + const scrollHeight = vi + .spyOn(Element.prototype, "scrollHeight", "get") + .mockReturnValue(505); + + const first = renderTrayPanel([provider("codex", "Codex", 61)], { + trayScalePercent: 150, + }); + + // 505 raw × 1.5 zoom = 757.5 → 758 rounded, + the 4px fudge = 762. + await waitFor(() => { + expect(setSize).toHaveBeenCalledWith( + expect.objectContaining({ width: 328, height: 762 }), + ); + }); + first.unmount(); + setSize.mockClear(); + + // Same zoom, taller content: round(700 × 1.5) + 4 = 1054 exceeds the + // mocked work-area cap (900 - 16 = 884), so the clamp still wins. + scrollHeight.mockReturnValue(700); + renderTrayPanel([provider("codex", "Codex", 61)], { + trayScalePercent: 150, + }); + + await waitFor(() => { + expect(setSize).toHaveBeenCalledWith( + expect.objectContaining({ width: 328, height: 884 }), + ); + }); + }); });