feat(reconnect): replace top banner with animated sidebar overlay (#1510)

This commit is contained in:
klopez4212
2026-07-04 08:37:34 -07:00
committed by GitHub
parent 20d3cdc22f
commit f35aeb7986
7 changed files with 171 additions and 117 deletions
+16 -4
View File
@@ -79,8 +79,9 @@ import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
import { ConnectionBanner } from "@/shared/ui/ConnectionBanner";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
@@ -180,6 +181,10 @@ export function AppShell() {
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined;
const relayConnectionCard = useSidebarRelayConnectionCard(
channelsErrorMessage,
workspacesHook.activeWorkspace?.relayUrl,
);
const memberChannels = React.useMemo(
() => channels.filter((channel) => channel.isMember),
[channels],
@@ -688,6 +693,7 @@ export function AppShell() {
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount + dueReminderBadge}
isAddWorkspaceOpen={isAddWorkspaceOpen}
relayConnectionCard={relayConnectionCard}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
@@ -821,13 +827,19 @@ export function AppShell() {
style={chromeCssVarDefaults}
>
<div className="relative z-10 mb-2 ml-px mr-2 mt-px flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-background shadow-[-1px_-1px_0_0_hsl(var(--sidebar-border)/0.45)]">
<ConnectionBanner
errorMessage={channelsErrorMessage}
/>
<Outlet />
</div>
</SidebarInset>
</MainInsetProvider>
<RelayConnectionOverlay
card={relayConnectionCard}
errorMessage={channelsErrorMessage}
hasWorkspaceRail={
workspaceRailEnabled &&
workspacesHook.workspaces.length > 1
}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>
</div>
)}
<AppShellOverlays
+111
View File
@@ -0,0 +1,111 @@
import { AnimatePresence, motion } from "motion/react";
import { AlertCircle } from "lucide-react";
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { cn } from "@/shared/lib/cn";
import { useIsMobile } from "@/shared/hooks/use-mobile";
import { useRelayConnection } from "@/shared/api/useRelayConnection";
import { useSidebar } from "@/shared/ui/sidebar";
type RelayConnectionOverlayProps = {
card: ReturnType<typeof useSidebarRelayConnectionCard>;
errorMessage?: string;
hasWorkspaceRail?: boolean;
isHuddleDrawerOpen?: boolean;
};
/**
* Fixed bottom-left overlay that shows the relay reconnect card when the
* sidebar is collapsed. When the sidebar is open, the card lives in the
* sidebar footer instead (and this overlay is hidden). Offsets itself for
* the workspace rail (48px) and huddle drawer when present.
*
* Also surfaces non-unreachable disconnect errors (e.g. auth rejections)
* when the sidebar is hidden, since those errors are only rendered inside
* the sidebar content area which is off-canvas when collapsed.
*/
export function RelayConnectionOverlay({
card,
errorMessage,
hasWorkspaceRail,
isHuddleDrawerOpen,
}: RelayConnectionOverlayProps) {
const { open: sidebarOpen, openMobile } = useSidebar();
const isMobile = useIsMobile();
const connectionState = useRelayConnection();
// Show the overlay when the sidebar surface isn't visible:
// - Desktop: sidebar is collapsed (open === false)
// - Mobile: the sheet is closed (openMobile === false)
const isSidebarSurfaceHidden = isMobile ? !openMobile : !sidebarOpen;
const shouldShowReconnectCard =
card.showSidebarRelayConnectionCard && isSidebarSurfaceHidden;
// Show a non-unreachable error (e.g. auth rejection) when the sidebar is
// hidden and the reconnect card isn't already covering it.
const hasNonUnreachableError =
Boolean(errorMessage) &&
!card.hasRelayUnreachableError &&
connectionState === "disconnected";
const shouldShowErrorFallback =
hasNonUnreachableError &&
isSidebarSurfaceHidden &&
!shouldShowReconnectCard;
return (
<AnimatePresence>
{shouldShowReconnectCard ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={cn(
"pointer-events-none fixed z-50 w-[284px]",
hasWorkspaceRail ? "left-[60px]" : "left-3",
isHuddleDrawerOpen
? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]"
: "bottom-3",
)}
exit={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: -20 }}
key="relay-connection-overlay"
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
<div className="pointer-events-auto rounded-xl bg-background shadow-md">
<SidebarRelayConnectionCard
isConnected={card.isRelayConnectionSuccess}
isReconnectPending={card.isRelayReconnectPending}
isWaitingOnReconnectHook={card.isWaitingOnReconnectHook}
onDismiss={card.onDismissRelayConnectionCard}
onReconnect={card.onReconnectRelay}
/>
</div>
</motion.div>
) : null}
{shouldShowErrorFallback ? (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={cn(
"pointer-events-none fixed z-50 w-[284px]",
hasWorkspaceRail ? "left-[60px]" : "left-3",
isHuddleDrawerOpen
? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]"
: "bottom-3",
)}
exit={{ opacity: 0, y: 20 }}
initial={{ opacity: 0, y: -20 }}
key="relay-error-overlay"
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
<div
className="pointer-events-auto flex items-center gap-2 rounded-xl bg-background px-3 py-2.5 text-sm text-destructive shadow-md"
data-testid="relay-error-overlay"
role="alert"
>
<AlertCircle aria-hidden="true" className="h-4 w-4 shrink-0" />
<span className="flex-1">{errorMessage}</span>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
+22 -29
View File
@@ -1,12 +1,13 @@
// biome-ignore format: keep compact to stay within file size limit
import { MessageCirclePlus } from "lucide-react";
import * as React from "react";
import { AnimatePresence } from "motion/react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import type { Workspace } from "@/features/workspaces/types";
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
import { useIsMobile } from "@/shared/hooks/use-mobile";
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
import {
useChannelSections,
@@ -34,7 +35,7 @@ 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 type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import {
SidebarLoadingContent,
useSidebarLoadingShape,
@@ -62,6 +63,7 @@ import {
SidebarMenu,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/shared/ui/sidebar";
type CollapsibleSidebarGroup =
@@ -84,6 +86,7 @@ type AppSidebarProps = {
isCreatingForum: boolean;
isOpeningDm: boolean;
profile?: Profile;
relayConnectionCard: ReturnType<typeof useSidebarRelayConnectionCard>;
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
selectedChannelId: string | null;
@@ -174,6 +177,7 @@ export function AppSidebar({
isCreatingForum,
isOpeningDm,
profile,
relayConnectionCard,
selfPresenceStatus,
errorMessage,
selectedChannelId,
@@ -225,10 +229,8 @@ export function AppSidebar({
const activeWorkingByChannelId = useActiveWorkingChannelsById();
const { status: updateStatus } = useUpdaterContext();
const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus);
const sidebarRelayConnectionCard = useSidebarRelayConnectionCard(
errorMessage,
activeWorkspace?.relayUrl,
);
const { open: sidebarOpen, openMobile } = useSidebar();
const isMobile = useIsMobile();
const [isSidebarUpdateCardDismissed, setIsSidebarUpdateCardDismissed] =
React.useState(false);
const showSidebarUpdateCard =
@@ -741,8 +743,7 @@ export function AppSidebar({
</>
) : null}
{errorMessage &&
!sidebarRelayConnectionCard.hasRelayUnreachableError ? (
{errorMessage && !relayConnectionCard.hasRelayUnreachableError ? (
<div className="px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
@@ -762,27 +763,19 @@ export function AppSidebar({
) : null}
<SidebarFooter>
<AnimatePresence>
{sidebarRelayConnectionCard.showSidebarRelayConnectionCard ? (
<SidebarRelayConnectionCard
className="mb-2 group-data-[collapsible=icon]:hidden"
isConnected={
sidebarRelayConnectionCard.isRelayConnectionSuccess
}
isReconnectPending={
sidebarRelayConnectionCard.isRelayReconnectPending
}
isWaitingOnReconnectHook={
sidebarRelayConnectionCard.isWaitingOnReconnectHook
}
onDismiss={
sidebarRelayConnectionCard.onDismissRelayConnectionCard
}
onReconnect={sidebarRelayConnectionCard.onReconnectRelay}
key="sidebar-relay-connection-card"
/>
) : null}
</AnimatePresence>
{relayConnectionCard.showSidebarRelayConnectionCard &&
(isMobile ? openMobile : sidebarOpen) ? (
<SidebarRelayConnectionCard
className="mb-2"
isConnected={relayConnectionCard.isRelayConnectionSuccess}
isReconnectPending={relayConnectionCard.isRelayReconnectPending}
isWaitingOnReconnectHook={
relayConnectionCard.isWaitingOnReconnectHook
}
onDismiss={relayConnectionCard.onDismissRelayConnectionCard}
onReconnect={relayConnectionCard.onReconnectRelay}
/>
) : null}
{showSidebarUpdateCard ? (
<div className="mb-2 group-data-[collapsible=icon]:hidden">
<SidebarUpdateCard
@@ -68,9 +68,16 @@ export function useSidebarRelayConnectionCard(
const hasRelayUnreachableError = errorMessage
? isRelayUnreachableError(errorMessage)
: false;
// True when the error is an application-level issue (e.g. auth rejection)
// rather than a network-level relay-unreachable error. In this case, the
// disconnected state should NOT trigger the reconnect card — the app shows
// a dedicated error path instead.
const hasNonUnreachableError =
Boolean(errorMessage) && !hasRelayUnreachableError;
const isRelayConnectionStateDegraded =
relayConnectionState === "reconnecting" ||
relayConnectionState === "stalled";
relayConnectionState === "stalled" ||
(relayConnectionState === "disconnected" && !hasNonUnreachableError);
const isRelayConnectionConnected = relayConnectionState === "connected";
const isRelayConnectionDisconnected = relayConnectionState === "disconnected";
const [isDismissed, setIsDismissed] = React.useState(false);
@@ -1,72 +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";
import { isRelayUnreachableError } from "@/shared/lib/relayError";
import { useSidebar } from "@/shared/ui/sidebar";
const COPY: Partial<Record<ConnectionState, string>> = {
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.
*/
type ConnectionBannerProps = {
errorMessage?: string;
};
export function ConnectionBanner({ errorMessage }: ConnectionBannerProps) {
const state = useRelayConnection();
const { isPending, isWaitingOnReconnectHook, reconnect } =
useReconnectRelay();
const { state: sidebarState } = useSidebar();
const hasCollapsedRelayError =
sidebarState === "collapsed" &&
state !== "connected" &&
Boolean(errorMessage && isRelayUnreachableError(errorMessage));
if (!isRelayConnectionDegraded(state) && !hasCollapsedRelayError) {
return null;
}
const message = hasCollapsedRelayError
? "Can't reach the relay."
: (COPY[state] ?? "Connection issue detected.");
const buttonLabel = isWaitingOnReconnectHook
? "Waiting to reconnect…"
: isPending
? "Reconnecting…"
: "Reconnect";
return (
<div
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"
>
<WifiOff className="h-3 w-3 shrink-0 text-warning" />
<span className="flex-1 text-muted-foreground">{message}</span>
<button
className="font-medium text-warning hover:underline disabled:opacity-50"
data-testid="connection-banner-reconnect"
disabled={isPending}
onClick={reconnect}
type="button"
>
{buttonLabel}
</button>
</div>
);
}
@@ -264,7 +264,10 @@ export function SidebarCompactActionCard({
type="button"
>
<motion.span
className="relative top-[0.1875rem] flex min-h-10 min-w-0 flex-1 flex-col justify-center"
className={cn(
"relative flex min-h-10 min-w-0 flex-1 flex-col justify-center",
description && "top-[0.1875rem]",
)}
layout="position"
transition={contentTransition}
>
+10 -10
View File
@@ -133,14 +133,12 @@ test("sidebar access failures use the reconnect card", async ({ page }) => {
await expectGenericReconnectCard(page);
});
test("collapsed sidebar relay failures use the connection banner", async ({
page,
}) => {
test("collapsed sidebar still shows the reconnect card", async ({ page }) => {
await installMockBridge(page, { channelsReadError: CONNECT_ERROR });
await page.goto("/");
// Drive degraded state so the card (and subsequently banner) appears.
// Drive degraded state so the card appears.
await setRelayConnectionState(page, "disconnected");
await expectGenericReconnectCard(page);
@@ -152,15 +150,17 @@ test("collapsed sidebar relay failures use the connection banner", async ({
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.");
// The card remains visible via the fixed overlay even with sidebar collapsed.
const card = page.getByTestId("sidebar-relay-unreachable");
await expect(card).toBeVisible();
await expect(card).toContainText("Can't reach the relay");
await setChannelsReadError(page, null);
await page.getByTestId("connection-banner-reconnect").click();
// Drive connected so the banner shows "Connected" and auto-dismisses.
await page.getByTestId("sidebar-reconnect").click();
// Drive connected so the card shows success and auto-dismisses.
await setRelayConnectionState(page, "connected");
await expect(banner).toBeHidden({ timeout: 10_000 });
await expect(card).toContainText("Connected");
await expect(card).toBeHidden({ timeout: 10_000 });
});
test("sidebar stalled relay state uses the reconnect card", async ({