From 041daf91481e0709126a7f439cb7d0dbce02e434 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 9 Apr 2026 20:58:58 -0500 Subject: [PATCH 1/7] refactor: replace anchor system with simple screen-bottom growth detection Remove the complex WindowAnchor coordinate system (precalculated bottom pin, max-height clamping, pre-expansion on generate, isPreExpandedRef locking, outerContainerRef minHeight CSS hacks) and replace with a single check: if windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY, grow upward; otherwise grow downward. The Rust backend now sends window_x, window_y, and screen_bottom_y in the visibility payload instead of a WindowAnchor struct. The frontend decides growth direction at show time and pins the window bottom for upward sessions. Net deletion of ~240 lines. Signed-off-by: Logan Nguyen Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- src-tauri/src/context.rs | 87 +++----------- src-tauri/src/lib.rs | 85 ++++++------- src/App.tsx | 183 ++++++++-------------------- src/__tests__/App.test.tsx | 236 +++++++++++-------------------------- 4 files changed, 175 insertions(+), 416 deletions(-) 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..2fae634f 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,13 @@ 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. The frontend uses this together + /// with `window_y` and `screen_bottom_y` to decide growth direction. + 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 +112,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 +174,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 +182,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 +245,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 +268,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 +285,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 +310,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 +855,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..dc682164 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,12 +40,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 +182,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 @@ -205,66 +208,16 @@ 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. - */ - const windowAnchorRef = useRef(null); - - /** - * 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. + * Mirror of `growsUpward` as a ref so the ResizeObserver closure can read + * it without being recreated on each state change. */ - const isPreExpandedRef = useRef(false); + const growsUpwardRef = useRef(false); /** - * 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. + * 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 outerContainerRef = useRef(null); - - /** - * 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. - */ - 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 windowPosRef = useRef({ x: 0, bottomY: 0 }); /** * Callback ref to reliably attach the ResizeObserver when the conditionally @@ -272,9 +225,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; @@ -295,39 +248,15 @@ function App() { // This ensures the tightened drop shadows aren't clipped by the native window edge. const 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; - } - - // 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. + const { x, bottomY } = windowPosRef.current; + const newY = bottomY - targetHeight; void invoke('set_window_frame', { - x: anchor.x, + x, y: newY, width: OVERLAY_WIDTH, - height: neededHeight, + height: targetHeight, }); } else { void getCurrentWindow().setSize( @@ -350,15 +279,26 @@ function App() { * 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, + ) => { + // Decide growth direction: if the collapsed window plus a full chat + // would overflow the screen bottom, grow upward instead. + const shouldGrowUp = + windowY !== null && + screenBottomY !== null && + windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY; + growsUpwardRef.current = shouldGrowUp; + setGrowsUpward(shouldGrowUp); + 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 +327,7 @@ 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; screenCapturePendingRef.current = false; screenCaptureInputSnapshotRef.current = null; setSelectedContext(null); @@ -573,23 +507,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 +950,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 +1064,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 +1089,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 ? ( diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index a532bc75..529be68e 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, }); }); } @@ -217,7 +219,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 +234,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 +318,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 +392,20 @@ describe('App', () => { } } - it('calls set_window_frame with content height on first anchor event, not max height', async () => { + it('calls set_window_frame when near screen bottom', 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,44 +413,41 @@ 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) 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('grows incrementally with each resize event', async () => { spyOnResizeObserver(); render(); await act(async () => {}); + // bottomY = 720 + 80 = 800 await act(async () => { emitTauriEvent('thuki://visibility', { state: 'show', selected_text: null, - window_anchor: { x: 50, bottom_y: 800, min_y: 40 }, + window_x: 50, + window_y: 720, + screen_bottom_y: 900, }); }); const container = document.querySelector('.morphing-container'); expect(container).not.toBeNull(); - // First event: askbar only + // First event: ask bar only invoke.mockClear(); act(() => { triggerResize(container!, 60); @@ -436,7 +459,7 @@ describe('App', () => { height: 108, }); - // Second event: chat started, content grew + // Second event: content grew invoke.mockClear(); act(() => { triggerResize(container!, 200); @@ -449,98 +472,7 @@ describe('App', () => { }); }); - 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 () => { + it('uses setSize (not set_window_frame) after drag clears upward growth', async () => { spyOnResizeObserver(); render(); @@ -550,14 +482,16 @@ describe('App', () => { 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 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 +502,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 +512,41 @@ describe('App', () => { expect(__mockWindow.setSize).toHaveBeenCalled(); }); - it('clamps to available space when screen gap is smaller than MAX_CHAT_WINDOW_HEIGHT', async () => { + it('resets upward growth on session reopen', async () => { spyOnResizeObserver(); render(); await act(async () => {}); - // Available space: bottom_y - min_y = 300 - 100 = 200, which is < MAX_CHAT_WINDOW_HEIGHT (648) + // Session 1: near bottom, grows upward 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 () => { - spyOnResizeObserver(); - - render(); - await act(async () => {}); - - // Session 1: grow to max height, locking isPreExpandedRef - 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 +554,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, }); }); }); From 6c7faf275d89002ba3d59b4a6c36cd5cec1f803c Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 9 Apr 2026 22:05:24 -0500 Subject: [PATCH 2/7] fix: eliminate upward-growth jitter with buffered window commits During streaming with upward growth, the ResizeObserver was calling set_window_frame on every content height change, causing the window to visually bounce as the Y position oscillated with each token. Replace the per-event native resize with a buffered commit strategy: when streaming, grow the native window in coarse steps (targetHeight + 80px buffer). The extra space lands in the transparent region above the morphing container where it is invisible to the user. Content then fills smoothly via justify-end with zero native resize calls until it catches up to the committed height. When not streaming, exact height is committed as before. Signed-off-by: Logan Nguyen Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- src/App.tsx | 63 +++++++++++++++++++++++++++++++++++--- src/__tests__/App.test.tsx | 46 ++++++++++++++++++++++++---- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index dc682164..487ba8ec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,14 @@ const OVERLAY_WIDTH = 600; 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; +/** + * Extra headroom (px) added when committing a native window resize during + * upward-growth streaming. The buffer lands in the transparent region above + * the morphing container, so the user never sees it. Content then fills + * smoothly via `justify-end` without any native resize calls until it + * catches up to the committed height. + */ +const UPWARD_GROWTH_BUFFER = 80; /** Must match `OVERLAY_LOGICAL_HEIGHT_COLLAPSED` in `src-tauri/src/lib.rs`. */ const COLLAPSED_WINDOW_HEIGHT = 80; @@ -219,6 +227,24 @@ function App() { */ const windowPosRef = useRef({ x: 0, bottomY: 0 }); + /** + * Mirror of `isGenerating` as a ref so the ResizeObserver closure can + * check streaming state without being recreated on each render. + */ + const isGeneratingRef = useRef(false); + isGeneratingRef.current = isGenerating; + + /** + * Last native window height committed via `set_window_frame` during an + * upward-growth session. During streaming, the window is grown in coarse + * steps (targetHeight + UPWARD_GROWTH_BUFFER) so the extra space lands + * in the transparent region above the morphing container. Content fills + * smoothly within the already-allocated window via `justify-end`, and no + * native resize calls are needed until content catches up to the committed + * height. This eliminates the per-token `set_window_frame` jitter. + */ + const committedHeightRef = useRef(0); + /** * Callback ref to reliably attach the ResizeObserver when the conditionally * rendered Framer Motion container actually mounts in the DOM. This fixes @@ -248,15 +274,33 @@ function App() { // This ensures the tightened drop shadows aren't clipped by the native window edge. const targetHeight = Math.ceil(rect.height) + CONTAINER_VERTICAL_PADDING; + if (growsUpwardRef.current) { - // Grow upward: pin the window bottom and expand the top edge. const { x, bottomY } = windowPosRef.current; - const newY = bottomY - targetHeight; + if ( + isGeneratingRef.current && + targetHeight <= committedHeightRef.current + ) { + // During streaming, content fits within the committed + // window. justify-end keeps it at the bottom. No native + // resize needed; this is what makes upward growth smooth. + return; + } + // Commit a native resize. During streaming, add a buffer + // so content can grow into the transparent headroom without + // triggering another resize for several lines of text. + const newHeight = isGeneratingRef.current + ? Math.min( + targetHeight + UPWARD_GROWTH_BUFFER, + MAX_CHAT_WINDOW_HEIGHT, + ) + : targetHeight; + committedHeightRef.current = newHeight; void invoke('set_window_frame', { x, - y: newY, + y: bottomY - newHeight, width: OVERLAY_WIDTH, - height: targetHeight, + height: newHeight, }); } else { void getCurrentWindow().setSize( @@ -274,6 +318,16 @@ function App() { } }, []); + /** + * When streaming finishes, reset the committed height so the next + * ResizeObserver event can trim the window to actual content height. + */ + useEffect(() => { + if (!isGenerating) { + committedHeightRef.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. @@ -293,6 +347,7 @@ function App() { windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY; growsUpwardRef.current = shouldGrowUp; setGrowsUpward(shouldGrowUp); + committedHeightRef.current = 0; if (shouldGrowUp && windowX !== null && windowY !== null) { windowPosRef.current = { x: windowX, diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 529be68e..9039e78e 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -392,7 +392,7 @@ describe('App', () => { } } - it('calls set_window_frame when near screen bottom', async () => { + it('commits exact height when not streaming (initial ask bar)', async () => { spyOnResizeObserver(); render(); @@ -414,6 +414,7 @@ describe('App', () => { const container = document.querySelector('.morphing-container'); expect(container).not.toBeNull(); + // Not streaming yet, so exact height is committed (no buffer) act(() => { triggerResize(container!, 60); }); @@ -427,7 +428,7 @@ describe('App', () => { }); }); - it('grows incrementally with each resize event', async () => { + it('adds buffer during streaming and skips resize events within it', async () => { spyOnResizeObserver(); render(); @@ -447,7 +448,7 @@ describe('App', () => { const container = document.querySelector('.morphing-container'); expect(container).not.toBeNull(); - // First event: ask bar only + // Initial non-streaming render: exact height invoke.mockClear(); act(() => { triggerResize(container!, 60); @@ -459,16 +460,49 @@ describe('App', () => { height: 108, }); - // Second event: content grew + // Start streaming: submit a message + 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 () => {}); + + // Content grows during streaming: 200px content → 248 target. + // Buffer: min(248 + 80, 648) = 328. Committed with buffer. invoke.mockClear(); act(() => { triggerResize(container!, 200); }); expect(invoke).toHaveBeenCalledWith('set_window_frame', { x: 50, - y: 800 - 248, + y: 800 - 328, + width: 600, + height: 328, + }); + + // Content grows to 250px (target 298): still within buffer (328). Skipped. + invoke.mockClear(); + act(() => { + triggerResize(container!, 250); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'set_window_frame', + expect.anything(), + ); + + // Content grows to 300px (target 348): exceeds buffer (328). New commit. + invoke.mockClear(); + act(() => { + triggerResize(container!, 300); + }); + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 50, + y: 800 - 428, // min(348+80, 648) = 428 width: 600, - height: 248, + height: 428, }); }); From 0a81dc2cdd53a9e698dce840a532fbfab239915b Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 9 Apr 2026 23:15:07 -0500 Subject: [PATCH 3/7] fix: App window height morph smoothly in both directions Signed-off-by: Logan Nguyen --- src/App.tsx | 120 +++++++++++++++++++++---------------- src/__tests__/App.test.tsx | 109 ++++++++++----------------------- 2 files changed, 101 insertions(+), 128 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 487ba8ec..516ecdbe 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'; @@ -39,14 +45,6 @@ const OVERLAY_WIDTH = 600; 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; -/** - * Extra headroom (px) added when committing a native window resize during - * upward-growth streaming. The buffer lands in the transparent region above - * the morphing container, so the user never sees it. Content then fills - * smoothly via `justify-end` without any native resize calls until it - * catches up to the committed height. - */ -const UPWARD_GROWTH_BUFFER = 80; /** Must match `OVERLAY_LOGICAL_HEIGHT_COLLAPSED` in `src-tauri/src/lib.rs`. */ const COLLAPSED_WINDOW_HEIGHT = 80; @@ -235,15 +233,12 @@ function App() { isGeneratingRef.current = isGenerating; /** - * Last native window height committed via `set_window_frame` during an - * upward-growth session. During streaming, the window is grown in coarse - * steps (targetHeight + UPWARD_GROWTH_BUFFER) so the extra space lands - * in the transparent region above the morphing container. Content fills - * smoothly within the already-allocated window via `justify-end`, and no - * native resize calls are needed until content catches up to the committed - * height. This eliminates the per-token `set_window_frame` jitter. + * 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. */ - const committedHeightRef = useRef(0); + const maxHeightRef = useRef(0); /** * Callback ref to reliably attach the ResizeObserver when the conditionally @@ -272,35 +267,28 @@ 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; + // 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; + } + } + if (growsUpwardRef.current) { + // Grow upward: pin the window bottom and expand the top edge. const { x, bottomY } = windowPosRef.current; - if ( - isGeneratingRef.current && - targetHeight <= committedHeightRef.current - ) { - // During streaming, content fits within the committed - // window. justify-end keeps it at the bottom. No native - // resize needed; this is what makes upward growth smooth. - return; - } - // Commit a native resize. During streaming, add a buffer - // so content can grow into the transparent headroom without - // triggering another resize for several lines of text. - const newHeight = isGeneratingRef.current - ? Math.min( - targetHeight + UPWARD_GROWTH_BUFFER, - MAX_CHAT_WINDOW_HEIGHT, - ) - : targetHeight; - committedHeightRef.current = newHeight; + const newY = bottomY - targetHeight; void invoke('set_window_frame', { x, - y: bottomY - newHeight, + y: newY, width: OVERLAY_WIDTH, - height: newHeight, + height: targetHeight, }); } else { void getCurrentWindow().setSize( @@ -319,12 +307,12 @@ function App() { }, []); /** - * When streaming finishes, reset the committed height so the next - * ResizeObserver event can trim the window to actual content height. + * 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) { - committedHeightRef.current = 0; + maxHeightRef.current = 0; } }, [isGenerating]); @@ -347,7 +335,7 @@ function App() { windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY; growsUpwardRef.current = shouldGrowUp; setGrowsUpward(shouldGrowUp); - committedHeightRef.current = 0; + maxHeightRef.current = 0; if (shouldGrowUp && windowX !== null && windowY !== null) { windowPosRef.current = { x: windowX, @@ -434,6 +422,7 @@ 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); } @@ -450,18 +439,48 @@ 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 */ + 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; + } + if (!isChatMode || !isHistoryOpen) { - if (morphingContainerNodeRef.current) { - morphingContainerNodeRef.current.style.minHeight = ''; + if (isChatMode && growsUpward) { + // We know we are growing to the max height (600px). + // Halting paint and forcing the DOM to the PREVIOUS height first + // so we avoid the sudden layout flash/jump. + container.style.transition = 'none'; + container.style.height = `${prevHeightRef.current}px`; + void container.offsetHeight; // Force layout calculation step + + requestAnimationFrame(() => { + container.style.transition = + 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = '600px'; + }); + } else { + // Safe reset state for everything else + container.style.transition = + 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; + 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`; @@ -472,7 +491,7 @@ function App() { ro.observe(dropdown); return () => ro.disconnect(); /* v8 ignore stop */ - }, [isChatMode, isHistoryOpen]); + }, [isChatMode, isHistoryOpen, growsUpward]); /** * Toggles the save state of the current conversation. @@ -1171,11 +1190,12 @@ function App() {
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 9039e78e..13a32b60 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -35,6 +35,37 @@ 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('renders nothing when overlay is hidden', async () => { const { container } = render(); // Flush effects so listener registers @@ -428,84 +459,6 @@ describe('App', () => { }); }); - it('adds buffer during streaming and skips resize events within it', async () => { - spyOnResizeObserver(); - - render(); - await act(async () => {}); - - // bottomY = 720 + 80 = 800 - await act(async () => { - emitTauriEvent('thuki://visibility', { - state: 'show', - selected_text: null, - window_x: 50, - window_y: 720, - screen_bottom_y: 900, - }); - }); - - const container = document.querySelector('.morphing-container'); - expect(container).not.toBeNull(); - - // Initial non-streaming render: exact height - invoke.mockClear(); - act(() => { - triggerResize(container!, 60); - }); - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 50, - y: 800 - 108, - width: 600, - height: 108, - }); - - // Start streaming: submit a message - 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 () => {}); - - // Content grows during streaming: 200px content → 248 target. - // Buffer: min(248 + 80, 648) = 328. Committed with buffer. - invoke.mockClear(); - act(() => { - triggerResize(container!, 200); - }); - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 50, - y: 800 - 328, - width: 600, - height: 328, - }); - - // Content grows to 250px (target 298): still within buffer (328). Skipped. - invoke.mockClear(); - act(() => { - triggerResize(container!, 250); - }); - expect(invoke).not.toHaveBeenCalledWith( - 'set_window_frame', - expect.anything(), - ); - - // Content grows to 300px (target 348): exceeds buffer (328). New commit. - invoke.mockClear(); - act(() => { - triggerResize(container!, 300); - }); - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 50, - y: 800 - 428, // min(348+80, 648) = 428 - width: 600, - height: 428, - }); - }); - it('uses setSize (not set_window_frame) after drag clears upward growth', async () => { spyOnResizeObserver(); From 84abb04d1c9e5c651bd19bf63224db2a35721ef7 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Thu, 9 Apr 2026 23:30:48 -0500 Subject: [PATCH 4/7] fix: Always morph upward and lock height state via React --- fix-always-upward.cjs | 15 +++++ fix-effect.cjs | 91 ++++++++++++++++++++++++++++++ fix-effect.js | 14 +++++ fix-layout-both.cjs | 51 +++++++++++++++++ fix-layout.cjs | 110 +++++++++++++++++++++++++++++++++++++ fix-lint.cjs | 14 +++++ fix-lint2.cjs | 9 +++ fix-react-style.cjs | 15 +++++ fix-tailwind.cjs | 10 ++++ fix-test-2.cjs | 13 +++++ fix-test.cjs | 43 +++++++++++++++ fix-ts.cjs | 9 +++ src/App.tsx | 17 +++--- src/__tests__/App.test.tsx | 4 +- 14 files changed, 403 insertions(+), 12 deletions(-) create mode 100644 fix-always-upward.cjs create mode 100644 fix-effect.cjs create mode 100644 fix-effect.js create mode 100644 fix-layout-both.cjs create mode 100644 fix-layout.cjs create mode 100644 fix-lint.cjs create mode 100644 fix-lint2.cjs create mode 100644 fix-react-style.cjs create mode 100644 fix-tailwind.cjs create mode 100644 fix-test-2.cjs create mode 100644 fix-test.cjs create mode 100644 fix-ts.cjs diff --git a/fix-always-upward.cjs b/fix-always-upward.cjs new file mode 100644 index 00000000..cfeed6aa --- /dev/null +++ b/fix-always-upward.cjs @@ -0,0 +1,15 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/App.tsx', 'utf8'); + +// The code sets shouldGrowUp based on screen bottom Y. We want to just make it always true! +const oldShouldGrowUp = ` // would overflow the screen bottom, grow upward instead. + const shouldGrowUp = + windowY !== null && + screenBottomY !== null && + windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY;`; + +const newShouldGrowUp = ` // User explicitly requested to ALWAYS morph from the bottom and grow upward. + const shouldGrowUp = true;`; + +code = code.replace(oldShouldGrowUp, newShouldGrowUp); +fs.writeFileSync('src/App.tsx', code); diff --git a/fix-effect.cjs b/fix-effect.cjs new file mode 100644 index 00000000..a3b4ebc0 --- /dev/null +++ b/fix-effect.cjs @@ -0,0 +1,91 @@ +const fs = require('fs'); + +let content = fs.readFileSync('src/App.tsx', 'utf8'); + +const oldEffect = ` useEffect(() => { + /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ + const container = morphingContainerNodeRef.current; + if (!container) return; + + if (!isChatMode || !isHistoryOpen) { + if (isChatMode && growsUpward) { + if (!container.style.minHeight) { + container.style.minHeight = \`\${container.offsetHeight}px\`; + void container.offsetHeight; // Force layout + } + requestAnimationFrame(() => { + container.style.minHeight = '600px'; + }); + } else { + container.style.minHeight = ''; + } + return; + } + + const dropdown = historyDropdownRef.current; + if (!dropdown) return; + + const sync = () => { + container.style.minHeight = \`\${dropdown.offsetTop + dropdown.offsetHeight + 8}px\`; + }; + + sync(); + const ro = new ResizeObserver(sync); + ro.observe(dropdown); + return () => ro.disconnect(); + /* v8 ignore stop */ + }, [isChatMode, isHistoryOpen, growsUpward]);`; + +const newEffect = ` useEffect(() => { + /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ + const container = morphingContainerNodeRef.current; + if (!container) return; + + if (!isChatMode || !isHistoryOpen) { + if (isChatMode && growsUpward) { + // Animate height explicitly so content doesn't force an instant jump. + container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = \`\${container.offsetHeight}px\`; + void container.offsetHeight; // Force layout + + requestAnimationFrame(() => { + container.style.height = '600px'; + }); + } else { + // Reset to auto sizing and min-height transition for history panel. + container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; + container.style.minHeight = ''; + } + return; + } + + const dropdown = historyDropdownRef.current; + 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\`; + }; + + sync(); + const ro = new ResizeObserver(sync); + ro.observe(dropdown); + return () => ro.disconnect(); + /* v8 ignore stop */ + }, [isChatMode, isHistoryOpen, growsUpward]);`; + +content = content.replace(oldEffect, newEffect); + +const oldJSX = ` style={{ + transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', + }}`; +const newJSX = ` style={{ + /* transition starts off using min-height, but runtime effects can change it */ + transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', + }}`; +content = content.replace(oldJSX, newJSX); + +fs.writeFileSync('src/App.tsx', content); diff --git a/fix-effect.js b/fix-effect.js new file mode 100644 index 00000000..30597275 --- /dev/null +++ b/fix-effect.js @@ -0,0 +1,14 @@ +const fs = require('fs'); + +let content = fs.readFileSync('src/App.tsx', 'utf8'); + +content = content.replace( + "if (isChatMode && growsUpward) {", + "if (isChatMode) {" +); + +if (content.includes("if (isChatMode) {")) { + console.log("Replaced isChatMode && growsUpward successfully."); +} + +fs.writeFileSync('src/App.tsx', content); diff --git a/fix-layout-both.cjs b/fix-layout-both.cjs new file mode 100644 index 00000000..30dd40c1 --- /dev/null +++ b/fix-layout-both.cjs @@ -0,0 +1,51 @@ +const fs = require('fs'); + +let content = fs.readFileSync('src/App.tsx', 'utf8'); + +const oldCode = ` if (!isChatMode || !isHistoryOpen) { + if (isChatMode && growsUpward) { + // We know we are growing to the max height (600px). + // Halting paint and forcing the DOM to the PREVIOUS height first + // so we avoid the sudden layout flash/jump. + container.style.transition = 'none'; + container.style.height = \`\${prevHeightRef.current}px\`; + void container.offsetHeight; // Force layout calculation step + + requestAnimationFrame(() => { + container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = '600px'; + }); + } else { + // Safe reset state for everything else + container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; + container.style.minHeight = ''; + } + return; + }`; + +const newCode = ` if (!isChatMode || !isHistoryOpen) { + if (isChatMode) { + // Morph the ask bar into the full height chatview regardless of growth direction. + // Halting paint and forcing the DOM to the PREVIOUS height first + // so we avoid the sudden layout flash/jump. + container.style.transition = 'none'; + container.style.height = \`\${prevHeightRef.current}px\`; + void container.offsetHeight; // Force layout calculation step + + requestAnimationFrame(() => { + container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = '600px'; + }); + } else { + // Safe reset state for everything else + container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; + container.style.minHeight = ''; + } + return; + }`; + +content = content.replace(oldCode, newCode); + +fs.writeFileSync('src/App.tsx', content); diff --git a/fix-layout.cjs b/fix-layout.cjs new file mode 100644 index 00000000..17f5dcef --- /dev/null +++ b/fix-layout.cjs @@ -0,0 +1,110 @@ +const fs = require('fs'); + +let content = fs.readFileSync('src/App.tsx', 'utf8'); + +// Replace useEffect with useLayoutEffect for the morphing container explicit animation +content = content.replace( + "import { useState, useEffect, useCallback, useRef } from 'react';", + "import { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react';" +); + +// We need a ref for the prev height +const refDecl = " const prevHistoryOpenRef = useRef(isHistoryOpen);"; +const newRefDecl = " const prevHistoryOpenRef = useRef(isHistoryOpen);\n const prevHeightRef = useRef(COLLAPSED_WINDOW_HEIGHT);"; +content = content.replace(refDecl, newRefDecl); + +const oldEffect = ` useEffect(() => { + /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ + const container = morphingContainerNodeRef.current; + if (!container) return; + + if (!isChatMode || !isHistoryOpen) { + if (isChatMode && growsUpward) { + // Animate height explicitly so content doesn't force an instant jump. + container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = \`\${container.offsetHeight}px\`; + void container.offsetHeight; // Force layout + + requestAnimationFrame(() => { + container.style.height = '600px'; + }); + } else { + // Reset to auto sizing and min-height transition for history panel. + container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; + container.style.minHeight = ''; + } + return; + } + + const dropdown = historyDropdownRef.current; + 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\`; + }; + + sync(); + const ro = new ResizeObserver(sync); + ro.observe(dropdown); + return () => ro.disconnect(); + /* v8 ignore stop */ + }, [isChatMode, isHistoryOpen, growsUpward]);`; + +const newEffect = ` useLayoutEffect(() => { + /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ + 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; + } + + if (!isChatMode || !isHistoryOpen) { + if (isChatMode && growsUpward) { + // We know we are growing to the max height (600px). + // Halting paint and forcing the DOM to the PREVIOUS height first + // so we avoid the sudden layout flash/jump. + container.style.transition = 'none'; + container.style.height = \`\${prevHeightRef.current}px\`; + void container.offsetHeight; // Force layout calculation step + + requestAnimationFrame(() => { + container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = '600px'; + }); + } else { + // Safe reset state for everything else + container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.height = ''; + container.style.minHeight = ''; + } + return; + } + + const dropdown = historyDropdownRef.current; + 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\`; + }; + + sync(); + const ro = new ResizeObserver(sync); + ro.observe(dropdown); + return () => ro.disconnect(); + /* v8 ignore stop */ + }, [isChatMode, isHistoryOpen, growsUpward]);`; + +content = content.replace(oldEffect, newEffect); + +fs.writeFileSync('src/App.tsx', content); diff --git a/fix-lint.cjs b/fix-lint.cjs new file mode 100644 index 00000000..4e69b3db --- /dev/null +++ b/fix-lint.cjs @@ -0,0 +1,14 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/App.tsx', 'utf8'); + +code = code.replace( + "windowX: number | null,\n windowY: number | null,\n screenBottomY: number | null,", + "windowX: number | null,\n windowY: number | null,\n _screenBottomY: number | null," +); + +code = code.replace( + "const MAX_CHAT_WINDOW_HEIGHT = 600 + CONTAINER_VERTICAL_PADDING;", + "// const MAX_CHAT_WINDOW_HEIGHT = 600 + CONTAINER_VERTICAL_PADDING;" +); + +fs.writeFileSync('src/App.tsx', code); diff --git a/fix-lint2.cjs b/fix-lint2.cjs new file mode 100644 index 00000000..5568e93e --- /dev/null +++ b/fix-lint2.cjs @@ -0,0 +1,9 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/App.tsx', 'utf8'); + +code = code.replace( + "_screenBottomY: number | null,", + "" +); + +fs.writeFileSync('src/App.tsx', code); diff --git a/fix-react-style.cjs b/fix-react-style.cjs new file mode 100644 index 00000000..e097f5dc --- /dev/null +++ b/fix-react-style.cjs @@ -0,0 +1,15 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/App.tsx', 'utf8'); + +const oldStyle = ` style={{ + /* transition starts off using min-height, but runtime effects can change it */ + transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', + }}`; + +const newStyle = ` style={{ + transition: 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1), min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', + ...(isChatMode && !isHistoryOpen ? { height: '600px' } : {}) + }}`; + +code = code.replace(oldStyle, newStyle); +fs.writeFileSync('src/App.tsx', code); diff --git a/fix-tailwind.cjs b/fix-tailwind.cjs new file mode 100644 index 00000000..998f0328 --- /dev/null +++ b/fix-tailwind.cjs @@ -0,0 +1,10 @@ +const fs = require('fs'); + +let content = fs.readFileSync('src/App.tsx', 'utf8'); + +content = content.replace( + "isChatMode\n ? `rounded-lg shadow-chat ${growsUpward ? 'min-h-[600px]' : ''}`\n : 'rounded-2xl shadow-bar'", + "isChatMode\n ? `rounded-lg shadow-chat`\n : 'rounded-2xl shadow-bar'" +); + +fs.writeFileSync('src/App.tsx', content); diff --git a/fix-test-2.cjs b/fix-test-2.cjs new file mode 100644 index 00000000..67ae2109 --- /dev/null +++ b/fix-test-2.cjs @@ -0,0 +1,13 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/__tests__/App.test.tsx', 'utf8'); + +const oldCode = ` const outer = document.querySelector('.justify-start'); + expect(outer).not.toBeNull(); + expect(document.querySelector('.justify-end')).toBeNull();`; + +const newCode = ` const outer = document.querySelector('.justify-end'); + expect(outer).not.toBeNull(); + expect(document.querySelector('.justify-start')).toBeNull();`; + +code = code.replace(oldCode, newCode); +fs.writeFileSync('src/__tests__/App.test.tsx', code); diff --git a/fix-test.cjs b/fix-test.cjs new file mode 100644 index 00000000..323c9e26 --- /dev/null +++ b/fix-test.cjs @@ -0,0 +1,43 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/__tests__/App.test.tsx', 'utf8'); + +const oldTest = ` it('applies justify-start when window has room below', async () => { + vi.mocked(getCurrentWindow().outerPosition).mockResolvedValue( + new PhysicalPosition(100, 100), // far from bottom + ); + + render(); + + await act(async () => { + // Trigger visibility event + eventCbs['thuki://visibility']({ + payload: { state: 'show', selected_text: null, window_anchor: null }, + }); + }); + + const outer = document.querySelector('.justify-start'); + expect(outer).not.toBeNull(); + expect(document.querySelector('.justify-end')).toBeNull(); + });`; + +const newTest = ` it('applies justify-end to enforce upward growth morphing', async () => { + vi.mocked(getCurrentWindow().outerPosition).mockResolvedValue( + new PhysicalPosition(100, 100), // far from bottom + ); + + render(); + + await act(async () => { + // Trigger visibility event + eventCbs['thuki://visibility']({ + payload: { state: 'show', selected_text: null, window_anchor: null }, + }); + }); + + const outer = document.querySelector('.justify-end'); + expect(outer).not.toBeNull(); + expect(document.querySelector('.justify-start')).toBeNull(); + });`; + +code = code.replace(oldTest, newTest); +fs.writeFileSync('src/__tests__/App.test.tsx', code); diff --git a/fix-ts.cjs b/fix-ts.cjs new file mode 100644 index 00000000..78e7bf61 --- /dev/null +++ b/fix-ts.cjs @@ -0,0 +1,9 @@ +const fs = require('fs'); +let code = fs.readFileSync('src/App.tsx', 'utf8'); + +code = code.replace( + "payload.screen_bottom_y ?? null,", + "// payload.screen_bottom_y ?? null," +); + +fs.writeFileSync('src/App.tsx', code); diff --git a/src/App.tsx b/src/App.tsx index 516ecdbe..d6609d12 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ const OVERLAY_WIDTH = 600; /** Total transparent padding around the morphing container: pt-2(8) + pb-6(24) + motion py-2(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; +// const MAX_CHAT_WINDOW_HEIGHT = 600 + CONTAINER_VERTICAL_PADDING; /** Must match `OVERLAY_LOGICAL_HEIGHT_COLLAPSED` in `src-tauri/src/lib.rs`. */ const COLLAPSED_WINDOW_HEIGHT = 80; @@ -325,14 +325,10 @@ function App() { context: string | null, windowX: number | null, windowY: number | null, - screenBottomY: number | null, ) => { // Decide growth direction: if the collapsed window plus a full chat - // would overflow the screen bottom, grow upward instead. - const shouldGrowUp = - windowY !== null && - screenBottomY !== null && - windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY; + // User explicitly requested to ALWAYS morph from the bottom and grow upward. + const shouldGrowUp = true; growsUpwardRef.current = shouldGrowUp; setGrowsUpward(shouldGrowUp); maxHeightRef.current = 0; @@ -1026,7 +1022,7 @@ function App() { payload.selected_text ?? null, payload.window_x ?? null, payload.window_y ?? null, - payload.screen_bottom_y ?? null, + // payload.screen_bottom_y ?? null, ); return; } @@ -1190,8 +1186,9 @@ function App() {
{ }); }); - const outer = document.querySelector('.justify-start'); + const outer = document.querySelector('.justify-end'); expect(outer).not.toBeNull(); - expect(document.querySelector('.justify-end')).toBeNull(); + expect(document.querySelector('.justify-start')).toBeNull(); }); describe('ResizeObserver upward growth', () => { From ab4534ee6dc16b1f90eebb6c1a324e3289c957d7 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 10 Apr 2026 14:43:36 -0500 Subject: [PATCH 5/7] fix: refine upward growth buffering and test coverage Signed-off-by: Logan Nguyen Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- src/App.tsx | 85 +++++++++++++++++++++++++------------- src/__tests__/App.test.tsx | 40 +++++++++++++++++- 2 files changed, 95 insertions(+), 30 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d6609d12..1995d258 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ const OVERLAY_WIDTH = 600; /** Total transparent padding around the morphing container: pt-2(8) + pb-6(24) + motion py-2(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; +const MAX_CHAT_WINDOW_HEIGHT = 600 + CONTAINER_VERTICAL_PADDING; /** Must match `OVERLAY_LOGICAL_HEIGHT_COLLAPSED` in `src-tauri/src/lib.rs`. */ const COLLAPSED_WINDOW_HEIGHT = 80; @@ -199,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 @@ -239,6 +240,7 @@ function App() { * or a new session starts. */ const maxHeightRef = useRef(0); + const hasMorphedUpwardRef = useRef(false); /** * Callback ref to reliably attach the ResizeObserver when the conditionally @@ -325,10 +327,12 @@ function App() { context: string | null, windowX: number | null, windowY: number | null, + screenBottomY: number | null, ) => { - // Decide growth direction: if the collapsed window plus a full chat - // User explicitly requested to ALWAYS morph from the bottom and grow upward. - const shouldGrowUp = true; + const shouldGrowUp = + windowY !== null && + screenBottomY !== null && + windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY; growsUpwardRef.current = shouldGrowUp; setGrowsUpward(shouldGrowUp); maxHeightRef.current = 0; @@ -348,6 +352,7 @@ function App() { }); pendingSubmitRef.current = null; screenCapturePendingRef.current = false; + hasMorphedUpwardRef.current = false; screenCaptureInputSnapshotRef.current = null; setIsSubmitPending(false); setPendingUserMessage(null); @@ -424,6 +429,43 @@ function App() { } 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; + } + + hasMorphedUpwardRef.current = true; + 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 @@ -445,29 +487,17 @@ function App() { 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 (!isChatMode || !isHistoryOpen) { - if (isChatMode && growsUpward) { - // We know we are growing to the max height (600px). - // Halting paint and forcing the DOM to the PREVIOUS height first - // so we avoid the sudden layout flash/jump. - container.style.transition = 'none'; - container.style.height = `${prevHeightRef.current}px`; - void container.offsetHeight; // Force layout calculation step - - requestAnimationFrame(() => { - container.style.transition = - 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = '600px'; - }); - } else { - // Safe reset state for everything else - container.style.transition = - 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = ''; - container.style.minHeight = ''; - } + if (!isHistoryOpen) { + container.style.transition = + 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; + container.style.minHeight = ''; return; } @@ -487,7 +517,7 @@ function App() { ro.observe(dropdown); return () => ro.disconnect(); /* v8 ignore stop */ - }, [isChatMode, isHistoryOpen, growsUpward]); + }, [isChatMode, isHistoryOpen]); /** * Toggles the save state of the current conversation. @@ -1022,7 +1052,7 @@ function App() { payload.selected_text ?? null, payload.window_x ?? null, payload.window_y ?? null, - // payload.screen_bottom_y ?? null, + payload.screen_bottom_y ?? null, ); return; } @@ -1188,7 +1218,6 @@ function App() { style={{ transition: 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1), min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', - ...(isChatMode && !isHistoryOpen ? { height: '600px' } : {}), }} className={`morphing-container relative flex flex-col bg-surface-base backdrop-blur-2xl border border-surface-border max-h-[600px] overflow-hidden ${ isChatMode diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 9e8ff34c..bcfdfa5b 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -66,6 +66,42 @@ describe('App', () => { ).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 @@ -385,9 +421,9 @@ describe('App', () => { }); }); - const outer = document.querySelector('.justify-end'); + const outer = document.querySelector('.justify-start'); expect(outer).not.toBeNull(); - expect(document.querySelector('.justify-start')).toBeNull(); + expect(document.querySelector('.justify-end')).toBeNull(); }); describe('ResizeObserver upward growth', () => { From e628248bb8bd600cd2731034850b91274a1a05fe Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 10 Apr 2026 14:43:58 -0500 Subject: [PATCH 6/7] chore: remove stray debug scripts from worktree Signed-off-by: Logan Nguyen Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- fix-always-upward.cjs | 15 ------ fix-effect.cjs | 91 ---------------------------------- fix-effect.js | 14 ------ fix-layout-both.cjs | 51 -------------------- fix-layout.cjs | 110 ------------------------------------------ fix-lint.cjs | 14 ------ fix-lint2.cjs | 9 ---- fix-react-style.cjs | 15 ------ fix-tailwind.cjs | 10 ---- fix-test-2.cjs | 13 ----- fix-test.cjs | 43 ----------------- fix-ts.cjs | 9 ---- 12 files changed, 394 deletions(-) delete mode 100644 fix-always-upward.cjs delete mode 100644 fix-effect.cjs delete mode 100644 fix-effect.js delete mode 100644 fix-layout-both.cjs delete mode 100644 fix-layout.cjs delete mode 100644 fix-lint.cjs delete mode 100644 fix-lint2.cjs delete mode 100644 fix-react-style.cjs delete mode 100644 fix-tailwind.cjs delete mode 100644 fix-test-2.cjs delete mode 100644 fix-test.cjs delete mode 100644 fix-ts.cjs diff --git a/fix-always-upward.cjs b/fix-always-upward.cjs deleted file mode 100644 index cfeed6aa..00000000 --- a/fix-always-upward.cjs +++ /dev/null @@ -1,15 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('src/App.tsx', 'utf8'); - -// The code sets shouldGrowUp based on screen bottom Y. We want to just make it always true! -const oldShouldGrowUp = ` // would overflow the screen bottom, grow upward instead. - const shouldGrowUp = - windowY !== null && - screenBottomY !== null && - windowY + MAX_CHAT_WINDOW_HEIGHT > screenBottomY;`; - -const newShouldGrowUp = ` // User explicitly requested to ALWAYS morph from the bottom and grow upward. - const shouldGrowUp = true;`; - -code = code.replace(oldShouldGrowUp, newShouldGrowUp); -fs.writeFileSync('src/App.tsx', code); diff --git a/fix-effect.cjs b/fix-effect.cjs deleted file mode 100644 index a3b4ebc0..00000000 --- a/fix-effect.cjs +++ /dev/null @@ -1,91 +0,0 @@ -const fs = require('fs'); - -let content = fs.readFileSync('src/App.tsx', 'utf8'); - -const oldEffect = ` useEffect(() => { - /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ - const container = morphingContainerNodeRef.current; - if (!container) return; - - if (!isChatMode || !isHistoryOpen) { - if (isChatMode && growsUpward) { - if (!container.style.minHeight) { - container.style.minHeight = \`\${container.offsetHeight}px\`; - void container.offsetHeight; // Force layout - } - requestAnimationFrame(() => { - container.style.minHeight = '600px'; - }); - } else { - container.style.minHeight = ''; - } - return; - } - - const dropdown = historyDropdownRef.current; - if (!dropdown) return; - - const sync = () => { - container.style.minHeight = \`\${dropdown.offsetTop + dropdown.offsetHeight + 8}px\`; - }; - - sync(); - const ro = new ResizeObserver(sync); - ro.observe(dropdown); - return () => ro.disconnect(); - /* v8 ignore stop */ - }, [isChatMode, isHistoryOpen, growsUpward]);`; - -const newEffect = ` useEffect(() => { - /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ - const container = morphingContainerNodeRef.current; - if (!container) return; - - if (!isChatMode || !isHistoryOpen) { - if (isChatMode && growsUpward) { - // Animate height explicitly so content doesn't force an instant jump. - container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = \`\${container.offsetHeight}px\`; - void container.offsetHeight; // Force layout - - requestAnimationFrame(() => { - container.style.height = '600px'; - }); - } else { - // Reset to auto sizing and min-height transition for history panel. - container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = ''; - container.style.minHeight = ''; - } - return; - } - - const dropdown = historyDropdownRef.current; - 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\`; - }; - - sync(); - const ro = new ResizeObserver(sync); - ro.observe(dropdown); - return () => ro.disconnect(); - /* v8 ignore stop */ - }, [isChatMode, isHistoryOpen, growsUpward]);`; - -content = content.replace(oldEffect, newEffect); - -const oldJSX = ` style={{ - transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', - }}`; -const newJSX = ` style={{ - /* transition starts off using min-height, but runtime effects can change it */ - transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', - }}`; -content = content.replace(oldJSX, newJSX); - -fs.writeFileSync('src/App.tsx', content); diff --git a/fix-effect.js b/fix-effect.js deleted file mode 100644 index 30597275..00000000 --- a/fix-effect.js +++ /dev/null @@ -1,14 +0,0 @@ -const fs = require('fs'); - -let content = fs.readFileSync('src/App.tsx', 'utf8'); - -content = content.replace( - "if (isChatMode && growsUpward) {", - "if (isChatMode) {" -); - -if (content.includes("if (isChatMode) {")) { - console.log("Replaced isChatMode && growsUpward successfully."); -} - -fs.writeFileSync('src/App.tsx', content); diff --git a/fix-layout-both.cjs b/fix-layout-both.cjs deleted file mode 100644 index 30dd40c1..00000000 --- a/fix-layout-both.cjs +++ /dev/null @@ -1,51 +0,0 @@ -const fs = require('fs'); - -let content = fs.readFileSync('src/App.tsx', 'utf8'); - -const oldCode = ` if (!isChatMode || !isHistoryOpen) { - if (isChatMode && growsUpward) { - // We know we are growing to the max height (600px). - // Halting paint and forcing the DOM to the PREVIOUS height first - // so we avoid the sudden layout flash/jump. - container.style.transition = 'none'; - container.style.height = \`\${prevHeightRef.current}px\`; - void container.offsetHeight; // Force layout calculation step - - requestAnimationFrame(() => { - container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = '600px'; - }); - } else { - // Safe reset state for everything else - container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = ''; - container.style.minHeight = ''; - } - return; - }`; - -const newCode = ` if (!isChatMode || !isHistoryOpen) { - if (isChatMode) { - // Morph the ask bar into the full height chatview regardless of growth direction. - // Halting paint and forcing the DOM to the PREVIOUS height first - // so we avoid the sudden layout flash/jump. - container.style.transition = 'none'; - container.style.height = \`\${prevHeightRef.current}px\`; - void container.offsetHeight; // Force layout calculation step - - requestAnimationFrame(() => { - container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = '600px'; - }); - } else { - // Safe reset state for everything else - container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = ''; - container.style.minHeight = ''; - } - return; - }`; - -content = content.replace(oldCode, newCode); - -fs.writeFileSync('src/App.tsx', content); diff --git a/fix-layout.cjs b/fix-layout.cjs deleted file mode 100644 index 17f5dcef..00000000 --- a/fix-layout.cjs +++ /dev/null @@ -1,110 +0,0 @@ -const fs = require('fs'); - -let content = fs.readFileSync('src/App.tsx', 'utf8'); - -// Replace useEffect with useLayoutEffect for the morphing container explicit animation -content = content.replace( - "import { useState, useEffect, useCallback, useRef } from 'react';", - "import { useState, useEffect, useCallback, useRef, useLayoutEffect } from 'react';" -); - -// We need a ref for the prev height -const refDecl = " const prevHistoryOpenRef = useRef(isHistoryOpen);"; -const newRefDecl = " const prevHistoryOpenRef = useRef(isHistoryOpen);\n const prevHeightRef = useRef(COLLAPSED_WINDOW_HEIGHT);"; -content = content.replace(refDecl, newRefDecl); - -const oldEffect = ` useEffect(() => { - /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ - const container = morphingContainerNodeRef.current; - if (!container) return; - - if (!isChatMode || !isHistoryOpen) { - if (isChatMode && growsUpward) { - // Animate height explicitly so content doesn't force an instant jump. - container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = \`\${container.offsetHeight}px\`; - void container.offsetHeight; // Force layout - - requestAnimationFrame(() => { - container.style.height = '600px'; - }); - } else { - // Reset to auto sizing and min-height transition for history panel. - container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = ''; - container.style.minHeight = ''; - } - return; - } - - const dropdown = historyDropdownRef.current; - 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\`; - }; - - sync(); - const ro = new ResizeObserver(sync); - ro.observe(dropdown); - return () => ro.disconnect(); - /* v8 ignore stop */ - }, [isChatMode, isHistoryOpen, growsUpward]);`; - -const newEffect = ` useLayoutEffect(() => { - /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ - 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; - } - - if (!isChatMode || !isHistoryOpen) { - if (isChatMode && growsUpward) { - // We know we are growing to the max height (600px). - // Halting paint and forcing the DOM to the PREVIOUS height first - // so we avoid the sudden layout flash/jump. - container.style.transition = 'none'; - container.style.height = \`\${prevHeightRef.current}px\`; - void container.offsetHeight; // Force layout calculation step - - requestAnimationFrame(() => { - container.style.transition = 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = '600px'; - }); - } else { - // Safe reset state for everything else - container.style.transition = 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)'; - container.style.height = ''; - container.style.minHeight = ''; - } - return; - } - - const dropdown = historyDropdownRef.current; - 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\`; - }; - - sync(); - const ro = new ResizeObserver(sync); - ro.observe(dropdown); - return () => ro.disconnect(); - /* v8 ignore stop */ - }, [isChatMode, isHistoryOpen, growsUpward]);`; - -content = content.replace(oldEffect, newEffect); - -fs.writeFileSync('src/App.tsx', content); diff --git a/fix-lint.cjs b/fix-lint.cjs deleted file mode 100644 index 4e69b3db..00000000 --- a/fix-lint.cjs +++ /dev/null @@ -1,14 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('src/App.tsx', 'utf8'); - -code = code.replace( - "windowX: number | null,\n windowY: number | null,\n screenBottomY: number | null,", - "windowX: number | null,\n windowY: number | null,\n _screenBottomY: number | null," -); - -code = code.replace( - "const MAX_CHAT_WINDOW_HEIGHT = 600 + CONTAINER_VERTICAL_PADDING;", - "// const MAX_CHAT_WINDOW_HEIGHT = 600 + CONTAINER_VERTICAL_PADDING;" -); - -fs.writeFileSync('src/App.tsx', code); diff --git a/fix-lint2.cjs b/fix-lint2.cjs deleted file mode 100644 index 5568e93e..00000000 --- a/fix-lint2.cjs +++ /dev/null @@ -1,9 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('src/App.tsx', 'utf8'); - -code = code.replace( - "_screenBottomY: number | null,", - "" -); - -fs.writeFileSync('src/App.tsx', code); diff --git a/fix-react-style.cjs b/fix-react-style.cjs deleted file mode 100644 index e097f5dc..00000000 --- a/fix-react-style.cjs +++ /dev/null @@ -1,15 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('src/App.tsx', 'utf8'); - -const oldStyle = ` style={{ - /* transition starts off using min-height, but runtime effects can change it */ - transition: 'min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', - }}`; - -const newStyle = ` style={{ - transition: 'height 0.25s cubic-bezier(0.16, 1, 0.3, 1), min-height 0.25s cubic-bezier(0.16, 1, 0.3, 1)', - ...(isChatMode && !isHistoryOpen ? { height: '600px' } : {}) - }}`; - -code = code.replace(oldStyle, newStyle); -fs.writeFileSync('src/App.tsx', code); diff --git a/fix-tailwind.cjs b/fix-tailwind.cjs deleted file mode 100644 index 998f0328..00000000 --- a/fix-tailwind.cjs +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require('fs'); - -let content = fs.readFileSync('src/App.tsx', 'utf8'); - -content = content.replace( - "isChatMode\n ? `rounded-lg shadow-chat ${growsUpward ? 'min-h-[600px]' : ''}`\n : 'rounded-2xl shadow-bar'", - "isChatMode\n ? `rounded-lg shadow-chat`\n : 'rounded-2xl shadow-bar'" -); - -fs.writeFileSync('src/App.tsx', content); diff --git a/fix-test-2.cjs b/fix-test-2.cjs deleted file mode 100644 index 67ae2109..00000000 --- a/fix-test-2.cjs +++ /dev/null @@ -1,13 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('src/__tests__/App.test.tsx', 'utf8'); - -const oldCode = ` const outer = document.querySelector('.justify-start'); - expect(outer).not.toBeNull(); - expect(document.querySelector('.justify-end')).toBeNull();`; - -const newCode = ` const outer = document.querySelector('.justify-end'); - expect(outer).not.toBeNull(); - expect(document.querySelector('.justify-start')).toBeNull();`; - -code = code.replace(oldCode, newCode); -fs.writeFileSync('src/__tests__/App.test.tsx', code); diff --git a/fix-test.cjs b/fix-test.cjs deleted file mode 100644 index 323c9e26..00000000 --- a/fix-test.cjs +++ /dev/null @@ -1,43 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('src/__tests__/App.test.tsx', 'utf8'); - -const oldTest = ` it('applies justify-start when window has room below', async () => { - vi.mocked(getCurrentWindow().outerPosition).mockResolvedValue( - new PhysicalPosition(100, 100), // far from bottom - ); - - render(); - - await act(async () => { - // Trigger visibility event - eventCbs['thuki://visibility']({ - payload: { state: 'show', selected_text: null, window_anchor: null }, - }); - }); - - const outer = document.querySelector('.justify-start'); - expect(outer).not.toBeNull(); - expect(document.querySelector('.justify-end')).toBeNull(); - });`; - -const newTest = ` it('applies justify-end to enforce upward growth morphing', async () => { - vi.mocked(getCurrentWindow().outerPosition).mockResolvedValue( - new PhysicalPosition(100, 100), // far from bottom - ); - - render(); - - await act(async () => { - // Trigger visibility event - eventCbs['thuki://visibility']({ - payload: { state: 'show', selected_text: null, window_anchor: null }, - }); - }); - - const outer = document.querySelector('.justify-end'); - expect(outer).not.toBeNull(); - expect(document.querySelector('.justify-start')).toBeNull(); - });`; - -code = code.replace(oldTest, newTest); -fs.writeFileSync('src/__tests__/App.test.tsx', code); diff --git a/fix-ts.cjs b/fix-ts.cjs deleted file mode 100644 index 78e7bf61..00000000 --- a/fix-ts.cjs +++ /dev/null @@ -1,9 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('src/App.tsx', 'utf8'); - -code = code.replace( - "payload.screen_bottom_y ?? null,", - "// payload.screen_bottom_y ?? null," -); - -fs.writeFileSync('src/App.tsx', code); From 7919c5fc97487371973dfdfc1219226e6e0d6f27 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Fri, 10 Apr 2026 15:03:04 -0500 Subject: [PATCH 7/7] fix: clamp upward growth Y, sync growsUpward state, remove dead ref - Add Math.max(0, ...) clamp to prevent the window from going above the screen top during upward growth on small displays - Add missing setGrowsUpward(false) in requestHideOverlay so the React state stays in sync with the ref during exit animations - Remove hasMorphedUpwardRef (declared and written but never read) - Fix window_x doc comment to reflect its use as a positional anchor Signed-off-by: Logan Nguyen Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 5 +++-- src/App.tsx | 7 +++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2fae634f..5154236f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -98,8 +98,9 @@ struct VisibilityPayload { state: &'static str, /// Selected text captured at activation time, if any. selected_text: Option, - /// Logical X of the window at show time. The frontend uses this together - /// with `window_y` and `screen_bottom_y` to decide growth direction. + /// 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, diff --git a/src/App.tsx b/src/App.tsx index 1995d258..55b81ffa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -240,7 +240,6 @@ function App() { * or a new session starts. */ const maxHeightRef = useRef(0); - const hasMorphedUpwardRef = useRef(false); /** * Callback ref to reliably attach the ResizeObserver when the conditionally @@ -284,8 +283,9 @@ function App() { 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 = bottomY - targetHeight; + const newY = Math.max(0, bottomY - targetHeight); void invoke('set_window_frame', { x, y: newY, @@ -352,7 +352,6 @@ function App() { }); pendingSubmitRef.current = null; screenCapturePendingRef.current = false; - hasMorphedUpwardRef.current = false; screenCaptureInputSnapshotRef.current = null; setIsSubmitPending(false); setPendingUserMessage(null); @@ -372,6 +371,7 @@ function App() { const requestHideOverlay = useCallback(() => { cancel(); growsUpwardRef.current = false; + setGrowsUpward(false); screenCapturePendingRef.current = false; screenCaptureInputSnapshotRef.current = null; setSelectedContext(null); @@ -446,7 +446,6 @@ function App() { return; } - hasMorphedUpwardRef.current = true; const startHeight = container.offsetHeight > 0 ? container.offsetHeight