From 62b24eeff0a7c2691e4d3b61f67e69933ed5d808 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 15:52:07 -0400 Subject: [PATCH 1/8] feat(desktop): add configurable transport reconnect hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build-time env var BUZZ_BUILD_RELAY_RECONNECT_CMD carries a JSON config blob validated into a typed ReconnectHookConfig struct at compile time. If unset (OSS builds), the Tauri command is a pure no-op. If set, the frontend invokes it before relay preconnect() to run transport-layer recovery steps (e.g. warp-cli connect + access-reauth) and poll for readiness. All subprocess steps are non-fatal — timeout or failure falls through to the existing relay reconnect. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/Cargo.toml | 2 + desktop/src-tauri/build.rs | 13 ++++ desktop/src-tauri/src/commands/mod.rs | 2 + .../src/commands/reconnect_hook_config.rs | 26 +++++++ .../src-tauri/src/commands/relay_reconnect.rs | 69 +++++++++++++++++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src/shared/api/useReconnectRelay.ts | 9 +++ 7 files changed, 122 insertions(+) create mode 100644 desktop/src-tauri/src/commands/reconnect_hook_config.rs create mode 100644 desktop/src-tauri/src/commands/relay_reconnect.rs diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index a94ca6d90d..c485e80212 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -21,6 +21,8 @@ default = [] mesh-llm = ["dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime"] [build-dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" tauri-build = { version = "2", features = [] } [target.'cfg(unix)'.dependencies] diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index cb5fcb48dc..d2893b5974 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,3 +1,7 @@ +// Shared schema, included from the same source the runtime command parses with, +// so the build-time validation below and the runtime parse cannot drift. +include!("src/commands/reconnect_hook_config.rs"); + fn main() { println!("cargo:rerun-if-env-changed=BUZZ_RELAY_URL"); println!("cargo:rerun-if-env-changed=BUZZ_RELAY_HTTP"); @@ -5,6 +9,7 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_UPDATER_ENDPOINT"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_HOST"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_DATABRICKS_MODEL"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { @@ -23,6 +28,14 @@ fn main() { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_DATABRICKS_MODEL={model}"); } + if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") { + let parsed: serde_json::Value = serde_json::from_str(&val) + .unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD is not valid JSON: {e}")); + serde_json::from_value::(parsed) + .unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}")); + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); + } + let updater_public_key = std::env::var("BUZZ_UPDATER_PUBLIC_KEY") .ok() .map(|value| value.trim().to_string()) diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 559577bf7b..a8bce10814 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -22,6 +22,7 @@ mod personas; mod prevent_sleep; mod profile; mod relay_members; +mod relay_reconnect; mod social; mod teams; mod workflows; @@ -49,6 +50,7 @@ pub use personas::*; pub use prevent_sleep::*; pub use profile::*; pub use relay_members::*; +pub use relay_reconnect::*; pub use social::*; pub use teams::*; pub use workflows::*; diff --git a/desktop/src-tauri/src/commands/reconnect_hook_config.rs b/desktop/src-tauri/src/commands/reconnect_hook_config.rs new file mode 100644 index 0000000000..b516adfb89 --- /dev/null +++ b/desktop/src-tauri/src/commands/reconnect_hook_config.rs @@ -0,0 +1,26 @@ +// Canonical `ReconnectHookConfig` definition, `include!`d into BOTH +// `build.rs` (compile-time validation) and `commands/relay_reconnect.rs` +// (runtime deserialization). build scripts cannot import from the crate, so +// sharing the source via `include!` is what guarantees the build-time check +// and the runtime parse use an identical schema — zero drift surface. +// +// Keep this file dependency-free: only `serde` derives, no crate-internal +// imports. Both consumers have `serde` available. + +/// Typed config carried by the build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD`. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] // build.rs only constructs it for validation +pub struct ReconnectHookConfig { + /// Ordered commands to run. Each inner vec is [program, arg1, arg2, ...]. + pub steps: Vec>, + /// Command whose stdout is polled for readiness after steps complete. + pub ready_probe: Vec, + /// RAW SUBSTRING matched in the probe's stdout to consider the transport + /// ready — NOT a parsed field. Pick a token unlikely to collide with other + /// substrings in the probe output (e.g. `warp-cli -j status` JSON, where + /// "Connected" can collide with "Connecting"/"Disconnected"). + pub ready_match: String, + /// Maximum milliseconds to poll before giving up (non-fatal timeout). + pub timeout_ms: u64, +} diff --git a/desktop/src-tauri/src/commands/relay_reconnect.rs b/desktop/src-tauri/src/commands/relay_reconnect.rs new file mode 100644 index 0000000000..9017122f01 --- /dev/null +++ b/desktop/src-tauri/src/commands/relay_reconnect.rs @@ -0,0 +1,69 @@ +//! Configurable transport-reconnect hook. +//! +//! When the build-time env var `BUZZ_BUILD_RELAY_RECONNECT_CMD` is set (internal +//! builds), this command runs an ordered sequence of subprocess steps followed by +//! a readiness poll before the frontend fires the relay WebSocket reconnect. +//! +//! OSS builds (env var unset) get a pure no-op — zero WARP knowledge compiled in. + +// Single source of truth for the config schema, shared with build.rs via +// `include!`. See reconnect_hook_config.rs for why this is shared, not a module. +include!("reconnect_hook_config.rs"); + +#[tauri::command] +pub async fn relay_reconnect_hook() -> Result<(), String> { + let Some(config_str) = option_env!("BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD") else { + return Ok(()); // OSS build — no-op + }; + + // Safe: build.rs already validated this parses correctly against the same schema. + let config: ReconnectHookConfig = serde_json::from_str(config_str) + .map_err(|e| format!("reconnect hook config parse error: {e}"))?; + + // spawn_blocking because the desktop Tauri crate doesn't enable tokio's + // `process` feature; std::process::Command + thread::sleep are synchronous + // and must not run on an async worker. The whole hook is non-fatal — a join + // failure logs and returns Ok so the frontend's relay reconnect still fires. + if let Err(e) = tokio::task::spawn_blocking(move || run_hook(&config)).await { + eprintln!("[relay_reconnect_hook] task join failed: {e}"); + } + + Ok(()) +} + +/// Runs the configured steps then polls the readiness probe. Every failure is +/// logged and swallowed — the caller treats the hook as best-effort. +fn run_hook(config: &ReconnectHookConfig) { + // Run each step sequentially (fixed-argv, no shell). + for step in &config.steps { + if step.is_empty() { + continue; + } + match std::process::Command::new(&step[0]).args(&step[1..]).output() { + Ok(o) if !o.status.success() => { + eprintln!("[relay_reconnect_hook] step {:?} exited {}", step, o.status); + } + Err(e) => { + eprintln!("[relay_reconnect_hook] failed to spawn {:?}: {e}", step); + } + _ => {} + } + } + + // Poll readiness probe until match or timeout. + if config.ready_probe.is_empty() { + return; + } + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(config.timeout_ms); + while std::time::Instant::now() < deadline { + if let Ok(output) = std::process::Command::new(&config.ready_probe[0]) + .args(&config.ready_probe[1..]) + .output() + { + if String::from_utf8_lossy(&output.stdout).contains(&config.ready_match) { + return; + } + } + std::thread::sleep(std::time::Duration::from_secs(1)); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 10a8f98a0f..266733abe6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -838,6 +838,7 @@ pub fn run() { get_active_workspace, set_prevent_sleep_active, get_agent_memory, + relay_reconnect_hook, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src/shared/api/useReconnectRelay.ts b/desktop/src/shared/api/useReconnectRelay.ts index d231bb30d2..8682edf3b6 100644 --- a/desktop/src/shared/api/useReconnectRelay.ts +++ b/desktop/src/shared/api/useReconnectRelay.ts @@ -11,6 +11,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; import { toast } from "sonner"; import { relayClient } from "@/shared/api/relayClient"; @@ -31,6 +32,14 @@ export function useReconnectRelay(): { inFlightRef.current = true; setIsPending(true); try { + // Run transport-layer reconnect hook (e.g. WARP VPN re-auth for internal builds). + // No-op in OSS builds. Non-fatal — transport failure shouldn't block relay reconnect. + try { + await invoke("relay_reconnect_hook"); + } catch (err) { + console.warn("[useReconnectRelay] reconnect hook failed:", err); + } + await relayClient.preconnect(); await queryClient.invalidateQueries(); // No success toast — the banner auto-hides once the connection state From ab75081e7686573215382b689b10ccae2be273f9 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 16:58:59 -0400 Subject: [PATCH 2/8] style: apply cargo fmt to relay reconnect hook Rust Lint CI (cargo fmt --check) flagged the from_value closure in build.rs and the Command builder chain in relay_reconnect.rs. Let cargo fmt produce the canonical reflow so CI matches byte-for-byte. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/build.rs | 5 +++-- desktop/src-tauri/src/commands/relay_reconnect.rs | 5 ++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index d2893b5974..5ffec1a622 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -31,8 +31,9 @@ fn main() { if let Ok(val) = std::env::var("BUZZ_BUILD_RELAY_RECONNECT_CMD") { let parsed: serde_json::Value = serde_json::from_str(&val) .unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD is not valid JSON: {e}")); - serde_json::from_value::(parsed) - .unwrap_or_else(|e| panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}")); + serde_json::from_value::(parsed).unwrap_or_else(|e| { + panic!("BUZZ_BUILD_RELAY_RECONNECT_CMD doesn't match ReconnectHookConfig: {e}") + }); println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_RECONNECT_CMD={val}"); } diff --git a/desktop/src-tauri/src/commands/relay_reconnect.rs b/desktop/src-tauri/src/commands/relay_reconnect.rs index 9017122f01..8768b093fc 100644 --- a/desktop/src-tauri/src/commands/relay_reconnect.rs +++ b/desktop/src-tauri/src/commands/relay_reconnect.rs @@ -39,7 +39,10 @@ fn run_hook(config: &ReconnectHookConfig) { if step.is_empty() { continue; } - match std::process::Command::new(&step[0]).args(&step[1..]).output() { + match std::process::Command::new(&step[0]) + .args(&step[1..]) + .output() + { Ok(o) if !o.status.success() => { eprintln!("[relay_reconnect_hook] step {:?} exited {}", step, o.status); } From d001069e1f5ebb50b9043e2235c3ef8117052152 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 17:13:33 -0400 Subject: [PATCH 3/8] fix: cap relay reconnect subprocesses with a wall-clock timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std::process::Command::output() blocks until the child exits, so a wedged warp-cli (the degraded-transport case this hook targets) would hang the spawn_blocking task forever — the frontend invoke never resolves, the Reconnect button spins, and a blocking-pool thread is pinned. timeout_ms only gated the poll loop between probe attempts, not any single subprocess. Route the step loop and the probe through a spawn + try_wait + kill-on-deadline helper modeled on media_transcode.rs run_ffmpeg_with_timeout, so each child is killed and reaped at the cap. Steps reuse timeout_ms per step; the probe phase stays a single timeout_ms ceiling by capping each attempt at the remaining budget. No config-schema change. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src-tauri/src/commands/relay_reconnect.rs | 79 ++++++++++++++++--- 1 file changed, 67 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/commands/relay_reconnect.rs b/desktop/src-tauri/src/commands/relay_reconnect.rs index 8768b093fc..f9593541aa 100644 --- a/desktop/src-tauri/src/commands/relay_reconnect.rs +++ b/desktop/src-tauri/src/commands/relay_reconnect.rs @@ -31,23 +31,74 @@ pub async fn relay_reconnect_hook() -> Result<(), String> { Ok(()) } +/// Run a fixed-argv command (`argv[0]` + `argv[1..]`) with a wall-clock cap. +/// +/// `std::process::Command::output()` blocks until the child exits — a wedged +/// `warp-cli` (the exact degraded-transport case this hook targets) would hang +/// forever, pinning the blocking-pool thread and leaving the frontend `invoke` +/// unresolved. So we spawn, poll `try_wait()` every 500ms, and kill+reap on the +/// deadline. Modeled on `media_transcode.rs` `run_ffmpeg_with_timeout`. +/// +/// stdout/stderr are piped and read only after the child exits. The pipe-buffer +/// deadlock noted there (a child blocking on write() when the ~64 KiB OS pipe +/// fills) does not apply: `warp-cli` emits a few lines, far below the buffer. +fn run_with_timeout( + argv: &[String], + timeout: std::time::Duration, +) -> Result { + let mut child = std::process::Command::new(&argv[0]) + .args(&argv[1..]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("spawn failed: {e}"))?; + + let deadline = std::time::Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stdout = child.stdout.take().map_or_else(Vec::new, |mut s| { + let mut buf = Vec::new(); + let _ = std::io::Read::read_to_end(&mut s, &mut buf); + buf + }); + return Ok(std::process::Output { + status, + stdout, + stderr: Vec::new(), + }); + } + Ok(None) => { + if std::time::Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); // reap zombie + return Err(format!("timed out after {}ms", timeout.as_millis())); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + Err(e) => return Err(format!("wait failed: {e}")), + } + } +} + /// Runs the configured steps then polls the readiness probe. Every failure is /// logged and swallowed — the caller treats the hook as best-effort. fn run_hook(config: &ReconnectHookConfig) { - // Run each step sequentially (fixed-argv, no shell). + let cap = std::time::Duration::from_millis(config.timeout_ms); + + // Run each step sequentially (fixed-argv, no shell). Each step is capped at + // `timeout_ms` so a hung child can't stall the whole hook — on cap it's + // killed, logged, and we move on, same non-fatal contract as a spawn error. for step in &config.steps { if step.is_empty() { continue; } - match std::process::Command::new(&step[0]) - .args(&step[1..]) - .output() - { + match run_with_timeout(step, cap) { Ok(o) if !o.status.success() => { eprintln!("[relay_reconnect_hook] step {:?} exited {}", step, o.status); } Err(e) => { - eprintln!("[relay_reconnect_hook] failed to spawn {:?}: {e}", step); + eprintln!("[relay_reconnect_hook] step {:?} failed: {e}", step); } _ => {} } @@ -57,15 +108,19 @@ fn run_hook(config: &ReconnectHookConfig) { if config.ready_probe.is_empty() { return; } - let deadline = std::time::Instant::now() + std::time::Duration::from_millis(config.timeout_ms); + let deadline = std::time::Instant::now() + cap; while std::time::Instant::now() < deadline { - if let Ok(output) = std::process::Command::new(&config.ready_probe[0]) - .args(&config.ready_probe[1..]) - .output() - { - if String::from_utf8_lossy(&output.stdout).contains(&config.ready_match) { + // Cap each probe at the time left to the deadline, so one wedged probe + // can't push the total probe phase past `timeout_ms`. + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + match run_with_timeout(&config.ready_probe, remaining) { + Ok(output) if String::from_utf8_lossy(&output.stdout).contains(&config.ready_match) => { return; } + Err(e) => { + eprintln!("[relay_reconnect_hook] probe failed: {e}"); + } + _ => {} } std::thread::sleep(std::time::Duration::from_secs(1)); } From 01680d9d315907091869dd13b9ca4ab859ce7f44 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 17:43:01 -0400 Subject: [PATCH 4/8] =?UTF-8?q?chore:=20address=20review=20MINORs=20?= =?UTF-8?q?=E2=80=94=20null=20stderr=20pipe,=20doc=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stdio::null() replaces Stdio::piped() for stderr since the helper never reads it — removes a latent deadlock if a future command floods the OS pipe buffer. Doc comment on timeout_ms now accurately describes its broader role as a per-process wall-clock cap. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/reconnect_hook_config.rs | 3 ++- desktop/src-tauri/src/commands/relay_reconnect.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/desktop/src-tauri/src/commands/reconnect_hook_config.rs b/desktop/src-tauri/src/commands/reconnect_hook_config.rs index b516adfb89..e02aafcaed 100644 --- a/desktop/src-tauri/src/commands/reconnect_hook_config.rs +++ b/desktop/src-tauri/src/commands/reconnect_hook_config.rs @@ -21,6 +21,7 @@ pub struct ReconnectHookConfig { /// substrings in the probe output (e.g. `warp-cli -j status` JSON, where /// "Connected" can collide with "Connecting"/"Disconnected"). pub ready_match: String, - /// Maximum milliseconds to poll before giving up (non-fatal timeout). + /// Per-process wall-clock cap (ms) for each step and the readiness probe + /// phase; non-fatal on expiry — the generic relay reconnect still fires. pub timeout_ms: u64, } diff --git a/desktop/src-tauri/src/commands/relay_reconnect.rs b/desktop/src-tauri/src/commands/relay_reconnect.rs index f9593541aa..06f8001c3f 100644 --- a/desktop/src-tauri/src/commands/relay_reconnect.rs +++ b/desktop/src-tauri/src/commands/relay_reconnect.rs @@ -49,7 +49,7 @@ fn run_with_timeout( let mut child = std::process::Command::new(&argv[0]) .args(&argv[1..]) .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) .spawn() .map_err(|e| format!("spawn failed: {e}"))?; From 1dfccc4bd3869788657464995a3d9a3fd55bef9f Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 19:42:12 -0400 Subject: [PATCH 5/8] fix(sidebar): flip reconnect prompt on live relay degradation The sidebar reconnect prompt only appeared once channelsQuery errored, which lags 60-120s behind a dropped socket (React Query staleTime + refetchInterval 60s plus one retry). OR-in the live, debounced connection state already powering ConnectionBanner so the prompt surfaces within ~2s of degradation, regardless of query error timing. Cached channels stay visible alongside the prompt. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../src/features/sidebar/ui/AppSidebar.tsx | 60 ++++++++++++------- desktop/tests/e2e/relay-reconnect.spec.ts | 44 ++++++++++++++ 2 files changed, 81 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 6f4b92d202..8a7303e2d0 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -9,6 +9,10 @@ import { MessageCirclePlus, Zap, } from "lucide-react"; +import { + isRelayConnectionDegraded, + useRelayConnection, +} from "@/shared/api/useRelayConnection"; import { useReconnectRelay } from "@/shared/api/useReconnectRelay"; import { isRelayUnreachableError, @@ -282,6 +286,18 @@ export function AppSidebar({ const { isPending: isReconnectPending, reconnect } = useReconnectRelay(); + // The sidebar reconnect prompt must surface the moment the relay drops, not + // only after `channelsQuery` finally errors (it has a 60s staleTime + + // refetchInterval, so the error lags 60-120s behind a dropped socket). + // OR-in the live, debounced connection state — same signal that drives + // ConnectionBanner — so the prompt flips within ~2s of degradation. + const relayConnectionState = useRelayConnection(); + const hasRelayUnreachableError = errorMessage + ? isRelayUnreachableError(errorMessage) + : false; + const isRelayConnectionDegradedNow = + hasRelayUnreachableError || isRelayConnectionDegraded(relayConnectionState); + const [createSectionState, setCreateSectionState] = React.useState<{ open: boolean; pendingChannelId: string | null; @@ -722,30 +738,28 @@ export function AppSidebar({ ) : null} - {errorMessage ? ( - isRelayUnreachableError(errorMessage) ? ( -
+ + {RELAY_UNREACHABLE_SHORT}{" "} + + -
- ) : ( -
- {errorMessage} -
- ) + {isReconnectPending ? "Reconnecting…" : "Reconnect"} + + + ) : errorMessage ? ( +
+ {errorMessage} +
) : null} diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 516286d716..f89ac51b48 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -19,6 +19,23 @@ async function setMockWebsocketSendsStalled( }, stall); } +async function driveConnectionDegraded( + page: import("@playwright/test").Page, + state: "reconnecting" | "stalled" | "disconnected", +) { + await page.evaluate((s) => { + const setter = ( + window as Window & { + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: string) => void; + } + ).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__; + if (!setter) { + throw new Error("E2E relay state setter is not installed."); + } + setter(s); + }, state); +} + test.beforeEach(async ({ page }) => { await installMockBridge(page); }); @@ -48,3 +65,30 @@ test("passive relay watchdog does not write while the websocket is half-open", a await page.getByTestId("send-message").click(); await expect(page.getByTestId("message-timeline")).toContainText(message); }); + +test("sidebar reconnect prompt flips on live relay degradation without a query error", async ({ + page, +}) => { + await page.goto("/"); + + // Healthy boot: channels render from the mock bridge and the reconnect + // prompt is absent (no query error, connection healthy). + await expect(page.getByTestId("channel-general")).toBeVisible(); + await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); + + // Drive ONLY the live connection state degraded — no channelsQuery error is + // set. Pre-fix the block keyed off `channelsQuery.error` alone, so it stays + // absent here; post-fix the dual signal surfaces it. + await driveConnectionDegraded(page, "disconnected"); + + // `disconnected` reports immediately (reconnecting/stalled debounce ~2s), + // but allow margin for the React render to flush. + await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({ + timeout: 5_000, + }); + await expect(page.getByTestId("sidebar-reconnect")).toBeVisible(); + + // The cached channel list stays visible alongside the prompt (layout intent: + // surface the affordance, don't yank context). + await expect(page.getByTestId("channel-general")).toBeVisible(); +}); From aeaa644f5b020f0440fe1f83d324ccd619b5404a Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 22:27:42 -0400 Subject: [PATCH 6/8] fix(desktop): gate profile-popover reconnect item on relay degradation The Reconnect to relay item in the profile popover rendered unconditionally because SidebarProfileCard always passed a truthy onReconnect. Gate it on the same isRelayConnectionDegraded live-state check the sidebar prompt uses so the popover hides the item when healthy. The || isPending guard keeps it visible while a reconnect is in flight so it does not vanish mid-click. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../sidebar/ui/SidebarProfileCard.tsx | 16 ++++++++++++- desktop/tests/e2e/relay-reconnect.spec.ts | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 97547a576e..300662b542 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -10,6 +10,10 @@ import type { Workspace } from "@/features/workspaces/types"; import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; import { useReconnectRelay } from "@/shared/api/useReconnectRelay"; +import { + isRelayConnectionDegraded, + useRelayConnection, +} from "@/shared/api/useRelayConnection"; import { cn } from "@/shared/lib/cn"; type SidebarProfileCardProps = { @@ -54,6 +58,12 @@ export function SidebarProfileCard({ // workspace-provider and QueryClient safe at this level. const selfProfileCache = useSelfProfileCache(); const { isPending, reconnect } = useReconnectRelay(); + // Only offer reconnect when the relay is actually degraded — keep the item + // visible while a reconnect is in flight so it does not vanish mid-click if + // the live state briefly flips. + const isRelayConnectionDegradedNow = isRelayConnectionDegraded( + useRelayConnection(), + ); const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const profileCardRef = React.useRef(null); @@ -134,7 +144,11 @@ export function SidebarProfileCard({ isStatusPending={isPresencePending} onClearUserStatus={onClearUserStatus} onOpenSettings={onOpenSettings} - onReconnect={() => void reconnect()} + onReconnect={ + isRelayConnectionDegradedNow || isPending + ? () => void reconnect() + : undefined + } onSetStatus={onSetPresenceStatus ?? (() => {})} onSetUserStatus={onSetUserStatus} triggerContainerRef={profileCardRef} diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index f89ac51b48..d0f8f72933 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -92,3 +92,26 @@ test("sidebar reconnect prompt flips on live relay degradation without a query e // surface the affordance, don't yank context). await expect(page.getByTestId("channel-general")).toBeVisible(); }); + +test("profile popover reconnect item is hidden when healthy and shown when degraded", async ({ + page, +}) => { + await page.goto("/"); + + // Healthy boot: open the profile popover and wait for it to mount. The + // reconnect item must be ABSENT because the relay is connected. Anchoring on + // a stable popover item first ensures the count assertion reflects the gate, + // not an un-mounted popover. Pre-fix the item rendered unconditionally, so + // this fails before the gate is added. + await expect(page.getByTestId("channel-general")).toBeVisible(); + await page.getByTestId("sidebar-profile-avatar-button").click(); + await expect(page.getByTestId("profile-popover-settings")).toBeVisible(); + await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0); + + // Drive the live connection degraded. The gate reads `useRelayConnection()`, + // so the item surfaces reactively while the popover stays open. + await driveConnectionDegraded(page, "disconnected"); + await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({ + timeout: 5_000, + }); +}); From 95f797b538e2fcb9668cc38deb4f35c5657475bf Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 22:42:12 -0400 Subject: [PATCH 7/8] test(desktop): add relay reconnect degraded-state screenshot spec Captures the profile-popover reconnect item and the sidebar relay-unreachable block in both healthy and degraded relay states, driving the real connectionStateEmitter via the E2E bridge. Each test asserts the failable gate condition before screenshotting so a regression breaks the capture, not just the image. Registers the spec in the smoke testMatch allowlist (both Playwright projects use explicit allowlists; an unlisted spec yields no tests). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/playwright.config.ts | 1 + .../e2e/relay-reconnect-screenshots.spec.ts | 120 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 desktop/tests/e2e/relay-reconnect-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7e22fb77c2..6812a05c83 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -37,6 +37,7 @@ export default defineConfig({ "**/video-attachment.spec.ts", "**/mentions.spec.ts", "**/relay-reconnect.spec.ts", + "**/relay-reconnect-screenshots.spec.ts", "**/workflows.spec.ts", "**/identity-archive.spec.ts", "**/identity-archive-hide.spec.ts", diff --git a/desktop/tests/e2e/relay-reconnect-screenshots.spec.ts b/desktop/tests/e2e/relay-reconnect-screenshots.spec.ts new file mode 100644 index 0000000000..3a20746730 --- /dev/null +++ b/desktop/tests/e2e/relay-reconnect-screenshots.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/relay-reconnect-screenshots"; + +async function settle(page: import("@playwright/test").Page) { + // Tolerate cancelled animations (skeleton → live swap rejects `.finished` + // with AbortError) AND indefinitely-running ones (the degraded-state pulse + // never resolves `.finished`): allSettled handles rejection, the timeout + // race handles infinite animations so this can never hang the test. + await page.evaluate(() => + Promise.race([ + Promise.allSettled(document.getAnimations().map((a) => a.finished)), + new Promise((resolve) => setTimeout(resolve, 1_000)), + ]), + ); +} + +/** Drive the relay client into a state via the real E2E connection-state seam. */ +async function driveConnectionState( + page: import("@playwright/test").Page, + state: "connected" | "disconnected", +) { + await page.evaluate((s) => { + const setter = ( + window as Window & { + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (state: string) => void; + } + ).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__; + if (!setter) throw new Error("E2E relay state setter not installed."); + setter(s); + }, state); +} + +async function openProfilePopover(page: import("@playwright/test").Page) { + await page.getByTestId("sidebar-profile-avatar-button").click(); + // Anchor on a stable popover child so the screenshot captures the open menu. + await expect(page.getByTestId("profile-popover-settings")).toBeVisible(); + // Await the Radix open animation on the [data-state] ancestor — the popper + // wrapper carries no animations, so screenshots would otherwise capture a + // half-faded popover. + await page.getByTestId("profile-popover-settings").evaluate((el) => + Promise.all( + el + .closest("[data-state]") + ?.getAnimations() + .map((a) => a.finished) ?? [], + ), + ); +} + +test.describe("relay reconnect affordance screenshots", () => { + test("01 — profile popover reconnect item hidden when healthy", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + + await expect(page.getByTestId("channel-general")).toBeVisible(); + await openProfilePopover(page); + await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0); + await settle(page); + + await page.screenshot({ + path: `${SHOTS}/01-profile-popover-healthy.png`, + }); + }); + + test("02 — profile popover reconnect item shown when degraded", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + + await expect(page.getByTestId("channel-general")).toBeVisible(); + await driveConnectionState(page, "disconnected"); + await openProfilePopover(page); + await expect(page.getByTestId("profile-popover-reconnect")).toBeVisible({ + timeout: 5_000, + }); + await settle(page); + + await page.screenshot({ + path: `${SHOTS}/02-profile-popover-degraded.png`, + }); + }); + + test("03 — sidebar has no reconnect prompt when healthy", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + + await expect(page.getByTestId("channel-general")).toBeVisible(); + await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); + await settle(page); + + await page.screenshot({ path: `${SHOTS}/03-sidebar-healthy.png` }); + }); + + test("04 — sidebar reconnect prompt shown when degraded, channels visible", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/"); + + await expect(page.getByTestId("channel-general")).toBeVisible(); + await driveConnectionState(page, "disconnected"); + await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({ + timeout: 5_000, + }); + await expect(page.getByTestId("sidebar-reconnect")).toBeVisible(); + // The cached channel list stays visible alongside the prompt. + await expect(page.getByTestId("channel-general")).toBeVisible(); + await settle(page); + + await page.screenshot({ path: `${SHOTS}/04-sidebar-degraded.png` }); + }); +}); From ffbd67c71622c998b922bc458ee33841d1b8bf06 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Mon, 15 Jun 2026 23:08:08 -0400 Subject: [PATCH 8/8] test(desktop): make sidebar relay block legible in degraded screenshot The 03/04 sidebar screenshots were visually identical: the sidebar-relay-unreachable block renders at the bottom of the scroll region, painted under the absolute z-30 profile footer, so it was occluded even though toBeVisible passed (it is in-DOM). The prior scroll helper matched the wrong overflowing descendant and no-opped. Scroll the exact [data-sidebar="content"] container fully down before capture so the block clears the footer, applied symmetrically to 03 (absent) and 04 (present). Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../e2e/relay-reconnect-screenshots.spec.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/desktop/tests/e2e/relay-reconnect-screenshots.spec.ts b/desktop/tests/e2e/relay-reconnect-screenshots.spec.ts index 3a20746730..1eaad25e74 100644 --- a/desktop/tests/e2e/relay-reconnect-screenshots.spec.ts +++ b/desktop/tests/e2e/relay-reconnect-screenshots.spec.ts @@ -33,6 +33,22 @@ async function driveConnectionState( }, state); } +async function scrollSidebarToBottom(page: import("@playwright/test").Page) { + // The relay block anchors at the bottom of the sidebar's scroll region and is + // painted UNDER the absolute, z-30 profile footer (it sits within the footer's + // 68px band when unscrolled — toBeVisible passes since it's in-DOM, but a human + // can't see it). The scroll region is specifically [data-sidebar="content"]; + // scrolling it fully down lifts the block ~96px clear of the footer. Targeting + // any old overflowing descendant matches the wrong element (e.g. a menu button), + // so the selector must be exact. + await page + .getByTestId("app-sidebar") + .locator('[data-sidebar="content"]') + .evaluate((scroller) => { + scroller.scrollTop = scroller.scrollHeight; + }); +} + async function openProfilePopover(page: import("@playwright/test").Page) { await page.getByTestId("sidebar-profile-avatar-button").click(); // Anchor on a stable popover child so the screenshot captures the open menu. @@ -94,9 +110,18 @@ test.describe("relay reconnect affordance screenshots", () => { await expect(page.getByTestId("channel-general")).toBeVisible(); await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); + // The relay block renders at the BOTTOM of the scrollable sidebar content, + // below the fold at this viewport. Scroll to the bottom so 03 frames the + // same region where 04 will show the block — making the absence legible. + await scrollSidebarToBottom(page); await settle(page); - await page.screenshot({ path: `${SHOTS}/03-sidebar-healthy.png` }); + // Frame the left sidebar directly — the relay-unreachable block lives there, + // and a full-window shot makes its presence/absence illegible against the + // unrelated top connection banner. + await page.getByTestId("app-sidebar").screenshot({ + path: `${SHOTS}/03-sidebar-healthy.png`, + }); }); test("04 — sidebar reconnect prompt shown when degraded, channels visible", async ({ @@ -113,8 +138,12 @@ test.describe("relay reconnect affordance screenshots", () => { await expect(page.getByTestId("sidebar-reconnect")).toBeVisible(); // The cached channel list stays visible alongside the prompt. await expect(page.getByTestId("channel-general")).toBeVisible(); + // Scroll the block clear of the occluding footer (symmetric with 03). + await scrollSidebarToBottom(page); await settle(page); - await page.screenshot({ path: `${SHOTS}/04-sidebar-degraded.png` }); + await page.getByTestId("app-sidebar").screenshot({ + path: `${SHOTS}/04-sidebar-degraded.png`, + }); }); });