diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c0ffacde..b983c20c 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. @@ -142,6 +146,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 @@ -186,6 +195,24 @@ fn emit_overlay_visibility( ); } +/// Emits a restore request and marks the overlay intended-visible. +/// +/// A restore makes the parked (minimized) overlay visible again, so +/// `OVERLAY_INTENDED_VISIBLE` must agree. If it were left `false` (it can be +/// after a hide that raced the minimized state), the next `toggle_overlay` +/// would read it and re-show instead of hiding the now-visible overlay. +fn emit_overlay_restore(app_handle: &tauri::AppHandle) { + OVERLAY_INTENDED_VISIBLE.store(true, Ordering::SeqCst); + emit_overlay_visibility( + app_handle, + OVERLAY_VISIBILITY_RESTORE, + None, + None, + None, + None, + ); +} + /// CoreGraphics display lookup - uses macOS-native `CGGetDisplaysWithPoint` /// for hit-testing instead of manual iteration + containment checks. /// All coordinates are in the Quartz display coordinate space (top-left of @@ -257,6 +284,10 @@ 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_restore(app_handle); + return; + } let already_visible = OVERLAY_INTENDED_VISIBLE.swap(true, Ordering::SeqCst); if already_visible { return; @@ -545,6 +576,14 @@ fn show_update_window(app_handle: &tauri::AppHandle) { /// Requests an animated hide sequence from the frontend. The actual native /// window hide is deferred until the frontend exit animation completes. fn request_overlay_hide(app_handle: &tauri::AppHandle) { + // A parked (minimized) conversation must survive a stray close request. + // While minimized the icon is a small NSPanel that can still receive + // Cmd+W / a system close, which routes here; hiding it would tear down the + // background stream the user explicitly minimized to keep running. Ignore + // the hide while minimized: the user restores first, then closes normally. + if OVERLAY_MINIMIZED.load(Ordering::SeqCst) { + return; + } if OVERLAY_INTENDED_VISIBLE.swap(false, Ordering::SeqCst) { emit_overlay_visibility( app_handle, @@ -565,6 +604,10 @@ 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_restore(app_handle); + return; + } if OVERLAY_INTENDED_VISIBLE.swap(true, Ordering::SeqCst) { return; } @@ -587,6 +630,10 @@ 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_restore(app_handle); + return; + } if OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst) { request_overlay_hide(app_handle); } else { @@ -625,6 +672,152 @@ fn set_window_frame(app_handle: tauri::AppHandle, x: f64, y: f64, width: f64, he }); } +/// Computes the AppKit target frame for a resize that keeps the window's +/// visual top-left corner fixed. +/// +/// AppKit screen coordinates are bottom-left origin (Y grows upward), so the +/// current top edge is `origin.y + height`. To keep that top edge (and the +/// left edge) stationary while the size changes, the new origin's Y must be +/// `top - new_height`. This is purely relative to the current frame: no screen +/// lookup, no multi-monitor math, no absolute Y-flip. Robust by construction. +/// +/// `cur` is `(origin_x, origin_y, width, height)`; the return is the same shape +/// for the target frame. +fn compute_top_left_anchored_target( + cur: (f64, f64, f64, f64), + w: f64, + h: f64, +) -> (f64, f64, f64, f64) { + let (cur_x, cur_y, _cur_w, cur_h) = cur; + let top = cur_y + cur_h; + let new_y = top - h; + (cur_x, new_y, w, h) +} + +/// Animates the main overlay NSPanel/NSWindow from its current native frame to +/// a new size, keeping the visual top-left corner fixed, using +/// `NSAnimationContext` so the OS compositor (Core Animation) drives the +/// animation. One IPC call per morph direction replaces the old per-frame +/// `setSize` storm. +/// +/// Excluded from coverage: thin wrapper over AppKit `NSWindow`/ +/// `NSAnimationContext` FFI that requires a real window and the macOS main +/// thread. The pure geometry is covered by +/// `compute_top_left_anchored_target`'s unit test. +#[tauri::command] +#[cfg_attr(coverage_nightly, coverage(off))] +fn animate_overlay_frame(app_handle: tauri::AppHandle, width: f64, height: f64, duration_ms: f64) { + // Never panic on bad input: reject non-finite / non-positive dimensions + // and clamp the duration to a sane range. A missing window handle is a + // silent no-op. + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return; + } + let duration_ms = if duration_ms.is_finite() { + duration_ms.clamp(0.0, 2000.0) + } else { + 0.0 + }; + + #[cfg(target_os = "macos")] + { + use objc2::encode::{Encode, Encoding, RefEncode}; + use objc2::rc::autoreleasepool; + use objc2::runtime::AnyObject; + use objc2::{class, msg_send}; + + // Local NSRect/NSPoint/NSSize. `core_graphics::geometry::CGRect` does + // not implement objc2's `Encode`, so it cannot be a `msg_send!` + // return/argument type. NSRect uses CGFloat = f64 on macOS and is + // layout-compatible with the AppKit `NSWindow` frame ABI. + #[repr(C)] + #[derive(Clone, Copy)] + struct NSPoint { + x: f64, + y: f64, + } + #[repr(C)] + #[derive(Clone, Copy)] + struct NSSize { + width: f64, + height: f64, + } + #[repr(C)] + #[derive(Clone, Copy)] + struct NSRect { + origin: NSPoint, + size: NSSize, + } + + unsafe impl Encode for NSPoint { + const ENCODING: Encoding = Encoding::Struct("CGPoint", &[f64::ENCODING, f64::ENCODING]); + } + unsafe impl Encode for NSSize { + const ENCODING: Encoding = Encoding::Struct("CGSize", &[f64::ENCODING, f64::ENCODING]); + } + unsafe impl Encode for NSRect { + const ENCODING: Encoding = + Encoding::Struct("CGRect", &[NSPoint::ENCODING, NSSize::ENCODING]); + } + unsafe impl RefEncode for NSRect { + const ENCODING_REF: Encoding = Encoding::Pointer(&Self::ENCODING); + } + + let handle = app_handle.clone(); + let _ = app_handle.run_on_main_thread(move || { + let Some(window) = handle.get_webview_window("main") else { + return; + }; + let Ok(ns_window) = window.ns_window() else { + return; + }; + if ns_window.is_null() { + return; + } + let win = ns_window as *mut AnyObject; + + autoreleasepool(|_| unsafe { + let cur: NSRect = msg_send![win, frame]; + let (tx, ty, tw, th) = compute_top_left_anchored_target( + (cur.origin.x, cur.origin.y, cur.size.width, cur.size.height), + width, + height, + ); + let target = NSRect { + origin: NSPoint { x: tx, y: ty }, + size: NSSize { + width: tw, + height: th, + }, + }; + + // duration 0 is the invisible endpoint snap used by the + // in-page morph: the painted web content already matches the + // target, so the OS frame must change instantly. The animator + // proxy still tweens (briefly) even at duration 0, so bypass + // NSAnimationContext entirely and set the frame directly on + // the window for a true immediate, non-animated change. + if duration_ms == 0.0 { + let _: () = msg_send![win, setFrame: target, display: true]; + } else { + let ctx_cls = class!(NSAnimationContext); + let _: () = msg_send![ctx_cls, beginGrouping]; + let ctx: *mut AnyObject = msg_send![ctx_cls, currentContext]; + let _: () = msg_send![ctx, setDuration: duration_ms / 1000.0]; + let animator: *mut AnyObject = msg_send![win, animator]; + let _: () = msg_send![animator, setFrame: target, display: true]; + let _: () = msg_send![ctx_cls, endGrouping]; + } + }); + }); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = (app_handle, width, height, duration_ms); + } +} + /// Synchronizes the Rust-side visibility tracking when the frontend /// completes its exit animation and hides the native window. #[tauri::command] @@ -632,6 +825,27 @@ fn set_window_frame(app_handle: tauri::AppHandle, x: f64, y: f64, width: f64, he fn notify_overlay_hidden(generation: tauri::State) { generation.cancel(); OVERLAY_INTENDED_VISIBLE.store(false, Ordering::SeqCst); + // The overlay is now fully hidden, so it can no longer be parked in the + // minimized icon. Clearing the flag here prevents it leaking `true` across + // a hide and routing the next activation to a restore of a gone window. + OVERLAY_MINIMIZED.store(false, Ordering::SeqCst); +} + +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) { + set_overlay_minimized_impl(minimized); } /// Called by the frontend once its visibility event listener is registered. @@ -1476,8 +1690,10 @@ pub fn run() { #[cfg(not(coverage))] ocr::extract_text_command, notify_overlay_hidden, + set_overlay_minimized, notify_frontend_ready, set_window_frame, + animate_overlay_frame, #[cfg(not(coverage))] permissions::check_accessibility_permission, #[cfg(not(coverage))] @@ -1563,6 +1779,30 @@ mod tests { assert!(100.0_f64.is_finite()); } + #[test] + fn top_left_anchored_target_keeps_top_and_left_fixed() { + // Current frame: origin (100, 200), size 600x700. + // AppKit top edge = 200 + 700 = 900. + let cur = (100.0_f64, 200.0_f64, 600.0_f64, 700.0_f64); + + // Shrink to a 48x48 square. + let (x, y, w, h) = compute_top_left_anchored_target(cur, 48.0, 48.0); + assert_eq!(x, 100.0, "left edge (origin.x) must not move"); + assert_eq!(w, 48.0, "width is set to the requested value"); + assert_eq!(h, 48.0, "height is set to the requested value"); + // Top edge after = new_y + new_h must equal the original top (900). + assert_eq!(y + h, 900.0, "visual top edge must stay fixed"); + assert_eq!(y, 852.0); + + // Grow back to a larger box: top edge still fixed at 900. + let (gx, gy, gw, gh) = compute_top_left_anchored_target(cur, 560.0, 648.0); + assert_eq!(gx, 100.0); + assert_eq!(gw, 560.0); + assert_eq!(gh, 648.0); + assert_eq!(gy + gh, 900.0, "visual top edge must stay fixed on grow"); + assert_eq!(gy, 252.0); + } + #[test] fn width_height_clamp_logic() { assert_eq!(0.5_f64.clamp(1.0, 10_000.0), 1.0); @@ -1577,6 +1817,24 @@ mod tests { assert!(!OVERLAY_INTENDED_VISIBLE.load(Ordering::SeqCst)); } + #[test] + fn set_overlay_minimized_toggles_flag() { + OVERLAY_MINIMIZED.store(false, Ordering::SeqCst); + set_overlay_minimized_impl(true); + assert!(OVERLAY_MINIMIZED.load(Ordering::SeqCst)); + set_overlay_minimized_impl(false); + 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); @@ -1591,6 +1849,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"); diff --git a/src/App.css b/src/App.css index 676eca2d..76226573 100644 --- a/src/App.css +++ b/src/App.css @@ -20,6 +20,11 @@ --color-tertiary: #e69c05; --color-neutral: #1a1a1c; + /* Status accent: "ready" green for the minimized done indicator. Sits + outside the warm brand palette on purpose, a conventional ready/available + signal that must read instantly distinct from the warm working state. */ + --color-ready: #34d399; + /* Surface tokens derived from the palette */ --color-surface-base: rgba(22, 18, 15, 0.98); --color-surface-elevated: rgba(38, 30, 24, 0.6); @@ -631,3 +636,210 @@ body { border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 28%, transparent); } + +/* ─── Minimized mascot icon ───────────────────────────────────────────────── + The floating mascot's opacity/scale are CSS-driven, NOT Framer Motion. + This is load-bearing: the overlay is a nonactivating NSPanel, and when it + is not the key window WKWebView throttles requestAnimationFrame. A Framer + rAF tween would then strand the mascot at its initial opacity:0 and the + icon would vanish (the disappearing-icon-after-many-toggles bug). A CSS + end-state is committed during style resolution and painted by the + compositor regardless of the JS rAF clock, so the settled icon is always + visible. The base/settled state is declaratively visible; collapse and + expand layer transient animations on top. Timings mirror the JS + MORPH_DURATION_S (0.36s) constant. */ +.thuki-mascot { + opacity: 1; + transform: scale(1); + transform-origin: center; +} +/* Bloom in on collapse. The base style above is already the visible + end-state, so even if this animation is throttled and never runs, the icon + still paints at opacity:1. `both` fill holds the 0% frame during the delay + and the 100% frame after. */ +.thuki-mascot-bloom { + animation: thuki-mascot-bloom 0.42s ease-out 0.04s both; +} +/* On expand the mascot snaps invisible INSTANTLY (no transition). This is + load-bearing, not cosmetic: expanding moves the native window (to grow the + chat out of the icon's corner), and WKWebView repaints its pre-resize + surface at the new window origin for ~1 frame before it reflows CSS. If the + mascot were still visible (mid-fade) during that frame it would flash at the + wrong corner and snap back — the jump. By forcing opacity:0 in the same + commit as the move dispatch, there is simply nothing visible to flash; the + chat scaling in from the anchor corner carries the "grows out of the icon" + continuity instead. Do NOT add a transition here. */ +.thuki-mascot-leaving { + opacity: 0; +} +@keyframes thuki-mascot-bloom { + 0% { + opacity: 0; + transform: scale(0.3); + } + 55% { + opacity: 1; + transform: scale(1.1); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +/* ─── Minimized Mascot Indicator ─── + * "Jelly Wobble + Jewel Dot": while a response streams in the background the + * parked mascot does a soft elastic squash-and-stretch (it looks alive, not + * badged) and a small glossy jewel breathes at its bottom-right corner. When a + * reply lands while minimized the mascot does one elastic pop and the jewel + * holds a steady warm amber, the persistent "reply waiting" cue. The wobble is + * the personality; the jewel is the explicit working/done signal. + */ + +/* Logo: alive while streaming. transform-origin center so the squash grows + symmetrically about the mascot's middle: right for a floating element (no + ground to settle onto) and it keeps the overshoot within the window's + margin on every side so nothing clips. */ +.minimized-logo-working { + transform-origin: center; + animation: minimized-wobble 1.6s ease-in-out infinite; +} +@keyframes minimized-wobble { + 0%, + 100% { + transform: scale(1, 1); + } + 25% { + transform: scale(1.07, 0.93); + } + 50% { + transform: scale(0.93, 1.07); + } + 75% { + transform: scale(1.03, 0.97); + } +} + +/* Logo: one-shot elastic pop the moment a reply lands while minimized. */ +.minimized-logo-done { + transform-origin: center; + animation: minimized-pop 1.3s cubic-bezier(0.2, 0.9, 0.2, 1.4); +} +@keyframes minimized-pop { + 0% { + transform: scale(1); + } + 30% { + transform: scale(1.15, 0.85); + } + 55% { + transform: scale(0.9, 1.12); + } + 80% { + transform: scale(1.04, 0.96); + } + 100% { + transform: scale(1); + } +} + +/* The jewel's three gradient stops are registered custom properties so they + INTERPOLATE: animating a registered re-renders the radial gradient + each frame. A plain `transition`/`@keyframes` on a `background` gradient + does not animate, which is why the working->done color change hard-cut + before. Initial values are the working (orange) stops. */ +@property --jewel-hi { + syntax: ''; + inherits: false; + initial-value: #ffd9a8; +} +@property --jewel-core { + syntax: ''; + inherits: false; + initial-value: #ff8d5c; +} +@property --jewel-rim { + syntax: ''; + inherits: false; + initial-value: #bc5c0b; +} + +/* Jewel status bead, pinned just inside the mascot's bottom-right corner. */ +.minimized-jewel { + position: absolute; + bottom: 1px; + right: 1px; + width: 9px; + height: 9px; + border-radius: 999px; + background: radial-gradient( + circle at 35% 30%, + var(--jewel-hi), + var(--jewel-core) 60%, + var(--jewel-rim) + ); +} +/* Working: warm orange jewel that gently breathes. */ +.minimized-jewel-working { + --jewel-hi: #ffd9a8; + --jewel-core: #ff8d5c; + --jewel-rim: #bc5c0b; + animation: minimized-jewel-breathe 1.6s ease-in-out infinite; +} +@keyframes minimized-jewel-breathe { + 0%, + 100% { + box-shadow: 0 0 0 2px rgba(255, 141, 92, 0.12); + opacity: 0.85; + } + 50% { + box-shadow: 0 0 0 5px rgba(255, 141, 92, 0.22); + opacity: 1; + } +} +/* Done: settles on a green "ready" jewel, but does NOT hard-cut from orange. + The handoff animation morphs the bead orange -> a bright pale-gold flash + (this avoids a muddy orange->green midtone and reads as a confirming "ding") + -> green, swelling the glow and popping scale once, in sync with the + mascot's completion pop. `both` holds the green end frame afterward. */ +.minimized-jewel-done { + --jewel-hi: #d1fae5; + --jewel-core: var(--color-ready); + --jewel-rim: #0f7a52; + box-shadow: 0 0 6px rgba(52, 211, 153, 0.7); + animation: minimized-jewel-handoff 0.85s cubic-bezier(0.2, 0.8, 0.2, 1.1) both; +} +@keyframes minimized-jewel-handoff { + 0% { + --jewel-hi: #ffd9a8; + --jewel-core: #ff8d5c; + --jewel-rim: #bc5c0b; + transform: scale(1); + box-shadow: 0 0 0 2px rgba(255, 141, 92, 0.25); + } + 45% { + --jewel-hi: #ffffff; + --jewel-core: #ffe6a6; + --jewel-rim: #e69c05; + transform: scale(1.3); + box-shadow: 0 0 8px rgba(255, 214, 140, 0.95); + } + 100% { + --jewel-hi: #d1fae5; + --jewel-core: var(--color-ready); + --jewel-rim: #0f7a52; + transform: scale(1); + box-shadow: 0 0 6px rgba(52, 211, 153, 0.7); + } +} + +/* Respect reduced-motion: keep the jewel (the status signal) but hold it + still, and freeze the mascot. The indicator still communicates state. */ +@media (prefers-reduced-motion: reduce) { + .minimized-logo-working, + .minimized-logo-done, + .minimized-jewel-working, + .minimized-jewel-done { + animation: none; + } +} diff --git a/src/App.tsx b/src/App.tsx index ad55bd9d..c0722419 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { motion, AnimatePresence } from 'framer-motion'; import type React from 'react'; +import { createPortal, flushSync } from 'react-dom'; import { useState, useEffect, @@ -10,7 +11,11 @@ import { } from 'react'; import { listen } from '@tauri-apps/api/event'; import { invoke, convertFileSrc } from '@tauri-apps/api/core'; -import { getCurrentWindow } from '@tauri-apps/api/window'; +import { + getCurrentWindow, + currentMonitor, + availableMonitors, +} from '@tauri-apps/api/window'; import { LogicalSize } from '@tauri-apps/api/dpi'; import { useOllama } from './hooks/useOllama'; import type { Message } from './hooks/useOllama'; @@ -22,10 +27,18 @@ import { getEnvironmentMessage, isComposeCapabilityConflict, } from './utils/capabilityConflicts'; +import { + computeExpandTarget, + computeCollapseTarget, + anchorToTransformOrigin, + pickMonitorForPoint, + type MorphAnchor, +} from './utils/morphGeometry'; import { ConversationView } from './view/ConversationView'; import { AskBarView } from './view/AskBarView'; import { OnboardingView } from './view/onboarding/index'; import type { OnboardingStage } from './view/onboarding/index'; +import { MinimizedIcon } from './components/MinimizedIcon'; import { HistoryPanel } from './components/HistoryPanel'; import { ModelPickerPanel } from './components/ModelPickerPanel'; import { ImagePreviewModal } from './components/ImagePreviewModal'; @@ -163,6 +176,57 @@ const CONTAINER_VERTICAL_PADDING = 48; */ const COLLAPSED_WINDOW_HEIGHT = 80; +/** + * Logical-pixel side length of the minimized floating-icon window. The native + * window shrinks to this square. The 48px mascot logo is centered inside it + * with a margin, so the working "jelly wobble" / completion pop can overshoot + * the logo's bounds, and the status jewel's glow can bloom at the bottom-right + * corner, without being clipped by the window frame (body overflow is hidden). + * This size is the icon footprint fed to the edge-aware morph geometry, so + * both the native window and the in-chat morph mascot use it. + */ +const MINIMIZED_WINDOW_SIZE = 68; + +/** + * Single source of truth for the chat-card collapse/expand tween duration, + * in seconds. The OS window itself does NOT animate during the morph; it + * only snap-resizes at the endpoints (`durationMs:0`) once the painted + * content already matches. Floating-window apps (Raycast, Alfred, Spotlight, + * Linear Cmd+K) sit at 0.15s to 0.25s for a plain close. Thuki's case is + * different: the chat morphs into a persistent mascot rather than just + * vanishing, so the transition needs a touch more time and travel to read as + * one connected morph instead of a fade followed by a separate pop-in. + * + * This value drives the EXPAND (restore) direction only. The collapse + * direction uses its own, longer duration (see `COLLAPSE_MORPH_DURATION_S`). + */ +const MORPH_DURATION_S = 0.36; +/** + * Duration of the COLLAPSE (minimize) chat-card shrink+fade, in seconds. + * Deliberately longer than the expand duration so the chat dissolving into + * the mascot reads as a slow, cinematic morph rather than a quick vanish. + * Only the collapse uses this; expand keeps `MORPH_DURATION_S`. + */ +const COLLAPSE_MORPH_DURATION_S = 0.55; +/** + * Apple-style ease-out curve (matches SwiftUI `.easeOut`). Applied to the + * chat-card collapse/expand transform so the shrink+fade reads as a clean + * decelerating settle rather than the overshoot-and-decay shape of the + * earlier curve. + */ +const MORPH_EASE = [0.32, 0.72, 0, 1] as const; +/** + * Grace period added on top of a morph's animation duration before the + * watchdog force-settles `morphPhase`, in milliseconds. The morph normally + * settles precisely via Framer Motion's `onAnimationComplete`, but that + * callback can fail to fire (e.g. an identical start/end target produces no + * animation, or WKWebView throttles the rAF clock on the nonactivating + * panel). Without a settle the machine strands mid-morph and the mascot can + * end up animated to invisible. The watchdog guarantees the phase always + * reaches its terminal state (`minimized` or `idle`) regardless. + */ +const MORPH_SETTLE_GRACE_MS = 250; + /** * Authoritative deadline from the start of the hide transition to the native * window hide call. Accounts for WKWebView `requestAnimationFrame` throttling @@ -205,7 +269,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 +286,63 @@ type OverlayState = 'visible' | 'hidden' | 'hiding'; function App() { const [query, setQuery] = useState(''); const [overlayState, setOverlayState] = useState('hidden'); + /** + * Minimize/restore morph state machine. The morph happens entirely in the + * web layer (GPU transforms); the OS window only snap-resizes at the + * endpoints (durationMs:0) when the painted content already matches the + * target size, so the resize is invisible against the transparent NSPanel. + * + * - `idle`: normal chat/ask. Content-driven window sizing (ResizeObserver + * + the two chat useLayoutEffects). Byte-identical to before. + * - `collapsing`: OS window stays at full chat size. The chat card scales + * down + translates toward the top-left while the bare + * mascot crossfades in. On complete: snap the OS window to + * 48x48 (content is already the mascot, so it is invisible). + * - `minimized`: settled. OS window is 48x48 so clicks pass through to + * the desktop around it. Only `` renders. + * - `expanding`: OS window resized back to full chat size on the SAME tick + * the in-page expand starts (no await). Mascot scales out + * into the chat card. On complete: back to `idle`. + */ + const [morphPhase, setMorphPhase] = useState< + 'idle' | 'collapsing' | 'minimized' | 'expanding' + >('idle'); + /** + * Which corner the chat is pinned to during the minimize/restore morph. + * Chosen edge-aware at expand time (so the chat unfolds into open space when + * the icon is near a screen edge) and reused on the matching collapse so the + * icon returns to the same spot. Drives both the chat card's + * transform-origin and where the floating mascot is rendered, which is what + * keeps the icon visually stationary while the window resizes under it. + * Defaults to top-left and is reset to top-left whenever the overlay is + * shown fresh. + */ + const [morphAnchor, setMorphAnchor] = useState('tl'); + /** + * Gates the chat-card's grow animation during expand. While false, the chat + * is held collapsed (scale 0.34, opacity 0 — invisible) so it cannot be seen + * during the native window move that repositions the overlay on screen. + * Flipped true only AFTER that move (and a paint yield), so the chat grows + * out of the anchor corner in the already-correctly-positioned window. This + * is what keeps the move flicker-free: across the move, BOTH the mascot + * (opacity 0) and the chat (collapsed) are invisible, so the ~1 stale frame + * WebKit may displace to the new origin contains nothing. + */ + const [expandReady, setExpandReady] = useState(false); + /** + * True whenever the overlay is not in the normal chat/ask state, i.e. + * during the collapse/expand morph or while settled as the floating icon. + * Gates the chat sizing machinery (ResizeObserver + chat useLayoutEffects) + * off so transforms are never fought by layout writes, and keeps + * Esc/Cmd+W ignored across the whole minimized lifecycle. + */ + const isMinimized = morphPhase !== 'idle'; + /** True only in the settled floating-icon state (chat subtree unmounted). */ + const isSettledMinimized = morphPhase === 'minimized'; + /** True only while a collapse/expand transform morph is in flight. */ + const isMorphing = morphPhase === 'collapsing' || morphPhase === 'expanding'; + /** 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); @@ -411,6 +533,23 @@ function App() { isVisible: isTipVisible, } = useTips(shouldRenderOverlay); + // Animate the tip typewriter only the FIRST time a given tip (tipKey) is + // shown. Minimizing unmounts the chat subtree (and TipBar with it); on + // restore TipBar remounts with the SAME tipKey, which would otherwise replay + // the typewriter from scratch. We remember the last tipKey we let animate + // (this ref survives minimize because App itself never unmounts) and tell + // TipBar to render an already-seen tip as static text instead of re-typing. + const animatedTipKeyRef = useRef(-1); + useEffect(() => { + // Once a tip is on screen, mark its key as animated. This effect runs + // after render, so the render that first shows a new tip still sees the + // old key and animates; only later mounts of the same key are static. + if (isTipVisible) { + animatedTipKeyRef.current = tipKey; + } + }, [isTipVisible, tipKey]); + const tipAlreadyAnimated = tipKey === animatedTipKeyRef.current; + const updater = useUpdater(); const chatSnoozed = useMemo( () => (updater.state.chat_snoozed_until ?? 0) * 1000 > Date.now(), @@ -423,6 +562,21 @@ function App() { */ const observerRef = useRef(null); + /** + * The layout-wrapper DOM node the ResizeObserver watches. Stored so the + * restore path can force a fresh observation once the expand morph settles + * (the observer does not re-fire on its own then, because the wrapper's + * layout box is unchanged across the morph — only its CSS transform is). + */ + const layoutWrapperNodeRef = useRef(null); + + /** + * Pending watchdog timer id that force-settles `morphPhase` if the morph's + * `onAnimationComplete` never fires. 0 means no timer scheduled + * (`clearTimeout(0)` is a safe no-op). + */ + const morphSettleTimerRef = useRef(0); + /** * Mirror of `growsUpward` as a ref so the ResizeObserver closure can read * it without being recreated on each state change. @@ -442,6 +596,37 @@ function App() { const isGeneratingRef = useRef(false); isGeneratingRef.current = isGenerating; + /** + * Mirror of `isMinimized` as a ref so the ResizeObserver closure can skip + * chat sizing across the entire minimized lifecycle (collapsing → settled → + * expanding). True whenever `morphPhase !== 'idle'`. + */ + const isMinimizedRef = useRef(false); + isMinimizedRef.current = isMinimized; + + /** + * True only while a collapse/expand transform morph is in flight. Read by + * the ResizeObserver and the two chat `useLayoutEffect`s to no-op so the + * in-page GPU transform is never fought by a width/height/min-height layout + * write (which would reflow the heavy chat tree and blank the morph). + * Mirrored from the `isMorphing` derived state every render. + */ + const isMorphingRef = useRef(false); + isMorphingRef.current = isMorphing; + + /** + * Mirror of `morphPhase` as a ref so the transform wrapper's + * `onAnimationComplete` handler reads the live phase instead of a stale + * closure value (the handler is a `useCallback` captured before the state + * update that schedules the animation completion). + */ + const morphPhaseRef = useRef(morphPhase); + morphPhaseRef.current = morphPhase; + + /** Mirror of `morphAnchor` for reading inside callbacks (collapse snap). */ + const morphAnchorRef = useRef(morphAnchor); + morphAnchorRef.current = morphAnchor; + /** * High-water mark for window height during streaming. While the LLM is * generating, the window only grows (never shrinks) to prevent jitter @@ -533,6 +718,7 @@ function App() { * chat is at max height, and the wrapper's natural height grows to include it. */ const setLayoutWrapperRef = useCallback((node: HTMLDivElement | null) => { + layoutWrapperNodeRef.current = node; if (observerRef.current) { observerRef.current.disconnect(); observerRef.current = null; @@ -545,6 +731,17 @@ function App() { requestAnimationFrame(() => { for (const entry of entries) { const rect = entry.target.getBoundingClientRect(); + + // While morphing or minimized the native `animate_overlay_frame` + // command owns the window frame (Core Animation drives the + // tween; the icon square is held by the collapse target). The + // observer must not call setSize/set_window_frame here or it + // would fight the native animation, so do nothing. + if (isMorphingRef.current || isMinimizedRef.current) { + continue; + } + + // Settled chat/ask. Unchanged content-driven sizing. // 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. let targetHeight = @@ -618,6 +815,16 @@ function App() { windowY: number | null, screenBottomY: number | null, ) => { + // A fresh show always starts from the normal chat/ask state. Reset any + // morph phase (and cancel a pending morph watchdog) left over from a + // prior minimized session that was hidden without settling, so the + // overlay never reappears stuck as the icon or mid-morph. Also clear the + // unseen-completion indicator since this is a brand-new session. + clearTimeout(morphSettleTimerRef.current); + morphSettleTimerRef.current = 0; + setMorphPhase('idle'); + setUnseenCompletion(false); + // Grow-up geometry mirrored in handleRestore; keep both in sync. const shouldGrowUp = windowY !== null && screenBottomY !== null && @@ -626,6 +833,13 @@ function App() { growsUpwardRef.current = shouldGrowUp; setGrowsUpward(shouldGrowUp); maxHeightRef.current = 0; + // A freshly shown overlay has no prior expand to mirror, so reset the + // morph anchor to the top-left default; the next minimize folds the icon + // into the chat's top-left, and a later expand recomputes edge-awareness + // from the icon's dragged position. + setMorphAnchor('tl'); + morphAnchorRef.current = 'tl'; + setExpandReady(false); if (shouldGrowUp && windowX !== null && windowY !== null) { windowPosRef.current = { x: windowX, @@ -682,6 +896,367 @@ function App() { }); }, [cancel]); + /** + * Restores the parked conversation. The OS window is snapped back to full + * chat size (`durationMs:0`, instant + invisible against the transparent + * NSPanel) on the SAME tick the in-page expand transform starts; never + * awaited first, since awaiting would briefly show the 48px mascot inside a + * full-size click-capturing rect. The mascot scales out into the chat card; + * the expand wrapper's `onAnimationComplete` hands back to `idle`, where the + * ResizeObserver resumes content-driven sizing and fine-tunes the height. + * + * Upward-growth geometry is recomputed from the live Tauri window position + * so the restored chat anchors correctly. The recompute is wrapped so a + * geometry-query failure still completes the restore (defaults to no + * grow-up) instead of leaving the overlay stuck minimized. + */ + const handleRestore = useCallback(() => { + // Only ever expand from a settled `minimized` state. A restore request + // from any other phase (a stale Rust `restore` event after rapid + // toggling, or a React/Rust minimized-flag desync) must NOT start an + // expand morph: the `expanding` transform target is identical to the + // `idle` target ({scale:1,opacity:1}), so Framer Motion runs no animation + // and `onAnimationComplete` never fires. That would strand `morphPhase` + // in `expanding` forever, where the mascot animates itself to opacity 0 + // and stays there: the "icon disappears after many toggles" bug. Re-sync + // the Rust minimized flag (so the two sides agree again) and bail. + if (morphPhaseRef.current !== 'minimized') { + void invoke('set_overlay_minimized', { minimized: false }); + return; + } + // Keep the panel key so WKWebView does not throttle the expand + // animation's requestAnimationFrame clock (mirrors handleMinimize). + // Otherwise `onAnimationComplete` can stall and the phase never settles + // back to `idle`. + void getCurrentWindow().setFocus(); + setUnseenCompletion(false); + setMorphPhase('expanding'); + // Hold the chat collapsed (invisible) until the window has moved; see + // expandReady. Combined with the mascot being opacity 0 during expand, + // this means nothing is visible while the native window repositions. + setExpandReady(false); + void invoke('set_overlay_minimized', { minimized: false }); + // The window currently sits where the floating icon is (the user may have + // dragged it anywhere), so we recompute the anchor live from the icon's + // current position. computeExpandTarget picks which corner of the panel is + // pinned to the icon (so it unfolds into open space) and the resulting + // on-screen top-left. That anchor drives the chat's transform-origin and + // the mascot's corner. Height includes CONTAINER_VERTICAL_PADDING so the + // bottom composer is not clipped before settleMorphPhase's re-measure. + // + // Flicker-free ordering (all three layers matter): + // 1. the mascot is opacity 0 the moment morphPhase is 'expanding'; + // 2. the chat is held collapsed (expandReady=false) so it too is + // invisible; + // 3. we then YIELD for a paint (double rAF) so WebKit actually paints + // that all-invisible state before the native window move — otherwise + // WebKit's last painted frame (the still-visible mascot from before + // the click) is what gets displaced to the new window origin for ~1 + // frame, which is the jump. Only after the move do we release the + // chat to grow (expandReady=true); it starts at opacity 0, so even a + // stale post-move frame shows nothing. + const fullWidth = overlayWidthRef.current; + const fullHeight = maxChatHeightRef.current + CONTAINER_VERTICAL_PADDING; + const yieldForPaint = () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + void (async () => { + try { + const win = getCurrentWindow(); + const [pos, scale, currentMon] = await Promise.all([ + win.outerPosition(), + win.scaleFactor(), + currentMonitor(), + ]); + const iconX = pos.x / scale; + const iconY = pos.y / scale; + // Logical bounds of the monitor under the icon. `currentMonitor()` can + // return null transiently during a display-topology change; rather + // than drop edge-awareness entirely (which can let the chat expand off + // an edge), recover by scanning `availableMonitors()` for the display + // actually under the icon. Containment matching can never pick a wrong + // monitor, so the worst case degrades to the no-clamp fallback below. + let monitorRect: { x: number; y: number; w: number; h: number } | null = + currentMon != null + ? { + x: currentMon.position.x / scale, + y: currentMon.position.y / scale, + w: currentMon.size.width / scale, + h: currentMon.size.height / scale, + } + : null; + if (monitorRect == null) { + const all = await availableMonitors(); + const hit = pickMonitorForPoint( + all.map((m) => ({ + x: m.position.x, + y: m.position.y, + w: m.size.width, + h: m.size.height, + })), + { x: pos.x, y: pos.y }, + ); + if (hit != null) { + monitorRect = { + x: hit.x / scale, + y: hit.y / scale, + w: hit.w / scale, + h: hit.h / scale, + }; + } + } + let anchor: MorphAnchor; + let growsUp: boolean; + let frameX: number; + let frameY: number; + if (monitorRect != null) { + const target = computeExpandTarget( + { x: iconX, y: iconY, size: MINIMIZED_WINDOW_SIZE }, + monitorRect, + { w: fullWidth, h: fullHeight }, + ); + anchor = target.anchor; + growsUp = target.growsUpward; + frameX = target.x; + frameY = target.y; + } else { + // No monitor geometry at all: keep the icon's top-left, no clamp. + anchor = 'tl'; + growsUp = false; + frameX = iconX; + frameY = iconY; + } + morphAnchorRef.current = anchor; + growsUpwardRef.current = growsUp; + maxHeightRef.current = 0; + windowPosRef.current = { x: frameX, bottomY: frameY + fullHeight }; + // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- intentional: commit the anchor (and the invisible state) before the imperative native window move below. + flushSync(() => { + setMorphAnchor(anchor); + setGrowsUpward(growsUp); + }); + await yieldForPaint(); + void invoke('set_window_frame', { + x: frameX, + y: frameY, + width: fullWidth, + height: fullHeight, + }); + setExpandReady(true); + /* v8 ignore start -- defensive: geometry query only rejects on a + real Tauri runtime failure, unreachable in the jsdom mock */ + } catch { + morphAnchorRef.current = 'tl'; + growsUpwardRef.current = false; + maxHeightRef.current = 0; + // eslint-disable-next-line @eslint-react/dom-no-flush-sync -- intentional: commit invisible state before the native window resize (see above). + flushSync(() => { + setMorphAnchor('tl'); + setGrowsUpward(false); + }); + await yieldForPaint(); + void invoke('animate_overlay_frame', { + width: fullWidth, + height: fullHeight, + durationMs: 0, + }); + setExpandReady(true); + } + /* v8 ignore stop */ + })(); + }, []); + + /** + * Minimizes the overlay to the floating icon without cancelling generation. + * The OS window stays at full chat size for the entire in-page tween; the + * transparent NSPanel under a chat that has reached opacity 0 shows nothing, + * so the user sees a clean chat → mascot handoff. The chat-card wrapper + * shrinks toward its top-left corner (scale 1 → 0.34) + fades 1 → 0, while + * the mascot springs in from scale 0.3 + opacity 0 to scale 1 + opacity 1 + * with a small overshoot. When the chat's animation settles, + * `settleMorphPhase` + * snaps the OS window to 48x48 (`durationMs:0`, invisible because the + * painted content is already the mascot) and switches `morphPhase` to + * `minimized`, which unmounts the chat subtree and lets clicks pass through + * to the desktop around the 48px square. + */ + const handleMinimize = useCallback(() => { + // Only collapse from the settled chat/ask state. The minimize button stays + // mounted throughout an expand (the chat subtree renders for every phase + // except the settled `minimized` one), so a click mid-expand would jump + // `morphPhase` from 'expanding' to 'collapsing' while that expand's pending + // async window-frame write is still in flight, desyncing window geometry. + // Mirrors handleRestore's `!== 'minimized'` guard. + /* v8 ignore next -- unreachable in the jsdom harness: the framer-motion + mock fires onAnimationComplete on mount, so the morph never lingers in a + non-idle, non-minimized phase for a click to land on. Real in the + browser, where the ~360ms expand tween runs with the button mounted. */ + if (morphPhaseRef.current !== 'idle') return; + growsUpwardRef.current = false; + setGrowsUpward(false); + // Keep the panel key for the duration of the morph. It is a + // nonactivating NSPanel, so WKWebView throttles requestAnimationFrame + // (and with it Framer Motion's tween/spring clock) whenever the panel is + // not the key window — which makes the collapse jump straight to the end + // state and read as a hard cut rather than a morph. Focusing does not + // activate the app (the panel stays nonactivating / Accessory policy); + // it only keeps the animation clock running at 60fps. + void getCurrentWindow().setFocus(); + setMorphPhase('collapsing'); + // Reset so the next expand re-enters the chat-held-collapsed prep state. + setExpandReady(false); + void invoke('set_overlay_minimized', { minimized: true }); + }, []); + + /** + * Settles the in-flight morph to its terminal phase. Driven by BOTH the + * transform wrapper's `onAnimationComplete` (the precise, normal path) and + * a watchdog timer (the fallback when that callback never fires). Reads the + * live `morphPhaseRef` and is idempotent: if the phase already settled, the + * branches below are skipped, so a duplicate call (callback + watchdog both + * firing, or a redirected animation firing twice) is harmless. + * + * - collapsing → the visible content is now just the 48px mascot at the + * window's top-left, so snap the OS window to that 48x48 square (instant, + * invisible) and settle to `minimized`, which unmounts the chat subtree + * and lets clicks pass through to the desktop. + * - expanding → hand control back to the normal chat/ask state so the + * ResizeObserver resumes content-driven sizing. + * + * The `invoke` side effect lives outside the `setMorphPhase` updater (a + * functional updater must be pure / Strict-Mode-double-invoke safe). + */ + const settleMorphPhase = useCallback(() => { + // Cancel any pending watchdog: whichever path (callback or timer) settles + // first wins, and the other becomes a no-op. + clearTimeout(morphSettleTimerRef.current); + morphSettleTimerRef.current = 0; + if (morphPhaseRef.current === 'collapsing') { + // Chat is now at opacity 0 (visually gone). Settle to minimized + // immediately (unmounts the chat subtree), then snap the OS window to + // the 48x48 square at the active anchor's corner of the chat's CURRENT + // frame, so the icon folds back to the exact screen spot the chat + // unfolded from. The mascot is rendered at that same corner, so it stays + // put through the resize. Reading the live frame here (rather than a + // value captured at minimize start) handles a chat that was dragged + // while expanded. The brief full-size-then-snap window is transparent + // and shows only the mascot, so it is invisible. + setMorphPhase('minimized'); + // Re-assert the Rust minimized flag at the collapse SETTLE. + // `handleMinimize` set it at collapse START; if an activation arrived + // mid-collapse, the activator consumed (cleared) the flag and emitted a + // restore that `handleRestore` ignored (phase was still 'collapsing'), + // leaving Rust=not-minimized while we now settle to minimized. Without + // this re-assert the two sides disagree and the NEXT activation takes the + // show/hide path, wiping the parked conversation. Re-asserting keeps them + // in sync so the next activation correctly restores. + void invoke('set_overlay_minimized', { minimized: true }); + const anchor = morphAnchorRef.current; + void (async () => { + try { + const win = getCurrentWindow(); + const [pos, size, scale] = await Promise.all([ + win.outerPosition(), + win.outerSize(), + win.scaleFactor(), + ]); + const target = computeCollapseTarget( + { + x: pos.x / scale, + y: pos.y / scale, + w: size.width / scale, + h: size.height / scale, + }, + anchor, + MINIMIZED_WINDOW_SIZE, + ); + void invoke('set_window_frame', { + x: target.x, + y: target.y, + width: MINIMIZED_WINDOW_SIZE, + height: MINIMIZED_WINDOW_SIZE, + }); + /* v8 ignore start -- defensive: geometry query only rejects on a + real Tauri runtime failure, unreachable in the jsdom mock */ + } catch { + void invoke('animate_overlay_frame', { + width: MINIMIZED_WINDOW_SIZE, + height: MINIMIZED_WINDOW_SIZE, + durationMs: 0, + }); + } + /* v8 ignore stop */ + })(); + return; + } + if (morphPhaseRef.current === 'expanding') { + setMorphPhase('idle'); + // The ResizeObserver does not re-fire on its own after the morph: the + // wrapper's layout box is unchanged across collapse/expand (only its + // CSS transform changed), and the observer callback is a no-op while + // morphing. So the content-driven window height set on restore is never + // corrected and the chat can stay clipped (off by the footer/padding, + // and inconsistent run-to-run depending on incidental reflows). Force + // one fresh observation on the next frame — after the idle render + // commits so the morph guard has cleared — so the window snaps to the + // true content height. + /* v8 ignore start -- ResizeObserver re-observation is a browser-only + correction; the jsdom mock never fires the observer callback, so + there is no observable sizing behavior to assert here. */ + const node = layoutWrapperNodeRef.current; + const observer = observerRef.current; + if (node && observer) { + requestAnimationFrame(() => { + observer.unobserve(node); + observer.observe(node); + }); + } + /* v8 ignore stop */ + } + }, []); + + /** + * Arms the watchdog that force-settles the morph if `onAnimationComplete` + * does not fire within the animation duration plus a grace period. Any + * previously armed timer is cleared first (clearTimeout(0) is a safe no-op + * when none is pending), so only one watchdog is ever outstanding. + */ + const scheduleMorphWatchdog = useCallback( + (durationS: number) => { + clearTimeout(morphSettleTimerRef.current); + morphSettleTimerRef.current = window.setTimeout( + settleMorphPhase, + durationS * 1000 + MORPH_SETTLE_GRACE_MS, + ); + }, + [settleMorphPhase], + ); + + // Arm the watchdog whenever a morph begins. A layout effect (not a passive + // effect) runs during commit, BEFORE the transform wrapper's passive + // `onAnimationComplete` effect, so in the normal case the precise callback + // clears this timer immediately and it never fires. It only fires — and + // force-settles the phase — when that callback is missed (identical + // start/end target, or a stalled rAF clock on the nonactivating panel). + useLayoutEffect(() => { + if (morphPhase === 'collapsing') { + scheduleMorphWatchdog(COLLAPSE_MORPH_DURATION_S); + } else if (morphPhase === 'expanding' && expandReady) { + // Arm the expand watchdog only once `expandReady` flips true — that is + // when the grow tween actually begins. The expand defers the tween + // behind a geometry-query IIFE (outerPosition + scaleFactor + + // currentMonitor + a paint yield); arming at phase entry instead would + // start the clock before the tween, so a slow/cold IPC round-trip could + // let the watchdog force-settle to `idle` mid-tween and snap the chat in. + scheduleMorphWatchdog(MORPH_DURATION_S); + } + }, [morphPhase, expandReady, scheduleMorphWatchdog]); + + // Clear any pending watchdog on unmount so it cannot fire into a torn-down + // tree. + useEffect(() => () => clearTimeout(morphSettleTimerRef.current), []); + /** Ref attached to the chat-mode history dropdown for click-outside detection. */ const historyDropdownRef = useRef(null); @@ -753,6 +1328,19 @@ function App() { } prevHistoryOpenRef.current = isHistoryOpen; + // Detect when a streamed completion finishes while the overlay is minimized. + // Uses the render-body ref pattern (not useEffect) to satisfy the + // @eslint-react/set-state-in-effect lint rule, mirroring prevHistoryOpenRef above. + const prevGeneratingRef = useRef(isGenerating); + // Gate on `isSettledMinimized` (the parked-icon state), not `isMinimized` + // (true throughout the collapse/expand morph too). A stream finishing during + // an in-flight RESTORE would otherwise re-raise the "unseen" indicator the + // restore just cleared, so it would wrongly show again on the next minimize. + if (prevGeneratingRef.current && !isGenerating && isSettledMinimized) { + setUnseenCompletion(true); + } + prevGeneratingRef.current = isGenerating; + /** * 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 @@ -766,6 +1354,9 @@ function App() { previousIsChatModeRef.current = isChatMode; if (!container) return; + // Park during the minimize/restore morph: a height write here would + // reflow the chat tree and fight the GPU transform (blank-sliver bug). + if (isMinimized || isMorphingRef.current) return; if (!growsUpward || isHistoryOpen || !isChatMode || wasChatMode) { return; } @@ -787,7 +1378,10 @@ function App() { return () => cancelAnimationFrame(frameId); /* v8 ignore stop */ - }, [growsUpward, isChatMode, isHistoryOpen]); + // `isMinimized` is in deps so the early-return guard is re-evaluated when + // the minimize/restore morph toggles it. When idle it is always false, so + // the not-minimized chat/ask sizing path is unchanged. + }, [growsUpward, isChatMode, isHistoryOpen, isMinimized]); /** * Observes the dropdown's height while it's open and mutates the morphing @@ -804,6 +1398,9 @@ function App() { /* v8 ignore start -- ResizeObserver + DOM mutations require a real browser */ const container = morphingContainerNodeRef.current; if (!container) return; + // Park during the minimize/restore morph: any min-height/height write + // here reflows the chat tree and fights the GPU transform. + if (isMinimized || isMorphingRef.current) return; // Track the height when we are NOT in chat mode natively. if (!isChatMode) { @@ -840,7 +1437,9 @@ function App() { ro.observe(dropdown); return () => ro.disconnect(); /* v8 ignore stop */ - }, [isChatMode, isHistoryOpen]); + // `isMinimized` in deps re-evaluates the morph guard; no behavior change + // for the not-minimized chat/ask path (always false there). + }, [isChatMode, isHistoryOpen, isMinimized]); /** * Toggles the save state of the current conversation. @@ -2062,6 +2661,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 +2692,7 @@ function App() { unlistenVisibility?.(); unlistenOnboarding?.(); }; - }, [replayEntranceAnimation, requestHideOverlay]); + }, [handleRestore, replayEntranceAnimation, requestHideOverlay]); /** * Combined close handler shared by the keyboard shortcut (Esc/Cmd+W) @@ -2101,9 +2704,10 @@ function App() { requestHideOverlay(); }, [requestHideOverlay]); - /** Hide window on Escape or Cmd+W (macOS) / Ctrl+W. */ + /** Hide window on Escape or Cmd+W (macOS) / Ctrl+W. No-op while minimized. */ useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { + if (isMinimized) return; if (((e.metaKey || e.ctrlKey) && e.key === 'w') || e.key === 'Escape') { e.preventDefault(); handleCloseOverlay(); @@ -2111,15 +2715,21 @@ function App() { }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [handleCloseOverlay]); + }, [handleCloseOverlay, isMinimized]); - /** Programmatic focus when the overlay becomes visible. */ + /** + * Programmatic focus when the overlay is visible and in the normal (idle) + * chat/ask state. Keyed on `morphPhase` as well as `overlayState` so a + * restore — which settles `minimized → idle` without `overlayState` ever + * leaving 'visible' — refocuses the composer. Without the `morphPhase` + * dependency the textarea stayed unfocused after restoring a parked chat. + */ useEffect(() => { - if (overlayState === 'visible') { + if (overlayState === 'visible' && morphPhase === 'idle') { const raf = requestAnimationFrame(() => inputRef.current?.focus()); return () => cancelAnimationFrame(raf); } - }, [overlayState]); + }, [overlayState, morphPhase]); /** * Commits the native window hide after a fixed deadline from the start of @@ -2191,6 +2801,79 @@ function App() { ); }, []); + /** + * In-page morph target for the chat-card transform wrapper. The morph is + * GPU transforms only (scale/x/y/opacity), never width/height/layout, + * so the heavy chat tree never reflows and there is no blank frame. The + * OS window does NOT resize during the animation; it only snap-resizes at + * the endpoints (durationMs:0) when the painted content already matches. + * + * `transformOrigin: top left` keeps the card's top-left corner pinned, so + * scaling alone makes the card visibly shrink toward the corner where the + * 48px window will snap. The exact scale/translate/curve are intentionally + * approximate and meant to be fine-tuned on-device. + * + * `collapsing` and `minimized` share the collapsed target so the wrapper + * does not snap back to identity mid-AnimatePresence-swap. + */ + // The chat-card collapse target: shrink the card down toward its top-left + // corner (transformOrigin: top-left = the corner where the 68px mascot + // lands) while fading out. The scale travel is large (down to ~0.34) so + // the card visibly funnels into the corner rather than just fading in + // place; that travel is what makes the collapse read as a morph. The old + // "tiny readable chat thumbnail" artifact is avoided by fading opacity to + // 0 well before the shrink finishes (see morphTransition: opacity runs at + // ~0.55x the scale duration), so by the time the card is small it is + // already invisible. We do NOT scale all the way to + // `MINIMIZED_WINDOW_SIZE / overlayWidth` (~0.06) because the visible part + // of the shrink ends once opacity hits 0, so any smaller target only + // affects the invisible tail. + // 'collapsing' and 'minimized' share the same target so the wrapper does + // not snap back to identity at the phase boundary; the chat subtree is + // also unmounted during 'minimized' so the visible result at both phases + // is just the portaled mascot. + const COLLAPSED_SCALE = 0.34; + // Tailwind inset classes that pin the floating mascot to the active anchor + // corner of the (portaled, viewport-fixed) window. For the 68px settled + // window all four corners coincide, so this also reads correctly minimized; + // it only matters visually while the full-size window resizes under the + // mascot during the morph, where the anchored corner stays on the icon. + const mascotCornerClass = { + tl: 'top-0 left-0', + tr: 'top-0 right-0', + bl: 'bottom-0 left-0', + br: 'bottom-0 right-0', + }[morphAnchor]; + // The chat card is collapsed (small + invisible) while collapsing, settled + // minimized, AND during the first part of expand (before expandReady) — that + // last case holds it invisible across the native window move so it cannot + // flash at the wrong position. It grows out of the anchor corner only once + // expandReady flips true, after the window has been repositioned. + const chatCollapsed = + morphPhase === 'collapsing' || + morphPhase === 'minimized' || + (morphPhase === 'expanding' && !expandReady); + const morphTransform = chatCollapsed + ? { scale: COLLAPSED_SCALE, opacity: 0 } + : { scale: 1, opacity: 1 }; + // Per-property timing: opacity fades faster than the scale shrinks so the + // card turns transparent before it gets small enough to read as a + // thumbnail. `onAnimationComplete` fires when the longest property (scale) + // settles, so the window snap to 48x48 still happens after the full + // collapse. Collapse uses the longer, more cinematic duration; expand + // keeps the snappier `MORPH_DURATION_S` so only the minimize direction is + // slowed down. + const morphDuration = + morphPhase === 'collapsing' ? COLLAPSE_MORPH_DURATION_S : MORPH_DURATION_S; + const morphTransition = { + scale: { duration: morphDuration, ease: MORPH_EASE }, + opacity: { duration: morphDuration * 0.55, ease: MORPH_EASE }, + }; + // The mascot's own entrance/exit animation is CSS-driven (see the + // `.thuki-mascot*` rules in App.css and the portal JSX below), not Framer + // Motion, so it is painted by the compositor even when the nonactivating + // panel loses key focus and rAF is throttled. + if (onboardingStage !== null) { return ( {shouldRenderOverlay ? ( @@ -2218,101 +2901,151 @@ function App() { animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: -16, scale: 0.98 }} transition={{ type: 'spring', stiffness: 260, damping: 24 }} - className="w-full px-4 py-2 overflow-visible" + className={ + isSettledMinimized + ? 'overflow-visible' + : 'w-full px-4 py-2 overflow-visible' + } > {/* Relative wrapper - positioning context for absolute-positioned dropdowns (history, model picker) so they can float above the - chat without being clipped. */} + chat without being clipped. Also the positioning context for + the morph mascot overlay (absolutely pinned to the top-left, + where the 48px window snaps when settled-minimized). */}
{/* Layout wrapper: provides visual appearance (background, border, border-radius, shadow) and is observed by ResizeObserver so the native window tracks the combined height of the chat area and the footer slot. The inner morphing container clips content during the morph animation; the footer slot sits outside it so it is never - clipped by overflow-hidden when chat is at max height. */} -
- {/* Morphing Container - flex column ensures the input bar + + {isSettledMinimized ? null : ( + + <> + {/* Morphing Container - flex column ensures the input bar always sticks to the bottom. overflow-hidden clips chat content during the morph animation. Visual styling lives on the outer layout wrapper so the footer extends the window without being clipped. */} -
- {/* Chat Messages Area - morphs in when in chat mode. */} - - {isChatMode ? ( - - ) : null} - - - {/* Ask-bar mode model picker drawer - above the input bar. - In chat mode the trigger and drawer move to the header area above. */} - {!isChatMode && ( - - {isModelPickerOpen && ollamaReachable ? ( - - - - ) : null} - - )} - - {/* Ask-bar mode history panel - inline below the input bar. + {/* Chat Messages Area - morphs in when in chat mode. */} + + {isChatMode ? ( + + ) : null} + + + {/* Ask-bar mode model picker drawer - above the input bar. + In chat mode the trigger and drawer move to the header area above. */} + {!isChatMode && ( + + {isModelPickerOpen && ollamaReachable ? ( + + + + ) : null} + + )} + + {/* Ask-bar mode history panel - inline below the input bar. The !isChatMode gate lives OUTSIDE AnimatePresence so that when a conversation is loaded (isChatMode → true) the panel unmounts instantly - no exit animation runs alongside ConversationView @@ -2321,120 +3054,180 @@ function App() { causing two rapid ResizeObserver → setSize() calls (jitter). AnimatePresence is still used for the manual toggle (isHistoryOpen) so the drawer height-animates smoothly open and closed. */} - {!isChatMode && ( - - {isHistoryOpen ? ( - - - - ) : null} - - )} - - {/* Capture error banner: shown when /screen capture fails so + {!isChatMode && ( + + {isHistoryOpen ? ( + + + + ) : null} + + )} + + {/* Capture error banner: shown when /screen capture fails so the user knows why the message was not sent. */} - {captureError && ( -
-

- {captureError} -

-
- )} + {captureError && ( +
+

+ {captureError} +

+
+ )} + + {/* Input Bar - always pinned to the bottom */} + + void invoke('warm_up_model') + } + /> +
- {/* Input Bar - always pinned to the bottom */} - void invoke('warm_up_model')} - /> -
- - {/* Footer slot — outside the morphing container so overflow-hidden + {/* Footer slot — outside the morphing container so overflow-hidden never clips it when chat is at max height. The layout wrapper provides the matching background, so both UpdateFooterBar and TipBar render seamlessly below the conversation area. UpdateFooterBar takes priority and renders in BOTH modes. */} - {showUpdate ? ( - - - void updater.openWindow()} - onLater={() => void updater.snoozeChat(24)} - /> + {showUpdate ? ( + + + void updater.openWindow()} + onLater={() => void updater.snoozeChat(24)} + /> + + + ) : ( + + {isTipVisible && ( + + + + )} + + )} + - - ) : ( - - {isTipVisible && ( - - - - )} - + )} + + + + {/* Morph mascot: portaled to so it is anchored to the + viewport top-left (0,0) regardless of the transformed chat + ancestors. That is exactly where the native 48px window + snaps on collapse, so the icon and the window frame coincide + by construction and the icon can never be clipped/vanish. + + Opacity/scale are driven by CSS (see `.thuki-mascot*` in + App.css), NOT Framer Motion. This is load-bearing: the panel + is a nonactivating NSPanel, and when it is not the key window + WKWebView throttles requestAnimationFrame. A Framer rAF tween + would then never repaint the mascot off its initial opacity:0 + and the icon would vanish (it did, after ~7-8 rapid toggles). + A CSS-declared end-state is painted by the compositor + regardless of the rAF clock, so the settled icon is always + visible. Phase → class: `collapsing` blooms in, `minimized` + is the static visible base, `expanding` fades out. */} + {isMinimized && + createPortal( +
+ +
, + document.body, )} -
{/* Chat-mode model picker dropdown - floating card identical in style to the chat-history dropdown. Anchored absolute right-3 top-10 diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index 06544dad..d7682bf9 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -15,7 +15,11 @@ import { enableChannelCaptureWithResponses, getLastChannel, } from '../testUtils/mocks/tauri'; -import { __mockWindow } from '../testUtils/mocks/tauri-window'; +import { + __mockWindow, + __setWindowGeometry, + __setAvailableMonitors, +} from '../testUtils/mocks/tauri-window'; import { useTips } from '../hooks/useTips'; vi.mock('../hooks/useTips', () => ({ @@ -735,6 +739,88 @@ 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('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 () => {}); @@ -7268,6 +7354,703 @@ describe('App', () => { }); }); + // ─── Minimize / restore (Task 7) ───────────────────────────────────────────── + + describe('minimize / restore', () => { + /** Helper: enter chat mode with one complete turn. */ + async function enterChatMode() { + 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 () => {}); + } + + it('minimizes to the floating icon without cancelling generation', async () => { + await enterChatMode(); + // Chat frame for the collapse-corner snap. No prior expand → anchor is + // the top-left default, so the icon folds to the chat's top-left. + __setWindowGeometry({ + x: 300, + y: 200, + scale: 1, + width: 400, + height: 700, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + // Generation is in flight (channel open, no Done yet) + invoke.mockClear(); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + // The collapse-corner snap queries the frame in settleMorphPhase's async + // path; flush microtasks so the set_window_frame call is observable. + await act(async () => {}); + + // MinimizedIcon should be rendered + expect( + screen.getByRole('button', { name: /restore thuki/i }), + ).toBeInTheDocument(); + + // ConversationView content should be gone + expect(screen.queryByText('hello')).toBeNull(); + + // set_overlay_minimized called with minimized: true + expect(invoke).toHaveBeenCalledWith('set_overlay_minimized', { + minimized: true, + }); + + // At the end of the collapse morph the OS window snaps to the 68px + // square at the anchor's corner of the chat frame. top-left anchor + + // frame (300,200) → icon folds to (300,200). + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 300, + y: 200, + width: 68, + height: 68, + }); + + // notify_overlay_hidden must NOT have been called (no cancel) + expect(invoke).not.toHaveBeenCalledWith('notify_overlay_hidden'); + // cancel_generation must NOT have been called + expect(invoke).not.toHaveBeenCalledWith('cancel_generation'); + }); + + it('strips chrome classes from layout wrapper when minimized', async () => { + await enterChatMode(); + + // Before minimize: layout wrapper has bg-surface-base and shadow-chat + // (isChatMode=true after enterChatMode) + const layoutWrappers = document.querySelectorAll( + '[class*="bg-surface-base"]', + ); + expect(layoutWrappers.length).toBeGreaterThan(0); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + // After minimize: no element with bg-surface-base class on the layout wrapper + const layoutWrappersAfter = document.querySelectorAll( + '[class*="bg-surface-base"]', + ); + expect(layoutWrappersAfter.length).toBe(0); + }); + + it('strips padding from root container when minimized and restores on un-minimize', async () => { + await enterChatMode(); + + // Before minimize: root has px-3 in className + const rootBefore = document.querySelector('.h-screen'); + expect(rootBefore?.className).toContain('px-3'); + expect(rootBefore?.className).toContain('pt-2'); + expect(rootBefore?.className).toContain('pb-6'); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + // After minimize: root must NOT have px-3/pt-2/pb-6 + const rootAfter = document.querySelector('.h-screen'); + expect(rootAfter?.className).not.toContain('px-3'); + expect(rootAfter?.className).not.toContain('pt-2'); + expect(rootAfter?.className).not.toContain('pb-6'); + + // Restore + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + + // After restore: padding is back + const rootRestored = document.querySelector('.h-screen'); + expect(rootRestored?.className).toContain('px-3'); + }); + + it('restores from the icon and clears the unseen indicator', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'world' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Minimize + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + expect( + screen.getByRole('button', { name: /restore thuki/i }), + ).toBeInTheDocument(); + + // Icon sits comfortably inside the monitor (no edge clamping), so the + // window expands anchored at the icon's top-left. + __setWindowGeometry({ + x: 200, + y: 150, + scale: 1, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + invoke.mockClear(); + + // Restore — MinimizedIcon fires onRestore via onPointerUp (not onClick) + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + // Restore geometry query + native frame set fire inside the async IIFE; + // flush microtasks so the invoke calls are observable. + await act(async () => {}); + + // set_overlay_minimized called with minimized: false + expect(invoke).toHaveBeenCalledWith('set_overlay_minimized', { + minimized: false, + }); + + // On restore the OS window is positioned on screen and grown to full + // chat size in one native frame set. With the icon away from any edge, + // the window keeps the icon's top-left (200,150). Height includes + // CONTAINER_VERTICAL_PADDING (48) so the bottom composer is not clipped + // before settleMorphPhase's post-settle re-measure. + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 200, + y: 150, + width: DEFAULT_CONFIG.window.overlayWidth, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, + }); + + // ConversationView shown again with same messages + expect(screen.getByText('hello')).toBeInTheDocument(); + expect(screen.getByText('world')).toBeInTheDocument(); + + // MinimizedIcon should be gone + expect( + screen.queryByRole('button', { name: /restore thuki/i }), + ).toBeNull(); + }); + + it('clamps the expanded window left when the icon is near the right edge', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + // Icon flush against the right edge of a 1440-wide monitor (x 1372 + 68 + // = 1440). + __setWindowGeometry({ + x: 1372, + y: 100, + scale: 1, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + invoke.mockClear(); + + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + + // Anchor top-right: the panel's right edge is pinned to the icon's right + // edge (1372 + 68), so the window unfolds leftward and stays on screen. + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 1372 + 68 - DEFAULT_CONFIG.window.overlayWidth, + y: 100, + width: DEFAULT_CONFIG.window.overlayWidth, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, + }); + }); + + it('anchors the expanded window to the bottom and grows upward when the icon is near the bottom edge', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + // Icon parked near the bottom edge of a 900-tall monitor. + __setWindowGeometry({ + x: 100, + y: 832, + scale: 1, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + invoke.mockClear(); + + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + + // Anchor bottom-left: the panel's bottom edge is pinned to the icon's + // bottom edge (832 + 68), so the top = 900 - fullHeight and the window + // unfolds upward instead of clipping off the bottom. + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 100, + y: 832 + 68 - (DEFAULT_CONFIG.window.maxChatHeight + 48), + width: DEFAULT_CONFIG.window.overlayWidth, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, + }); + // Bottom-anchored → the root container grows upward. + expect(document.querySelector('.h-screen.justify-end')).not.toBeNull(); + }); + + it('folds the icon back to its origin after a right-edge expand', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // First minimize (default top-left anchor). + let minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + await act(async () => {}); + + // Park the icon flush against the right edge, then restore → the expand + // anchors top-right and the chat unfolds left to (1440 - overlayWidth). + __setWindowGeometry({ + x: 1372, + y: 100, + scale: 1, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + + // The chat now occupies this frame (top-right anchored). Point the + // collapse query at it. + const fullHeight = DEFAULT_CONFIG.window.maxChatHeight + 48; + __setWindowGeometry({ + x: 1372 + 68 - DEFAULT_CONFIG.window.overlayWidth, + y: 100, + width: DEFAULT_CONFIG.window.overlayWidth, + height: fullHeight, + scale: 1, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + invoke.mockClear(); + + // Second minimize → collapse reuses the top-right anchor, folding the + // icon back to its original right-edge spot (1372, 100). + minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 1372, + y: 100, + width: 68, + height: 68, + }); + }); + + it('raises the unseen dot when generation finishes while minimized', async () => { + await enterChatMode(); + // Minimize while streaming is in flight + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + expect( + screen.getByRole('button', { name: /restore thuki/i }), + ).toBeInTheDocument(); + // No ready dot yet — still generating + expect(screen.queryByTestId('minimized-ready-dot')).toBeNull(); + + // Complete the stream + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'done!' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Ready dot should appear + expect(screen.getByTestId('minimized-ready-dot')).toBeInTheDocument(); + + // Restore clears it + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + expect(screen.queryByTestId('minimized-ready-dot')).toBeNull(); + }); + + it('recomputes upward growth on restore when near screen bottom', async () => { + // Place window near the screen bottom so shouldGrowUp becomes true. + // maxChatHeight=648, CONTAINER_VERTICAL_PADDING=48: need windowY + 648 + 48 > screenBottom. + // With monitorHeight=900, monitorY=0: windowY=700 → 700+696=1396 > 900 → growsUpward. + __setWindowGeometry({ + x: 100, + y: 700, + scale: 1, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + // Restore — geometry query fires inside the async IIFE + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + + // Wait for the async geometry IIFE to settle + await act(async () => {}); + + // Root container should have justify-end (growsUpward true). + // Use compound selector to target the root div (h-screen) specifically, + // since chat bubbles also contain .justify-end child elements. + const outer = document.querySelector('.h-screen.justify-end'); + expect(outer).not.toBeNull(); + }); + + it('recomputes downward growth on restore when away from screen bottom', async () => { + // windowY=100, monitorHeight=900: 100+648+48=796 < 900 → growsDownward. + __setWindowGeometry({ + x: 100, + y: 100, + scale: 1, + monitorX: 0, + monitorY: 0, + monitorWidth: 1440, + monitorHeight: 900, + }); + + // Show overlay near bottom first so it starts with justify-end + render(); + await act(async () => {}); + await act(async () => { + emitTauriEvent('thuki://visibility', { + state: 'show', + selected_text: null, + window_x: 100, + window_y: 750, + screen_bottom_y: 900, + }); + }); + expect(document.querySelector('.h-screen.justify-end')).not.toBeNull(); + + enableChannelCaptureWithResponses({ + get_model_picker_state: { + active: 'gemma4:e2b', + all: ['gemma4:e2b'], + ollamaReachable: true, + }, + }); + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: 'hi' } }); + }); + act(() => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + await act(async () => {}); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + // After minimize, growsUpward is forced false + expect(document.querySelector('.h-screen.justify-start')).not.toBeNull(); + + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + + // With windowY=100 away from bottom → justify-start on root container + expect(document.querySelector('.h-screen.justify-end')).toBeNull(); + expect(document.querySelector('.h-screen.justify-start')).not.toBeNull(); + }); + + it('recomputes null monitor as no-grow-up on restore', async () => { + __setWindowGeometry({ x: 100, y: 700, scale: 1, monitorNull: true }); + + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + + // null monitor → screenBottomY null → shouldGrowUp false → root uses justify-start + expect(document.querySelector('.h-screen.justify-end')).toBeNull(); + expect(document.querySelector('.h-screen.justify-start')).not.toBeNull(); + }); + + it('recovers edge-awareness from availableMonitors when currentMonitor is null', async () => { + // currentMonitor() is null (transient during a display change), but the + // icon sits near the bottom edge of a monitor that availableMonitors() + // can still report. The fallback finds the containing monitor by + // position, so the expand stays edge-aware and grows upward instead of + // dropping the clamp. + __setWindowGeometry({ x: 100, y: 832, scale: 1, monitorNull: true }); + __setAvailableMonitors([ + { position: { x: 0, y: 0 }, size: { width: 1440, height: 900 } }, + ]); + + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + invoke.mockClear(); + const restoreBtn = screen.getByRole('button', { name: /restore thuki/i }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + + // The recovered monitor (height 900) makes the near-bottom icon (832+68) + // anchor bottom and grow upward, exactly as if currentMonitor had + // returned it. The clamped top = 900 - (maxChatHeight + 48). + expect(invoke).toHaveBeenCalledWith('set_window_frame', { + x: 100, + y: 832 + 68 - (DEFAULT_CONFIG.window.maxChatHeight + 48), + width: DEFAULT_CONFIG.window.overlayWidth, + height: DEFAULT_CONFIG.window.maxChatHeight + 48, + }); + expect(document.querySelector('.h-screen.justify-end')).not.toBeNull(); + + // Restore shared mock state for subsequent tests. + __setAvailableMonitors([]); + __setWindowGeometry({ monitorNull: false }); + }); + + it('ignores Escape and Cmd+W while minimized', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + invoke.mockClear(); + + // Fire Escape while minimized + act(() => { + fireEvent.keyDown(window, { key: 'Escape' }); + }); + // Fire Cmd+W while minimized + act(() => { + fireEvent.keyDown(window, { key: 'w', metaKey: true }); + }); + + await act(async () => {}); + + // MinimizedIcon still shown + expect( + screen.getByRole('button', { name: /restore thuki/i }), + ).toBeInTheDocument(); + // notify_overlay_hidden must NOT have been called + expect(invoke).not.toHaveBeenCalledWith('notify_overlay_hidden'); + }); + + it('handles restore visibility event while minimized without wiping conversation', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Token', data: 'world' }); + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Minimize + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + + // Emit a restore visibility event (hotkey/tray path) + await act(async () => { + emitTauriEvent('thuki://visibility', { state: 'restore' }); + }); + await act(async () => {}); + + // Conversation still intact + expect(screen.getByText('hello')).toBeInTheDocument(); + expect(screen.getByText('world')).toBeInTheDocument(); + // MinimizedIcon gone + expect( + screen.queryByRole('button', { name: /restore thuki/i }), + ).toBeNull(); + }); + + it('keeps the mascot available across many minimize/restore cycles', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + + // Toggle repeatedly. The icon must reappear on every minimize and + // disappear on every restore; it must never get stranded invisible + // (the disappearing-icon bug). + for (let i = 0; i < 5; i++) { + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + expect( + screen.getByRole('button', { name: /restore thuki/i }), + ).toBeInTheDocument(); + + const restoreBtn = screen.getByRole('button', { + name: /restore thuki/i, + }); + await act(async () => { + fireEvent.pointerDown(restoreBtn, { clientX: 0, clientY: 0 }); + fireEvent.pointerUp(restoreBtn); + }); + await act(async () => {}); + expect( + screen.queryByRole('button', { name: /restore thuki/i }), + ).toBeNull(); + // Chat is back so the next iteration can minimize again. + expect(screen.getByText('hello')).toBeInTheDocument(); + } + }); + + it('ignores a restore request while not minimized and re-syncs the flag', async () => { + await enterChatMode(); + act(() => { + getLastChannel()?.simulateMessage({ type: 'Done' }); + }); + await act(async () => {}); + invoke.mockClear(); + + // A stray restore event while idle must NOT start an expand morph + // (which would strand the state machine in 'expanding'); it only + // re-syncs the Rust minimized flag. + await act(async () => { + emitTauriEvent('thuki://visibility', { state: 'restore' }); + }); + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('set_overlay_minimized', { + minimized: false, + }); + // Never minimized: no mascot, chat still visible. + expect( + screen.queryByRole('button', { name: /restore thuki/i }), + ).toBeNull(); + expect(screen.getByText('hello')).toBeInTheDocument(); + + // The machine is not stranded: a subsequent minimize still works. + const minimizeBtn = screen.getByRole('button', { name: /minimize/i }); + await act(async () => { + fireEvent.click(minimizeBtn); + }); + expect( + screen.getByRole('button', { name: /restore thuki/i }), + ).toBeInTheDocument(); + }); + }); + describe('text base CSS variable', () => { it('writes window.textBasePx to --thuki-text-base on on mount', async () => { document.documentElement.style.removeProperty('--thuki-text-base'); diff --git a/src/components/MinimizedIcon.tsx b/src/components/MinimizedIcon.tsx new file mode 100644 index 00000000..376a7d9b --- /dev/null +++ b/src/components/MinimizedIcon.tsx @@ -0,0 +1,96 @@ +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: the mascot does a soft elastic "jelly wobble" and a warm jewel + * breathes at its bottom-right corner while a response streams. + * - Done: the mascot does one elastic pop and the jewel holds a steady amber + * when a reply landed while minimized and has not been seen. + */ +export const MinimizedIcon = memo(function MinimizedIcon({ + isWorking, + hasUnseen, + onRestore, +}: MinimizedIconProps) { + const downPosRef = useRef<{ x: number; y: number } | null>(null); + const draggedRef = useRef(false); + + // Wobble while working; one-shot pop the moment a reply lands; otherwise still. + const logoMotion = isWorking + ? ' minimized-logo-working' + : hasUnseen + ? ' minimized-logo-done' + : ''; + + return ( + + ); +}); diff --git a/src/components/TipBar.tsx b/src/components/TipBar.tsx index 734f50f8..0bda09fb 100644 --- a/src/components/TipBar.tsx +++ b/src/components/TipBar.tsx @@ -28,13 +28,28 @@ interface TipBarProps { tip: Tip; tipKey: number; suppressed?: boolean; + /** + * Render the tip as final, static text instead of replaying the typewriter. + * Set when this tipKey was already animated in a prior mount (e.g. before a + * minimize), so re-expanding shows the settled tip rather than re-typing it. + */ + skipAnimation?: boolean; } -export function TipBar({ tip, tipKey, suppressed }: TipBarProps) { +export function TipBar({ + tip, + tipKey, + suppressed, + skipAnimation, +}: TipBarProps) { const text = tipText(tip); const url = tipUrl(tip); const spanRef = useRef(null); const timersRef = useRef[]>([]); + // Read at effect-run time (not via deps) so a re-render that flips this flag + // mid-animation cannot re-trigger the effect and wipe an in-progress type. + const skipAnimationRef = useRef(skipAnimation); + skipAnimationRef.current = skipAnimation; useEffect(() => { const span = spanRef.current; @@ -45,6 +60,13 @@ export function TipBar({ tip, tipKey, suppressed }: TipBarProps) { timersRef.current.forEach(clearTimeout); timersRef.current = []; + if (skipAnimationRef.current) { + // Already typed on a previous mount: show the settled text at once. + span.textContent = text; + span.style.color = '#8a8a8e'; + return; + } + const addTimer = (fn: () => void, ms: number) => { // eslint-disable-next-line @eslint-react/web-api-no-leaked-timeout const id = setTimeout(fn, ms); diff --git a/src/components/WindowControls.tsx b/src/components/WindowControls.tsx index 4cff3671..acc7b179 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. @@ -167,7 +173,11 @@ export const WindowControls = memo(function WindowControls({ return (
-
+ {/* gap-2 (8px) between the 12px dots → 20px center-to-center, matching + the macOS traffic-light spacing. Spacing lives on the container so it + stays uniform; per-dot `ml-*` margins fought the hit-area negative + margins and inflated the first gap. */} +
{/* Close button - reveals × icon on group hover. Padding enlarges the hit area to ~24×24px without changing the 12×12px visual dot; negative margin preserves flex spacing. */} @@ -202,16 +212,45 @@ export const WindowControls = memo(function WindowControls({
- {/* Minimize - decorative only */} -