Polish sidebar update and relay cards (#1009)

This commit is contained in:
klopez4212
2026-06-17 18:48:35 +01:00
committed by GitHub
parent 1a37ee3951
commit d4cd919b85
37 changed files with 2077 additions and 332 deletions
+1
View File
@@ -62,6 +62,7 @@ export default defineConfig({
"**/integration.spec.ts",
"**/profile.spec.ts",
"**/sidebar.spec.ts",
"**/sidebar-relay-card.spec.ts",
"**/tokens.spec.ts",
"**/persona-env-vars.spec.ts",
"**/mesh-compute.spec.ts",
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Emerge Tools, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+8 -6
View File
@@ -219,6 +219,10 @@ export function AppShell() {
const channelsQuery = useChannelsQuery();
const { refetch: refetchChannels } = channelsQuery;
const channels = channelsQuery.data ?? [];
const channelsErrorMessage =
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined;
const memberChannels = React.useMemo(
() => channels.filter((channel) => channel.isMember),
[channels],
@@ -775,11 +779,7 @@ export function AppShell() {
activeWorkspace={workspacesHook.activeWorkspace}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined
}
errorMessage={channelsErrorMessage}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount}
isAddWorkspaceOpen={isAddWorkspaceOpen}
@@ -911,7 +911,9 @@ export function AppShell() {
className="min-h-0 min-w-0 overflow-hidden"
style={chromeCssVarDefaults}
>
<ConnectionBanner />
<ConnectionBanner
errorMessage={channelsErrorMessage}
/>
<Outlet />
</SidebarInset>
</MainInsetProvider>
@@ -1,6 +1,10 @@
import * as React from "react";
import { toast } from "sonner";
import { SidebarRelayConnectionCompactCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
import { cn } from "@/shared/lib/cn";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import {
@@ -17,11 +21,131 @@ type ProfileStepProps = {
state: ProfileStepState;
};
function ErrorBanner({ message }: { message: string | null }) {
const ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS = 2_500;
function OnboardingRelayConnectionErrorCard({
isSaving,
message,
}: {
isSaving: boolean;
message: string;
}) {
const { isPending: isReconnectPending, reconnect } = useReconnectRelay();
const [dismissedErrorMessage, setDismissedErrorMessage] = React.useState<
string | null
>(null);
const [isReconnectActionPending, setIsReconnectActionPending] =
React.useState(false);
const [hasSuccess, setHasSuccess] = React.useState(false);
const reconnectActionPendingRef = React.useRef(false);
const successTimeoutRef = React.useRef<number | null>(null);
const wasSavingRef = React.useRef(isSaving);
const isActionPending = isReconnectActionPending || isReconnectPending;
React.useEffect(() => {
return () => {
if (successTimeoutRef.current !== null) {
window.clearTimeout(successTimeoutRef.current);
}
};
}, []);
React.useEffect(() => {
if (isSaving && !wasSavingRef.current) {
if (successTimeoutRef.current !== null) {
window.clearTimeout(successTimeoutRef.current);
successTimeoutRef.current = null;
}
setDismissedErrorMessage(null);
setHasSuccess(false);
}
wasSavingRef.current = isSaving;
}, [isSaving]);
const markSuccess = React.useCallback(() => {
setHasSuccess(true);
if (successTimeoutRef.current !== null) {
window.clearTimeout(successTimeoutRef.current);
}
successTimeoutRef.current = window.setTimeout(() => {
successTimeoutRef.current = null;
setDismissedErrorMessage(message);
}, ONBOARDING_CONNECTIVITY_SUCCESS_AUTO_DISMISS_MS);
}, [message]);
const runConnectivityAction = React.useCallback(
(runAction: () => Promise<boolean | undefined>) => {
if (reconnectActionPendingRef.current) {
return;
}
reconnectActionPendingRef.current = true;
setIsReconnectActionPending(true);
setHasSuccess(false);
void Promise.resolve()
.then(runAction)
.then((didReconnect) => {
if (didReconnect !== false) {
markSuccess();
}
})
.catch((error) => {
const detail = error instanceof Error ? error.message : String(error);
toast.error(`Could not reconnect to the relay. ${detail}`);
})
.finally(() => {
reconnectActionPendingRef.current = false;
setIsReconnectActionPending(false);
});
},
[markSuccess],
);
const handleReconnectRelay = React.useCallback(() => {
runConnectivityAction(reconnect);
}, [reconnect, runConnectivityAction]);
if (dismissedErrorMessage === message) {
return null;
}
return (
<div className="fixed bottom-4 left-4 z-50 w-[calc(100vw-2rem)] text-left sm:bottom-6 sm:left-6 sm:w-[22rem]">
<SidebarRelayConnectionCompactCard
actionTestId="onboarding-reconnect-relay"
isActionDisabled={isActionPending}
isConnected={hasSuccess}
isReconnectPending={isActionPending}
onDismiss={() => setDismissedErrorMessage(message)}
onReconnect={handleReconnectRelay}
surface="secondary"
testId="onboarding-relay-reconnect-card"
/>
</div>
);
}
function ErrorBanner({
isSaving,
message,
}: {
isSaving: boolean;
message: string | null;
}) {
if (!message) {
return null;
}
if (isRelayUnreachableError(message)) {
return (
<OnboardingRelayConnectionErrorCard
isSaving={isSaving}
key={message}
message={message}
/>
);
}
return (
<p className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{message}
@@ -117,7 +241,7 @@ export function ProfileStep({
</label>
{saveRecovery.errorMessage ? (
<ErrorBanner message={saveRecovery.errorMessage} />
<ErrorBanner isSaving={isSaving} message={saveRecovery.errorMessage} />
) : null}
<div className="mt-12 flex w-full max-w-[500px] flex-col gap-3">
@@ -1,5 +1,5 @@
import * as React from "react";
import { ChevronRight, RefreshCw, Smile } from "lucide-react";
import { ChevronRight, Smile } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
@@ -28,8 +28,6 @@ interface ProfilePopoverProps {
onSetUserStatus: (text: string, emoji: string) => void;
onClearUserStatus: () => void;
onOpenSettings: (section?: "profile" | "appearance") => void;
onReconnect?: () => void;
isReconnectPending?: boolean;
children: React.ReactNode;
// Optional outer container whose clicks should NOT close the popover.
// Used when auxiliary triggers (avatar, status text) live alongside the
@@ -68,8 +66,6 @@ export function ProfilePopover({
onSetUserStatus,
onClearUserStatus,
onOpenSettings,
onReconnect,
isReconnectPending,
children,
triggerContainerRef,
workspaceSwitcherSlot,
@@ -275,27 +271,6 @@ export function ProfilePopover({
</kbd>
</button>
{onReconnect ? (
<button
className={MENU_ITEM_CLASS}
data-testid="profile-popover-reconnect"
disabled={isReconnectPending}
onClick={() => {
closePopover();
onReconnect();
}}
role="menuitem"
type="button"
>
<RefreshCw
className={`h-4 w-4 shrink-0 text-muted-foreground${isReconnectPending ? " animate-spin" : ""}`}
/>
<span className="flex-1">
{isReconnectPending ? "Reconnecting…" : "Reconnect to relay"}
</span>
</button>
) : null}
{workspaceSwitcherSlot ? (
<>
<hr className="my-1 h-px border-0 bg-border" />
@@ -0,0 +1,101 @@
import * as React from "react";
import { CircleArrowUp } from "lucide-react";
import { useUpdaterContext } from "./hooks/UpdaterProvider";
import { shouldShowSidebarUpdateCard } from "./sidebarUpdateCardVisibility";
import { SidebarCompactActionCard } from "@/shared/ui/sidebar-action-card";
import { Spinner } from "@/shared/ui/spinner";
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<number | null>(null);
const restartTimeoutRef = React.useRef<number | null>(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 (
<SidebarCompactActionCard
actionAriaLabel="Restart now to apply update"
actionDisabled={isRestartPending}
actionTestId={actionTestId}
description={isRestartPending ? "Restarting" : "Click to restart"}
dismissLabel="Dismiss update notification"
icon={
isRestartPending ? (
<Spinner aria-hidden="true" className="h-5 w-5 border-2" />
) : (
<CircleArrowUp aria-hidden="true" className="h-5 w-5" />
)
}
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 (
<SidebarUpdateCompactCard
actionTestId="sidebar-update-restart"
onDismiss={onDismiss}
testId="sidebar-update-card"
/>
);
}
@@ -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 {
@@ -0,0 +1,3 @@
export function shouldShowSidebarUpdateCard(status: { state: string }) {
return status.state === "ready";
}
+70 -44
View File
@@ -10,16 +10,8 @@ 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 { AnimatePresence } from "motion/react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
@@ -48,11 +40,16 @@ import {
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
import { 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,
@@ -60,6 +57,7 @@ import type {
Profile,
UserStatus,
} from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import {
Sidebar,
SidebarContent,
@@ -228,6 +226,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;
@@ -236,6 +259,12 @@ export function AppSidebar({
const [createDialogKind, setCreateDialogKind] =
React.useState<CreateChannelKind | null>(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
@@ -288,20 +317,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;
@@ -563,7 +578,10 @@ export function AppSidebar({
testId="sidebar-more-unread-above"
/>
) : null}
<SidebarContent className="pb-32" ref={scrollRef}>
<SidebarContent
className={cn(sidebarContentBottomPaddingClass)}
ref={scrollRef}
>
{isLoading ? (
<SidebarLoadingContent shape={sidebarLoadingShape} />
) : null}
@@ -754,25 +772,8 @@ export function AppSidebar({
</>
) : null}
{isRelayConnectionDegradedNow ? (
<div
className="px-3 py-2 text-sm"
data-testid="sidebar-relay-unreachable"
>
<span className="text-muted-foreground">
{RELAY_UNREACHABLE_SHORT}{" "}
</span>
<button
className="text-primary hover:underline disabled:opacity-50"
data-testid="sidebar-reconnect"
disabled={isReconnectPending}
onClick={() => void reconnect()}
type="button"
>
{isReconnectPending ? "Reconnecting…" : "Reconnect"}
</button>
</div>
) : errorMessage ? (
{errorMessage &&
!sidebarRelayConnectionCard.hasRelayUnreachableError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
@@ -781,7 +782,7 @@ export function AppSidebar({
{unreadBelowCount > 0 ? (
<MoreUnreadButton
bottomClassName="bottom-28"
bottomClassName={unreadBelowBottomClass}
count={unreadBelowCount}
icon={<ArrowDown />}
onClick={scrollToNextBelow}
@@ -791,6 +792,31 @@ export function AppSidebar({
) : null}
<SidebarFooter className="absolute inset-x-0 bottom-0 z-30 bg-sidebar/55 backdrop-blur-xl supports-[backdrop-filter]:bg-sidebar/45 dark:bg-sidebar/45 dark:supports-[backdrop-filter]:bg-sidebar/35">
<AnimatePresence>
{sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? (
<SidebarRelayConnectionCard
className="mb-2 group-data-[collapsible=icon]:hidden"
isConnected={
sidebarRelayConnectionCard.isRelayConnectionSuccess
}
isReconnectPending={
sidebarRelayConnectionCard.isRelayReconnectPending
}
onDismiss={
sidebarRelayConnectionCard.onDismissRelayConnectionCard
}
onReconnect={sidebarRelayConnectionCard.onReconnectRelay}
key="sidebar-relay-connection-card"
/>
) : null}
</AnimatePresence>
{showSidebarUpdateCard ? (
<div className="mb-2 group-data-[collapsible=icon]:hidden">
<SidebarUpdateCard
onDismiss={() => setIsSidebarUpdateCardDismissed(true)}
/>
</div>
) : null}
<SidebarMenu>
<SidebarMenuItem>
<SidebarProfileCard
@@ -9,11 +9,6 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
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,17 +49,7 @@ export function SidebarProfileCard({
selfUserStatus,
workspaces,
}: SidebarProfileCardProps) {
// Called locally rather than threading props from AppShell — both hooks are
// 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<HTMLDivElement | null>(null);
const toggleProfilePopover = React.useCallback(
@@ -140,15 +125,9 @@ export function SidebarProfileCard({
avatarUrl={profile?.avatarUrl ?? null}
currentStatus={selfPresenceStatus}
displayName={resolvedDisplayName}
isReconnectPending={isPending}
isStatusPending={isPresencePending}
onClearUserStatus={onClearUserStatus}
onOpenSettings={onOpenSettings}
onReconnect={
isRelayConnectionDegradedNow || isPending
? () => void reconnect()
: undefined
}
onSetStatus={onSetPresenceStatus ?? (() => {})}
onSetUserStatus={onSetUserStatus}
triggerContainerRef={profileCardRef}
@@ -0,0 +1,98 @@
import { Check, CloudOff } from "lucide-react";
import {
SidebarCompactActionCard,
type SidebarActionCardSurface,
} from "@/shared/ui/sidebar-action-card";
import { Spinner } from "@/shared/ui/spinner";
type SidebarRelayConnectionCardProps = {
isActionDisabled?: boolean;
actionTestId?: string;
className?: string;
isConnected?: boolean;
isReconnectPending: boolean;
onDismiss?: () => void;
onReconnect: () => void;
surface?: SidebarActionCardSurface;
testId?: string;
};
export function SidebarRelayConnectionCard({
actionTestId,
className,
isActionDisabled = false,
isConnected = false,
isReconnectPending,
onDismiss,
onReconnect,
surface,
}: SidebarRelayConnectionCardProps) {
return (
<SidebarRelayConnectionCompactCard
actionTestId={actionTestId ?? "sidebar-reconnect"}
className={className}
isActionDisabled={isActionDisabled}
isConnected={isConnected}
isReconnectPending={isReconnectPending}
onDismiss={onDismiss}
onReconnect={onReconnect}
surface={surface}
testId="sidebar-relay-unreachable"
/>
);
}
export function SidebarRelayConnectionCompactCard({
actionTestId,
className,
isActionDisabled = false,
isConnected = false,
isReconnectPending,
onDismiss,
onReconnect,
surface,
testId = "sidebar-relay-unreachable-compact",
}: SidebarRelayConnectionCardProps) {
return (
<SidebarCompactActionCard
actionAriaLabel={isConnected ? "Connected" : "Connect to relay"}
actionDisabled={isActionDisabled || isReconnectPending || isConnected}
actionTestId={actionTestId}
description={
isConnected
? undefined
: isReconnectPending
? "Reconnecting"
: "Click to connect"
}
dismissLabel="Dismiss relay notification"
iconKey={
isConnected ? "connected" : isReconnectPending ? "pending" : "idle"
}
icon={
isConnected ? (
<Check aria-hidden="true" className="h-5 w-5" />
) : isReconnectPending ? (
<Spinner aria-hidden="true" className="h-5 w-5 border-2" />
) : (
<CloudOff aria-hidden="true" className="h-5 w-5" />
)
}
className={className}
onAction={onReconnect}
onDismiss={onDismiss}
role={isConnected ? "status" : "alert"}
surface={surface}
testId={testId}
title={
isConnected
? "Connected"
: isReconnectPending
? "Connecting"
: "Can't reach the relay"
}
tone={isConnected ? "success" : "neutral"}
/>
);
}
@@ -0,0 +1,222 @@
import * as React from "react";
import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
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;
}
// Don't let one workspace clear another workspace's success state.
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 isRelayConnectionStateDegraded =
relayConnectionState === "reconnecting" ||
relayConnectionState === "stalled";
const isRelayConnectionConnected = relayConnectionState === "connected";
const isRelayConnectionDisconnected = relayConnectionState === "disconnected";
const [isDismissed, setIsDismissed] = React.useState(false);
const hasSuccess = React.useSyncExternalStore(
subscribeRelayConnectivitySuccess,
() => getRelayConnectivitySuccessSnapshot(relayUrl),
() => false,
);
const [isWindowVisible, setIsWindowVisible] =
React.useState(isDocumentVisible);
const hasActiveRelayUnreachableError =
hasRelayUnreachableError && !hasSuccess;
const isRelayConnectionActuallyDegraded =
hasActiveRelayUnreachableError || isRelayConnectionStateDegraded;
const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected;
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<number | null>(null);
const connectivityTimeoutRef = React.useRef<number | null>(null);
const isRelayReconnectPending =
isReconnectPending || connectivityAction === "relay-connection";
React.useEffect(() => {
if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) {
setIsDismissed(false);
}
}, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]);
React.useEffect(() => {
if (isRelayConnectionStateDegraded || isRelayConnectionDisconnected) {
setRelayConnectivitySuccess(relayUrl, false);
setIsDismissed(false);
}
}, [isRelayConnectionDisconnected, 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<void>) => {
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 {
hasRelayUnreachableError,
isRelayConnectionSuccess,
isRelayReconnectPending,
onDismissRelayConnectionCard: () => {
setRelayConnectivitySuccess(relayUrl, false);
setIsDismissed(true);
},
onReconnectRelay: handleReconnectRelay,
showSidebarRelayConnectionCard: show,
};
}
@@ -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();
+7 -4
View File
@@ -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() {
<ThemeProvider defaultTheme="houston">
<TooltipProvider delayDuration={300}>
<EmojiBurstProvider>
<UpdaterProvider>
<App />
</UpdaterProvider>
<Toaster />
<PoofBurstProvider>
<UpdaterProvider>
<App />
</UpdaterProvider>
<Toaster />
</PoofBurstProvider>
</EmojiBurstProvider>
</TooltipProvider>
</ThemeProvider>
+49 -8
View File
@@ -5,7 +5,7 @@
* Deliberately uses `relayClient.preconnect()` + `queryClient.invalidateQueries()`
* rather than the full `reconnectWorkspace()` path, which unmounts the entire
* React tree and clears drafts. The goal here is a transparent re-handshake
* when WARP VPN comes back online; the user should not lose their in-progress
* when the transport comes back online; the user should not lose their in-progress
* compose state.
*/
@@ -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<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
let timeoutId: number | null = null;
const timeout = new Promise<never>((_, 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<void>;
reconnect: () => Promise<boolean>;
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).
// Run the transport-layer reconnect hook configured by 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.");
toast.error("Reconnect failed — check your network.");
console.error("[useReconnectRelay] reconnect failed:", err);
return false;
} finally {
inFlightRef.current = false;
setIsPending(false);
+1 -36
View File
@@ -1,11 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isRelayUnreachableError,
relayErrorDetail,
RELAY_UNREACHABLE_MESSAGE,
} from "./relayError.ts";
import { isRelayUnreachableError } from "./relayError.ts";
// ── isRelayUnreachableError ───────────────────────────────────────────────────
@@ -49,34 +45,3 @@ test("isRelayUnreachableError: plain object returns false", () => {
false,
);
});
// ── relayErrorDetail ──────────────────────────────────────────────────────────
test("relayErrorDetail: strips prefix and trims for Error", () => {
const err = new Error("relay unreachable: connection refused ");
assert.equal(relayErrorDetail(err), "connection refused");
});
test("relayErrorDetail: strips prefix and trims for string", () => {
assert.equal(
relayErrorDetail("relay unreachable: 403 Forbidden from Cloudflare Access"),
"403 Forbidden from Cloudflare Access",
);
});
test("relayErrorDetail: prefix with no detail returns RELAY_UNREACHABLE_MESSAGE", () => {
assert.equal(
relayErrorDetail("relay unreachable:"),
RELAY_UNREACHABLE_MESSAGE,
);
});
test("relayErrorDetail: unrelated Error returns generic message", () => {
const detail = relayErrorDetail(new Error("something else"));
assert.equal(detail, RELAY_UNREACHABLE_MESSAGE);
});
test("relayErrorDetail: null returns generic message", () => {
const detail = relayErrorDetail(null);
assert.equal(detail, RELAY_UNREACHABLE_MESSAGE);
});
-18
View File
@@ -32,21 +32,3 @@ export function isRelayUnreachableError(error: unknown): boolean {
}
return false;
}
/**
* Returns a human-readable detail string for an error.
*
* When the error is classified as a relay-unreachable error, strips the
* prefix and trims whitespace so the UI sees only the Rust-authored detail
* (e.g. "connection refused" or "403 Forbidden from Cloudflare Access").
*
* Falls back to a generic connectivity message for anything unclassified.
*/
export function relayErrorDetail(error: unknown): string {
if (isRelayUnreachableError(error)) {
const message = error instanceof Error ? error.message : (error as string);
const detail = message.slice(RELAY_UNREACHABLE_PREFIX.length).trim();
return detail || RELAY_UNREACHABLE_MESSAGE;
}
return RELAY_UNREACHABLE_MESSAGE;
}
+252
View File
@@ -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: "<percentage>";
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;
+18 -4
View File
@@ -6,6 +6,8 @@ import {
} from "@/shared/api/useRelayConnection";
import { useReconnectRelay } from "@/shared/api/useReconnectRelay";
import type { ConnectionState } from "@/shared/api/relayClientShared";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
import { useSidebar } from "@/shared/ui/sidebar";
const COPY: Partial<Record<ConnectionState, string>> = {
reconnecting: "Reconnecting to relay…",
@@ -20,17 +22,29 @@ const COPY: Partial<Record<ConnectionState, string>> = {
* The strip auto-disappears once the state transitions back to "connected"
* no success toast needed.
*/
export function ConnectionBanner() {
type ConnectionBannerProps = {
errorMessage?: string;
};
export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
const state = useRelayConnection();
const { isPending, reconnect } = useReconnectRelay();
const { state: sidebarState } = useSidebar();
const hasCollapsedRelayError =
sidebarState === "collapsed" &&
Boolean(errorMessage && isRelayUnreachableError(errorMessage));
if (!isRelayConnectionDegraded(state)) return null;
if (!isRelayConnectionDegraded(state) && !hasCollapsedRelayError) {
return null;
}
const message = COPY[state] ?? "Connection issue detected.";
const message = hasCollapsedRelayError
? "Can't reach the relay."
: (COPY[state] ?? "Connection issue detected.");
return (
<div
className="flex shrink-0 items-center gap-2 border-b border-warning/30 bg-warning/5 px-3 py-2 text-xs"
className="relative z-30 mt-10 flex shrink-0 items-center gap-2 border-b border-warning/30 bg-warning/5 px-3 py-2 text-xs"
data-testid="connection-banner"
role="alert"
>
+190
View File
@@ -0,0 +1,190 @@
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.
}
}
export function PoofBurstProvider({ children }: { children: React.ReactNode }) {
const [bursts, setBursts] = useState<PoofBurst[]>([]);
const idRef = useRef(0);
const timeoutIdsRef = useRef<number[]>([]);
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),
{ ...getPoofOrigin(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}
<div aria-hidden="true" className="buzz-poof-layer">
{bursts.map((burst) => (
<div
className="buzz-poof-burst"
key={burst.id}
style={
{
"--buzz-poof-size": `${burst.size}px`,
"--buzz-poof-x": `${burst.x}px`,
"--buzz-poof-y": `${burst.y}px`,
} as PoofStyle
}
>
{POOF_FRAMES.map((frame, index) => (
<img
alt=""
className={`buzz-poof-frame buzz-poof-frame-${index + 1}`}
decoding="async"
draggable={false}
key={`${burst.id}-${frame.id}`}
src={frame.src}
/>
))}
</div>
))}
</div>
</>
);
}
@@ -0,0 +1,337 @@
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<SidebarActionDescriptionTransition>(() => ({
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 (
<span className="buzz-sidebar-action-description">
{transition.current}
</span>
);
}
return (
<span className="buzz-sidebar-action-description">
<span className="sr-only">{transition.current}</span>
<span aria-hidden className="buzz-sidebar-action-description__motion">
<span
className="buzz-sidebar-action-description__reel"
key={`${transition.version}-${transition.previous}-${transition.current}`}
>
<span>{transition.previous}</span>
<span>{transition.current}</span>
</span>
</span>
</span>
);
}
function SidebarActionDismissButton({
className,
isDismissing,
label,
onDismiss,
onDismissStart,
testId,
}: SidebarActionDismissButtonProps) {
const dismissTimeoutRef = React.useRef<number | null>(null);
React.useEffect(() => {
return () => {
if (dismissTimeoutRef.current !== null) {
window.clearTimeout(dismissTimeoutRef.current);
}
};
}, []);
return (
<button
aria-label={label}
className={cn(
"group/dismiss pointer-events-none absolute -right-1 -top-2 z-10 h-6 w-6 rounded-full text-muted-foreground/45 transition-colors duration-150 ease-out hover:text-foreground/80 focus-visible:pointer-events-auto focus-visible:text-foreground/80 focus-visible:outline-hidden group-hover/sidebar-action-card:pointer-events-auto group-hover/sidebar-compact-action-card:pointer-events-auto",
POOF_TRIGGER_CLASS,
className,
)}
data-testid={testId}
disabled={isDismissing}
onClick={(event) => {
event.stopPropagation();
if (dismissTimeoutRef.current !== null) {
return;
}
onDismissStart();
dismissTimeoutRef.current = window.setTimeout(() => {
dismissTimeoutRef.current = null;
onDismiss();
}, POOF_DURATION_MS);
}}
type="button"
>
<span className="flex h-full w-full scale-95 items-center justify-center rounded-full bg-background opacity-0 shadow-sm ring-1 ring-border/70 transition-[opacity,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:scale-100 group-focus-visible/dismiss:scale-100 group-focus-visible/dismiss:opacity-100 group-focus-visible/dismiss:ring-2 group-focus-visible/dismiss:ring-muted-foreground/40 group-hover/sidebar-action-card:scale-100 group-hover/sidebar-action-card:opacity-100 group-hover/sidebar-compact-action-card:scale-100 group-hover/sidebar-compact-action-card:opacity-100">
<X aria-hidden="true" className="h-4 w-4" />
</span>
</button>
);
}
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.28,
ease: [0.22, 1, 0.36, 1] as const,
};
const cardHiddenState = shouldReduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.9 };
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 (
<motion.div
animate={isDismissing ? cardHiddenState : cardVisibleState}
className={cn(
"group/sidebar-compact-action-card relative w-full origin-bottom",
POOF_ORIGIN_CLASS,
isDismissing && "pointer-events-none",
className,
)}
data-dismissing={isDismissing ? "true" : undefined}
data-testid={testId}
exit={cardHiddenState}
initial={cardHiddenState}
role={role}
transition={cardTransition}
>
<button
aria-label={actionAriaLabel}
className={cn(
"flex w-full items-center gap-3 rounded-xl border px-3 py-3 text-left shadow-xs transition-[background-color,border-color,color,box-shadow] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-muted-foreground/40 disabled:cursor-default disabled:opacity-100",
isSuccess
? "buzz-sidebar-action-card--success disabled:cursor-default disabled:opacity-100"
: surface === "secondary"
? "border-border/70 bg-secondary/80 text-secondary-foreground hover:border-border hover:bg-secondary dark:bg-secondary/60 dark:hover:bg-secondary/70"
: "border-border/70 bg-background/70 text-foreground hover:border-border hover:bg-muted/40 dark:bg-background/50 dark:hover:bg-muted/30",
)}
data-testid={actionTestId}
disabled={actionDisabled}
onClick={onAction}
type="button"
>
<motion.span
className="relative top-[0.1875rem] flex min-h-10 min-w-0 flex-1 flex-col justify-center"
layout="position"
transition={contentTransition}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
animate={{ opacity: 1, y: 0 }}
className="block text-sm font-semibold leading-tight"
exit={contentExit}
initial={contentInitial}
key={title}
transition={contentTransition}
>
{title}
</motion.span>
</AnimatePresence>
<AnimatePresence initial={false}>
{description ? (
<motion.span
animate={{ opacity: 1, y: 0 }}
className="mt-1 block text-xs leading-snug text-muted-foreground"
exit={contentExit}
initial={contentInitial}
key="description"
transition={contentTransition}
>
<SidebarActionDescriptionText
shouldReduceMotion={Boolean(shouldReduceMotion)}
value={description}
/>
</motion.span>
) : null}
</AnimatePresence>
</motion.span>
<motion.span
className={cn(
"ml-auto flex h-10 w-10 shrink-0 items-center justify-center transition-colors duration-150 ease-out",
isSuccess
? "buzz-sidebar-action-card__success-icon"
: "text-muted-foreground group-hover/sidebar-compact-action-card:text-foreground",
)}
layout="position"
transition={contentTransition}
>
<AnimatePresence initial={false} mode="wait">
<motion.span
animate={{ opacity: 1, scale: 1 }}
className="flex h-5 w-5 items-center justify-center"
exit={{ opacity: 0, scale: shouldReduceMotion ? 1 : 0.95 }}
initial={{ opacity: 0, scale: shouldReduceMotion ? 1 : 0.95 }}
key={resolvedIconKey}
transition={contentTransition}
>
{icon}
</motion.span>
</AnimatePresence>
</motion.span>
</button>
{onDismiss ? (
<SidebarActionDismissButton
className={dismissClassName}
isDismissing={isDismissing}
label={dismissLabel}
onDismiss={onDismiss}
onDismissStart={() => setIsDismissing(true)}
testId={dismissTestId(testId)}
/>
) : null}
</motion.div>
);
}
+72 -1
View File
@@ -75,8 +75,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
@@ -2938,6 +2943,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;
@@ -3677,6 +3688,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,
@@ -5977,7 +6038,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.
@@ -6603,6 +6664,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<typeof handleGetChannelWorkflows>[0],
+1
View File
@@ -997,6 +997,7 @@ test("mention text is highlighted in sent messages", async ({ page }) => {
const input = page.getByTestId("message-input");
await input.fill("Hey @bo");
await autocomplete(page).getByText("bob").click();
await expect(input).toHaveText("Hey @bob ");
await page.keyboard.type(suffix);
await page.getByTestId("send-message").click();
+106
View File
@@ -1141,6 +1141,112 @@ 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");
});
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 (network 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");
});
test("relay access failures use the generic reconnect card", async ({
page,
}) => {
await seedActiveIdentity(page, BLANK_TYLER_IDENTITY);
await installMockBridge(
page,
{
profileUpdateError: "relay unreachable: 403 Forbidden",
},
{ 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");
});
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,
}) => {
@@ -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,40 +76,24 @@ 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 },
});
});
test("03 — home feed unreachable", async ({ page }) => {
await installMockBridge(page, { feedReadError: RELAY_UNREACHABLE });
await page.goto("/");
// HomeView renders the error card when the feed query fails.
await expect(
page.getByText(
"Can't reach the relay — check your VPN or network connection.",
),
).toBeVisible();
await settle(page);
await page.screenshot({
path: `${SHOTS}/03-home-unreachable.png`,
clip: { x: 0, y: 0, width: 1280, height: 500 },
});
});
test("04 — canvas unreachable in management sheet", async ({ page }) => {
test("03 — canvas unreachable in management sheet", async ({ page }) => {
await installMockBridge(page, { canvasReadError: RELAY_UNREACHABLE });
await page.goto("/");
@@ -135,11 +123,11 @@ test.describe("relay connectivity screenshots", () => {
// Capture the whole sheet so the error renders in its Canvas-section context.
await sheet.screenshot({
path: `${SHOTS}/04-canvas-unreachable.png`,
path: `${SHOTS}/03-canvas-unreachable.png`,
});
});
test("05 — cached identity shown offline (avatar + display name)", async ({
test("04 — cached identity shown offline (avatar + display name)", async ({
page,
}) => {
// Seed the self-profile cache BEFORE installMockBridge so addInitScript
@@ -168,11 +156,11 @@ test.describe("relay connectivity screenshots", () => {
await settle(page);
await profileCard.screenshot({
path: `${SHOTS}/05-cached-identity-offline.png`,
path: `${SHOTS}/04-cached-identity-offline.png`,
});
});
test("06 — no-cache npub fallback when offline", async ({ page }) => {
test("05 — no-cache npub fallback when offline", async ({ page }) => {
// No cache seeded — profile card falls back to the mock identity npub name.
await installMockBridge(page, { profileReadError: RELAY_UNREACHABLE });
await page.goto("/");
@@ -183,11 +171,11 @@ test.describe("relay connectivity screenshots", () => {
await settle(page);
await profileCard.screenshot({
path: `${SHOTS}/06-no-cache-npub-fallback.png`,
path: `${SHOTS}/05-no-cache-npub-fallback.png`,
});
});
test("07profile popover reconnect button while degraded", async ({
test("06sidebar card shows connected after external relay recovery", async ({
page,
}) => {
await installMockBridge(page);
@@ -196,31 +184,21 @@ 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({
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 page.getByTestId("sidebar-profile-avatar-button").click();
const reconnectBtn = page.getByTestId("profile-popover-reconnect");
await expect(reconnectBtn).toBeVisible();
await driveConnectionDegraded(page, "connected");
// Wait for the Radix popover open animation on the [data-state] ancestor —
// the popper wrapper itself carries no animations, so querying it returns
// an empty list and screenshots capture a half-faded popover.
await reconnectBtn.evaluate((el) =>
Promise.all(
el
.closest("[data-state]")
?.getAnimations()
.map((a) => a.finished) ?? [],
),
);
// Clip to the sidebar-bottom region that includes the open popover.
await page.screenshot({
path: `${SHOTS}/07-profile-popover-reconnect.png`,
clip: { x: 0, y: 300, width: 480, height: 420 },
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,
});
});
});
@@ -20,7 +20,7 @@ async function settle(page: import("@playwright/test").Page) {
/** 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",
state: "connected" | "reconnecting" | "stalled" | "disconnected",
) {
await page.evaluate((s) => {
const setter = (
@@ -49,60 +49,8 @@ async function scrollSidebarToBottom(page: import("@playwright/test").Page) {
});
}
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 ({
test("01 — sidebar has no reconnect prompt when healthy", async ({
page,
}) => {
await installMockBridge(page);
@@ -111,8 +59,8 @@ 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.
// below the fold at this viewport. Scroll to the bottom so 01 frames the
// same region where 02 will show the block — making the absence legible.
await scrollSidebarToBottom(page);
await settle(page);
@@ -120,30 +68,30 @@ test.describe("relay reconnect affordance screenshots", () => {
// 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`,
path: `${SHOTS}/01-sidebar-healthy.png`,
});
});
test("04 — sidebar reconnect prompt shown when degraded, channels visible", async ({
test("02 — 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 driveConnectionState(page, "stalled");
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 5_000,
timeout: 10_000,
});
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).
// Scroll the block clear of the occluding footer (symmetric with 01).
await scrollSidebarToBottom(page);
await settle(page);
await page.getByTestId("app-sidebar").screenshot({
path: `${SHOTS}/04-sidebar-degraded.png`,
path: `${SHOTS}/02-sidebar-degraded.png`,
});
});
});
+10 -15
View File
@@ -79,12 +79,11 @@ test("sidebar reconnect prompt flips on live relay degradation without a query e
// 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");
await driveConnectionDegraded(page, "stalled");
// `disconnected` reports immediately (reconnecting/stalled debounce ~2s),
// but allow margin for the React render to flush.
// `stalled` is debounced before surfacing, then React needs a render tick.
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 5_000,
timeout: 10_000,
});
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
@@ -93,25 +92,21 @@ test("sidebar reconnect prompt flips on live relay degradation without a query e
await expect(page.getByTestId("channel-general")).toBeVisible();
});
test("profile popover reconnect item is hidden when healthy and shown when degraded", async ({
test("profile popover does not show relay reconnect controls", 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,
// The sidebar owns the relay reconnect affordance; the profile popover stays
// focused on profile/settings/workspace actions even while the relay is down.
await driveConnectionDegraded(page, "stalled");
await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0);
});
@@ -0,0 +1,214 @@
import { expect, type Page, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const CONNECT_ERROR = "relay unreachable: could not connect to relay";
const PROXY_ERROR =
"relay unreachable: relay returned an unexpected HTML page (network sign-in?)";
const ACCESS_ERROR = "relay unreachable: 403 Forbidden";
const RELAY_AUTH_ERROR = "Relay authentication rejected.";
type RelayConnectionState =
| "connected"
| "connecting"
| "disconnected"
| "idle"
| "reconnecting"
| "stalled";
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);
}
async function setRelayConnectionState(
page: Page,
state: RelayConnectionState,
) {
await page.waitForFunction(
() =>
typeof (
window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: unknown;
}
).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__ === "function",
);
await page.evaluate((nextState) => {
const testWindow = window as Window & {
__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: (
state: RelayConnectionState,
) => void;
};
const setConnectionState =
testWindow.__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__;
if (!setConnectionState) {
throw new Error("Mock relay connection state helper is not installed.");
}
setConnectionState(nextState);
}, state);
}
async function expectGenericReconnectCard(page: Page) {
const card = page.getByTestId("sidebar-relay-unreachable");
await expect(card).toBeVisible();
await expect(card).toContainText("Can't reach the relay");
await expect(card).toContainText("Click to connect");
await expect(page.getByTestId("sidebar-reconnect")).toBeVisible();
return card;
}
test("sidebar generic relay failures use the reconnect card", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
await expectGenericReconnectCard(page);
});
test("sidebar proxy sign-in failures use the reconnect card", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: PROXY_ERROR });
await page.goto("/");
await expectGenericReconnectCard(page);
});
test("sidebar access failures use the reconnect card", async ({ page }) => {
await installMockBridge(page, { channelsReadError: ACCESS_ERROR });
await page.goto("/");
await expectGenericReconnectCard(page);
});
test("collapsed sidebar relay failures use the connection banner", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
await expectGenericReconnectCard(page);
await page
.getByRole("button", { exact: true, name: "Toggle Sidebar" })
.click();
await expect(
page.locator('[data-state="collapsed"][data-collapsible="offcanvas"]'),
).toHaveCount(1);
const banner = page.getByTestId("connection-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Can't reach the relay.");
await setChannelsReadError(page, null);
await page.getByTestId("connection-banner-reconnect").click();
await expect(banner).toBeHidden({ timeout: 10_000 });
});
test("sidebar stalled relay state uses the reconnect card", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/");
await expect(page.getByTestId("channel-general")).toBeVisible();
await setRelayConnectionState(page, "stalled");
await expectGenericReconnectCard(page);
});
test("sidebar application auth disconnects stay on the error path", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: RELAY_AUTH_ERROR });
await page.goto("/");
await setRelayConnectionState(page, "disconnected");
await expect(page.getByText(RELAY_AUTH_ERROR)).toBeVisible();
await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0);
});
test("sidebar reconnect action shows connected before hiding", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
const card = await expectGenericReconnectCard(page);
await setChannelsReadError(page, null);
await page.getByTestId("sidebar-reconnect").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("sidebar reconnect action suppresses stale refresh errors after success", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
const card = await expectGenericReconnectCard(page);
await page.getByTestId("sidebar-reconnect").click();
await page.waitForTimeout(500);
await expect(card).toBeVisible();
await expect(card).toContainText("Connected");
await expect(card).not.toContainText("Can't reach the relay");
await page.waitForTimeout(6_500);
await expect(card).toBeHidden();
});
test("sidebar connected success clears when relay degrades again", async ({
page,
}) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
const card = await expectGenericReconnectCard(page);
await setChannelsReadError(page, null);
await page.getByTestId("sidebar-reconnect").click();
await expect(card).toContainText("Connected");
await setRelayConnectionState(page, "stalled");
await expect(card).toContainText("Can't reach the relay", {
timeout: 10_000,
});
await expect(card).toContainText("Click to connect");
await expect(card).not.toContainText("Connected");
});
+87
View File
@@ -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();
});
+4
View File
@@ -99,8 +99,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
+31 -23
View File
@@ -21,6 +21,7 @@
// --viewport <WxH> Viewport dimensions (default: 1280x720)
// --outdir <path> Output directory (default: test-results/screenshots)
// --messages <path> 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";
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() {}
}
body;
onclick = null;
title;
constructor(title, options) {
super();
this.title = title;
this.body = options?.body ?? null;
}
close() {}
}
Object.defineProperty(window, "Notification", {
configurable: true,
value: MockNotification,
writable: true,
});
Object.defineProperty(window, "Notification", {
configurable: true,
value: MockNotification,
writable: true,
});
window.__BUZZ_E2E__ = { mode: "mock" };
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
});
window.__BUZZ_E2E__ = {
mode: "mock",
...(updateReady ? { mock: { updateAvailable: true } } : {}),
};
window.__BUZZ_E2E_APP_BADGE_COUNT__ = 0;
},
{ updateReady: args["update-ready"] },
);
try {
if (args.messages) {