From 3209ea771325de47c16abd424fd8378472eb6df5 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Mon, 18 May 2026 15:33:55 -0500 Subject: [PATCH 01/27] feat(minimize): add OVERLAY_MINIMIZED atomic and set_overlay_minimized command Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c0ffacde..551c0d2a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -142,6 +142,11 @@ const ONBOARDING_LOGICAL_HEIGHT: f64 = 640.0; /// between the frontend exit animation and rapid activation toggles. static OVERLAY_INTENDED_VISIBLE: AtomicBool = AtomicBool::new(false); +/// True while the overlay is collapsed into the floating minimized icon. +/// Read by the activator layer so any activation restores the parked +/// conversation instead of showing/hiding. +static OVERLAY_MINIMIZED: AtomicBool = AtomicBool::new(false); + /// True on first process launch; cleared when the frontend signals readiness. /// Used to show the overlay automatically on startup without a race condition: /// the frontend calls `notify_frontend_ready` after its event listener is @@ -634,6 +639,16 @@ fn notify_overlay_hidden(generation: tauri::State Date: Mon, 18 May 2026 15:37:34 -0500 Subject: [PATCH 02/27] feat(minimize): add restore overlay visibility variant Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 551c0d2a..ce1ef504 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -127,6 +127,10 @@ const OVERLAY_LOGICAL_HEIGHT_COLLAPSED: f64 = 80.0; const OVERLAY_VISIBILITY_EVENT: &str = "thuki://visibility"; const OVERLAY_VISIBILITY_SHOW: &str = "show"; const OVERLAY_VISIBILITY_HIDE_REQUEST: &str = "hide-request"; +/// Emitted while the overlay is parked in the minimized icon and an +/// activation occurs. The frontend restores the chat without the +/// fresh-session wipe that OVERLAY_VISIBILITY_SHOW triggers. +const OVERLAY_VISIBILITY_RESTORE: &str = "restore"; /// Frontend event that triggers the onboarding screen when one or more /// required permissions have not yet been granted. @@ -1616,6 +1620,13 @@ mod tests { assert_eq!(OVERLAY_VISIBILITY_HIDE_REQUEST, "hide-request"); } + #[test] + fn restore_visibility_constant_is_distinct() { + assert_ne!(OVERLAY_VISIBILITY_RESTORE, OVERLAY_VISIBILITY_SHOW); + assert_ne!(OVERLAY_VISIBILITY_RESTORE, OVERLAY_VISIBILITY_HIDE_REQUEST); + assert_eq!(OVERLAY_VISIBILITY_RESTORE, "restore"); + } + #[test] fn onboarding_event_constant_matches() { assert_eq!(ONBOARDING_EVENT, "thuki://onboarding"); From 8ae8882792acaf97948c576b43e633c49d32ef3e Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Mon, 18 May 2026 15:43:37 -0500 Subject: [PATCH 03/27] feat(minimize): route activations to restore while overlay is minimized Signed-off-by: Logan Nguyen --- src-tauri/src/lib.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce1ef504..ffa4a93a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -266,6 +266,17 @@ fn monitor_info_fallback() -> (f64, f64, f64, f64) { /// back to global coordinates for `set_position`. #[cfg(target_os = "macos")] fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationContext) { + if take_minimized_for_restore() { + emit_overlay_visibility( + app_handle, + OVERLAY_VISIBILITY_RESTORE, + None, + None, + None, + None, + ); + return; + } let already_visible = OVERLAY_INTENDED_VISIBLE.swap(true, Ordering::SeqCst); if already_visible { return; @@ -574,6 +585,17 @@ fn request_overlay_hide(app_handle: &tauri::AppHandle) { /// (e.g. Windows global hotkey) are implemented. #[cfg(not(target_os = "macos"))] fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationContext) { + if take_minimized_for_restore() { + emit_overlay_visibility( + app_handle, + OVERLAY_VISIBILITY_RESTORE, + None, + None, + None, + None, + ); + return; + } if OVERLAY_INTENDED_VISIBLE.swap(true, Ordering::SeqCst) { return; } @@ -596,6 +618,17 @@ fn show_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationCo /// Uses an atomic flag as the single source of truth for intended visibility, /// which avoids race conditions with the native panel state during animations. fn toggle_overlay(app_handle: &tauri::AppHandle, ctx: crate::context::ActivationContext) { + if take_minimized_for_restore() { + emit_overlay_visibility( + app_handle, + OVERLAY_VISIBILITY_RESTORE, + None, + None, + None, + None, + ); + return; + } if OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst) { request_overlay_hide(app_handle); } else { @@ -647,6 +680,13 @@ fn set_overlay_minimized_impl(minimized: bool) { OVERLAY_MINIMIZED.store(minimized, Ordering::SeqCst); } +/// Returns true and clears the flag if the overlay was minimized. Used by +/// the activator layer to route any activation to a restore instead of a +/// show or hide while a conversation is parked. +fn take_minimized_for_restore() -> bool { + OVERLAY_MINIMIZED.swap(false, Ordering::SeqCst) +} + #[tauri::command] #[cfg_attr(coverage_nightly, coverage(off))] fn set_overlay_minimized(minimized: bool) { @@ -1606,6 +1646,15 @@ mod tests { assert!(!OVERLAY_MINIMIZED.load(Ordering::SeqCst)); } + #[test] + fn minimized_guard_clears_flag() { + OVERLAY_MINIMIZED.store(true, Ordering::SeqCst); + let consumed = take_minimized_for_restore(); + assert!(consumed); + assert!(!OVERLAY_MINIMIZED.load(Ordering::SeqCst)); + assert!(!take_minimized_for_restore()); + } + #[test] fn launch_show_pending_consumed_exactly_once() { LAUNCH_SHOW_PENDING.store(true, Ordering::SeqCst); From eb6cc9f3fb065ec8cd1e30d13e11773458f7fe4c Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Mon, 18 May 2026 15:56:06 -0500 Subject: [PATCH 04/27] feat(minimize): add restore payload variant and minimize state Signed-off-by: Logan Nguyen --- src/App.tsx | 22 ++++++++++++++++++-- src/__tests__/App.test.tsx | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index ad55bd9d..b3383210 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -205,7 +205,8 @@ type OverlayVisibilityPayload = window_y: number | null; screen_bottom_y: number | null; } - | { state: 'hide-request' }; + | { state: 'hide-request' } + | { state: 'restore' }; type OverlayState = 'visible' | 'hidden' | 'hiding'; /** @@ -221,6 +222,10 @@ type OverlayState = 'visible' | 'hidden' | 'hiding'; function App() { const [query, setQuery] = useState(''); const [overlayState, setOverlayState] = useState('hidden'); + /** True when the overlay is parked as a floating minimized icon. */ + const [isMinimized, setIsMinimized] = useState(false); + /** True when a streaming completion finished while the overlay was minimized. */ + const [unseenCompletion, setUnseenCompletion] = useState(false); /** Non-null when the backend signals onboarding is needed; holds the current stage. */ const [onboardingStage, setOnboardingStage] = useState(null); @@ -682,6 +687,15 @@ function App() { }); }, [cancel]); + /** + * Clears the minimized state when the user restores the overlay via hotkey or tray. + * Task 7 will extend this with full restore logic (window show, scroll pin, etc.). + */ + const handleRestore = useCallback(() => { + setIsMinimized(false); + setUnseenCompletion(false); + }, []); + /** Ref attached to the chat-mode history dropdown for click-outside detection. */ const historyDropdownRef = useRef(null); @@ -2062,6 +2076,10 @@ function App() { unlistenVisibility = await listen( OVERLAY_VISIBILITY_EVENT, ({ payload }) => { + if (payload.state === 'restore') { + handleRestore(); + return; + } if (payload.state === 'show') { replayEntranceAnimation( payload.selected_text ?? null, @@ -2089,7 +2107,7 @@ function App() { unlistenVisibility?.(); unlistenOnboarding?.(); }; - }, [replayEntranceAnimation, requestHideOverlay]); + }, [handleRestore, replayEntranceAnimation, requestHideOverlay]); /** * Combined close handler shared by the keyboard shortcut (Esc/Cmd+W) diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 06544dad..2a70e586 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -735,6 +735,48 @@ describe('App', () => { ).toBeInTheDocument(); }); + it('handles a restore visibility event without wiping the conversation', async () => { + // Arrange: render App and drive it into chat mode with one complete turn. + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + }); + + render(); + await act(async () => {}); + await showOverlay(); + + 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 () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'world' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Confirm the conversation is present (chat mode with messages). + expect(screen.getByText('hello')).toBeInTheDocument(); + expect(screen.getByText('world')).toBeInTheDocument(); + + // Act: dispatch a restore visibility event. + await act(async () => { + emitTauriEvent('thuki://visibility', { state: 'restore' }); + }); + + // Assert: existing messages are still rendered (conversation was NOT wiped). + expect(screen.getByText('hello')).toBeInTheDocument(); + expect(screen.getByText('world')).toBeInTheDocument(); + }); + it('hides overlay on Escape key', async () => { render(); await act(async () => {}); From 0677c2d67905bb24c0b064ea0272a31f524335cc Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Mon, 18 May 2026 16:04:46 -0500 Subject: [PATCH 05/27] feat(minimize): add MinimizedIcon floating component Signed-off-by: Logan Nguyen --- src/components/MinimizedIcon.tsx | 81 +++++++++++++++++++ .../__tests__/MinimizedIcon.test.tsx | 73 +++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/components/MinimizedIcon.tsx create mode 100644 src/components/__tests__/MinimizedIcon.test.tsx diff --git a/src/components/MinimizedIcon.tsx b/src/components/MinimizedIcon.tsx new file mode 100644 index 00000000..a433e4db --- /dev/null +++ b/src/components/MinimizedIcon.tsx @@ -0,0 +1,81 @@ +import { memo, useRef } from 'react'; +import { getCurrentWindow } from '@tauri-apps/api/window'; + +interface MinimizedIconProps { + /** True while a response is still streaming in the background. */ + isWorking: boolean; + /** True when a response finished while minimized and has not been seen. */ + hasUnseen: boolean; + /** Restore the parked conversation. */ + onRestore: () => void; +} + +const DRAG_THRESHOLD_PX = 6; + +/** + * Floating minimized icon shown when the chat overlay is collapsed. + * + * Renders the Thuki logo in a small circular button. Supports: + * - Dragging: pointer move past threshold calls the native window drag. + * - Restore: plain click (no drag) calls onRestore. + * - Working pulse: animated ring when isWorking is true. + * - Ready dot: small indicator dot when hasUnseen is true. + */ +export const MinimizedIcon = memo(function MinimizedIcon({ + isWorking, + hasUnseen, + onRestore, +}: MinimizedIconProps) { + const downPosRef = useRef<{ x: number; y: number } | null>(null); + const draggedRef = useRef(false); + + return ( + + ); +}); diff --git a/src/components/__tests__/MinimizedIcon.test.tsx b/src/components/__tests__/MinimizedIcon.test.tsx new file mode 100644 index 00000000..f04a2ad2 --- /dev/null +++ b/src/components/__tests__/MinimizedIcon.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { __mockWindow } from '../../testUtils/mocks/tauri-window'; +import { MinimizedIcon } from '../MinimizedIcon'; + +describe('MinimizedIcon', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls onRestore on a plain click (no drag)', () => { + const onRestore = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: /restore thuki/i }); + fireEvent.pointerDown(btn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(btn, { clientX: 1, clientY: 1 }); + expect(onRestore).toHaveBeenCalledTimes(1); + }); + + it('does not call onRestore when the pointer moves past the drag threshold', () => { + const onRestore = vi.fn(); + render(); + const btn = screen.getByRole('button', { name: /restore thuki/i }); + fireEvent.pointerDown(btn, { clientX: 0, clientY: 0 }); + fireEvent.pointerMove(btn, { clientX: 40, clientY: 40 }); + fireEvent.pointerUp(btn, { clientX: 40, clientY: 40 }); + expect(onRestore).not.toHaveBeenCalled(); + }); + + it('starts the native drag when moved past the threshold', () => { + render(); + const btn = screen.getByRole('button', { name: /restore thuki/i }); + fireEvent.pointerDown(btn, { clientX: 0, clientY: 0 }); + fireEvent.pointerMove(btn, { clientX: 40, clientY: 40 }); + expect(__mockWindow.startDragging).toHaveBeenCalled(); + }); + + it('ignores pointermove with no prior pointerdown', () => { + render(); + const btn = screen.getByRole('button', { name: /restore thuki/i }); + fireEvent.pointerMove(btn, { clientX: 40, clientY: 40 }); + expect(__mockWindow.startDragging).not.toHaveBeenCalled(); + }); + + it('shows the working state when isWorking is true', () => { + render(); + expect(screen.getByTestId('minimized-working')).toBeInTheDocument(); + }); + + it('does not show the working state when isWorking is false', () => { + render(); + expect(screen.queryByTestId('minimized-working')).not.toBeInTheDocument(); + }); + + it('shows the ready dot when hasUnseen is true', () => { + render(); + expect(screen.getByTestId('minimized-ready-dot')).toBeInTheDocument(); + }); + + it('does not show the ready dot when hasUnseen is false', () => { + render(); + expect(screen.queryByTestId('minimized-ready-dot')).not.toBeInTheDocument(); + }); + + it('does not re-start drag on subsequent moves past threshold', () => { + render(); + const btn = screen.getByRole('button', { name: /restore thuki/i }); + fireEvent.pointerDown(btn, { clientX: 0, clientY: 0 }); + fireEvent.pointerMove(btn, { clientX: 40, clientY: 40 }); + fireEvent.pointerMove(btn, { clientX: 80, clientY: 80 }); + expect(__mockWindow.startDragging).toHaveBeenCalledTimes(1); + }); +}); From 26a169e1140dfbc83bb38ebb59ff80f92a26340d Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Mon, 18 May 2026 16:14:12 -0500 Subject: [PATCH 06/27] feat(minimize): wire minimize button through ConversationView to WindowControls Signed-off-by: Logan Nguyen --- src/App.tsx | 9 ++++ src/__tests__/App.test.tsx | 40 ++++++++++++++++ src/components/WindowControls.tsx | 46 ++++++++++++++++--- .../__tests__/WindowControls.test.tsx | 33 +++++++++++++ src/view/ConversationView.tsx | 7 +++ 5 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b3383210..a0600df7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -696,6 +696,14 @@ function App() { setUnseenCompletion(false); }, []); + /** + * Minimizes the overlay to the floating icon. + * Task 7 will extend this with window hide and geometry logic. + */ + const handleMinimize = useCallback(() => { + setIsMinimized(true); + }, []); + /** Ref attached to the chat-mode history dropdown for click-outside detection. */ const historyDropdownRef = useRef(null); @@ -2293,6 +2301,7 @@ function App() { ollamaReachable ? handleModelPickerToggle : undefined } isModelPickerOpen={isModelPickerOpen} + onMinimize={handleMinimize} /> ) : null} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 2a70e586..383c5338 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -777,6 +777,46 @@ describe('App', () => { expect(screen.getByText('world')).toBeInTheDocument(); }); + it('clicking Minimize button in chat mode calls setIsMinimized (handleMinimize stub)', async () => { + // Arrange: render App in chat mode with one complete turn. + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + }); + + render(); + await act(async () => {}); + await showOverlay(); + + 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 () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'hi' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Act: click the Minimize button rendered in the chat header. + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + expect(minimizeBtn).toBeInTheDocument(); + act(() => { + fireEvent.click(minimizeBtn); + }); + + // Assert: no throw; the stub runs without error. + // Task 7 will assert the full minimize effect (MinimizedIcon visible, etc.). + expect(minimizeBtn).toBeTruthy(); + }); + it('hides overlay on Escape key', async () => { render(); await act(async () => {}); diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index 4cff3671..b5367ca6 100644 --- a/src/components/WindowControls.tsx +++ b/src/components/WindowControls.tsx @@ -145,6 +145,11 @@ interface WindowControlsProps { onModelPickerToggle?: () => void; /** Drives `aria-expanded` on the pill button. */ isModelPickerOpen?: boolean; + /** + * Called when the user clicks the minimize (yellow) dot. Omit to keep the + * dot inert and decorative (ask-bar mode or no conversation to park). + */ + onMinimize?: () => void; } /** Decorative dot color for inactive buttons. */ @@ -160,6 +165,7 @@ export const WindowControls = memo(function WindowControls({ activeModel, onModelPickerToggle, isModelPickerOpen = false, + onMinimize, }: WindowControlsProps) { // Disabled only when there is nothing to save yet and the conversation hasn't // been saved. Once saved the button stays active so the user can unsave. @@ -202,12 +208,40 @@ export const WindowControls = memo(function WindowControls({ - {/* Minimize - decorative only */} -