diff --git a/src-tauri/src/context.rs b/src-tauri/src/context.rs index db38f0f4..beb59950 100644 --- a/src-tauri/src/context.rs +++ b/src-tauri/src/context.rs @@ -350,12 +350,6 @@ const WINDOW_BOTTOM_PADDING: f64 = 32.0; pub(crate) const SCREEN_MARGIN: f64 = 16.0; /// macOS menu bar height approximation (logical pts). pub(crate) const MENU_BAR_HEIGHT: f64 = 24.0; -/// Minimum screen space (logical pts) needed below the initial window bottom -/// for the conversation to expand freely. Derived from the frontend's -/// `max-h-[600px]` CSS constraint plus a small safety margin. -/// When less space is available, the window is pinned to grow upward instead. -const UPWARD_GROWTH_THRESHOLD: f64 = 600.0; - /// Result of the window placement calculation. #[derive(Debug, Clone, PartialEq)] pub struct WindowPlacement { @@ -363,11 +357,6 @@ pub struct WindowPlacement { pub x: f64, /// Logical Y of the window's top-left corner. pub y: f64, - /// When `Some`, the bar was flipped **above** the selection because the screen - /// bottom was too close. The value is the logical Y the window bottom should - /// stay pinned to as the conversation grows (so the frontend can reposition - /// upward by computing `y = anchor_bottom_y - current_window_height`). - pub anchor_bottom_y: Option, } /// Returns the top-center position for the no-selection spawn point. @@ -381,11 +370,7 @@ fn top_center( let x_max = (screen_width - window_width - SCREEN_MARGIN).max(x_min); let x = ((screen_width - window_width) / 2.0).clamp(x_min, x_max); let y = MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0; - WindowPlacement { - x, - y, - anchor_bottom_y: None, - } + WindowPlacement { x, y } } /// Positions the window to the right of `anchor_x / anchor_bottom_y`, flipping @@ -421,25 +406,19 @@ fn anchor_near( let below_y = anchor_bottom_y - ANCHOR_OFFSET_Y; if below_y + window_height <= screen_height - SCREEN_MARGIN { - // Enough room below → normal downward placement. + // Enough room below: place just below the selection. WindowPlacement { x, y: below_y.max(y_min), - anchor_bottom_y: None, } } else { - // Flip above: shift the window bottom down by WINDOW_BOTTOM_PADDING so - // the bar's visible content bottom (not the transparent window edge) sits - // ANCHOR_OFFSET_Y pts above anchor_top_y. Clamped to screen_height so - // the window never extends off the screen's lower edge. + // Not enough room below: flip above the selection. Shift by + // WINDOW_BOTTOM_PADDING so the bar's visible content bottom (not the + // transparent window edge) sits ANCHOR_OFFSET_Y pts above anchor_top_y. let fixed_bottom = (anchor_top_y - ANCHOR_OFFSET_Y + WINDOW_BOTTOM_PADDING).min(screen_height); let y = (fixed_bottom - window_height).max(y_min); - WindowPlacement { - x, - y, - anchor_bottom_y: Some(fixed_bottom), - } + WindowPlacement { x, y } } } @@ -454,8 +433,8 @@ pub fn calculate_window_position( window_width: f64, window_height: f64, ) -> WindowPlacement { - let placement = if let Some(rect) = ctx.bounds { - // AX provided full bounds → anchor to the end of the selection. + if let Some(rect) = ctx.bounds { + // AX provided full bounds: position near the end of the selection. anchor_near( rect.x + rect.width, rect.y + rect.height, @@ -485,23 +464,7 @@ pub fn calculate_window_position( } else { // No selection → top center of screen. top_center(screen_width, screen_height, window_width, window_height) - }; - - // Secondary check: if the flip logic above did not set an anchor, determine - // whether there is enough room below for the conversation to expand fully. - // If not, pin the window bottom so the conversation can grow upward instead - // of being clipped by the screen edge. - if placement.anchor_bottom_y.is_none() { - let initial_bottom = placement.y + window_height; - let space_below = screen_height - SCREEN_MARGIN - initial_bottom; - if space_below < UPWARD_GROWTH_THRESHOLD { - return WindowPlacement { - anchor_bottom_y: Some(initial_bottom), - ..placement - }; - } } - placement } // ─── Tests ──────────────────────────────────────────────────────────────────── @@ -549,12 +512,10 @@ mod tests { let p = calculate_window_position(&ctx_no_selection(), SW, SH, WW, WH); assert_eq!(p.x, (SW - WW) / 2.0); assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0); - assert_eq!(p.anchor_bottom_y, None); } #[test] fn text_with_no_bounds_and_no_mouse_falls_back_to_top_center() { - // Same top-center position — no anchor needed since the bar grows downward. let ctx = ActivationContext { selected_text: Some("hello world".to_string()), bounds: None, @@ -565,40 +526,34 @@ mod tests { let x_max = (SW - WW - SCREEN_MARGIN).max(x_min); assert_eq!(p.x, ((SW - WW) / 2.0).clamp(x_min, x_max)); assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN + 120.0); - assert_eq!(p.anchor_bottom_y, None); } #[test] - fn text_with_no_bounds_uses_mouse_as_anchor() { - // Mouse at (400, 300). placement.y ≈ 298. space_below = 900-16-378 = 506 < 600 → anchor. + fn text_with_no_bounds_uses_mouse_position() { + // Mouse at (400, 300). below_y = 298. Room below → normal placement. let ctx = ctx_text_no_bounds_with_mouse(400.0, 300.0); let p = calculate_window_position(&ctx, SW, SH, WW, WH); assert_eq!(p.x, 400.0 + ANCHOR_OFFSET_X); let expected_y = 300.0 - ANCHOR_OFFSET_Y; assert!((p.y - expected_y).abs() < 0.01); - assert_eq!(p.anchor_bottom_y, Some(expected_y + WH)); } #[test] - fn selection_with_room_anchors_to_end() { - // Selection at x=100, y=300, w=80, h=20 → end at (180, 320). - // placement.y ≈ 318. space_below = 900-16-398 = 486 < 600 → anchor pinned. + fn selection_positions_near_end() { + // Selection at x=100, y=300, w=80, h=20. End at (180, 320). let ctx = ctx_with_bounds(100.0, 300.0, 80.0, 20.0); let p = calculate_window_position(&ctx, SW, SH, WW, WH); assert_eq!(p.x, 180.0 + ANCHOR_OFFSET_X); let expected_y = 320.0 - ANCHOR_OFFSET_Y; assert!((p.y - expected_y).abs() < 0.01); - assert_eq!(p.anchor_bottom_y, Some(expected_y + WH)); } #[test] - fn no_anchor_when_plenty_of_room_below() { - // Selection near top of screen: placement.y ≈ 18. space_below = 900-16-98 = 786 > 600. + fn selection_near_top_clamps_to_menu_bar() { + // Selection near top of screen: below_y = 18, clamped to y_min = 40. let ctx = ctx_with_bounds(100.0, 0.0, 80.0, 20.0); let p = calculate_window_position(&ctx, SW, SH, WW, WH); - // below_y = 20-2 = 18, clamped to y_min = 40. assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN); - assert_eq!(p.anchor_bottom_y, None); } #[test] @@ -621,15 +576,11 @@ mod tests { #[test] fn y_flips_above_when_selection_near_screen_bottom() { - // Selection: y=870, h=20 → bottom=890. - // below_y = 888. 888+80=968 > 900-16=884 → flip above. - // fixed_bottom = min(870 - 2 + 32, 900) = min(900, 900) = 900. - // y = (900-80).max(40) = 820. - // Visible content bottom = 900 - WINDOW_BOTTOM_PADDING(32) = 868 → 2px above sel top(870). + // Selection: y=870, h=20. below_y=888. 888+80=968 > 884 → flip above. + // fixed_bottom = min(870-2+32, 900) = 900. y = (900-80).max(40) = 820. let ctx = ctx_with_bounds(100.0, 870.0, 80.0, 20.0); let p = calculate_window_position(&ctx, SW, SH, WW, WH); assert_eq!(p.y, 820.0); - assert_eq!(p.anchor_bottom_y, Some(900.0)); } #[test] @@ -639,7 +590,6 @@ mod tests { let ctx = ctx_with_bounds(100.0, 10.0, 80.0, 20.0); let p = calculate_window_position(&ctx, SW, SH, WW, WH); assert_eq!(p.y, MENU_BAR_HEIGHT + SCREEN_MARGIN); - assert_eq!(p.anchor_bottom_y, None); } #[test] @@ -657,11 +607,12 @@ mod tests { } #[test] - fn very_tall_screen_no_anchor_bottom() { + fn very_tall_screen_positions_below() { let ctx = ctx_with_bounds(100.0, 100.0, 80.0, 20.0); let tall_screen = 2000.0; let p = calculate_window_position(&ctx, SW, tall_screen, WW, WH); - assert_eq!(p.anchor_bottom_y, None); + // below_y = 118. 118+80=198 < 2000-16=1984 → placed below. + assert_eq!(p.y, 120.0 - ANCHOR_OFFSET_Y); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fe03f347..5154236f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -91,20 +91,6 @@ static OVERLAY_INTENDED_VISIBLE: AtomicBool = AtomicBool::new(false); /// registered, so the show event is guaranteed to have a listener. static LAUNCH_SHOW_PENDING: AtomicBool = AtomicBool::new(true); -/// Fixed-bottom anchor emitted when the bar is positioned above the selection. -/// The frontend pins the window bottom to `bottom_y` as the conversation grows. -#[derive(Clone, serde::Serialize)] -struct WindowAnchor { - /// Logical X of the window top-left (preserved during height changes). - x: f64, - /// Logical Y the window bottom must stay pinned to. - bottom_y: f64, - /// Minimum Y the window top may reach (monitor top + menu-bar clearance). - /// On above-monitors this is negative, preventing the frontend's clamp - /// from yanking the window back onto the primary display. - min_y: f64, -} - /// Payload emitted to the frontend on every visibility transition. #[derive(Clone, serde::Serialize)] struct VisibilityPayload { @@ -112,9 +98,14 @@ struct VisibilityPayload { state: &'static str, /// Selected text captured at activation time, if any. selected_text: Option, - /// Present when the window was flipped above the selection. The frontend - /// uses this to keep the window bottom anchored as the chat grows. - window_anchor: Option, + /// Logical X of the window at show time. Used with `window_y` and + /// `screen_bottom_y` to decide growth direction, and as the pinned X + /// coordinate for `set_window_frame` calls during upward growth. + window_x: Option, + /// Logical Y of the window top-left at show time. + window_y: Option, + /// Logical Y of the screen bottom edge (monitor origin + height). + screen_bottom_y: Option, } /// Emits a visibility transition to the frontend animation controller. @@ -122,14 +113,18 @@ fn emit_overlay_visibility( app_handle: &tauri::AppHandle, state: &'static str, selected_text: Option, - window_anchor: Option, + window_x: Option, + window_y: Option, + screen_bottom_y: Option, ) { let _ = app_handle.emit( OVERLAY_VISIBILITY_EVENT, VisibilityPayload { state, selected_text, - window_anchor, + window_x, + window_y, + screen_bottom_y, }, ); } @@ -180,10 +175,6 @@ mod cg_displays { } } -/// Minimum Y offset from the top of any monitor — menu bar plus edge margin. -/// Must match `MENU_BAR_HEIGHT + SCREEN_MARGIN` in `context.rs`. -const MONITOR_TOP_CLEARANCE: f64 = 40.0; - /// Returns the Quartz-coordinate bounds of the display containing /// `(global_x, global_y)`, falling back to the main display. #[cfg(target_os = "macos")] @@ -192,7 +183,7 @@ fn find_target_monitor(global_x: f64, global_y: f64) -> (f64, f64, f64, f64) { } /// Returns Quartz-coordinate bounds of the main display as a fallback -/// when no anchor point is available. +/// when no positioning context is available. #[cfg(target_os = "macos")] fn monitor_info_fallback() -> (f64, f64, f64, f64) { cg_displays::main_display() @@ -255,26 +246,21 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo let global = crate::context::WindowPlacement { x: p.x + mon_x, y: p.y + mon_y, - anchor_bottom_y: p.anchor_bottom_y.map(|y| y + mon_y), }; let _ = window.set_position(tauri::Position::Logical(tauri::LogicalPosition::new( global.x, global.y, ))); - // Menu-bar clearance in global coordinates for this monitor. - let global_min_y = mon_y + MONITOR_TOP_CLEARANCE; - Some((global, global_min_y)) + let screen_bottom = mon_y + screen_h; + Some((global, screen_bottom)) } else { None }; - let window_anchor = placement.and_then(|(p, min_y)| { - p.anchor_bottom_y.map(|bottom_y| WindowAnchor { - x: p.x, - bottom_y, - min_y, - }) - }); + let (window_x, window_y, screen_bottom_y) = match &placement { + Some((p, sb)) => (Some(p.x), Some(p.y), Some(*sb)), + None => (None, None, None), + }; match app_handle.get_webview_panel("main") { Ok(panel) => { @@ -283,7 +269,9 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo app_handle, OVERLAY_VISIBILITY_SHOW, selected_text, - window_anchor, + window_x, + window_y, + screen_bottom_y, ); } Err(e) => { @@ -298,7 +286,14 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo /// window hide is deferred until the frontend exit animation completes. fn request_overlay_hide(app_handle: &tauri::AppHandle) { if OVERLAY_INTENDED_VISIBLE.swap(false, Ordering::SeqCst) { - emit_overlay_visibility(app_handle, OVERLAY_VISIBILITY_HIDE_REQUEST, None, None); + emit_overlay_visibility( + app_handle, + OVERLAY_VISIBILITY_HIDE_REQUEST, + None, + None, + None, + None, + ); } } @@ -316,7 +311,14 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo if let Some(window) = app_handle.get_webview_window("main") { let _ = window.show(); let _ = window.set_focus(); - emit_overlay_visibility(app_handle, OVERLAY_VISIBILITY_SHOW, ctx.selected_text, None); + emit_overlay_visibility( + app_handle, + OVERLAY_VISIBILITY_SHOW, + ctx.selected_text, + None, + None, + None, + ); } } @@ -854,12 +856,4 @@ mod tests { assert_eq!(OVERLAY_LOGICAL_WIDTH, 600.0); assert_eq!(OVERLAY_LOGICAL_HEIGHT_COLLAPSED, 80.0); } - - #[test] - fn monitor_top_clearance_matches_context() { - assert_eq!( - MONITOR_TOP_CLEARANCE, - crate::context::MENU_BAR_HEIGHT + crate::context::SCREEN_MARGIN - ); - } } diff --git a/src/App.tsx b/src/App.tsx index ff3ac732..55b81ffa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,12 @@ import { motion, AnimatePresence } from 'framer-motion'; import type React from 'react'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { + useState, + useEffect, + useCallback, + useRef, + useLayoutEffect, +} from 'react'; import { listen } from '@tauri-apps/api/event'; import { invoke, convertFileSrc } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -40,12 +46,16 @@ const CONTAINER_VERTICAL_PADDING = 48; /** Max morphing-container height in chat mode (matches `max-h-[600px]`) + vertical padding. */ const MAX_CHAT_WINDOW_HEIGHT = 600 + CONTAINER_VERTICAL_PADDING; -type WindowAnchor = { x: number; bottom_y: number; min_y: number }; +/** Must match `OVERLAY_LOGICAL_HEIGHT_COLLAPSED` in `src-tauri/src/lib.rs`. */ +const COLLAPSED_WINDOW_HEIGHT = 80; + type OverlayVisibilityPayload = | { state: 'show'; selected_text: string | null; - window_anchor: WindowAnchor | null; + window_x: number | null; + window_y: number | null; + screen_bottom_y: number | null; } | { state: 'hide-request' }; type OverlayState = 'visible' | 'hidden' | 'hiding'; @@ -178,11 +188,10 @@ function App() { } | null>(null); /** - * True when the window was spawned with an upward-growth anchor. Used to - * flip the outer container to `justify-end` so the morphing container pins - * to the bottom of the pre-expanded window and content grows upward. + * True when the window is near the screen bottom and should grow upward. + * Flips the outer container to `justify-end` so content pins to the bottom. */ - const [isAnchoredUpward, setIsAnchoredUpward] = useState(false); + const [growsUpward, setGrowsUpward] = useState(false); /** * Determines whether the UI has entered "chat mode" — i.e., the morphing @@ -190,6 +199,7 @@ function App() { * to chat-window mode are animated via Framer Motion `layout` prop. */ const isChatMode = messages.length > 0 || isGenerating || isSubmitPending; + const previousIsChatModeRef = useRef(isChatMode); /** * The bookmark save button is active once the AI has produced at least one @@ -205,66 +215,31 @@ function App() { const observerRef = useRef(null); /** - * Holds the window anchor for the "above selection" spawn case. - * Stored in a ref (not state) so the ResizeObserver closure can read the - * latest value without needing to be recreated on each anchor change. + * Mirror of `growsUpward` as a ref so the ResizeObserver closure can read + * it without being recreated on each state change. */ - const windowAnchorRef = useRef(null); + const growsUpwardRef = useRef(false); /** - * Set once the first ResizeObserver event has expanded the window to max - * height for an anchored session. While true, all subsequent observer - * events for the anchor path are skipped — the window stays at max and - * content grows inside it. Reset when the anchor is cleared. + * Stores the window's fixed bottom Y and X for upward-growth sessions. + * The bottom stays pinned while the top edge moves up as content grows. */ - const isPreExpandedRef = useRef(false); + const windowPosRef = useRef({ x: 0, bottomY: 0 }); /** - * Ref attached to the outermost layout div. Used to set an explicit - * `minHeight` before calling `set_window_frame` in the anchor path so the - * CSS layout matches the new window dimensions before WKWebView's viewport - * size event arrives — preventing the one-frame flash where `h-screen` is - * still the old small height but the window has already repositioned upward. + * Mirror of `isGenerating` as a ref so the ResizeObserver closure can + * check streaming state without being recreated on each render. */ - const outerContainerRef = useRef(null); + const isGeneratingRef = useRef(false); + isGeneratingRef.current = isGenerating; /** - * When the LLM starts generating and the window has an upward anchor, expand - * immediately to max height before any streaming tokens arrive. - * - * Streamdown opens empty block elements (`

`) before their content, - * causing the morphing container to grow in sudden steps. Each step triggers - * a ResizeObserver → set_window_frame cycle that repositions the window - * upward — visible as a jittery jump during upward-anchor sessions. - * - * Expanding to max height in a single `useEffect` call (before the first - * token paint) gives the streaming text a fixed canvas to fill, eliminating - * all incremental upward repositioning during the response. + * High-water mark for window height during streaming. While the LLM is + * generating, the window only grows (never shrinks) to prevent jitter + * from Streamdown's block-element reflows. Reset when generation ends + * or a new session starts. */ - useEffect(() => { - if (!isGenerating || !windowAnchorRef.current || isPreExpandedRef.current) - return; - const anchor = windowAnchorRef.current; - const maxHeight = Math.min( - MAX_CHAT_WINDOW_HEIGHT, - anchor.bottom_y - anchor.min_y, - ); - const newY = anchor.bottom_y - maxHeight; - isPreExpandedRef.current = true; - // Pre-set CSS min-height so justify-end positions correctly during the - // WKWebView viewport update lag that follows set_window_frame. - /* v8 ignore start -- DOM ref null guard: always set when overlay is visible */ - if (outerContainerRef.current) { - outerContainerRef.current.style.minHeight = `${maxHeight}px`; - } - /* v8 ignore stop */ - void invoke('set_window_frame', { - x: anchor.x, - y: newY, - width: OVERLAY_WIDTH, - height: maxHeight, - }); - }, [isGenerating]); + const maxHeightRef = useRef(0); /** * Callback ref to reliably attach the ResizeObserver when the conditionally @@ -272,9 +247,9 @@ function App() { * the bug where a standard useEffect would run before the DOM node was ready, * leaving the native window stuck at 600x700. * - * When a window anchor is present (bar spawned above selection), the observer - * also repositions the window upward to keep its bottom pinned to the anchor - * as the conversation grows. + * When `growsUpwardRef` is true (window near screen bottom), the observer + * also repositions the window upward to keep its bottom pinned as the + * conversation grows. */ const setContainerRef = useCallback((node: HTMLDivElement | null) => { morphingContainerNodeRef.current = node; @@ -293,41 +268,29 @@ function App() { const rect = entry.target.getBoundingClientRect(); // Total vertical room: 8px (pt-2) + 24px (pb-6) + 16px (motion py-2) = 48px. // This ensures the tightened drop shadows aren't clipped by the native window edge. - const targetHeight = + let targetHeight = Math.ceil(rect.height) + CONTAINER_VERTICAL_PADDING; - const anchor = windowAnchorRef.current; - if (anchor) { - // Once the window has reached max height for this anchor - // session, skip all further adjustments — content scrolls - // internally inside the fixed-size window. - if (isPreExpandedRef.current) return; - - const maxHeight = Math.min( - MAX_CHAT_WINDOW_HEIGHT, - anchor.bottom_y - anchor.min_y, - ); - const neededHeight = Math.min(targetHeight, maxHeight); - // Lock the observer once max height is reached. - if (neededHeight >= maxHeight) { - isPreExpandedRef.current = true; + // During streaming, only allow the window to grow (never + // shrink) to prevent jitter from Streamdown block reflows. + if (isGeneratingRef.current) { + if (targetHeight > maxHeightRef.current) { + maxHeightRef.current = targetHeight; + } else { + targetHeight = maxHeightRef.current; } + } - // Pre-set CSS min-height before the native resize so the - // WKWebView layout is correct during its viewport update lag. - if (outerContainerRef.current) { - outerContainerRef.current.style.minHeight = `${neededHeight}px`; - } - // Grow upward incrementally: pin the window bottom to the - // anchor and expand the top edge as content grows. Because - // `set_window_frame` applies position + size atomically on - // the main thread, there is no inter-frame jitter. - const newY = anchor.bottom_y - neededHeight; + if (growsUpwardRef.current) { + // Grow upward: pin the window bottom and expand the top edge. + // Clamp Y so the window never extends above the menu bar. + const { x, bottomY } = windowPosRef.current; + const newY = Math.max(0, bottomY - targetHeight); void invoke('set_window_frame', { - x: anchor.x, + x, y: newY, width: OVERLAY_WIDTH, - height: neededHeight, + height: targetHeight, }); } else { void getCurrentWindow().setSize( @@ -345,20 +308,40 @@ function App() { } }, []); + /** + * Reset the high-water mark when streaming finishes so the window can + * shrink back to its natural content height on the next resize event. + */ + useEffect(() => { + if (!isGenerating) { + maxHeightRef.current = 0; + } + }, [isGenerating]); + /** * Replays the entrance sequence by transitioning the overlay to the visible state. * Clears conversation state for a fresh session each time the overlay appears. */ const replayEntranceAnimation = useCallback( - (context: string | null, anchor: WindowAnchor | null) => { - windowAnchorRef.current = anchor; - isPreExpandedRef.current = false; - /* v8 ignore start -- DOM ref null guard: always set when overlay is visible */ - if (outerContainerRef.current) { - outerContainerRef.current.style.minHeight = ''; + ( + context: string | null, + windowX: number | null, + windowY: number | null, + screenBottomY: number | null, + ) => { + const shouldGrowUp = + windowY !== null && + screenBottomY !== null && + windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY; + growsUpwardRef.current = shouldGrowUp; + setGrowsUpward(shouldGrowUp); + maxHeightRef.current = 0; + if (shouldGrowUp && windowX !== null && windowY !== null) { + windowPosRef.current = { + x: windowX, + bottomY: windowY + COLLAPSED_WINDOW_HEIGHT, + }; } - /* v8 ignore stop */ - setIsAnchoredUpward(anchor !== null); setSessionId((id) => id + 1); setQuery(''); setSelectedContext(context); @@ -387,13 +370,8 @@ function App() { */ const requestHideOverlay = useCallback(() => { cancel(); - windowAnchorRef.current = null; - isPreExpandedRef.current = false; - /* v8 ignore start -- DOM ref null guard: always set when overlay is visible */ - if (outerContainerRef.current) { - outerContainerRef.current.style.minHeight = ''; - } - /* v8 ignore stop */ + growsUpwardRef.current = false; + setGrowsUpward(false); screenCapturePendingRef.current = false; screenCaptureInputSnapshotRef.current = null; setSelectedContext(null); @@ -445,11 +423,48 @@ function App() { // Uses a ref-based approach to avoid the @eslint-react/set-state-in-effect // warning from calling setState synchronously inside an effect body. const prevHistoryOpenRef = useRef(isHistoryOpen); + const prevHeightRef = useRef(COLLAPSED_WINDOW_HEIGHT); if (prevHistoryOpenRef.current && !isHistoryOpen) { setPendingNewConversation(false); } prevHistoryOpenRef.current = isHistoryOpen; + /** + * When a submit flips the UI from ask-bar mode into chat mode while the + * window is pinned near the bottom edge, animate the container from its + * current height to the fixed full chat height. This is intentionally scoped + * to the upward-growth path so the downward path remains unchanged. + */ + useLayoutEffect(() => { + /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ + const container = morphingContainerNodeRef.current; + const wasChatMode = previousIsChatModeRef.current; + previousIsChatModeRef.current = isChatMode; + + if (!container) return; + if (!growsUpward || isHistoryOpen || !isChatMode || wasChatMode) { + return; + } + + const startHeight = + container.offsetHeight > 0 + ? container.offsetHeight + : prevHeightRef.current; + container.style.transition = 'none'; + container.style.minHeight = ''; + container.style.height = `${startHeight}px`; + void container.offsetHeight; + + const frameId = requestAnimationFrame(() => { + // 0.4s and slightly softer cubic bezier specifically for upward morph + container.style.transition = 'height 0.4s cubic-bezier(0.2, 0.8, 0.2, 1)'; + container.style.height = '600px'; + }); + + return () => cancelAnimationFrame(frameId); + /* v8 ignore stop */ + }, [growsUpward, isChatMode, isHistoryOpen]); + /** * Observes the dropdown's height while it's open and mutates the morphing * container's `min-height` style directly (bypassing React state) so the @@ -461,18 +476,36 @@ function App() { * indirect chain that broke timing. ResizeObserver tracks async conversation * list load so `min-height` stays accurate as content populates. */ - useEffect(() => { + useLayoutEffect(() => { /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ - if (!isChatMode || !isHistoryOpen) { - if (morphingContainerNodeRef.current) { - morphingContainerNodeRef.current.style.minHeight = ''; - } + const container = morphingContainerNodeRef.current; + if (!container) return; + + // Track the height when we are NOT in chat mode natively. + if (!isChatMode) { + const h = container.offsetHeight; + // offsetHeight might read 0 if hidden, so default to collapsed + prevHeightRef.current = h > 0 ? h : COLLAPSED_WINDOW_HEIGHT; + container.style.transition = + 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; + container.style.minHeight = ''; + return; + } + + if (!isHistoryOpen) { + container.style.transition = + 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.minHeight = ''; return; } const dropdown = historyDropdownRef.current; - const container = morphingContainerNodeRef.current; - if (!dropdown || !container) return; + if (!dropdown) return; + + container.style.transition = + 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; // Let history panel dictate it via minHeight const sync = () => { container.style.minHeight = `${dropdown.offsetTop + dropdown.offsetHeight + 8}px`; @@ -573,23 +606,8 @@ function App() { /** * Shared reset sequence for all "start a new conversation" paths. - * - * Mirrors what `replayEntranceAnimation` does for the anchor-mode state so - * the Tauri window shrinks back to ask-bar height regardless of whether the - * session was launched from a text-selection anchor: - * - * - `isPreExpandedRef.current = false` unblocks the ResizeObserver in anchor - * mode so it can call `set_window_frame` with the (smaller) ask-bar height. - * - Clearing `outerContainerRef.current.style.minHeight` removes the inline - * CSS constraint that was keeping the outer container at the expanded height. */ const resetForNewConversation = useCallback(() => { - isPreExpandedRef.current = false; - /* v8 ignore start -- DOM ref null guard */ - if (outerContainerRef.current) { - outerContainerRef.current.style.minHeight = ''; - } - /* v8 ignore stop */ reset(); resetHistory(); setIsHistoryOpen(false); @@ -1031,7 +1049,9 @@ function App() { if (payload.state === 'show') { replayEntranceAnimation( payload.selected_text ?? null, - payload.window_anchor ?? null, + payload.window_x ?? null, + payload.window_y ?? null, + payload.screen_bottom_y ?? null, ); return; } @@ -1143,14 +1163,13 @@ function App() { e.preventDefault(); void getCurrentWindow().startDragging(); - // After the user repositions the window, drop the upward-grow anchor so + // After the user repositions the window, drop the upward-grow mode so // subsequent conversation growth tracks the new position downward. window.addEventListener( 'mouseup', () => { - windowAnchorRef.current = null; - isPreExpandedRef.current = false; - setIsAnchoredUpward(false); + growsUpwardRef.current = false; + setGrowsUpward(false); }, { once: true }, ); @@ -1169,9 +1188,8 @@ function App() { // Minimal padding (pt-2 pb-6) provides just enough physical clearance for the // tightened drop shadow to render without clipping at the native window edge.
{shouldRenderOverlay ? ( @@ -1197,11 +1215,12 @@ function App() {
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index a532bc75..bcfdfa5b 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -15,7 +15,9 @@ async function showOverlay(selectedText: string | null = null) { emitTauriEvent('thuki://visibility', { state: 'show', selected_text: selectedText, - window_anchor: null, + window_x: null, + window_y: null, + screen_bottom_y: null, }); }); } @@ -33,6 +35,73 @@ describe('App', () => { expect(invoke).toHaveBeenCalledWith('get_model_config'); }); + it('grows upward when near bottom screen edge', async () => { + const { container } = render(); + await act(async () => {}); + + await act(async () => { + emitTauriEvent('thuki://visibility', { + state: 'show', + selected_text: null, + window_x: 50, + window_y: 1000, + screen_bottom_y: 1100, + }); + }); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + await act(async () => { + fireEvent.change(textarea, { target: { value: 'hi' } }); + }); + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }); + }); + // This should morph into max-height window + await act(async () => { + await new Promise((r) => requestAnimationFrame(r)); + }); + expect( + (container.querySelector('.morphing-container') as HTMLElement).style + .height, + ).toBe('600px'); + }); + + it('keeps full chat height after clicking the expanded upward chat surface', async () => { + const { container } = render(); + await act(async () => {}); + + await act(async () => { + emitTauriEvent('thuki://visibility', { + state: 'show', + selected_text: null, + window_x: 50, + window_y: 1000, + screen_bottom_y: 1100, + }); + }); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + await act(async () => { + fireEvent.change(textarea, { target: { value: 'hi' } }); + fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' }); + }); + + const morphingContainer = container.querySelector( + '.morphing-container', + ) as HTMLElement; + expect(morphingContainer.style.height).toBe('600px'); + + const chatArea = container.querySelector('.chat-area'); + expect(chatArea).not.toBeNull(); + + act(() => { + fireEvent.mouseDown(chatArea!); + fireEvent.mouseUp(window); + }); + + expect(morphingContainer.style.height).toBe('600px'); + }); + it('renders nothing when overlay is hidden', async () => { const { container } = render(); // Flush effects so listener registers @@ -217,7 +286,7 @@ describe('App', () => { expect(__mockWindow.startDragging).toHaveBeenCalled(); }); - it('clears anchor ref on mouseup after drag', async () => { + it('clears upward growth on mouseup after drag', async () => { render(); await act(async () => {}); @@ -232,12 +301,11 @@ describe('App', () => { fireEvent.mouseDown(container!); }); - // startDragging was called — now fire mouseup to cover the mouseup handler + // startDragging was called; fire mouseup to cover the mouseup handler act(() => { fireEvent.mouseUp(window); }); - // No assertion needed — just exercising the mouseup callback (windowAnchorRef = null) expect(__mockWindow.startDragging).toHaveBeenCalled(); }); @@ -317,25 +385,48 @@ describe('App', () => { ); }); - it('applies justify-end layout when overlay opens with anchor', async () => { + it('applies justify-end when window is near screen bottom', async () => { render(); await act(async () => {}); - // Show overlay with a window anchor (upward-growth mode) + // Show overlay near screen bottom: window_y=750, screen_bottom=900. + // 750 + MAX_CHAT_WINDOW_HEIGHT(648) = 1398 > 900 → grows upward. await act(async () => { emitTauriEvent('thuki://visibility', { state: 'show', selected_text: null, - window_anchor: { x: 100, bottom_y: 800, min_y: 50 }, + window_x: 100, + window_y: 750, + screen_bottom_y: 900, }); }); - // The outer container should use justify-end for bottom-pinning const outer = document.querySelector('.justify-end'); expect(outer).not.toBeNull(); }); - describe('ResizeObserver window sizing with anchor', () => { + it('applies justify-start when window has room below', async () => { + render(); + await act(async () => {}); + + // Show overlay near top: window_y=100, screen_bottom=900. + // 100 + 648 = 748 < 900 → grows downward. + await act(async () => { + emitTauriEvent('thuki://visibility', { + state: 'show', + selected_text: null, + window_x: 100, + window_y: 100, + screen_bottom_y: 900, + }); + }); + + const outer = document.querySelector('.justify-start'); + expect(outer).not.toBeNull(); + expect(document.querySelector('.justify-end')).toBeNull(); + }); + + describe('ResizeObserver upward growth', () => { let capturedCallback: ResizeObserverCallback | null = null; function spyOnResizeObserver() { @@ -368,18 +459,20 @@ describe('App', () => { } } - it('calls set_window_frame with content height on first anchor event, not max height', async () => { + it('commits exact height when not streaming (initial ask bar)', async () => { spyOnResizeObserver(); render(); await act(async () => {}); - // Show with anchor — bottom_y=884 means the window is at the bottom of a 900px screen + // window_y=804, screen_bottom=900. bottomY = 804+80 = 884. await act(async () => { emitTauriEvent('thuki://visibility', { state: 'show', selected_text: null, - window_anchor: { x: 100, bottom_y: 884, min_y: 40 }, + window_x: 100, + window_y: 804, + screen_bottom_y: 900, }); }); @@ -387,27 +480,22 @@ describe('App', () => { const container = document.querySelector('.morphing-container'); expect(container).not.toBeNull(); - expect(capturedCallback).not.toBeNull(); - // Simulate first observer event: only the askbar is visible (~60px content) + // Not streaming yet, so exact height is committed (no buffer) act(() => { triggerResize(container!, 60); }); - // REGRESSION: must use content height (60+48=108), NOT max height (648) + // bottomY(884) - targetHeight(108) = 776 expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 884 - 108, // 776 — window bottom stays pinned, top moves to fit content + y: 776, width: 600, height: 108, }); - expect(invoke).not.toHaveBeenCalledWith( - 'set_window_frame', - expect.objectContaining({ height: 648 }), - ); }); - it('grows incrementally: each resize event updates position and height', async () => { + it('uses setSize (not set_window_frame) after drag clears upward growth', async () => { spyOnResizeObserver(); render(); @@ -417,147 +505,16 @@ describe('App', () => { emitTauriEvent('thuki://visibility', { state: 'show', selected_text: null, - window_anchor: { x: 50, bottom_y: 800, min_y: 40 }, + window_x: 100, + window_y: 804, + screen_bottom_y: 900, }); }); const container = document.querySelector('.morphing-container'); expect(container).not.toBeNull(); - // First event: askbar only - invoke.mockClear(); - act(() => { - triggerResize(container!, 60); - }); - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 50, - y: 800 - 108, - width: 600, - height: 108, - }); - - // Second event: chat started, content grew - invoke.mockClear(); - act(() => { - triggerResize(container!, 200); - }); - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 50, - y: 800 - 248, - width: 600, - height: 248, - }); - }); - - it('immediately expands to max height when isGenerating becomes true with upward anchor', async () => { - spyOnResizeObserver(); - - render(); - await act(async () => {}); - - // Show with anchor - await act(async () => { - emitTauriEvent('thuki://visibility', { - state: 'show', - selected_text: null, - window_anchor: { x: 100, bottom_y: 884, min_y: 40 }, - }); - }); - - // Small initial resize (ask bar only, isGenerating=false) - const container = document.querySelector('.morphing-container'); - expect(container).not.toBeNull(); - act(() => { - triggerResize(container!, 60); - }); - - // Submit a message — causes isGenerating to become true - invoke.mockClear(); - const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); - act(() => { - fireEvent.change(textarea, { target: { value: 'hello' } }); - }); - act(() => { - fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); - }); - await act(async () => {}); - - // Must immediately call set_window_frame with max height - // max = min(648, 884 - 40 = 844) = 648; newY = 884 - 648 = 236 - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 100, - y: 236, - width: 600, - height: 648, - }); - - // Subsequent resize events must be no-ops (isPreExpandedRef is now true) - invoke.mockClear(); - act(() => { - triggerResize(container!, 100); - }); - expect(invoke).not.toHaveBeenCalledWith( - 'set_window_frame', - expect.anything(), - ); - }); - - it('locks at max height and skips further resize events', async () => { - spyOnResizeObserver(); - - render(); - await act(async () => {}); - - await act(async () => { - emitTauriEvent('thuki://visibility', { - state: 'show', - selected_text: null, - window_anchor: { x: 100, bottom_y: 884, min_y: 40 }, - }); - }); - - const container = document.querySelector('.morphing-container'); - expect(container).not.toBeNull(); - - // Grow to max height (content=600 → window=648) - invoke.mockClear(); - act(() => { - triggerResize(container!, 600); - }); - expect(invoke).toHaveBeenCalledWith( - 'set_window_frame', - expect.objectContaining({ height: 648 }), - ); - - // Next event should be a no-op (isPreExpandedRef is now true) - invoke.mockClear(); - act(() => { - triggerResize(container!, 620); - }); - expect(invoke).not.toHaveBeenCalledWith( - 'set_window_frame', - expect.anything(), - ); - }); - - it('uses setSize (not set_window_frame) after drag clears the anchor', async () => { - spyOnResizeObserver(); - - render(); - await act(async () => {}); - - await act(async () => { - emitTauriEvent('thuki://visibility', { - state: 'show', - selected_text: null, - window_anchor: { x: 100, bottom_y: 884, min_y: 40 }, - }); - }); - - const container = document.querySelector('.morphing-container'); - expect(container).not.toBeNull(); - - // Simulate drag: mousedown then mouseup clears the anchor + // Drag clears upward growth act(() => { fireEvent.mouseDown(container!); }); @@ -568,7 +525,6 @@ describe('App', () => { invoke.mockClear(); __mockWindow.setSize.mockClear?.(); - // After drag, anchor is null — ResizeObserver should use setSize, not set_window_frame act(() => { triggerResize(container!, 60); }); @@ -579,78 +535,41 @@ describe('App', () => { expect(__mockWindow.setSize).toHaveBeenCalled(); }); - it('clamps to available space when screen gap is smaller than MAX_CHAT_WINDOW_HEIGHT', async () => { - spyOnResizeObserver(); - - render(); - await act(async () => {}); - - // Available space: bottom_y - min_y = 300 - 100 = 200, which is < MAX_CHAT_WINDOW_HEIGHT (648) - await act(async () => { - emitTauriEvent('thuki://visibility', { - state: 'show', - selected_text: null, - window_anchor: { x: 50, bottom_y: 300, min_y: 100 }, - }); - }); - - const container = document.querySelector('.morphing-container'); - expect(container).not.toBeNull(); - - invoke.mockClear(); - // Content height (300) → targetHeight (348) exceeds available space (200) → clamped to 200 - act(() => { - triggerResize(container!, 300); - }); - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 50, - y: 100, // bottom_y (300) - clamped height (200) = 100 - width: 600, - height: 200, // clamped to available space, not targetHeight (348) or MAX (648) - }); - - // isPreExpandedRef is now true — next event is a no-op - invoke.mockClear(); - act(() => { - triggerResize(container!, 400); - }); - expect(invoke).not.toHaveBeenCalledWith( - 'set_window_frame', - expect.anything(), - ); - }); - - it('isPreExpandedRef resets on session reopen, allowing incremental growth again', async () => { + it('resets upward growth on session reopen', async () => { spyOnResizeObserver(); render(); await act(async () => {}); - // Session 1: grow to max height, locking isPreExpandedRef + // Session 1: near bottom, grows upward await act(async () => { emitTauriEvent('thuki://visibility', { state: 'show', selected_text: null, - window_anchor: { x: 100, bottom_y: 884, min_y: 40 }, + window_x: 100, + window_y: 804, + screen_bottom_y: 900, }); }); const container1 = document.querySelector('.morphing-container'); act(() => { - triggerResize(container1!, 600); // locks isPreExpandedRef = true + triggerResize(container1!, 60); }); - // Close the overlay — requestHideOverlay resets isPreExpandedRef to false + // Close await act(async () => { emitTauriEvent('thuki://visibility', { state: 'hide-request' }); }); - // Session 2: reopen with new anchor — incremental growth must work again + // Session 2: reopen near bottom again await act(async () => { emitTauriEvent('thuki://visibility', { state: 'show', selected_text: null, - window_anchor: { x: 100, bottom_y: 884, min_y: 40 }, + window_x: 100, + window_y: 804, + screen_bottom_y: 900, }); }); @@ -658,15 +577,15 @@ describe('App', () => { expect(container2).not.toBeNull(); invoke.mockClear(); - // Small content — must NOT be skipped even though the previous session was locked act(() => { triggerResize(container2!, 60); }); + // bottomY = 804+80 = 884. 884-108 = 776. expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 100, - y: 776, // bottom_y (884) - neededHeight (108) = 776 + y: 776, width: 600, - height: 108, // content height (60) + padding (48) + height: 108, }); }); });