diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1881fbed7..14907bee9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -48,9 +48,11 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; -use tauri::{Emitter, Manager, RunEvent}; +use tauri::{Emitter, Listener, Manager, RunEvent}; use tauri_plugin_window_state::StateFlags; +const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; + #[tauri::command] fn perform_sidebar_default_haptic() { #[cfg(target_os = "macos")] @@ -152,6 +154,70 @@ fn toggle_maximize(window: &tauri::Window) { } } +fn reveal_initial_window(window: &tauri::Window) { + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to reveal main window: {error}"); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window: {error}"); + } +} + +#[cfg(target_os = "macos")] +fn set_initial_window_backing(window: &tauri::Window) { + // The window remains transparent at runtime for vibrancy. Use an opaque + // native backing only across the first visible frames so the previous app + // cannot show through before WebKit has submitted its first surface. + if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { + eprintln!("buzz-desktop: failed to set initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +async fn clear_initial_window_backing(window: &tauri::Window) { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if let Err(error) = window.set_background_color(None) { + eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) { + const MAX_POLLS: usize = 120; + const REQUIRED_STABLE_POLLS: usize = 4; + + let mut previous_bounds = None; + let mut stable_polls = 0; + + for _ in 0..MAX_POLLS { + // Accept whatever geometry the window-state plugin restores — maximized + // or a normal saved size. macOS applies the restore asynchronously, so + // we only need consecutive identical outer bounds to know it settled. + // Gating on `is_maximized()` here would leave `bounds` permanently + // `None` for restored non-maximized windows and stall the reveal until + // the poll timeout. + let bounds = match (window.outer_position(), window.outer_size()) { + (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), + _ => None, + }; + + if bounds.is_some() && bounds == previous_bounds { + stable_polls += 1; + if stable_polls >= REQUIRED_STABLE_POLLS { + return; + } + } else { + stable_polls = 0; + } + previous_bounds = bounds; + + tokio::time::sleep(std::time::Duration::from_millis(16)).await; + } + + eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() @@ -172,11 +238,61 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin( tauri_plugin_window_state::Builder::default() - // Visibility is excluded: the window starts hidden and the - // frontend shows it once ready. + // Visibility is excluded: the native reveal plugin below + // shows the window after saved geometry has been restored. .with_state_flags(StateFlags::all() & !StateFlags::VISIBLE) .build(), ) + .plugin( + tauri::plugin::Builder::<_, ()>::new("initial-window-reveal") + .on_webview_ready(|webview| { + if webview.label() != "main" { + return; + } + + // macOS applies the restored geometry asynchronously. Wait + // for several identical outer bounds and for React to + // commit the startup surface before revealing it. + let window = webview.window(); + + #[cfg(target_os = "macos")] + { + set_initial_window_backing(&window); + + let (initial_render_tx, initial_render_rx) = tokio::sync::oneshot::channel(); + window + .app_handle() + .once(INITIAL_RENDER_READY_EVENT, move |_| { + let _ = initial_render_tx.send(()); + }); + + tauri::async_runtime::spawn(async move { + wait_for_stable_initial_window_geometry(&window).await; + + if tokio::time::timeout( + std::time::Duration::from_secs(5), + initial_render_rx, + ) + .await + .is_err() + { + eprintln!( + "buzz-desktop: initial render did not commit before reveal timeout" + ); + } + + reveal_initial_window(&window); + clear_initial_window_backing(&window).await; + }); + } + + #[cfg(not(target_os = "macos"))] + { + reveal_initial_window(&window); + } + }) + .build(), + ) .plugin(tauri_plugin_websocket::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 5e3cb46be..ca34c6241 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -20,15 +20,11 @@ "width": 800, "height": 600, "maximized": true, - "visible": true, + "visible": false, "transparent": true, "titleBarStyle": "Overlay", "hiddenTitle": true, "dragDropEnabled": false, - "trafficLightPosition": { - "x": 16, - "y": 24 - }, "backgroundThrottling": "disabled", "minWidth": 800, "minHeight": 500 diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index e978edf92..26bdb2ffb 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,4 +1,5 @@ -import { getCurrentWindow } from "@tauri-apps/api/window"; +import { isTauri } from "@tauri-apps/api/core"; +import { emit } from "@tauri-apps/api/event"; import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; import { Hexagon } from "lucide-react"; @@ -39,16 +40,26 @@ import { StepProgress } from "@/shared/ui/step-progress"; const LOADING_TEXT = "Setting up your workspace..."; // Minimum time the cold-boot splash stays on screen. A real boot resolves the -// workspace in well under 100ms, and the hidden Tauri window (visible:false -// until getCurrentWindow().show()) takes longer than that to put its first -// frame on screen — without a hold, the bee is unmounted before it is ever -// visible. The hold runs as an overlay above the already-mounted app, so +// workspace in well under 100ms, and the native window setup plus first paint +// can take longer than that — without a hold, the bee is unmounted before it is +// ever visible. The hold runs as an overlay above the already-mounted app, so // time-to-interactive is unchanged; only the reveal waits. const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200; const BOOT_SPLASH_FADE_MS = 200; +const INITIAL_RENDER_READY_EVENT = "initial-render-ready"; type BootSplashPhase = "holding" | "fading" | "done"; +function useInitialRenderReady() { + useLayoutEffect(() => { + if (!isTauri()) { + return; + } + + void emit(INITIAL_RENDER_READY_EVENT); + }, []); +} + // E2E runs skip the hold (it would slow every spec's boot and block pointer // actionability); a spec can opt back in via __BUZZ_E2E__.bootSplashHoldMs. function bootSplashHoldMs(): number { @@ -323,10 +334,7 @@ export function App() { // Mounted at the root so Cmd/Ctrl+R reloads in every app state, // including the loading and first-run setup screens below. useReloadShortcut(); - - useLayoutEffect(() => { - void getCurrentWindow().show(); - }, []); + useInitialRenderReady(); const [sharedIdentity, setSharedIdentity] = useState(null); useEffect(() => { diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 0f0020534..793662c5f 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -65,11 +65,11 @@ export function AppTopChrome({ }: AppTopChromeProps) { const topChromeRef = React.useRef(null); const isFullscreen = useIsFullscreen(); - // On macOS the traffic-light buttons overlay the chrome (see - // `trafficLightPosition` in `tauri.conf.json`), so the nav row clears their - // x-position. When the workspace rail is present it already occupies the far - // left, so the nav row only needs to clear the lights past the rail edge - // rather than the full offset. In fullscreen those buttons hide. + // On macOS the native traffic-light buttons overlay the chrome, so the nav + // row clears their x-position. When the workspace rail is present it already + // occupies the far left, so the nav row only needs to clear the lights past + // the rail edge rather than the full offset. In fullscreen those buttons + // hide. // // Fixed px on purpose: the native traffic lights do not scale with the app's // Cmd +/- text zoom (rem), so rem-based clearance shrinks under them when @@ -80,7 +80,7 @@ export function AppTopChrome({ ? "pl-[32px]" : "pl-[80px]" : "pl-3"; - const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null; + const navRowAlignmentClass = macChrome ? "-translate-y-[4px]" : null; React.useEffect(() => { const topChrome = topChromeRef.current; diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index 0f28bf12f..a15bcfa43 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -246,7 +246,7 @@ export function MessageReactions({ return (
{ + const body = await row.locator(".message-markdown").first().boundingBox(); + const reactions = await row.getByTestId("message-reactions").boundingBox(); + if (!body || !reactions) { + throw new Error("message body or reactions are not laid out"); + } + return Math.round(reactions.y - (body.y + body.height)); +} + /** Click a quick-reaction tray button by emoji (hover → click tray button). */ async function addQuickReaction( row: import("@playwright/test").Locator, @@ -93,11 +105,13 @@ test("reaction pills render left-to-right in the order reactions were added", as const pills = await getPillOrder(row); expect(pills).toEqual(["👍", "❤️", "😂"]); + await expect.poll(() => getBodyToReactionGap(row)).toBe(6); // Screenshot for PR visual proof. const screenshotDir = path.resolve("test-results/reaction-order-screenshots"); fs.mkdirSync(screenshotDir, { recursive: true }); const reactionBar = row.getByTestId("message-reactions"); + await waitForAnimations(page); await reactionBar.screenshot({ path: path.join(screenshotDir, "reaction-order-chronological.png"), }); @@ -154,6 +168,7 @@ test("a later emoji that accrues more reactors stays to the right of an earlier const screenshotDir = path.resolve("test-results/reaction-order-screenshots"); fs.mkdirSync(screenshotDir, { recursive: true }); const reactionBar = row.getByTestId("message-reactions"); + await waitForAnimations(page); await reactionBar.screenshot({ path: path.join(screenshotDir, "reaction-order-count-stable.png"), });