Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -821,13 +827,18 @@ 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}
hasWorkspaceRail={
workspaceRailEnabled &&
workspacesHook.workspaces.length > 1
}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>
</div>
)}
<AppShellOverlays
Expand Down
66 changes: 66 additions & 0 deletions desktop/src/app/RelayConnectionOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { AnimatePresence, motion } from "motion/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 { useSidebar } from "@/shared/ui/sidebar";

type RelayConnectionOverlayProps = {
card: ReturnType<typeof useSidebarRelayConnectionCard>;
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.
*/
export function RelayConnectionOverlay({
card,
hasWorkspaceRail,
isHuddleDrawerOpen,
}: RelayConnectionOverlayProps) {
const { open: sidebarOpen, openMobile } = useSidebar();
const isMobile = useIsMobile();

// 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 shouldShow =
card.showSidebarRelayConnectionCard && isSidebarSurfaceHidden;

return (
<AnimatePresence>
{shouldShow ? (
<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
Comment thread
klopez4212 marked this conversation as resolved.
Resolved
isConnected={card.isRelayConnectionSuccess}
isReconnectPending={card.isRelayReconnectPending}
isWaitingOnReconnectHook={card.isWaitingOnReconnectHook}
onDismiss={card.onDismissRelayConnectionCard}
onReconnect={card.onReconnectRelay}
/>
</div>
</motion.div>
) : null}
</AnimatePresence>
);
}
51 changes: 22 additions & 29 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
SidebarMenu,
SidebarMenuItem,
SidebarRail,
useSidebar,
} from "@/shared/ui/sidebar";

type CollapsibleSidebarGroup =
Expand All @@ -84,6 +86,7 @@ type AppSidebarProps = {
isCreatingForum: boolean;
isOpeningDm: boolean;
profile?: Profile;
relayConnectionCard: ReturnType<typeof useSidebarRelayConnectionCard>;
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
selectedChannelId: string | null;
Expand Down Expand Up @@ -174,6 +177,7 @@ export function AppSidebar({
isCreatingForum,
isOpeningDm,
profile,
relayConnectionCard,
selfPresenceStatus,
errorMessage,
selectedChannelId,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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>
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
klopez4212 marked this conversation as resolved.
Resolved
const isRelayConnectionConnected = relayConnectionState === "connected";
const isRelayConnectionDisconnected = relayConnectionState === "disconnected";
const [isDismissed, setIsDismissed] = React.useState(false);
Expand Down
72 changes: 0 additions & 72 deletions desktop/src/shared/ui/ConnectionBanner.tsx

This file was deleted.

5 changes: 4 additions & 1 deletion desktop/src/shared/ui/sidebar-action-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down
20 changes: 10 additions & 10 deletions desktop/tests/e2e/sidebar-relay-card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 ({
Expand Down
Loading