Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 119 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -152,6 +154,70 @@ fn toggle_maximize(window: &tauri::Window) {
}
}

fn reveal_initial_window<R: tauri::Runtime>(window: &tauri::Window<R>) {
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<R: tauri::Runtime>(window: &tauri::Window<R>) {
// 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<R: tauri::Runtime>(window: &tauri::Window<R>) {
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<R: tauri::Runtime>(window: &tauri::Window<R>) {
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()
Expand All @@ -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());
Expand Down
6 changes: 1 addition & 5 deletions desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<boolean | null>(null);
useEffect(() => {
Expand Down
12 changes: 6 additions & 6 deletions desktop/src/app/AppTopChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ export function AppTopChrome({
}: AppTopChromeProps) {
const topChromeRef = React.useRef<HTMLDivElement>(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
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/ui/MessageReactions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export function MessageReactions({
return (
<div
className={cn(
"group/reactions mt-1.5 flex flex-wrap items-center gap-1.5 pt-1",
"group/reactions mt-1.5 flex flex-wrap items-center gap-1.5",
className,
)}
data-testid="message-reactions"
Expand Down
15 changes: 15 additions & 0 deletions desktop/tests/e2e/reaction-order.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as fs from "node:fs";
import * as path from "node:path";

import { installMockBridge } from "../helpers/bridge";
import { waitForAnimations } from "../helpers/animations";

// Reaction ordering end-to-end guard.
//
Expand Down Expand Up @@ -52,6 +53,17 @@ async function getPillOrder(
return labels;
}

async function getBodyToReactionGap(
row: import("@playwright/test").Locator,
): Promise<number> {
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,
Expand Down Expand Up @@ -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"),
});
Expand Down Expand Up @@ -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"),
});
Expand Down
Loading