,
+ ) => {
+ if (connectivityActionRef.current !== null) {
+ return;
+ }
+
+ connectivityActionRef.current = action;
+ setConnectivityAction(action);
+ setSuccessAction(null);
+ void Promise.resolve()
+ .then(runAction)
+ .then((didReconnect) => {
+ if (didReconnect !== false) {
+ markSuccess(action);
+ }
+ })
+ .catch((error) => {
+ const detail = error instanceof Error ? error.message : String(error);
+ const label =
+ action === "refresh-access"
+ ? "Could not refresh VPN access."
+ : action === "connect-vpn"
+ ? "Could not turn on VPN."
+ : "Could not reconnect to the relay.";
+ toast.error(`${label} ${detail}`);
+ })
+ .finally(() => {
+ connectivityActionRef.current = null;
+ setConnectivityAction(null);
+ });
+ },
+ [markSuccess],
+ );
+
+ const handleConnectWarpVpn = React.useCallback(() => {
+ runConnectivityAction("connect-vpn", reconnect);
+ }, [reconnect, runConnectivityAction]);
+
+ const handleReconnectRelay = React.useCallback(() => {
+ runConnectivityAction("reconnect-relay", reconnect);
+ }, [reconnect, runConnectivityAction]);
+
+ const handleRefreshWarpAccess = React.useCallback(() => {
+ runConnectivityAction("refresh-access", reconnect);
+ }, [reconnect, runConnectivityAction]);
+
+ if (dismissedErrorMessage === message) {
+ return null;
+ }
+
+ return (
+
+ {cardVariant === "refresh-access" ? (
+ setDismissedErrorMessage(message)}
+ surface="secondary"
+ testId="onboarding-vpn-access-refresh-card"
+ />
+ ) : cardVariant === "connect-vpn" ? (
+ setDismissedErrorMessage(message)}
+ surface="secondary"
+ testId="onboarding-vpn-off-card"
+ />
+ ) : (
+ setDismissedErrorMessage(message)}
+ onReconnect={handleReconnectRelay}
+ surface="secondary"
+ testId="onboarding-relay-reconnect-card"
+ />
+ )}
+
+ );
+}
+
+function ErrorBanner({
+ isSaving,
+ message,
+ relayUrl,
+}: {
+ isSaving: boolean;
+ message: string | null;
+ relayUrl?: string | null;
+}) {
if (!message) {
return null;
}
+ if (isRelayUnreachableError(message)) {
+ return (
+
+ );
+ }
+
return (
{message}
@@ -32,6 +232,7 @@ function ErrorBanner({ message }: { message: string | null }) {
export function ProfileStep({
actions,
direction,
+ relayUrl,
transitionEffect = "line-slide",
state,
}: ProfileStepProps) {
@@ -117,7 +318,11 @@ export function ProfileStep({
{saveRecovery.errorMessage ? (
-
+
) : null}
diff --git a/desktop/src/features/settings/SidebarUpdateCard.tsx b/desktop/src/features/settings/SidebarUpdateCard.tsx
new file mode 100644
index 000000000..71ed7aa2f
--- /dev/null
+++ b/desktop/src/features/settings/SidebarUpdateCard.tsx
@@ -0,0 +1,100 @@
+import * as React from "react";
+import { CircleArrowUp, Loader2 } from "lucide-react";
+
+import { useUpdaterContext } from "./hooks/UpdaterProvider";
+import { shouldShowSidebarUpdateCard } from "./sidebarUpdateCardVisibility";
+import { SidebarCompactActionCard } from "@/shared/ui/sidebar-action-card";
+
+type SidebarUpdateCardProps = {
+ onDismiss: () => void;
+};
+
+type SidebarUpdateCompactCardProps = SidebarUpdateCardProps & {
+ actionTestId?: string;
+ testId?: string;
+};
+
+export function SidebarUpdateCompactCard({
+ actionTestId,
+ onDismiss,
+ testId = "sidebar-update-card-compact",
+}: SidebarUpdateCompactCardProps) {
+ const { relaunch } = useUpdaterContext();
+ const [isRestartPending, setIsRestartPending] = React.useState(false);
+ const restartPendingRef = React.useRef(false);
+ const restartFrameRef = React.useRef
(null);
+ const restartTimeoutRef = React.useRef(null);
+
+ React.useEffect(() => {
+ return () => {
+ if (restartFrameRef.current !== null) {
+ window.cancelAnimationFrame(restartFrameRef.current);
+ }
+ if (restartTimeoutRef.current !== null) {
+ window.clearTimeout(restartTimeoutRef.current);
+ }
+ restartPendingRef.current = false;
+ };
+ }, []);
+
+ const handleRestart = React.useCallback(() => {
+ if (restartPendingRef.current) {
+ return;
+ }
+
+ restartPendingRef.current = true;
+ setIsRestartPending(true);
+ restartFrameRef.current = window.requestAnimationFrame(() => {
+ restartFrameRef.current = null;
+ restartTimeoutRef.current = window.setTimeout(() => {
+ restartTimeoutRef.current = null;
+ void relaunch()
+ .catch((error) => {
+ console.error("[SidebarUpdateCard] relaunch failed:", error);
+ })
+ .finally(() => {
+ restartPendingRef.current = false;
+ setIsRestartPending(false);
+ });
+ }, 0);
+ });
+ }, [relaunch]);
+
+ return (
+
+ ) : (
+
+ )
+ }
+ iconKey={isRestartPending ? "pending" : "idle"}
+ onAction={handleRestart}
+ onDismiss={onDismiss}
+ testId={testId}
+ title="Ready to update!"
+ />
+ );
+}
+
+export function SidebarUpdateCard({ onDismiss }: SidebarUpdateCardProps) {
+ const { status } = useUpdaterContext();
+
+ if (!shouldShowSidebarUpdateCard(status)) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/desktop/src/features/settings/hooks/use-updater.ts b/desktop/src/features/settings/hooks/use-updater.ts
index 21c7bd46f..3b9c5e632 100644
--- a/desktop/src/features/settings/hooks/use-updater.ts
+++ b/desktop/src/features/settings/hooks/use-updater.ts
@@ -1,7 +1,6 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
-import { toast } from "sonner";
export type UpdateStatus =
| { state: "idle" }
@@ -87,10 +86,6 @@ export function useUpdater() {
updateRef.current = null;
setStatus({ state: "ready" });
- toast("Update ready", {
- description: "Restart when you're ready to apply the update.",
- duration: 8000,
- });
} catch (err) {
setStatus({ state: "error", message: toErrorMessage(err) });
} finally {
diff --git a/desktop/src/features/settings/sidebarUpdateCardVisibility.ts b/desktop/src/features/settings/sidebarUpdateCardVisibility.ts
new file mode 100644
index 000000000..61159f7f3
--- /dev/null
+++ b/desktop/src/features/settings/sidebarUpdateCardVisibility.ts
@@ -0,0 +1,3 @@
+export function shouldShowSidebarUpdateCard(status: { state: string }) {
+ return status.state === "ready";
+}
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 8a7303e2d..f881a215f 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -9,15 +9,6 @@ import {
MessageCirclePlus,
Zap,
} from "lucide-react";
-import {
- isRelayConnectionDegraded,
- useRelayConnection,
-} from "@/shared/api/useRelayConnection";
-import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
-import {
- isRelayUnreachableError,
- RELAY_UNREACHABLE_SHORT,
-} from "@/shared/lib/relayError";
import * as React from "react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
@@ -47,11 +38,20 @@ import {
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
+import {
+ SidebarBlockAccessRefreshCompactCard,
+ SidebarBlockVpnOffCompactCard,
+ SidebarRelayConnectionCard,
+} from "@/features/sidebar/ui/SidebarRelayConnectionCard";
+import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import {
SidebarLoadingContent,
useSidebarLoadingShape,
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
import { SECTION_ICON_BUTTON_CLASS } from "@/features/sidebar/ui/sidebarSectionStyles";
+import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
+import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
+import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility";
import type {
Channel,
ChannelVisibility,
@@ -59,6 +59,7 @@ import type {
Profile,
UserStatus,
} from "@/shared/api/types";
+import { cn } from "@/shared/lib/cn";
import {
Sidebar,
SidebarContent,
@@ -224,6 +225,31 @@ export function AppSidebar({
onStarChannel,
onUnstarChannel,
}: AppSidebarProps) {
+ const { status: updateStatus } = useUpdaterContext();
+ const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus);
+ const sidebarRelayConnectionCard = useSidebarRelayConnectionCard(
+ errorMessage,
+ activeWorkspace?.relayUrl,
+ );
+ const [isSidebarUpdateCardDismissed, setIsSidebarUpdateCardDismissed] =
+ React.useState(false);
+ const showSidebarUpdateCard =
+ canShowSidebarUpdateCard && !isSidebarUpdateCardDismissed;
+ const sidebarFooterCardCount =
+ (sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? 1 : 0) +
+ (showSidebarUpdateCard ? 1 : 0);
+ const sidebarContentBottomPaddingClass =
+ sidebarFooterCardCount >= 2
+ ? "pb-[18rem]"
+ : sidebarFooterCardCount >= 1
+ ? "pb-52"
+ : "pb-32";
+ const unreadBelowBottomClass =
+ sidebarFooterCardCount >= 2
+ ? "bottom-56"
+ : sidebarFooterCardCount >= 1
+ ? "bottom-44"
+ : "bottom-28";
const [isNewDmOpenInternal, setIsNewDmOpenInternal] = React.useState(false);
const isNewDmOpen = isNewDmOpenProp ?? isNewDmOpenInternal;
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
@@ -232,6 +258,12 @@ export function AppSidebar({
const [createDialogKind, setCreateDialogKind] =
React.useState(null);
+ React.useEffect(() => {
+ if (!canShowSidebarUpdateCard) {
+ setIsSidebarUpdateCardDismissed(false);
+ }
+ }, [canShowSidebarUpdateCard]);
+
// Allow the create-channel dialog to be opened from outside (e.g. the
// ⌘⇧N global shortcut in AppShell), mirroring the controlled new-DM lift.
// When the external flag flips on, open the "stream" create dialog; the
@@ -284,20 +316,6 @@ export function AppSidebar({
unassignChannel,
} = useChannelSections(currentPubkey);
- 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;
@@ -547,7 +565,10 @@ export function AppSidebar({
testId="sidebar-more-unread-above"
/>
) : null}
-
+
{isLoading ? (
) : null}
@@ -738,25 +759,8 @@ export function AppSidebar({
>
) : null}
- {isRelayConnectionDegradedNow ? (
-
-
- {RELAY_UNREACHABLE_SHORT}{" "}
-
-
-
- ) : errorMessage ? (
+ {errorMessage &&
+ !sidebarRelayConnectionCard.hasRelayUnreachableError ? (
{errorMessage}
@@ -765,7 +769,7 @@ export function AppSidebar({
{unreadBelowCount > 0 ? (
}
onClick={scrollToNextBelow}
@@ -775,6 +779,67 @@ export function AppSidebar({
) : null}
+ {sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? (
+
+ {sidebarRelayConnectionCard.cardVariant === "refresh-access" ? (
+
+ ) : sidebarRelayConnectionCard.cardVariant === "connect-vpn" ? (
+
+ ) : (
+
+ )}
+
+ ) : null}
+ {showSidebarUpdateCard ? (
+
+ setIsSidebarUpdateCardDismissed(true)}
+ />
+
+ ) : null}
void;
+ onReconnect: () => void;
+ surface?: SidebarActionCardSurface;
+ testId?: string;
+};
+
+type SidebarBlockConnectivityCardProps = {
+ actionTestId?: string;
+ isActionDisabled: boolean;
+ isActionPending: boolean;
+ isActionSuccess?: boolean;
+ onAction: () => void;
+ onDismiss?: () => void;
+ surface?: SidebarActionCardSurface;
+ testId?: string;
+};
+
+export function SidebarRelayConnectionCard({
+ actionTestId,
+ isActionDisabled = false,
+ isConnected = false,
+ isReconnectPending,
+ onDismiss,
+ onReconnect,
+ surface,
+}: SidebarRelayConnectionCardProps) {
+ return (
+
+ );
+}
+
+export function SidebarRelayConnectionCompactCard({
+ actionTestId,
+ isActionDisabled = false,
+ isConnected = false,
+ isReconnectPending,
+ onDismiss,
+ onReconnect,
+ surface,
+ testId = "sidebar-relay-unreachable-compact",
+}: SidebarRelayConnectionCardProps) {
+ return (
+
+ ) : isReconnectPending ? (
+
+ ) : (
+
+ )
+ }
+ onAction={onReconnect}
+ onDismiss={onDismiss}
+ role={isConnected ? "status" : "alert"}
+ surface={surface}
+ testId={testId}
+ title={isConnected ? "Connected" : "Can't reach the relay"}
+ tone={isConnected ? "success" : "neutral"}
+ />
+ );
+}
+
+export function SidebarBlockVpnOffCompactCard({
+ actionTestId,
+ isActionDisabled,
+ isActionPending,
+ isActionSuccess = false,
+ onAction,
+ onDismiss,
+ surface,
+ testId = "sidebar-block-vpn-off-compact",
+}: SidebarBlockConnectivityCardProps) {
+ return (
+
+ ) : isActionPending ? (
+
+ ) : (
+
+ )
+ }
+ onAction={onAction}
+ onDismiss={onDismiss}
+ surface={surface}
+ testId={testId}
+ title={isActionSuccess ? "Connected" : "Turn on VPN"}
+ tone={isActionSuccess ? "success" : "neutral"}
+ />
+ );
+}
+
+export function SidebarBlockAccessRefreshCompactCard({
+ actionTestId,
+ isActionDisabled,
+ isActionPending,
+ isActionSuccess = false,
+ onAction,
+ onDismiss,
+ surface,
+ testId = "sidebar-block-access-refresh-compact",
+}: SidebarBlockConnectivityCardProps) {
+ return (
+
+ ) : isActionPending ? (
+
+ ) : (
+
+ )
+ }
+ onAction={onAction}
+ onDismiss={onDismiss}
+ surface={surface}
+ testId={testId}
+ title={isActionSuccess ? "Connected" : "Refresh VPN access"}
+ tone={isActionSuccess ? "success" : "neutral"}
+ />
+ );
+}
diff --git a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts
new file mode 100644
index 000000000..2bd0e9f21
--- /dev/null
+++ b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts
@@ -0,0 +1,236 @@
+import * as React from "react";
+
+import {
+ isRelayConnectionDegraded,
+ useRelayConnection,
+} from "@/shared/api/useRelayConnection";
+import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
+import { resolveRelayConnectivityCardVariant } from "@/shared/lib/relayConnectivityCard";
+import { isRelayUnreachableError } from "@/shared/lib/relayError";
+
+const SIDEBAR_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 6_000;
+const DEFAULT_RELAY_SUCCESS_KEY = "__default-relay__";
+
+let relayConnectivitySuccessKey: string | null = null;
+const relayConnectivitySuccessListeners = new Set<() => void>();
+
+function relaySuccessKey(relayUrl: string | null | undefined) {
+ return relayUrl ?? DEFAULT_RELAY_SUCCESS_KEY;
+}
+
+function subscribeRelayConnectivitySuccess(listener: () => void) {
+ relayConnectivitySuccessListeners.add(listener);
+ return () => relayConnectivitySuccessListeners.delete(listener);
+}
+
+function getRelayConnectivitySuccessSnapshot(
+ relayUrl: string | null | undefined,
+) {
+ return relayConnectivitySuccessKey === relaySuccessKey(relayUrl);
+}
+
+function setRelayConnectivitySuccess(
+ relayUrl: string | null | undefined,
+ next: boolean,
+) {
+ const nextKey = next ? relaySuccessKey(relayUrl) : null;
+ if (relayConnectivitySuccessKey === nextKey) {
+ return;
+ }
+
+ if (!next && relayConnectivitySuccessKey !== relaySuccessKey(relayUrl)) {
+ return;
+ }
+
+ relayConnectivitySuccessKey = nextKey;
+ for (const listener of relayConnectivitySuccessListeners) {
+ listener();
+ }
+}
+
+export function resetSidebarRelayConnectionCardState() {
+ if (relayConnectivitySuccessKey === null) {
+ return;
+ }
+
+ relayConnectivitySuccessKey = null;
+ for (const listener of relayConnectivitySuccessListeners) {
+ listener();
+ }
+}
+
+function isDocumentVisible() {
+ return document.visibilityState === "visible";
+}
+
+export function useSidebarRelayConnectionCard(
+ errorMessage?: string,
+ relayUrl?: string | null,
+) {
+ const relayConnectionState = useRelayConnection();
+ const hasRelayUnreachableError = errorMessage
+ ? isRelayUnreachableError(errorMessage)
+ : false;
+ const cardVariant = resolveRelayConnectivityCardVariant(
+ errorMessage,
+ relayUrl,
+ );
+ const lastProblemCardVariantRef = React.useRef(cardVariant);
+ const isRelayConnectionStateDegraded =
+ isRelayConnectionDegraded(relayConnectionState);
+ const isRelayConnectionActuallyDegraded =
+ hasRelayUnreachableError || isRelayConnectionStateDegraded;
+ const isRelayConnectionConnected = relayConnectionState === "connected";
+ const [isDismissed, setIsDismissed] = React.useState(false);
+ const hasSuccess = React.useSyncExternalStore(
+ subscribeRelayConnectivitySuccess,
+ () => getRelayConnectivitySuccessSnapshot(relayUrl),
+ () => false,
+ );
+ const [isWindowVisible, setIsWindowVisible] =
+ React.useState(isDocumentVisible);
+ const isRelayConnectionSuccess =
+ hasSuccess && !isRelayConnectionActuallyDegraded;
+ const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess;
+ const show = canShow && !isDismissed;
+ const wasProblemCardVisibleRef = React.useRef(false);
+ const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
+ const [connectivityAction, setConnectivityAction] = React.useState<
+ "relay-connection" | null
+ >(null);
+ const connectivityActionRef = React.useRef<"relay-connection" | null>(null);
+ const connectivityFrameRef = React.useRef(null);
+ const connectivityTimeoutRef = React.useRef(null);
+ const isRelayReconnectPending =
+ isReconnectPending || connectivityAction === "relay-connection";
+
+ React.useEffect(() => {
+ if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) {
+ setIsDismissed(false);
+ }
+ }, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]);
+
+ React.useEffect(() => {
+ if (isRelayConnectionActuallyDegraded) {
+ lastProblemCardVariantRef.current = cardVariant;
+ }
+ }, [cardVariant, isRelayConnectionActuallyDegraded]);
+
+ React.useEffect(() => {
+ if (isRelayConnectionStateDegraded) {
+ setRelayConnectivitySuccess(relayUrl, false);
+ setIsDismissed(false);
+ }
+ }, [isRelayConnectionStateDegraded, relayUrl]);
+
+ React.useEffect(() => {
+ if (isRelayConnectionActuallyDegraded) {
+ wasProblemCardVisibleRef.current = show && !isRelayConnectionSuccess;
+ return;
+ }
+
+ if (wasProblemCardVisibleRef.current && isRelayConnectionConnected) {
+ wasProblemCardVisibleRef.current = false;
+ setRelayConnectivitySuccess(relayUrl, true);
+ }
+ }, [
+ isRelayConnectionSuccess,
+ relayUrl,
+ show,
+ isRelayConnectionActuallyDegraded,
+ isRelayConnectionConnected,
+ ]);
+
+ React.useEffect(() => {
+ if (!isRelayConnectionSuccess) {
+ return;
+ }
+
+ if (!isWindowVisible) {
+ return;
+ }
+
+ const timeout = window.setTimeout(() => {
+ setRelayConnectivitySuccess(relayUrl, false);
+ setIsDismissed(true);
+ }, SIDEBAR_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS);
+
+ return () => window.clearTimeout(timeout);
+ }, [isRelayConnectionSuccess, isWindowVisible, relayUrl]);
+
+ React.useEffect(() => {
+ const updateWindowVisible = () => setIsWindowVisible(isDocumentVisible());
+
+ document.addEventListener("visibilitychange", updateWindowVisible);
+
+ return () => {
+ document.removeEventListener("visibilitychange", updateWindowVisible);
+ };
+ }, []);
+
+ React.useEffect(() => {
+ return () => {
+ if (connectivityFrameRef.current !== null) {
+ window.cancelAnimationFrame(connectivityFrameRef.current);
+ }
+ if (connectivityTimeoutRef.current !== null) {
+ window.clearTimeout(connectivityTimeoutRef.current);
+ }
+ connectivityActionRef.current = null;
+ };
+ }, []);
+
+ const startConnectivityAction = React.useCallback(
+ (runAction: () => Promise) => {
+ if (connectivityActionRef.current !== null) {
+ return;
+ }
+
+ connectivityActionRef.current = "relay-connection";
+ setConnectivityAction("relay-connection");
+ connectivityFrameRef.current = window.requestAnimationFrame(() => {
+ connectivityFrameRef.current = null;
+ connectivityTimeoutRef.current = window.setTimeout(() => {
+ connectivityTimeoutRef.current = null;
+ void Promise.resolve()
+ .then(runAction)
+ .catch((error) => {
+ console.error("[AppSidebar] connectivity action failed:", error);
+ })
+ .finally(() => {
+ connectivityActionRef.current = null;
+ setConnectivityAction(null);
+ });
+ }, 0);
+ });
+ },
+ [],
+ );
+
+ const handleReconnectRelay = React.useCallback(() => {
+ startConnectivityAction(async () => {
+ setRelayConnectivitySuccess(relayUrl, false);
+ const didReconnect = await reconnect();
+ if (didReconnect) {
+ wasProblemCardVisibleRef.current = false;
+ setIsDismissed(false);
+ setRelayConnectivitySuccess(relayUrl, true);
+ }
+ });
+ }, [reconnect, relayUrl, startConnectivityAction]);
+
+ return {
+ cardVariant: isRelayConnectionSuccess
+ ? lastProblemCardVariantRef.current
+ : cardVariant,
+ hasRelayUnreachableError,
+ isRelayConnectionSuccess,
+ isRelayReconnectPending,
+ onDismissRelayConnectionCard: () => {
+ setRelayConnectivitySuccess(relayUrl, false);
+ setIsDismissed(true);
+ },
+ onReconnectRelay: handleReconnectRelay,
+ showSidebarRelayConnectionCard: show,
+ };
+}
diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts
index 50604e193..a678c34d4 100644
--- a/desktop/src/features/workspaces/useWorkspaceInit.ts
+++ b/desktop/src/features/workspaces/useWorkspaceInit.ts
@@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { clearAllDrafts } from "@/features/messages/lib/useDrafts";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
+import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import { initFirstWorkspace } from "./workspaceStorage";
@@ -25,6 +26,7 @@ import type { Workspace } from "./types";
function resetWorkspaceState(): void {
relayClient.disconnect();
resetAgentObserverStore();
+ resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetVideoPlayerState();
clearSearchHitEventCache();
diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx
index cf41ee4b2..f55a918ad 100644
--- a/desktop/src/main.tsx
+++ b/desktop/src/main.tsx
@@ -7,6 +7,7 @@ import { migrateLegacyWorkspaceStorageBeforeRender } from "@/features/workspaces
import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
+import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider";
import { Toaster } from "@/shared/ui/sonner";
import { TooltipProvider } from "@/shared/ui/tooltip";
@@ -53,10 +54,12 @@ function renderApp() {
-
-
-
-
+
+
+
+
+
+
diff --git a/desktop/src/shared/api/useReconnectRelay.ts b/desktop/src/shared/api/useReconnectRelay.ts
index 8682edf3b..77ce4f214 100644
--- a/desktop/src/shared/api/useReconnectRelay.ts
+++ b/desktop/src/shared/api/useReconnectRelay.ts
@@ -16,8 +16,30 @@ import { toast } from "sonner";
import { relayClient } from "@/shared/api/relayClient";
+const RECONNECT_HOOK_TIMEOUT_MS = 20_000;
+const RELAY_PRECONNECT_TIMEOUT_MS = 15_000;
+
+function withTimeout(
+ promise: Promise,
+ timeoutMs: number,
+ label: string,
+): Promise {
+ let timeoutId: number | null = null;
+ const timeout = new Promise((_, reject) => {
+ timeoutId = window.setTimeout(() => {
+ reject(new Error(`${label} timed out after ${timeoutMs}ms`));
+ }, timeoutMs);
+ });
+
+ return Promise.race([promise, timeout]).finally(() => {
+ if (timeoutId !== null) {
+ window.clearTimeout(timeoutId);
+ }
+ });
+}
+
export function useReconnectRelay(): {
- reconnect: () => Promise;
+ reconnect: () => Promise;
isPending: boolean;
} {
const queryClient = useQueryClient();
@@ -28,25 +50,44 @@ export function useReconnectRelay(): {
const inFlightRef = React.useRef(false);
const reconnect = React.useCallback(async () => {
- if (inFlightRef.current) return;
+ if (inFlightRef.current) return false;
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");
+ await withTimeout(
+ invoke("relay_reconnect_hook"),
+ RECONNECT_HOOK_TIMEOUT_MS,
+ "reconnect hook",
+ );
} catch (err) {
console.warn("[useReconnectRelay] reconnect hook failed:", err);
}
- await relayClient.preconnect();
- await queryClient.invalidateQueries();
+ await withTimeout(
+ relayClient.preconnect(),
+ RELAY_PRECONNECT_TIMEOUT_MS,
+ "relay preconnect",
+ );
+ // Let callers render the recovered/connected state before refetching the
+ // sidebar data. The refetch can briefly swap the sidebar into loading UI.
+ window.setTimeout(() => {
+ void queryClient.invalidateQueries().catch((error) => {
+ console.error(
+ "[useReconnectRelay] failed to refresh queries after reconnect:",
+ error,
+ );
+ });
+ }, 0);
// No success toast — the banner auto-hides once the connection state
// transitions back to "connected", which is the user-visible confirmation.
+ return true;
} catch (err) {
toast.error("Reconnect failed — check your VPN or network.");
console.error("[useReconnectRelay] reconnect failed:", err);
+ return false;
} finally {
inFlightRef.current = false;
setIsPending(false);
diff --git a/desktop/src/shared/lib/relayConnectivityCard.test.mjs b/desktop/src/shared/lib/relayConnectivityCard.test.mjs
new file mode 100644
index 000000000..0a8bfa1b3
--- /dev/null
+++ b/desktop/src/shared/lib/relayConnectivityCard.test.mjs
@@ -0,0 +1,107 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ isBlockRelayUrl,
+ resolveRelayConnectivityCardVariant,
+} from "./relayConnectivityCard.ts";
+
+test("isBlockRelayUrl recognizes Block-owned relay hosts", () => {
+ assert.equal(isBlockRelayUrl("wss://sprout-oss.stage.blox.sqprod.co"), true);
+ assert.equal(isBlockRelayUrl("wss://relay.block.xyz"), true);
+ assert.equal(isBlockRelayUrl("wss://relay.squareup.com"), true);
+});
+
+test("isBlockRelayUrl rejects custom and malformed relay URLs", () => {
+ assert.equal(isBlockRelayUrl("wss://relay.example.com"), false);
+ assert.equal(isBlockRelayUrl("not a url"), false);
+ assert.equal(isBlockRelayUrl(null), false);
+});
+
+test("resolveRelayConnectivityCardVariant keeps custom workspaces generic", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "relay unreachable: relay returned an unexpected HTML page (VPN or proxy sign-in?)",
+ "wss://relay.example.com",
+ ),
+ "reconnect-relay",
+ );
+});
+
+test("resolveRelayConnectivityCardVariant offers VPN for generic Block relay failures", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "relay unreachable: could not connect to relay",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "connect-vpn",
+ );
+});
+
+test("resolveRelayConnectivityCardVariant offers VPN for generic Block proxy failures", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "relay unreachable: relay returned an unexpected HTML page (VPN or proxy sign-in?)",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "connect-vpn",
+ );
+});
+
+test("resolveRelayConnectivityCardVariant offers VPN for Block HTTP redirects", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "HTTP error: 302 Found",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "connect-vpn",
+ );
+});
+
+test("resolveRelayConnectivityCardVariant offers VPN for Cloudflare Access redirects without reauth detail", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "HTTP error: 302 Found - Cloudflare Access sign-in required",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "connect-vpn",
+ );
+});
+
+test("resolveRelayConnectivityCardVariant offers VPN for Cloudflare Access/VPN reauth redirects", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "relay unreachable: network sign-in required (Cloudflare Access / VPN) - re-authenticate and reconnect",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "connect-vpn",
+ );
+});
+
+test("resolveRelayConnectivityCardVariant offers access refresh for auth failures", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "relay unreachable: 403 Forbidden from Cloudflare Access",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "refresh-access",
+ );
+});
+
+test("resolveRelayConnectivityCardVariant keeps Block application auth failures generic", () => {
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "Relay authentication rejected.",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "reconnect-relay",
+ );
+
+ assert.equal(
+ resolveRelayConnectivityCardVariant(
+ "not authorized to access this workspace",
+ "wss://sprout-oss.stage.blox.sqprod.co",
+ ),
+ "reconnect-relay",
+ );
+});
diff --git a/desktop/src/shared/lib/relayConnectivityCard.ts b/desktop/src/shared/lib/relayConnectivityCard.ts
new file mode 100644
index 000000000..c0101fe73
--- /dev/null
+++ b/desktop/src/shared/lib/relayConnectivityCard.ts
@@ -0,0 +1,94 @@
+import {
+ isRelayUnreachableError,
+ relayErrorDetail,
+} from "@/shared/lib/relayError";
+
+export type RelayConnectivityCardVariant =
+ | "connect-vpn"
+ | "reconnect-relay"
+ | "refresh-access";
+
+export function isBlockRelayUrl(relayUrl: string | null | undefined) {
+ if (!relayUrl) {
+ return false;
+ }
+
+ try {
+ const url = new URL(
+ relayUrl.replace("ws://", "http://").replace("wss://", "https://"),
+ );
+ const host = url.hostname.toLowerCase();
+ return (
+ host === "block.xyz" ||
+ host.endsWith(".block.xyz") ||
+ host === "sqprod.co" ||
+ host.endsWith(".sqprod.co") ||
+ host === "squareup.com" ||
+ host.endsWith(".squareup.com")
+ );
+ } catch {
+ return false;
+ }
+}
+
+function normalizedRelayErrorDetail(errorMessage: string | null | undefined) {
+ if (!errorMessage) {
+ return "";
+ }
+
+ return (
+ isRelayUnreachableError(errorMessage)
+ ? relayErrorDetail(errorMessage)
+ : errorMessage
+ ).toLowerCase();
+}
+
+function isBlockConnectivityFailure(errorMessage: string | null | undefined) {
+ if (!errorMessage) {
+ return false;
+ }
+
+ if (isRelayUnreachableError(errorMessage)) {
+ return true;
+ }
+
+ const detail = normalizedRelayErrorDetail(errorMessage);
+ return (
+ detail.includes("cloudflare access") ||
+ detail.includes("network sign-in") ||
+ detail.includes("sign-in required") ||
+ detail.includes("vpn") ||
+ detail.includes("proxy sign-in") ||
+ detail.includes("http error: 302") ||
+ detail.includes("302 found")
+ );
+}
+
+function shouldRefreshBlockVpnAccess(errorMessage: string | null | undefined) {
+ const detail = normalizedRelayErrorDetail(errorMessage);
+ return (
+ isBlockConnectivityFailure(errorMessage) &&
+ (detail.includes("expired") ||
+ detail.includes("unauthorized") ||
+ detail.includes("forbidden") ||
+ detail.includes("401") ||
+ detail.includes("403"))
+ );
+}
+
+export function resolveRelayConnectivityCardVariant(
+ errorMessage: string | null | undefined,
+ relayUrl: string | null | undefined,
+): RelayConnectivityCardVariant {
+ if (!isBlockRelayUrl(relayUrl)) {
+ return "reconnect-relay";
+ }
+
+ if (shouldRefreshBlockVpnAccess(errorMessage)) {
+ return "refresh-access";
+ }
+
+ return isBlockConnectivityFailure(errorMessage)
+ ? "connect-vpn"
+ : "reconnect-relay";
+}
diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css
index 853b53925..16029a697 100644
--- a/desktop/src/shared/styles/globals.css
+++ b/desktop/src/shared/styles/globals.css
@@ -142,6 +142,169 @@
}
}
+.buzz-poof-layer {
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+ position: fixed;
+ z-index: 2147483647;
+}
+
+.buzz-poof-burst {
+ contain: layout paint style;
+ height: var(--buzz-poof-size);
+ left: 0;
+ position: absolute;
+ top: 0;
+ transform: translate3d(
+ calc(var(--buzz-poof-x) - 50%),
+ calc(var(--buzz-poof-y) - 50%),
+ 0
+ );
+ width: var(--buzz-poof-size);
+}
+
+.buzz-poof-frame {
+ animation: buzz-poof-frame 400ms linear both;
+ height: 100%;
+ inset: 0;
+ object-fit: contain;
+ opacity: 0;
+ position: absolute;
+ transform: translate3d(0, 0, 0) scale(0.92);
+ transform-origin: center;
+ width: 100%;
+}
+
+.buzz-poof-frame-1 {
+ animation-name: buzz-poof-frame-1;
+}
+
+.buzz-poof-frame-2 {
+ animation-name: buzz-poof-frame-2;
+}
+
+.buzz-poof-frame-3 {
+ animation-name: buzz-poof-frame-3;
+}
+
+.buzz-poof-frame-4 {
+ animation-name: buzz-poof-frame-4;
+}
+
+.buzz-poof-frame-5 {
+ animation-name: buzz-poof-frame-5;
+}
+
+@keyframes buzz-poof-frame-1 {
+ 0%,
+ 19.99% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(0.94);
+ }
+
+ 20%,
+ 100% {
+ opacity: 0;
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+}
+
+@keyframes buzz-poof-frame-2 {
+ 0%,
+ 19.99% {
+ opacity: 0;
+ }
+
+ 20%,
+ 39.99% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(1);
+ }
+
+ 40%,
+ 100% {
+ opacity: 0;
+ transform: translate3d(0, 0, 0) scale(1.01);
+ }
+}
+
+@keyframes buzz-poof-frame-3 {
+ 0%,
+ 39.99% {
+ opacity: 0;
+ }
+
+ 40%,
+ 59.99% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(1.01);
+ }
+
+ 60%,
+ 100% {
+ opacity: 0;
+ transform: translate3d(0, 0, 0) scale(1.02);
+ }
+}
+
+@keyframes buzz-poof-frame-4 {
+ 0%,
+ 59.99% {
+ opacity: 0;
+ }
+
+ 60%,
+ 79.99% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(1.02);
+ }
+
+ 80%,
+ 100% {
+ opacity: 0;
+ transform: translate3d(0, 0, 0) scale(1.03);
+ }
+}
+
+@keyframes buzz-poof-frame-5 {
+ 0%,
+ 79.99% {
+ opacity: 0;
+ }
+
+ 80%,
+ 99.99% {
+ opacity: 1;
+ transform: translate3d(0, 0, 0) scale(1.03);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translate3d(0, 0, 0) scale(1.04);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .buzz-poof-frame {
+ animation: buzz-poof-frame-reduced 180ms ease-out both;
+ }
+
+ .buzz-poof-frame:not(.buzz-poof-frame-3) {
+ display: none;
+ }
+}
+
+@keyframes buzz-poof-frame-reduced {
+ 0% {
+ opacity: 0.9;
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}
+
@property --buzz-grainient-x-0 {
syntax: "";
inherits: false;
@@ -404,6 +567,95 @@
display: none;
}
+.buzz-sidebar-action-card--success {
+ background-color: color-mix(
+ in srgb,
+ var(--status-added) 12%,
+ hsl(var(--background)) 88%
+ );
+ border-color: color-mix(
+ in srgb,
+ var(--status-added) 34%,
+ hsl(var(--border)) 66%
+ );
+ color: hsl(var(--foreground));
+}
+
+.buzz-sidebar-action-card--success:hover {
+ background-color: color-mix(
+ in srgb,
+ var(--status-added) 16%,
+ hsl(var(--background)) 84%
+ );
+}
+
+.dark .buzz-sidebar-action-card--success {
+ background-color: color-mix(
+ in srgb,
+ var(--status-added) 15%,
+ hsl(var(--background)) 85%
+ );
+ border-color: color-mix(
+ in srgb,
+ var(--status-added) 42%,
+ hsl(var(--border)) 58%
+ );
+}
+
+.dark .buzz-sidebar-action-card--success:hover {
+ background-color: color-mix(
+ in srgb,
+ var(--status-added) 19%,
+ hsl(var(--background)) 81%
+ );
+}
+
+.buzz-sidebar-action-card__success-icon {
+ color: var(--status-added);
+}
+
+.buzz-sidebar-action-description {
+ --buzz-sidebar-action-description-line-height: 1.375em;
+ display: inline-block;
+ line-height: var(--buzz-sidebar-action-description-line-height);
+ max-width: 100%;
+ overflow: hidden;
+ white-space: nowrap;
+}
+
+.buzz-sidebar-action-description__motion {
+ display: block;
+ height: var(--buzz-sidebar-action-description-line-height);
+ overflow: hidden;
+}
+
+.buzz-sidebar-action-description__reel {
+ animation: buzz-sidebar-action-description-roll-up 260ms
+ cubic-bezier(0.2, 0.8, 0.2, 1) both;
+ display: flex;
+ flex-direction: column;
+ line-height: var(--buzz-sidebar-action-description-line-height);
+ will-change: transform;
+}
+
+.buzz-sidebar-action-description__reel > span {
+ display: block;
+ height: var(--buzz-sidebar-action-description-line-height);
+ line-height: var(--buzz-sidebar-action-description-line-height);
+}
+
+@keyframes buzz-sidebar-action-description-roll-up {
+ from {
+ transform: translateY(0);
+ }
+
+ to {
+ transform: translateY(
+ calc(-1 * var(--buzz-sidebar-action-description-line-height))
+ );
+ }
+}
+
.buzz-animated-count__slot {
display: inline-block;
height: 1em;
diff --git a/desktop/src/shared/ui/ConnectionBanner.tsx b/desktop/src/shared/ui/ConnectionBanner.tsx
deleted file mode 100644
index abfe35a64..000000000
--- a/desktop/src/shared/ui/ConnectionBanner.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import { WifiOff } from "lucide-react";
-
-import {
- isRelayConnectionDegraded,
- useRelayConnection,
-} from "@/shared/api/useRelayConnection";
-import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
-import type { ConnectionState } from "@/shared/api/relayClientShared";
-
-const COPY: Partial> = {
- reconnecting: "Reconnecting to relay…",
- stalled: "Connection lost — relay is not responding.",
- disconnected: "Disconnected from relay.",
-};
-
-/**
- * Thin warning strip surfaced when the relay connection is degraded.
- *
- * Renders null while the connection is healthy so it takes up no layout space.
- * The strip auto-disappears once the state transitions back to "connected" —
- * no success toast needed.
- */
-export function ConnectionBanner() {
- const state = useRelayConnection();
- const { isPending, reconnect } = useReconnectRelay();
-
- if (!isRelayConnectionDegraded(state)) return null;
-
- const message = COPY[state] ?? "Connection issue detected.";
-
- return (
-
-
- {message}
-
-
- );
-}
diff --git a/desktop/src/shared/ui/PoofBurstProvider.tsx b/desktop/src/shared/ui/PoofBurstProvider.tsx
new file mode 100644
index 000000000..339f80c80
--- /dev/null
+++ b/desktop/src/shared/ui/PoofBurstProvider.tsx
@@ -0,0 +1,194 @@
+import React, { type CSSProperties, useEffect, useRef, useState } from "react";
+
+export const POOF_TRIGGER_CLASS = "buzz-poof-trigger";
+export const POOF_ORIGIN_CLASS = "buzz-poof-origin";
+
+export const POOF_DURATION_MS = 430;
+
+const POOF_SOUND_URL = "/pow/plop.m4a";
+const POOF_SIZE_SCALE = 0.6375;
+const POOF_FRAMES = [
+ { id: "poof-1", src: "/pow/poof1@3x.png" },
+ { id: "poof-2", src: "/pow/poof2@3x.png" },
+ { id: "poof-3", src: "/pow/poof3@3x.png" },
+ { id: "poof-4", src: "/pow/poof4@3x.png" },
+ { id: "poof-5", src: "/pow/poof5@3x.png" },
+] as const;
+
+let poofAudio: HTMLAudioElement | null = null;
+let lastPointerDownTrigger: Element | null = null;
+
+type PoofBurst = {
+ id: number;
+ size: number;
+ x: number;
+ y: number;
+};
+
+type PoofStyle = CSSProperties & {
+ "--buzz-poof-size": string;
+ "--buzz-poof-x": string;
+ "--buzz-poof-y": string;
+};
+
+function getPoofOrigin(target: Element) {
+ const origin = target.closest(`.${POOF_ORIGIN_CLASS}`) ?? target;
+ const rect = origin.getBoundingClientRect();
+ const baseSize = Math.min(Math.max(rect.width * 0.54, 104), 190);
+
+ return {
+ size: baseSize * POOF_SIZE_SCALE,
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2,
+ };
+}
+
+function playPoofSound() {
+ try {
+ poofAudio ??= new Audio(POOF_SOUND_URL);
+ poofAudio.volume = 0.34;
+ poofAudio.currentTime = 0;
+ poofAudio.play().catch(() => {
+ // Best-effort — browsers can still block audio playback.
+ });
+ } catch {
+ // Best-effort only: audio may be unavailable or blocked.
+ }
+}
+
+function triggerPoof(target: Element) {
+ return getPoofOrigin(target);
+}
+
+export function PoofBurstProvider({ children }: { children: React.ReactNode }) {
+ const [bursts, setBursts] = useState([]);
+ const idRef = useRef(0);
+ const timeoutIdsRef = useRef([]);
+
+ useEffect(() => {
+ for (const frame of POOF_FRAMES) {
+ const image = new Image();
+ image.src = frame.src;
+ }
+
+ try {
+ poofAudio ??= new Audio(POOF_SOUND_URL);
+ poofAudio.preload = "auto";
+ poofAudio.load();
+ } catch {
+ // Best-effort only.
+ }
+ }, []);
+
+ useEffect(() => {
+ function emitPoof(target: Element) {
+ const id = idRef.current;
+ idRef.current += 1;
+
+ setBursts((current) => [
+ ...current.slice(-5),
+ { ...triggerPoof(target), id },
+ ]);
+ playPoofSound();
+
+ const timeoutId = window.setTimeout(() => {
+ setBursts((current) => current.filter((burst) => burst.id !== id));
+ }, POOF_DURATION_MS);
+ timeoutIdsRef.current.push(timeoutId);
+ }
+
+ function findTriggerTarget(event: Event) {
+ return event.target instanceof Element
+ ? event.target.closest(`.${POOF_TRIGGER_CLASS}`)
+ : null;
+ }
+
+ function handleDocumentPointerDown(event: PointerEvent) {
+ if (event.button !== 0) {
+ return;
+ }
+
+ const target = findTriggerTarget(event);
+
+ if (!target) {
+ return;
+ }
+
+ lastPointerDownTrigger = target;
+ window.setTimeout(() => {
+ if (lastPointerDownTrigger === target) {
+ lastPointerDownTrigger = null;
+ }
+ }, POOF_DURATION_MS);
+ emitPoof(target);
+ }
+
+ function handleDocumentClick(event: MouseEvent) {
+ const target =
+ event.target instanceof Element
+ ? event.target.closest(`.${POOF_TRIGGER_CLASS}`)
+ : null;
+
+ if (!target) {
+ return;
+ }
+
+ if (lastPointerDownTrigger === target) {
+ lastPointerDownTrigger = null;
+ return;
+ }
+
+ emitPoof(target);
+ }
+
+ document.addEventListener("pointerdown", handleDocumentPointerDown, {
+ capture: true,
+ });
+ document.addEventListener("click", handleDocumentClick, { capture: true });
+
+ return () => {
+ document.removeEventListener("pointerdown", handleDocumentPointerDown, {
+ capture: true,
+ });
+ document.removeEventListener("click", handleDocumentClick, {
+ capture: true,
+ });
+ for (const timeoutId of timeoutIdsRef.current) {
+ window.clearTimeout(timeoutId);
+ }
+ timeoutIdsRef.current = [];
+ };
+ }, []);
+
+ return (
+ <>
+ {children}
+
+ {bursts.map((burst) => (
+
+ {POOF_FRAMES.map((frame, index) => (
+

+ ))}
+
+ ))}
+
+ >
+ );
+}
diff --git a/desktop/src/shared/ui/sidebar-action-card.tsx b/desktop/src/shared/ui/sidebar-action-card.tsx
new file mode 100644
index 000000000..6d342f601
--- /dev/null
+++ b/desktop/src/shared/ui/sidebar-action-card.tsx
@@ -0,0 +1,336 @@
+import * as React from "react";
+import type { ReactNode } from "react";
+import { X } from "lucide-react";
+import {
+ AnimatePresence,
+ motion,
+ type Transition,
+ useReducedMotion,
+} from "motion/react";
+
+import { cn } from "@/shared/lib/cn";
+import {
+ POOF_DURATION_MS,
+ POOF_ORIGIN_CLASS,
+ POOF_TRIGGER_CLASS,
+} from "@/shared/ui/PoofBurstProvider";
+
+type SidebarActionCardTone = "neutral" | "success";
+export type SidebarActionCardSurface = "background" | "secondary";
+
+type SidebarCompactActionCardProps = {
+ actionAriaLabel: string;
+ actionDisabled?: boolean;
+ actionTestId?: string;
+ className?: string;
+ description?: string;
+ dismissClassName?: string;
+ dismissLabel?: string;
+ iconKey?: string;
+ icon: ReactNode;
+ onAction: () => void;
+ onDismiss?: () => void;
+ role?: "alert" | "status";
+ surface?: SidebarActionCardSurface;
+ testId: string;
+ title: string;
+ tone?: SidebarActionCardTone;
+};
+
+type SidebarActionDismissButtonProps = {
+ className?: string;
+ isDismissing: boolean;
+ label: string;
+ onDismiss: () => void;
+ onDismissStart: () => void;
+ testId: string;
+};
+
+type SidebarActionDescriptionTransition = {
+ current: string;
+ isAnimating: boolean;
+ previous: string;
+ version: number;
+};
+
+const SIDEBAR_ACTION_DESCRIPTION_SETTLE_DELAY_MS = 260;
+
+function SidebarActionDescriptionText({
+ shouldReduceMotion,
+ value,
+}: {
+ shouldReduceMotion: boolean;
+ value: string;
+}) {
+ const [transition, setTransition] =
+ React.useState(() => ({
+ current: value,
+ isAnimating: false,
+ previous: value,
+ version: 0,
+ }));
+
+ React.useLayoutEffect(() => {
+ setTransition((currentTransition) => {
+ if (currentTransition.current === value) {
+ return currentTransition;
+ }
+
+ return {
+ current: value,
+ isAnimating: !shouldReduceMotion,
+ previous: currentTransition.current,
+ version: currentTransition.version + 1,
+ };
+ });
+ }, [shouldReduceMotion, value]);
+
+ React.useEffect(() => {
+ if (!transition.isAnimating) {
+ return;
+ }
+
+ const timeoutId = window.setTimeout(() => {
+ setTransition((currentTransition) => {
+ if (currentTransition.version !== transition.version) {
+ return currentTransition;
+ }
+
+ return {
+ ...currentTransition,
+ isAnimating: false,
+ previous: currentTransition.current,
+ };
+ });
+ }, SIDEBAR_ACTION_DESCRIPTION_SETTLE_DELAY_MS);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [transition.isAnimating, transition.version]);
+
+ if (shouldReduceMotion || !transition.isAnimating) {
+ return (
+
+ {transition.current}
+
+ );
+ }
+
+ return (
+
+ {transition.current}
+
+
+ {transition.previous}
+ {transition.current}
+
+
+
+ );
+}
+
+function SidebarActionDismissButton({
+ className,
+ isDismissing,
+ label,
+ onDismiss,
+ onDismissStart,
+ testId,
+}: SidebarActionDismissButtonProps) {
+ const dismissTimeoutRef = React.useRef(null);
+
+ React.useEffect(() => {
+ return () => {
+ if (dismissTimeoutRef.current !== null) {
+ window.clearTimeout(dismissTimeoutRef.current);
+ }
+ };
+ }, []);
+
+ return (
+
+ );
+}
+
+function dismissTestId(testId: string) {
+ return testId === "sidebar-update-card"
+ ? "sidebar-update-dismiss"
+ : `${testId}-dismiss`;
+}
+
+export function SidebarCompactActionCard({
+ actionAriaLabel,
+ actionDisabled = false,
+ actionTestId,
+ className,
+ description,
+ dismissClassName,
+ dismissLabel = "Dismiss notification",
+ icon,
+ iconKey,
+ onAction,
+ onDismiss,
+ role,
+ surface = "background",
+ testId,
+ title,
+ tone = "neutral",
+}: SidebarCompactActionCardProps) {
+ const shouldReduceMotion = useReducedMotion();
+ const isSuccess = tone === "success";
+ const [isDismissing, setIsDismissing] = React.useState(false);
+ const resolvedIconKey = iconKey ?? title;
+ const cardTransition: Transition = shouldReduceMotion
+ ? { duration: 0.08 }
+ : {
+ duration: 0.16,
+ ease: [0.22, 1, 0.36, 1] as const,
+ };
+ const cardHiddenState = shouldReduceMotion
+ ? { opacity: 0 }
+ : { opacity: 0, scale: 0.96 };
+ const cardVisibleState = shouldReduceMotion
+ ? { opacity: 1 }
+ : { opacity: 1, scale: 1 };
+ const contentTransition: Transition = shouldReduceMotion
+ ? { duration: 0 }
+ : {
+ duration: 0.16,
+ ease: [0.22, 1, 0.36, 1] as const,
+ };
+ const contentInitial = shouldReduceMotion
+ ? { opacity: 0 }
+ : { opacity: 0, y: 3 };
+ const contentExit = shouldReduceMotion
+ ? { opacity: 0 }
+ : { opacity: 0, y: -3 };
+
+ return (
+
+
+ {onDismiss ? (
+ setIsDismissing(true)}
+ testId={dismissTestId(testId)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 836906ffd..7d86668da 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -73,8 +73,13 @@ type E2eConfig = {
profileReadDelayMs?: number;
profileReadError?: string;
profileUpdateError?: string;
+ profileUpdateErrors?: string[];
searchProfiles?: MockSearchProfileSeed[];
+ updateAvailable?: boolean;
updateChannelDelayMs?: number;
+ updateDownloadDelayMs?: number;
+ restartDelayMs?: number;
+ updateVersion?: string;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — see tests/helpers/bridge.ts:MockBridgeOptions for
@@ -2934,6 +2939,12 @@ async function handleUpdateProfile(
const identity = getIdentity(config);
if (!identity) {
const profileUpdateError = config?.mock?.profileUpdateError;
+ const profileUpdateErrors = config?.mock?.profileUpdateErrors;
+ const nextProfileUpdateError = profileUpdateErrors?.shift();
+ if (nextProfileUpdateError) {
+ throw new Error(nextProfileUpdateError);
+ }
+
if (profileUpdateError) {
if (config?.mock) {
config.mock.profileUpdateError = undefined;
@@ -3668,6 +3679,56 @@ async function handleSetChannelPurpose(
});
}
+type MockUpdaterChannel = {
+ onmessage?: (event: { event: "Finished" }) => void;
+};
+
+function notifyUpdaterFinished(payload: unknown) {
+ const channel = (payload as { onEvent?: MockUpdaterChannel } | null)?.onEvent;
+ channel?.onmessage?.({ event: "Finished" });
+}
+
+function handleUpdaterCheck(config: E2eConfig | undefined) {
+ if (!config?.mock?.updateAvailable) {
+ return null;
+ }
+
+ const version = config.mock.updateVersion ?? "0.3.18";
+
+ return {
+ rid: 42,
+ currentVersion: "0.3.17",
+ version,
+ date: "2026-06-12T00:00:00Z",
+ body: `Mock update ${version}`,
+ rawJson: null,
+ };
+}
+
+async function handleUpdaterDownloadAndInstall(
+ payload: unknown,
+ config: E2eConfig | undefined,
+) {
+ const delayMs = config?.mock?.updateDownloadDelayMs ?? 0;
+
+ if (delayMs > 0) {
+ await new Promise((resolve) => window.setTimeout(resolve, delayMs));
+ }
+
+ notifyUpdaterFinished(payload);
+ return null;
+}
+
+async function handleRestart(config: E2eConfig | undefined) {
+ const delayMs = config?.mock?.restartDelayMs ?? 0;
+
+ if (delayMs > 0) {
+ await new Promise((resolve) => window.setTimeout(resolve, delayMs));
+ }
+
+ return null;
+}
+
async function handleArchiveChannel(
args: { channelId: string },
config: E2eConfig | undefined,
@@ -5942,7 +6003,7 @@ export function maybeInstallE2eTauriMocks() {
};
window.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ = (state) => {
// Directly emit a connection state change on the relay client singleton,
- // for tests that need to drive ConnectionBanner without waiting for the
+ // for tests that need to drive degraded relay UI without waiting for the
// real auth-timeout + reconnect-debounce cycle (~10 s). Reaches the
// TS-private emitter via a cast so the production class carries no
// test-only seam.
@@ -6561,6 +6622,16 @@ export function maybeInstallE2eTauriMocks() {
case "plugin:window|set_badge_count":
case "plugin:window|set_badge_label":
return null;
+ case "plugin:updater|check":
+ return handleUpdaterCheck(activeConfig);
+ case "plugin:updater|download_and_install":
+ return handleUpdaterDownloadAndInstall(payload, activeConfig);
+ case "relay_reconnect_hook":
+ return null;
+ case "plugin:resources|close":
+ return null;
+ case "plugin:process|restart":
+ return handleRestart(activeConfig);
case "get_channel_workflows":
return handleGetChannelWorkflows(
payload as Parameters[0],
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 5a0caf762..9d7573859 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -1140,6 +1140,223 @@ test("failed first profile saves can be skipped for the current session", async
await expectHomeView(page);
});
+test("generic relay save failures use the generic reconnect card", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError: "relay unreachable: could not connect to relay",
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(
+ page.getByTestId("onboarding-relay-reconnect-card"),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-relay-reconnect-card"),
+ ).toContainText("Can't reach the relay");
+ await expect(
+ page.getByTestId("onboarding-relay-reconnect-card"),
+ ).toContainText("Click to connect");
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toHaveCount(0);
+ await expect(
+ page.getByTestId("onboarding-vpn-access-refresh-card"),
+ ).toHaveCount(0);
+});
+
+test("custom relay proxy sign-in failures use the generic reconnect card", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError:
+ "relay unreachable: relay returned an unexpected HTML page (VPN or proxy sign-in?)",
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(
+ page.getByTestId("onboarding-relay-reconnect-card"),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-relay-reconnect-card"),
+ ).toContainText("Can't reach the relay");
+ await expect(
+ page.getByTestId("onboarding-vpn-access-refresh-card"),
+ ).toHaveCount(0);
+});
+
+test("Block relay save failures offer the VPN card", async ({ page }) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError: "relay unreachable: could not connect to relay",
+ },
+ {
+ relayWsUrl: "wss://buzz-oss.stage.blox.sqprod.co",
+ skipOnboardingSeed: true,
+ },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toBeVisible();
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toContainText(
+ "Turn on VPN",
+ );
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toContainText(
+ "Click to connect",
+ );
+ await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount(
+ 0,
+ );
+ await expect(
+ page.getByTestId("onboarding-vpn-access-refresh-card"),
+ ).toHaveCount(0);
+});
+
+test("Block relay proxy sign-in failures offer the VPN card", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError:
+ "relay unreachable: relay returned an unexpected HTML page (VPN or proxy sign-in?)",
+ },
+ {
+ relayWsUrl: "wss://buzz-oss.stage.blox.sqprod.co",
+ skipOnboardingSeed: true,
+ },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toBeVisible();
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toContainText(
+ "Turn on VPN",
+ );
+ await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount(
+ 0,
+ );
+ await expect(
+ page.getByTestId("onboarding-vpn-access-refresh-card"),
+ ).toHaveCount(0);
+});
+
+test("Block relay Cloudflare Access failures offer refresh access", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError:
+ "relay unreachable: 403 Forbidden from Cloudflare Access",
+ },
+ {
+ relayWsUrl: "wss://buzz-oss.stage.blox.sqprod.co",
+ skipOnboardingSeed: true,
+ },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(
+ page.getByTestId("onboarding-vpn-access-refresh-card"),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("onboarding-vpn-access-refresh-card"),
+ ).toContainText("Refresh VPN access");
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount(
+ 0,
+ );
+});
+
+test("Block relay Cloudflare Access redirects offer the VPN card", async ({
+ page,
+}) => {
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateError:
+ "relay unreachable: network sign-in required (Cloudflare Access / VPN) - re-authenticate and reconnect",
+ },
+ {
+ relayWsUrl: "wss://buzz-oss.stage.blox.sqprod.co",
+ skipOnboardingSeed: true,
+ },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toBeVisible();
+ await expect(page.getByTestId("onboarding-vpn-off-card")).toContainText(
+ "Turn on VPN",
+ );
+ await expect(
+ page.getByTestId("onboarding-vpn-access-refresh-card"),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount(
+ 0,
+ );
+});
+
+test("dismissed relay save failures reappear on retry", async ({ page }) => {
+ const relayError = "relay unreachable: could not connect to relay";
+ await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
+ await installMockBridge(
+ page,
+ {
+ profileUpdateErrors: [relayError, relayError],
+ },
+ { skipOnboardingSeed: true },
+ );
+ await page.goto("/");
+
+ await page.getByTestId("onboarding-display-name").fill("Morty QA");
+ await page.getByTestId("onboarding-next").click();
+ await expect(
+ page.getByTestId("onboarding-relay-reconnect-card"),
+ ).toBeVisible();
+
+ await page.getByTestId("onboarding-relay-reconnect-card").hover();
+ await page.getByTestId("onboarding-relay-reconnect-card-dismiss").click();
+ await expect(page.getByTestId("onboarding-relay-reconnect-card")).toHaveCount(
+ 0,
+ );
+
+ await page.getByTestId("onboarding-next").click();
+ await expect(
+ page.getByTestId("onboarding-relay-reconnect-card"),
+ ).toBeVisible();
+});
+
test("existing relay profile with display name auto-completes onboarding", async ({
page,
}) => {
diff --git a/desktop/tests/e2e/relay-connectivity-screenshots.spec.ts b/desktop/tests/e2e/relay-connectivity-screenshots.spec.ts
index ee42430d7..254706d12 100644
--- a/desktop/tests/e2e/relay-connectivity-screenshots.spec.ts
+++ b/desktop/tests/e2e/relay-connectivity-screenshots.spec.ts
@@ -49,22 +49,26 @@ async function driveConnectionDegraded(
}
test.describe("relay connectivity screenshots", () => {
- test("01 — sidebar unreachable banner", async ({ page }) => {
+ test("01 — sidebar unreachable card", async ({ page }) => {
await installMockBridge(page, { channelsReadError: RELAY_UNREACHABLE });
await page.goto("/");
- await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible();
+ const relayCard = page.getByTestId("sidebar-relay-unreachable");
+ await expect(relayCard).toBeVisible();
+ await expect(relayCard).toContainText("Can't reach the relay");
+ await expect(relayCard).toContainText("Click to connect");
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
+ await expect(page.getByTestId("connection-banner")).toHaveCount(0);
await settle(page);
- // Clip to sidebar width (256px) so the banner and channel list are both visible.
+ // Clip to sidebar width (256px) so the card and channel list are both visible.
await page.screenshot({
path: `${SHOTS}/01-sidebar-unreachable.png`,
clip: { x: 0, y: 0, width: 256, height: 720 },
});
});
- test("02 — connection banner while reconnecting", async ({ page }) => {
+ test("02 — sidebar reconnect card while reconnecting", async ({ page }) => {
await installMockBridge(page);
await page.goto("/");
@@ -72,18 +76,20 @@ test.describe("relay connectivity screenshots", () => {
await expect(page.getByTestId("channel-general")).toBeVisible();
await driveConnectionDegraded(page);
- // ConnectionBanner debounces non-healthy states by 2 s before rendering.
- await expect(page.getByTestId("connection-banner")).toBeVisible({
+ // useRelayConnection debounces non-healthy states by 2 s before surfacing.
+ const relayCard = page.getByTestId("sidebar-relay-unreachable");
+ await expect(relayCard).toBeVisible({
timeout: 5_000,
});
- await expect(page.getByTestId("connection-banner-reconnect")).toBeVisible();
+ await expect(relayCard).toContainText("Can't reach the relay");
+ await expect(relayCard).toContainText("Click to connect");
+ await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
await settle(page);
- // Capture a horizontal strip spanning the full width that shows the banner
- // above the content pane.
+ // Clip to the sidebar, where degraded relay state is now surfaced.
await page.screenshot({
- path: `${SHOTS}/02-connection-banner.png`,
- clip: { x: 0, y: 0, width: 1280, height: 180 },
+ path: `${SHOTS}/02-sidebar-reconnecting.png`,
+ clip: { x: 0, y: 0, width: 256, height: 720 },
});
});
@@ -196,8 +202,8 @@ test.describe("relay connectivity screenshots", () => {
await expect(page.getByTestId("channel-general")).toBeVisible();
await driveConnectionDegraded(page);
- // 2 s debounce on the "reconnecting" state before ConnectionBanner shows.
- await expect(page.getByTestId("connection-banner")).toBeVisible({
+ // 2 s debounce on the "reconnecting" state before the sidebar card shows.
+ await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 5_000,
});
@@ -223,4 +229,31 @@ test.describe("relay connectivity screenshots", () => {
clip: { x: 0, y: 300, width: 480, height: 420 },
});
});
+
+ test("08 — sidebar card shows connected after external relay recovery", async ({
+ page,
+ }) => {
+ await installMockBridge(page);
+ await page.goto("/");
+
+ await expect(page.getByTestId("channel-general")).toBeVisible();
+ await driveConnectionDegraded(page);
+
+ const relayCard = page.getByTestId("sidebar-relay-unreachable");
+ await expect(relayCard).toBeVisible({
+ timeout: 5_000,
+ });
+ await expect(relayCard).toContainText("Can't reach the relay");
+ await expect(relayCard).toContainText("Click to connect");
+
+ await driveConnectionDegraded(page, "connected");
+
+ await expect(relayCard).toContainText("Connected");
+ await expect(relayCard).not.toContainText("Click to connect");
+ await page.waitForTimeout(3_000);
+ await expect(relayCard).toContainText("Connected");
+ await expect(relayCard).toBeHidden({
+ timeout: 5_000,
+ });
+ });
});
diff --git a/desktop/tests/e2e/sidebar-relay-card.spec.ts b/desktop/tests/e2e/sidebar-relay-card.spec.ts
new file mode 100644
index 000000000..cd39ad210
--- /dev/null
+++ b/desktop/tests/e2e/sidebar-relay-card.spec.ts
@@ -0,0 +1,195 @@
+import { expect, type Page, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+const BLOCK_RELAY_URL = "wss://sprout-oss.stage.blox.sqprod.co";
+const CUSTOM_RELAY_URL = "wss://relay.example.com";
+const CONNECT_ERROR = "relay unreachable: could not connect to relay";
+const PROXY_ERROR =
+ "relay unreachable: relay returned an unexpected HTML page (VPN or proxy sign-in?)";
+const CLOUDFLARE_ACCESS_ERROR =
+ "relay unreachable: 403 Forbidden from Cloudflare Access";
+const CLOUDFLARE_ACCESS_REDIRECT_ERROR =
+ "relay unreachable: network sign-in required (Cloudflare Access / VPN) - re-authenticate and reconnect";
+const RELAY_AUTH_ERROR = "Relay authentication rejected.";
+
+async function setChannelsReadError(page: Page, error: string | null) {
+ await page.evaluate((nextError) => {
+ const testWindow = window as Window & {
+ __BUZZ_E2E__?: { mock?: { channelsReadError?: string } };
+ };
+
+ if (!testWindow.__BUZZ_E2E__?.mock) {
+ throw new Error("Mock bridge config is not installed.");
+ }
+
+ if (nextError === null) {
+ delete testWindow.__BUZZ_E2E__.mock.channelsReadError;
+ return;
+ }
+
+ testWindow.__BUZZ_E2E__.mock.channelsReadError = nextError;
+ }, error);
+}
+
+test("Block workspace sidebar generic relay failures offer the VPN card", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: CONNECT_ERROR },
+ { relayWsUrl: BLOCK_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ const card = page.getByTestId("sidebar-vpn-off");
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Turn on VPN");
+ await expect(page.getByTestId("sidebar-connect-vpn")).toBeVisible();
+ await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
+});
+
+test("Block workspace sidebar proxy failures offer the VPN card", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: PROXY_ERROR },
+ { relayWsUrl: BLOCK_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ const card = page.getByTestId("sidebar-vpn-off");
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Turn on VPN");
+ await expect(page.getByTestId("sidebar-connect-vpn")).toBeVisible();
+ await expect(page.getByTestId("sidebar-vpn-access-refresh")).toHaveCount(0);
+});
+
+test("Block workspace sidebar Cloudflare Access failures offer access refresh", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: CLOUDFLARE_ACCESS_ERROR },
+ { relayWsUrl: BLOCK_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ const card = page.getByTestId("sidebar-vpn-access-refresh");
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Refresh VPN access");
+ await expect(page.getByTestId("sidebar-refresh-vpn-access")).toBeVisible();
+ await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
+});
+
+test("Block workspace sidebar Cloudflare Access redirects offer the VPN card", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: CLOUDFLARE_ACCESS_REDIRECT_ERROR },
+ { relayWsUrl: BLOCK_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ const card = page.getByTestId("sidebar-vpn-off");
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Turn on VPN");
+ await expect(page.getByTestId("sidebar-connect-vpn")).toBeVisible();
+ await expect(page.getByTestId("sidebar-vpn-access-refresh")).toHaveCount(0);
+});
+
+test("Block workspace sidebar application auth failures stay on the error path", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: RELAY_AUTH_ERROR },
+ { relayWsUrl: BLOCK_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ await expect(page.getByText(RELAY_AUTH_ERROR)).toBeVisible();
+ await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
+ await expect(page.getByTestId("sidebar-vpn-access-refresh")).toHaveCount(0);
+ await expect(page.getByTestId("sidebar-vpn-off")).toHaveCount(0);
+});
+
+test("Block workspace sidebar VPN action shows connected before hiding", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: CONNECT_ERROR },
+ { relayWsUrl: BLOCK_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ const card = page.getByTestId("sidebar-vpn-off");
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Turn on VPN");
+
+ await setChannelsReadError(page, null);
+ await page.getByTestId("sidebar-connect-vpn").click();
+
+ await expect(card).toContainText("Connected");
+ await expect(card).not.toContainText("Click to connect");
+
+ await page.waitForTimeout(3_000);
+ await expect(card).toContainText("Connected");
+ await expect(card).toBeHidden({ timeout: 5_000 });
+});
+
+test("Block workspace sidebar VPN action stays actionable when refresh still fails", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: CONNECT_ERROR },
+ { relayWsUrl: BLOCK_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ const card = page.getByTestId("sidebar-vpn-off");
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Turn on VPN");
+
+ await page.getByTestId("sidebar-connect-vpn").click();
+
+ await page.waitForTimeout(500);
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Turn on VPN");
+ await expect(card).not.toContainText("Connected");
+
+ await page.waitForTimeout(6_500);
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Turn on VPN");
+ await expect(card).not.toContainText("Connected");
+});
+
+test("custom workspace sidebar proxy failures stay generic", async ({
+ page,
+}) => {
+ await installMockBridge(
+ page,
+ { channelsReadError: PROXY_ERROR },
+ { relayWsUrl: CUSTOM_RELAY_URL },
+ );
+
+ await page.goto("/");
+
+ const card = page.getByTestId("sidebar-relay-unreachable");
+ await expect(card).toBeVisible();
+ await expect(card).toContainText("Can't reach the relay");
+ await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
+ await expect(page.getByTestId("sidebar-vpn-access-refresh")).toHaveCount(0);
+ await expect(page.getByTestId("sidebar-vpn-off")).toHaveCount(0);
+});
diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts
index 18c2b1882..c791359e0 100644
--- a/desktop/tests/e2e/sidebar.spec.ts
+++ b/desktop/tests/e2e/sidebar.spec.ts
@@ -66,3 +66,90 @@ test("resizes, persists, and snaps to the default sidebar width", async ({
.poll(() => storedSidebarWidth(page))
.toBe(String(DEFAULT_SIDEBAR_WIDTH));
});
+
+test("shows a sidebar update card when an update is ready", async ({
+ page,
+}) => {
+ await page.goto("/");
+ await expect(page.getByTestId("app-sidebar")).toBeVisible();
+
+ await page.evaluate(() => {
+ const testWindow = window as Window & {
+ __BUZZ_E2E__?: { mock?: { updateAvailable?: boolean } };
+ };
+
+ testWindow.__BUZZ_E2E__ = {
+ ...(testWindow.__BUZZ_E2E__ ?? {}),
+ mock: {
+ ...(testWindow.__BUZZ_E2E__?.mock ?? {}),
+ restartDelayMs: 500,
+ updateAvailable: true,
+ },
+ };
+ });
+
+ await page.getByTestId("sidebar-profile-card").click();
+ await page.getByTestId("profile-popover-settings").click();
+ await page.getByTestId("settings-nav-updates").click();
+ await page.getByRole("button", { name: "Check for Updates" }).click();
+ await expect(page.getByTestId("settings-panel-updates")).toContainText(
+ "Update installed. Restart to apply.",
+ );
+
+ await page.getByTestId("settings-back-to-app").click();
+
+ const updateCard = page.getByTestId("sidebar-update-card");
+ await expect(updateCard).toBeVisible();
+ await expect(updateCard).toContainText("Ready to update!");
+ await expect(updateCard).toContainText("Click to restart");
+ await expect(page.getByTestId("sidebar-update-restart")).toBeVisible();
+ const reservedCardHeight = await updateCard.evaluate(
+ (element) => (element as HTMLElement).offsetHeight,
+ );
+
+ await page.getByTestId("sidebar-update-restart").click();
+ await expect(updateCard).toContainText("Restarting");
+ await expect(page.getByTestId("sidebar-update-restart")).toBeDisabled();
+
+ await expect
+ .poll(() =>
+ page.evaluate(
+ () =>
+ (
+ window as Window & {
+ __BUZZ_E2E_COMMANDS__?: string[];
+ }
+ ).__BUZZ_E2E_COMMANDS__ ?? [],
+ ),
+ )
+ .toContain("plugin:process|restart");
+
+ const dismissButton = page.getByTestId("sidebar-update-dismiss");
+ await updateCard.hover();
+ const dismissButtonBox = await dismissButton.boundingBox();
+ expect(dismissButtonBox).not.toBeNull();
+ if (!dismissButtonBox) return;
+
+ await page.mouse.move(
+ dismissButtonBox.x + dismissButtonBox.width / 2,
+ dismissButtonBox.y + dismissButtonBox.height / 2,
+ );
+ await page.mouse.down();
+ await expect(page.locator(".buzz-poof-burst")).toHaveCount(1);
+ await expect(updateCard).toBeVisible();
+ await page.mouse.up();
+ await expect(updateCard).toHaveAttribute("data-dismissing", "true");
+ await expect
+ .poll(() =>
+ updateCard.evaluate((element) => (element as HTMLElement).offsetHeight),
+ )
+ .toBe(reservedCardHeight);
+ await expect
+ .poll(() =>
+ updateCard.evaluate((element) =>
+ Number.parseFloat(getComputedStyle(element).opacity),
+ ),
+ )
+ .toBeLessThan(0.05);
+ await expect(updateCard).toBeHidden();
+});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index eb43cad8b..87be7497a 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -98,8 +98,12 @@ type MockBridgeOptions = {
profileReadDelayMs?: number;
profileReadError?: string;
profileUpdateError?: string;
+ profileUpdateErrors?: string[];
searchProfiles?: MockSearchProfileSeed[];
+ updateAvailable?: boolean;
updateChannelDelayMs?: number;
+ updateDownloadDelayMs?: number;
+ updateVersion?: string;
stallWebsocketSends?: boolean;
userSearchDelayMs?: number;
// NIP-IA gate inputs — drive the archive-button gate matrix in
diff --git a/desktop/tests/helpers/screenshot.mjs b/desktop/tests/helpers/screenshot.mjs
index 096a6d182..552247d81 100644
--- a/desktop/tests/helpers/screenshot.mjs
+++ b/desktop/tests/helpers/screenshot.mjs
@@ -21,6 +21,7 @@
// --viewport Viewport dimensions (default: 1280x720)
// --outdir Output directory (default: test-results/screenshots)
// --messages JSON file with messages to inject before capture
+// --update-ready Mock an available update so the sidebar update card renders
import { parseArgs } from "node:util";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
@@ -40,6 +41,7 @@ const { values: args } = parseArgs({
viewport: { type: "string", default: "1280x720" },
outdir: { type: "string", default: "test-results/screenshots" },
messages: { type: "string" },
+ "update-ready": { type: "boolean", default: false },
},
strict: true,
});
@@ -107,31 +109,37 @@ await page.addInitScript(
);
// Install E2E mock bridge config + MockNotification (mirrors installBridge in bridge.ts)
-await page.addInitScript(() => {
- class MockNotification extends EventTarget {
- static permission = "granted";
- static async requestPermission() {
- return "granted";
- }
- body;
- onclick = null;
- title;
- constructor(title, options) {
- super();
- this.title = title;
- this.body = options?.body ?? null;
+await page.addInitScript(
+ ({ updateReady }) => {
+ class MockNotification extends EventTarget {
+ static permission = "granted";
+ static async requestPermission() {
+ return "granted";
+ }
+ body;
+ onclick = null;
+ title;
+ constructor(title, options) {
+ super();
+ this.title = title;
+ this.body = options?.body ?? null;
+ }
+ close() {}
}
- close() {}
- }
- Object.defineProperty(window, "Notification", {
- configurable: true,
- value: MockNotification,
- writable: true,
- });
-
- window.__BUZZ_E2E__ = { mode: "mock" };
- window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
-});
+ Object.defineProperty(window, "Notification", {
+ configurable: true,
+ value: MockNotification,
+ writable: true,
+ });
+
+ window.__BUZZ_E2E__ = {
+ mode: "mock",
+ ...(updateReady ? { mock: { updateAvailable: true } } : {}),
+ };
+ window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
+ },
+ { updateReady: args["update-ready"] },
+);
try {
if (args.messages) {