From bc1d57c865c65b529eaabf606eb4d8c8a0dac211 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 31 Mar 2026 22:06:38 -0500 Subject: [PATCH 1/3] fix: eliminate streaming jitter during upward window growth When the overlay spawns near the bottom screen edge and grows upward, per-token window resizing caused visible jitter because macOS can't atomically reposition + resize in sync with the webview render cycle. Fix: pre-expand the window to max chat height in a single IPC call when streaming starts, skip per-token resizes during streaming, and flip the outer container to justify-end so content pins to the bottom and grows upward naturally within the already-sized frame. The ResizeObserver resumes normal duty once streaming ends to shrink-to-fit. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 72 ++++++++++++++++++++++++++++++++++---- src/__tests__/App.test.tsx | 67 +++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6f56e2fc..faf53099 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, + useLayoutEffect, + useCallback, + useRef, +} from 'react'; import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -25,6 +31,8 @@ const HIDE_COMMIT_DELAY_MS = 350; 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; type WindowAnchor = { x: number; bottom_y: number; min_y: number }; type OverlayVisibilityPayload = @@ -62,6 +70,13 @@ function App() { const [sessionId, setSessionId] = useState(0); const [selectedContext, setSelectedContext] = useState(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. + */ + const [isAnchoredUpward, setIsAnchoredUpward] = useState(false); + /** * Determines whether the UI has entered "chat mode" — i.e., the morphing * chat window state with message bubbles. Transitions from input-bar mode @@ -82,6 +97,13 @@ function App() { */ const windowAnchorRef = useRef(null); + /** + * Mirror of `isGenerating` readable by the ResizeObserver closure without + * needing to recreate the observer. Synced via useLayoutEffect so it's + * current before any RAF callbacks fire. + */ + const isGeneratingRef = useRef(false); + /** * Callback ref to reliably attach the ResizeObserver when the conditionally * rendered Framer Motion container actually mounts in the DOM. This fixes @@ -111,10 +133,12 @@ function App() { Math.ceil(rect.height) + CONTAINER_VERTICAL_PADDING; const anchor = windowAnchorRef.current; if (anchor) { - // Grow upward: invoke a single Rust command that sets both position - // and size in the same main-thread block so macOS coalesces them - // into one display frame — eliminating the downward-flash that - // occurs when JS fires two separate async IPC round-trips. + // During streaming the window is pre-expanded to max height + // (see useEffect on isGenerating below), so skip per-token + // resizes that would cause upward-growth jitter. Once + // streaming ends the observer resumes normal duty. + if (isGeneratingRef.current) return; + const newY = Math.max( anchor.min_y, anchor.bottom_y - targetHeight, @@ -141,6 +165,39 @@ function App() { } }, []); + /** + * Keep the ref in sync before any RAF / ResizeObserver callbacks can read it. + * useLayoutEffect fires synchronously after DOM mutations but before paint, + * guaranteeing the ref is current when the ResizeObserver's RAF runs. + */ + useLayoutEffect(() => { + isGeneratingRef.current = isGenerating; + }, [isGenerating]); + + /** + * Pre-expand the window to max chat height when streaming starts and the + * window is in upward-growth mode (anchor present). This eliminates + * per-token window resizing — the single expansion happens once, and + * content fills naturally inside the already-sized window. When streaming + * ends, the ResizeObserver resumes and shrinks the window to fit. + */ + useEffect(() => { + const anchor = windowAnchorRef.current; + if (!isGenerating || !anchor) return; + + const maxHeight = Math.min( + MAX_CHAT_WINDOW_HEIGHT, + anchor.bottom_y - anchor.min_y, + ); + const newY = anchor.bottom_y - maxHeight; + void invoke('set_window_frame', { + x: anchor.x, + y: newY, + width: OVERLAY_WIDTH, + height: maxHeight, + }); + }, [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. @@ -148,6 +205,7 @@ function App() { const replayEntranceAnimation = useCallback( (context: string | null, anchor: WindowAnchor | null) => { windowAnchorRef.current = anchor; + setIsAnchoredUpward(anchor !== null); setSessionId((id) => id + 1); setQuery(''); setSelectedContext(context); @@ -163,6 +221,7 @@ function App() { */ const requestHideOverlay = useCallback(() => { windowAnchorRef.current = null; + setIsAnchoredUpward(false); setSelectedContext(null); setOverlayState((currentState) => { if (currentState === 'hidden' || currentState === 'hiding') { @@ -316,6 +375,7 @@ function App() { 'mouseup', () => { windowAnchorRef.current = null; + setIsAnchoredUpward(false); }, { once: true }, ); @@ -326,7 +386,7 @@ function App() { // 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 8ef07280..dce1d852 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -308,6 +308,73 @@ describe('App', () => { ); }); + it('pre-expands window to max height when streaming starts with anchor', async () => { + render(); + await act(async () => {}); + + // Show overlay with a window anchor (upward-growth mode) + await act(async () => { + emitTauriEvent('thuki://visibility', { + state: 'show', + selected_text: null, + window_anchor: { x: 100, bottom_y: 800, min_y: 50 }, + }); + }); + + 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 () => {}); + + // The pre-expansion useEffect should have called set_window_frame + // with the max chat window height (600 + 48 = 648) + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 100, + y: 800 - 648, + width: 600, + height: 648, + }); + }); + + it('clamps pre-expansion height to available screen space', async () => { + render(); + await act(async () => {}); + + // Anchor near top of screen — only 200px available + await act(async () => { + emitTauriEvent('thuki://visibility', { + state: 'show', + selected_text: null, + window_anchor: { x: 100, bottom_y: 250, min_y: 50 }, + }); + }); + + 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 () => {}); + + // Should clamp to available space: 250 - 50 = 200 + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 100, + y: 50, + width: 600, + height: 200, + }); + }); + it('requestHideOverlay is a no-op when already hidden', async () => { render(); await act(async () => {}); From b3bb0e86089dbe9e13f9f13f701af1c584be0efa Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 31 Mar 2026 22:39:33 -0500 Subject: [PATCH 2/3] fix: pre-expand anchored window at overlay open to eliminate submit flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the pre-expansion from streaming start to overlay open. The first ResizeObserver event now immediately expands the window to max chat height — this fires during Framer Motion's entrance fade-in (opacity 0→1), making the expansion invisible. By the time the user interacts with the input bar, the window is already at full size. Simplifies the implementation: removes useLayoutEffect, isGeneratingRef, containerNodeRef, and the container freeze/transition animation. Replaces them with a single isPreExpandedRef flag that gates the ResizeObserver. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 76 +++++++++++--------------------------- src/__tests__/App.test.tsx | 57 ++-------------------------- 2 files changed, 26 insertions(+), 107 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index faf53099..0f507c8e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,12 +1,6 @@ import { motion, AnimatePresence } from 'framer-motion'; import type React from 'react'; -import { - useState, - useEffect, - useLayoutEffect, - useCallback, - useRef, -} from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { listen } from '@tauri-apps/api/event'; import { invoke } from '@tauri-apps/api/core'; import { getCurrentWindow } from '@tauri-apps/api/window'; @@ -98,11 +92,12 @@ function App() { const windowAnchorRef = useRef(null); /** - * Mirror of `isGenerating` readable by the ResizeObserver closure without - * needing to recreate the observer. Synced via useLayoutEffect so it's - * current before any RAF callbacks fire. + * 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. */ - const isGeneratingRef = useRef(false); + const isPreExpandedRef = useRef(false); /** * Callback ref to reliably attach the ResizeObserver when the conditionally @@ -133,21 +128,24 @@ function App() { Math.ceil(rect.height) + CONTAINER_VERTICAL_PADDING; const anchor = windowAnchorRef.current; if (anchor) { - // During streaming the window is pre-expanded to max height - // (see useEffect on isGenerating below), so skip per-token - // resizes that would cause upward-growth jitter. Once - // streaming ends the observer resumes normal duty. - if (isGeneratingRef.current) return; - - const newY = Math.max( - anchor.min_y, - anchor.bottom_y - targetHeight, + // On the very first observer event for an anchored session, + // expand the window to max height immediately. This fires + // during the Framer Motion entrance fade-in (opacity 0→1), + // so the user never sees the jump. All subsequent events + // are skipped — content grows inside the fixed window. + if (isPreExpandedRef.current) return; + isPreExpandedRef.current = true; + + const maxHeight = Math.min( + MAX_CHAT_WINDOW_HEIGHT, + anchor.bottom_y - anchor.min_y, ); + const newY = anchor.bottom_y - maxHeight; void invoke('set_window_frame', { x: anchor.x, y: newY, width: OVERLAY_WIDTH, - height: targetHeight, + height: maxHeight, }); } else { void getCurrentWindow().setSize( @@ -165,39 +163,6 @@ function App() { } }, []); - /** - * Keep the ref in sync before any RAF / ResizeObserver callbacks can read it. - * useLayoutEffect fires synchronously after DOM mutations but before paint, - * guaranteeing the ref is current when the ResizeObserver's RAF runs. - */ - useLayoutEffect(() => { - isGeneratingRef.current = isGenerating; - }, [isGenerating]); - - /** - * Pre-expand the window to max chat height when streaming starts and the - * window is in upward-growth mode (anchor present). This eliminates - * per-token window resizing — the single expansion happens once, and - * content fills naturally inside the already-sized window. When streaming - * ends, the ResizeObserver resumes and shrinks the window to fit. - */ - useEffect(() => { - const anchor = windowAnchorRef.current; - if (!isGenerating || !anchor) return; - - const maxHeight = Math.min( - MAX_CHAT_WINDOW_HEIGHT, - anchor.bottom_y - anchor.min_y, - ); - const newY = anchor.bottom_y - maxHeight; - void invoke('set_window_frame', { - x: anchor.x, - y: newY, - width: OVERLAY_WIDTH, - height: maxHeight, - }); - }, [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. @@ -205,6 +170,7 @@ function App() { const replayEntranceAnimation = useCallback( (context: string | null, anchor: WindowAnchor | null) => { windowAnchorRef.current = anchor; + isPreExpandedRef.current = false; setIsAnchoredUpward(anchor !== null); setSessionId((id) => id + 1); setQuery(''); @@ -221,6 +187,7 @@ function App() { */ const requestHideOverlay = useCallback(() => { windowAnchorRef.current = null; + isPreExpandedRef.current = false; setIsAnchoredUpward(false); setSelectedContext(null); setOverlayState((currentState) => { @@ -375,6 +342,7 @@ function App() { 'mouseup', () => { windowAnchorRef.current = null; + isPreExpandedRef.current = false; setIsAnchoredUpward(false); }, { once: true }, diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index dce1d852..c5c63d82 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -308,7 +308,7 @@ describe('App', () => { ); }); - it('pre-expands window to max height when streaming starts with anchor', async () => { + it('applies justify-end layout when overlay opens with anchor', async () => { render(); await act(async () => {}); @@ -321,58 +321,9 @@ describe('App', () => { }); }); - 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 () => {}); - - // The pre-expansion useEffect should have called set_window_frame - // with the max chat window height (600 + 48 = 648) - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 100, - y: 800 - 648, - width: 600, - height: 648, - }); - }); - - it('clamps pre-expansion height to available screen space', async () => { - render(); - await act(async () => {}); - - // Anchor near top of screen — only 200px available - await act(async () => { - emitTauriEvent('thuki://visibility', { - state: 'show', - selected_text: null, - window_anchor: { x: 100, bottom_y: 250, min_y: 50 }, - }); - }); - - 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 () => {}); - - // Should clamp to available space: 250 - 50 = 200 - expect(invoke).toHaveBeenCalledWith('set_window_frame', { - x: 100, - y: 50, - width: 600, - height: 200, - }); + // The outer container should use justify-end for bottom-pinning + const outer = document.querySelector('.justify-end'); + expect(outer).not.toBeNull(); }); it('requestHideOverlay is a no-op when already hidden', async () => { From daa960a0ba5897505e0caf9afe50c04ebf8f251f Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Tue, 31 Mar 2026 22:45:27 -0500 Subject: [PATCH 3/3] fix: keep justify-end during exit animation to prevent dismiss flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't clear isAnchoredUpward in requestHideOverlay — flipping from justify-end to justify-start during the Framer Motion exit animation caused content to jump from bottom to top of the max-height window. The state is already reset in replayEntranceAnimation on next open. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 0f507c8e..555676df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -188,7 +188,6 @@ function App() { const requestHideOverlay = useCallback(() => { windowAnchorRef.current = null; isPreExpandedRef.current = false; - setIsAnchoredUpward(false); setSelectedContext(null); setOverlayState((currentState) => { if (currentState === 'hidden' || currentState === 'hiding') {