Files
buzz/desktop/src/app/AppShell.tsx
Taylor Ho c442a90a17 fix(desktop-chrome): preserve balanced layout when sidebar collapses (#6000)
**Category:** fix
**User Impact:** The desktop content surface now keeps balanced chrome
spacing when the sidebar is collapsed or multiple communities are
visible, with a cleaner sidebar transition.

**Problem:** Collapsing the left sidebar removed the content surface's
left inset, while multi-community layouts also exposed uneven visible
gutters and sidebar content during the exit transition. **Solution:**
Preserve an 8px visible gutter around the content surface, clip and fade
only the sidebar's inner content while it trails off canvas, and leave
the opaque shell in place until the existing visibility transition
completes.

<details>
<summary>File changes</summary>

**desktop/src/app/BuzzThemeSurfaces.tsx**
Preserves a balanced visible left gutter after accounting for the
content-edge shadow.

**desktop/src/shared/ui/sidebar.tsx**
Clips the collapsing sidebar, disables interaction off canvas, and
fades/translates its inner content without exposing a different
background.

**desktop/tests/e2e/community-rail.spec.ts**
Adds gutter assertions for single-community collapsed-sidebar and
multi-community layouts.

**desktop/tests/e2e/sidebar.spec.ts**
Covers the collapse opacity, translation, clipping, pointer-event
behavior, and restoration on reopen.

</details>

## Reproduction steps

1. Launch the desktop app with the Buzz theme and at least two
communities.
2. Collapse the channel sidebar.
3. Confirm the main content surface keeps equal visible left and right
gutters and does not overlap the community rail.
4. Reopen and collapse the sidebar again; confirm its contents fade and
trail right while the chrome background stays opaque, then restore fully
when reopened.

## Screenshots

| Single community | Multiple communities |
| --- | --- |
| ![Sidebar collapse with a single
community](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6000/multiple-communities-collapse.gif)
| ![Sidebar collapse with multiple
communities](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6000/single-community-collapse.gif)
|

## Validation

- `pnpm build:e2e`
- focused sidebar integration tests: 2 passed
- collapsed multi-community smoke regression: 1 passed
- Biome check on all four changed files
- pre-push desktop check, typecheck, and test hooks
- `git diff --check`

Related issue/PR: none found.

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-18 12:54:40 -07:00

958 lines
37 KiB
TypeScript

import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers";
import { useTerminalContext } from "@/app/useTerminalContext";
import { AppShellProvider } from "@/app/AppShellContext";
import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
import { AppHuddleShell } from "@/app/AppHuddleShell";
import { AppTopChrome } from "@/app/AppTopChrome";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions";
import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog";
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useAppShellKeyboardShortcuts } from "@/app/useAppShellKeyboardShortcuts";
import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications";
import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects";
import { useChannelActivityProjection } from "@/app/useChannelActivityProjection";
import { useTauriWindowDrag } from "@/app/useTauriWindowDrag";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import { useHuddlePresentation } from "@/app/useHuddlePresentation";
import { shouldShowSidebarChannel } from "@/app/huddleChannelVisibility";
import {
channelsQueryKey,
useChannelsQuery,
useCreateChannelMutation,
useHideDmMutation,
useOpenDmMutation,
} from "@/features/channels/hooks";
import { useUnreadChannels } from "@/features/channels/useUnreadChannels";
import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications";
import { useFeedItemState } from "@/features/home/useFeedItemState";
import { useThreadFollows } from "@/features/messages/lib/useThreadFollows";
import {
useHomeFeedNotifications,
useHomeFeedNotificationState,
} from "@/features/notifications/hooks";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh";
import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManagedAgentRuntimeReconciliation";
import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy";
import { usePersonaSync } from "@/features/agents/lib/usePersonaSync";
import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion";
import { AgentManagementDialogs } from "@/features/agents/ui/AgentManagementDialogs";
import { RequestedAgentCreateDialogs } from "@/features/agents/ui/RequestedAgentCreateDialogs";
import {
usePresenceSession,
usePresenceSubscription,
} from "@/features/presence/hooks";
import {
useSetUserStatusMutation,
useUserStatusQuery,
useUserStatusSubscription,
} from "@/features/user-status/hooks";
import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
import { useArchiveSync } from "@/features/local-archive/archiveSyncManager";
import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed";
import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed";
import { useProfileQuery } from "@/features/profile/hooks";
import { SendFeedbackController } from "@/features/settings/ui/SendFeedbackController";
import {
DEFAULT_SETTINGS_SECTION,
type SettingsSection,
isSettingsSection,
} from "@/features/settings/ui/SettingsPanels";
import { useDueReminderBadgeCount } from "@/features/reminders/hooks";
import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest";
import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
import { useCommunities } from "@/features/communities/useCommunities";
import {
consumePendingCommunityRestore,
loadCommunityDestination,
saveCommunityDestination,
} from "@/features/communities/communityNavigationStorage";
import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock";
import { joinChannel } from "@/shared/api/tauri";
import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
import { useAppDeepLinks } from "@/shared/useAppDeepLinks";
import { SidebarProvider } from "@/shared/ui/sidebar";
import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu";
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
import { LazySettingsScreen } from "@/app/LazySettingsScreen";
const EMPTY_CHANNELS: Channel[] = [];
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
useWebviewScrollBoundaryLock();
const communitiesHook = useCommunities();
const {
handleHuddleCompanionOpen,
handleHuddleEnded,
handleHuddleStartPendingChange,
handleHuddleStarted,
handleHuddleVisibilityChange,
handleSidebarChannelSelect,
huddleBackingChannelIds,
revealedHuddleChannelIds,
isHuddleCompanionOpen,
isHuddleDrawerOpen,
isHuddleRoom,
isHuddleRoomStarting,
showHuddleInMainApp,
viewHuddleChannel,
} = useHuddlePresentation();
const hasCommunityRail = communitiesHook.communities.length > 1;
const addCommunityDialog = useAddCommunityDialogState();
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
null,
);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [scopeSearchFocusRequest, setScopeSearchFocusRequest] =
React.useState(0);
const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false);
const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false);
const mainInsetRef = React.useRef<HTMLElement>(null);
const location = useLocation();
const queryClient = useQueryClient();
useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot
const {
goAgents,
goChannel,
goHome,
goNewMessage,
goProjects,
goPulse,
goSettings,
goWorkflows,
closeSettings,
openSearchHit,
} = useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
const { selectedChannelId, selectedView } = React.useMemo(
() => deriveShellRoute(location.pathname),
[location.pathname],
);
const {
removeCommunity: handleRemoveCommunity,
switchCommunity: handleSwitchCommunity,
} = useCommunityNavigationTransitions({
communities: communitiesHook,
goHome,
selectedChannelId,
selectedView,
});
// Settings lives in history so back returns to the previous app entry.
const settingsOpen = location.pathname === "/settings";
const locationSearchSection = (location.search as { section?: unknown })
.section;
const settingsSection: SettingsSection = isSettingsSection(
locationSearchSection,
)
? locationSearchSection
: DEFAULT_SETTINGS_SECTION;
const startupReady = useDeferredStartup();
const identityQuery = useIdentityQuery();
const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes(
identityQuery.data?.pubkey,
communitiesHook.activeCommunity?.relayUrl,
);
const { starredChannelIds, starChannel, unstarChannel } = useChannelStars(
identityQuery.data?.pubkey,
communitiesHook.activeCommunity?.relayUrl,
);
usePersonaSync(
identityQuery.data?.pubkey,
communitiesHook.activeCommunity?.relayUrl,
);
useAgentsDataRefresh();
// Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON).
useAutoRestartPolicy();
// Owner-global observer ingestion: receives + decrypts agent observer
// frames and keeps derived active-turn liveness in sync app-wide, so no
// individual screen/panel has to mount its own bridge for ingestion.
// Intentionally mounted without a `startupReady`/identity guard: before
// `currentPubkey` resolves the hook ingests managed agents only, and
// relay-owned agents join automatically once identity arrives. Adding a
// guard here would drop managed-agent coverage during startup.
useAgentObserverIngestion();
// Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not
// deferred): seeds kind 24200 for fresh identities, no-ops for explicit
// opt-outs. Frames before the listener opens are permanently lost.
const observerReconciled = useObserverArchiveReconciliation(
identityQuery.data?.pubkey,
);
// useArchiveSync must wait for reconciliation, or listeners could open
// before kind 24200 is guaranteed present in the subscription.
useArchiveSync(observerReconciled);
// Kind 44200 is relay-persisted (durable) and stays deferred: missed
// startup frames can be replayed, so there's no ordering constraint.
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
useAgentMetricArchiveSeed(deferredPubkey);
const profileQuery = useProfileQuery();
useRelayAutoHeal();
usePresenceSubscription();
useUserStatusSubscription();
useCommunityEmojiLiveUpdates();
useMembershipNotifications(identityQuery.data?.pubkey);
const presenceSession = usePresenceSession(deferredPubkey);
const selfStatusQuery = useUserStatusQuery(
deferredPubkey ? [deferredPubkey] : [],
);
const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey);
const { feedProfilesQuery, homeFeedQuery, notificationSettings } =
useHomeFeedNotifications(identityQuery.data?.pubkey);
const feedItemState = useFeedItemState(identityQuery.data?.pubkey);
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data ?? [];
useReminderNotifications(
identityQuery.data?.pubkey,
notificationSettings.settings,
channels,
);
const refetchHomeFeedFromLiveSignal = React.useEffectEvent(() => {
void homeFeedQuery.refetch();
});
useLiveHomeFeedActions(
identityQuery.data?.pubkey,
refetchHomeFeedFromLiveSignal,
);
const { refetch: refetchChannels } = channelsQuery;
const channelsErrorMessage =
channelsQuery.error instanceof Error
? channelsQuery.error.message
: undefined;
const relayConnectionCard = useSidebarRelayConnectionCard(
channelsErrorMessage,
communitiesHook.activeCommunity?.relayUrl,
`${communitiesHook.activeCommunity?.id ?? "none"}-${communitiesHook.reinitKey}`,
);
const memberChannels = React.useMemo(
() => channels.filter((channel) => channel.isMember),
[channels],
);
const sidebarChannels = React.useMemo(
() =>
memberChannels.filter(
(channel) =>
channel.archivedAt === null &&
shouldShowSidebarChannel(
channel,
huddleBackingChannelIds,
revealedHuddleChannelIds,
),
),
[huddleBackingChannelIds, memberChannels, revealedHuddleChannelIds],
);
const hasRestoredCommunityDestinationRef = React.useRef(false);
React.useEffect(() => {
const activeCommunityId = communitiesHook.activeCommunity?.id;
if (
hasRestoredCommunityDestinationRef.current ||
!channelsQuery.isSuccess ||
channelsQuery.dataUpdatedAt === 0 ||
!activeCommunityId
) {
return;
}
hasRestoredCommunityDestinationRef.current = true;
// Restoration belongs to an explicit community transition. Cold boot and
// reconnect remounts must preserve the route the user explicitly opened.
if (!consumePendingCommunityRestore(activeCommunityId)) {
return;
}
const destination = loadCommunityDestination(activeCommunityId);
if (!destination || destination.kind === "home") {
return;
}
const channelIsAvailable = sidebarChannels.some(
(channel) => channel.id === destination.channelId,
);
if (!channelIsAvailable) {
saveCommunityDestination(activeCommunityId, { kind: "home" });
void goHome({ replace: true });
return;
}
// The normal switch path writes the remembered channel into the hash before
// the target community mounts, so no intermediate Inbox frame is painted.
// Older transition callers may still arrive at neutral Home; repair those.
if (selectedView === "home") {
void goChannel(destination.channelId, { replace: true });
}
}, [
channelsQuery.dataUpdatedAt,
channelsQuery.isSuccess,
communitiesHook.activeCommunity?.id,
goChannel,
goHome,
selectedView,
sidebarChannels,
]);
const { activeChannel, terminalContext } = useTerminalContext({
channelId: selectedChannelId,
channels,
locationSearch: location.search,
pubkey: identityQuery.data?.pubkey,
relayUrl: communitiesHook.activeCommunity?.relayUrl,
});
const managedChannel = React.useMemo(() => {
const targetChannelId = managedChannelId ?? selectedChannelId;
return targetChannelId
? (channels.find((channel) => channel.id === targetChannelId) ?? null)
: null;
}, [channels, managedChannelId, selectedChannelId]);
const {
handleChannelNotification,
handleDmNotification,
handleThreadReplyDesktopNotification,
} = useAppShellDesktopNotifications({
channels,
enabled: !isHuddleRoom,
goChannel,
goHome,
notificationSettings: notificationSettings.settings,
openSearchHit,
pubkey: identityQuery.data?.pubkey,
silentChannelIds: huddleBackingChannelIds,
});
const {
followedRootIds,
isFollowing: isFollowingThread,
followThread,
unfollowThread,
} = useThreadFollows(identityQuery.data?.pubkey);
const {
markAllChannelsRead: markAllChannelReadMarkers,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
unreadChannelIds,
topLevelUnreadChannelIds,
unreadChannelCounts,
highPriorityUnreadChannelIds,
unreadChannelNotificationCount,
getEffectiveTimestamp: getChannelReadAt,
getOwnTimestamp: getOwnReadAt,
readStateVersion,
setContextParentResolver,
participatedRootIds,
authoredRootIds,
mentionedRootIds,
recordThreadInteraction,
threadActivityItems,
mutedRootIds,
muteThread,
unmuteThread,
} = useUnreadChannels(
isHuddleRoom ? EMPTY_CHANNELS : sidebarChannels,
isHuddleRoom ? null : activeChannel,
{
pubkey: identityQuery.data?.pubkey,
relayClient,
relayUrl: communitiesHook.activeCommunity?.relayUrl,
currentPubkey: identityQuery.data?.pubkey,
mutedChannelIds,
notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
onChannelMessage: handleChannelNotification,
onDmMessage: handleDmNotification,
onLiveMention: refetchHomeFeedFromLiveSignal,
onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification,
followedRootIds,
},
);
const {
getThreadReadAt,
markThreadRead,
getMessageReadAt,
getChannelActivityItemReadAt,
markMessageRead,
threadActivityFeedItems,
locallyUnreadFeedItems,
unreadThreadFeedItems,
unreadThreadChannelIds,
} = useChannelActivityProjection({
channels,
feed: homeFeedQuery.data?.feed,
unreadFeedItemIds: feedItemState.unreadSet,
getChannelReadAt,
getOwnReadAt,
markChannelRead,
readStateVersion,
threadActivityItems,
mutedRootIds,
});
const markAllChannelsRead = React.useCallback(() => {
markAllReadSources({
activeChannelId: activeChannel?.id ?? null,
channelActivityItems: unreadThreadFeedItems,
markAllChannelReadMarkers,
markActiveChannelRead: (channelId, createdAt) =>
markChannelRead(channelId, new Date(createdAt * 1_000).toISOString()),
undoUnreadFeedItem: feedItemState.undoUnread,
unreadFeedItemIds: feedItemState.unreadSet,
});
}, [
activeChannel?.id,
feedItemState.undoUnread,
feedItemState.unreadSet,
markAllChannelReadMarkers,
markChannelRead,
unreadThreadFeedItems,
]);
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
useHomeFeedNotificationState(
homeFeedQuery.data,
identityQuery.data?.pubkey,
notificationSettings.settings,
notificationSettings.setDesktopEnabled,
!isHuddleRoom,
selectedView === "home" && !settingsOpen,
getChannelReadAt,
readStateVersion,
highPriorityUnreadChannelIds,
feedProfilesQuery.data?.profiles,
mutedChannelIds,
feedItemState.unreadSet,
threadActivityFeedItems,
getThreadReadAt,
getMessageReadAt,
channels,
huddleBackingChannelIds,
);
const dueReminderBadge = useDueReminderBadgeCount(
identityQuery.data?.pubkey,
notificationSettings.settings.homeBadgeEnabled,
);
const isNotifiedForThread = React.useCallback(
(rootId: string) =>
!mutedRootIds.has(rootId) &&
(followedRootIds.has(rootId) ||
participatedRootIds.has(rootId) ||
authoredRootIds.has(rootId) ||
mentionedRootIds.has(rootId)),
[
followedRootIds,
mutedRootIds,
participatedRootIds,
authoredRootIds,
mentionedRootIds,
],
);
const handleFollowThread = React.useCallback(
(rootId: string) => {
followThread(rootId);
unmuteThread(rootId);
},
[followThread, unmuteThread],
);
const handleUnfollowThread = React.useCallback(
(rootId: string) => {
unfollowThread(rootId);
muteThread(rootId);
},
[unfollowThread, muteThread],
);
const createChannelMutation = useCreateChannelMutation(),
createForumMutation = useCreateChannelMutation();
const { applyCanvas, applyAgents } = useApplyTemplate();
const openDmMutation = useOpenDmMutation();
const hideDmMutation = useHideDmMutation();
const {
browseDialogType,
openBrowseChannels: handleOpenBrowseChannels,
onBrowseDialogOpenChange: handleBrowseDialogOpenChange,
getCreateSuccess,
} = useChannelBrowserDialog(() => void refetchChannels());
const handleOpenSearch = React.useCallback(() => {
setSearchFocusRequest((request) => request + 1);
void refetchChannels();
}, [refetchChannels]);
const handleOpenChannelSearch = React.useCallback(() => {
setScopeSearchFocusRequest((request) => request + 1);
void refetchChannels();
}, [refetchChannels]);
const handleBrowseChannelJoin = React.useCallback(
async (channelId: string) => {
await joinChannel(channelId);
await queryClient.invalidateQueries({ queryKey: channelsQueryKey });
},
[queryClient],
);
const handleCreateChannel = React.useCallback(
async (
{
description,
name,
visibility,
ttlSeconds,
templateId,
}: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
},
onCreated?: (channelId: string) => void,
) => {
const createdChannel = await createChannelMutation.mutateAsync({
name,
description,
channelType: "stream",
visibility,
ttlSeconds,
});
await applyCanvas(templateId, createdChannel.id, name);
await goChannel(createdChannel.id);
onCreated?.(createdChannel.id);
void applyAgents(templateId, createdChannel.id);
},
[applyAgents, applyCanvas, createChannelMutation, goChannel],
);
const handleCreateForum = React.useCallback(
async ({
description,
name,
visibility,
ttlSeconds,
templateId,
}: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => {
const createdForum = await createForumMutation.mutateAsync({
name,
description,
channelType: "forum",
visibility,
ttlSeconds,
});
await applyCanvas(templateId, createdForum.id, name);
await goChannel(createdForum.id);
void applyAgents(templateId, createdForum.id);
},
[applyAgents, applyCanvas, createForumMutation, goChannel],
);
// The channel browser can create either a stream or a forum depending on
// which section opened it. Route to the matching handler.
const handleBrowseChannelCreate = React.useCallback(
async (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => {
if (browseDialogType === "forum") {
await handleCreateForum(input);
} else {
await handleCreateChannel(input, getCreateSuccess() ?? undefined);
}
},
[
browseDialogType,
handleCreateChannel,
handleCreateForum,
getCreateSuccess,
],
);
const handleHideDm = React.useCallback(
async (channelId: string) => {
try {
await hideDmMutation.mutateAsync(channelId);
} catch {
return;
}
if (selectedChannelId === channelId) {
void goHome();
}
},
[goHome, hideDmMutation, selectedChannelId],
);
const handleOpenSettings = React.useCallback(
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
setIsChannelManagementOpen(false);
void goSettings(section);
},
[goSettings],
);
const handleCloseSettings = React.useCallback(
() => closeSettings(),
[closeSettings],
);
// Section switches rewrite the settings entry rather than stacking one
// history entry per section, so back always exits settings in one step.
const handleSettingsSectionChange = React.useCallback(
(section: SettingsSection) => {
void goSettings(section, { replace: true });
},
[goSettings],
);
const handleOpenSearchResult = React.useCallback(
(hit: SearchHit) => {
void openSearchHit(hit);
},
[openSearchHit],
);
useAppShellLifecycleEffects({
desktopBadgeEnabled: !isHuddleRoom,
homeBadgeCountExcludingHighPriority,
unreadChannelIds,
unreadChannelNotificationCount,
});
// Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route.
useAppDeepLinks(!isHuddleRoom);
const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true),
[],
);
useAppShellKeyboardShortcuts({
activeChannelId: selectedView === "channel" ? selectedChannelId : null,
canSearchCurrentChannel:
selectedView === "channel" && Boolean(activeChannel),
disabled: settingsOpen || isHuddleRoom,
onBrowseChannels: handleOpenBrowseChannels,
onCreateChannel: handleOpenCreateChannel,
onGoHome: goHome,
onNewMessage: goNewMessage,
onSearchCurrentChannel: handleOpenChannelSearch,
onSearchEverything: handleOpenSearch,
});
useSettingsShortcuts({
onClose: handleCloseSettings,
onOpenSettings: handleOpenSettings,
open: isHuddleRoom ? undefined : settingsOpen,
});
useMarkAsReadShortcuts({
activeChannelId: activeChannel?.id ?? null,
activeChannelLastMessageAt: activeChannel?.lastMessageAt,
markAllChannelsRead,
markChannelRead,
selectedView,
});
return (
<PreventSleepProvider>
{!isHuddleRoom ? (
<AppShellTrayMenu
channels={channels}
goChannel={goChannel}
openCreateChannel={handleOpenCreateChannel}
/>
) : null}
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
markAllChannelsRead,
markChannelRead,
markChannelUnread,
clearChannelUnreadSource,
openBrowseChannels: handleOpenBrowseChannels,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: (channelId?: string) => {
setManagedChannelId(
typeof channelId === "string" ? channelId : null,
);
setIsChannelManagementOpen(true);
},
getChannelReadAt,
getThreadReadAt,
markThreadRead,
getMessageReadAt,
getChannelActivityItemReadAt,
markMessageRead,
readStateVersion,
setContextParentResolver,
followThread: handleFollowThread,
unfollowThread: handleUnfollowThread,
isFollowingThread,
isNotifiedForThread,
recordThreadInteraction,
isThreadMuted: (rootId) => mutedRootIds.has(rootId),
threadActivityItems,
threadActivityFeedItems,
locallyUnreadFeedItems,
unreadThreadFeedItems,
unreadThreadChannelIds,
topLevelUnreadChannelIds,
hasSidebarUnreadProjections: true,
feedItemState,
onOpenSettings: handleOpenSettings,
}}
>
<AppHuddleShell
currentPubkey={identityQuery.data?.pubkey}
isCompanionOpen={isHuddleCompanionOpen}
isDrawerOpen={isHuddleDrawerOpen}
isRoom={isHuddleRoom}
onCompanionOpen={handleHuddleCompanionOpen}
onHuddleStartPendingChange={handleHuddleStartPendingChange}
onHuddleStarted={handleHuddleStarted}
onShowHuddleInMainApp={showHuddleInMainApp}
onViewHuddleChannel={viewHuddleChannel}
onVisibilityChange={handleHuddleVisibilityChange}
>
{hasCommunityRail && !isHuddleRoom ? (
<CommunityRail
activeCommunityId={communitiesHook.activeCommunity?.id ?? null}
onAddCommunity={addCommunityDialog.openDialog}
onReorderCommunities={communitiesHook.reorderCommunities}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
communities={communitiesHook.communities}
/>
) : null}
<SidebarProvider
className="relative z-10 min-h-0 min-w-0 flex-1 flex-col overflow-visible"
data-testid="app-sidebar-layer"
>
<AppProfilePanelProvider>
{!settingsOpen && !isHuddleRoom ? (
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
hasCommunityRail={hasCommunityRail}
onGoBack={goBack}
onGoForward={goForward}
/>
) : null}
{settingsOpen ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={identityQuery.data?.displayName}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={notificationSettings.permission}
notificationSettings={notificationSettings.settings}
onClose={handleCloseSettings}
onSectionChange={handleSettingsSectionChange}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetSlotAlertsEnabled={
notificationSettings.setSlotAlertsEnabled
}
onSetNotifyWhileViewing={
notificationSettings.setNotifyWhileViewing
}
onSetAllSlotAlertsEnabled={
notificationSettings.setAllSlotAlertsEnabled
}
onSetSoundForSlot={notificationSettings.setSoundForSlot}
section={settingsSection}
/>
</React.Suspense>
</div>
) : (
<div className="relative flex min-h-0 flex-1 overflow-visible">
{!isHuddleRoom ? (
<AppSidebar
activeCommunity={communitiesHook.activeCommunity}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={channelsErrorMessage}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount + dueReminderBadge}
addCommunityPrefill={addCommunityDialog.prefill}
isAddCommunityOpen={addCommunityDialog.open}
relayConnectionCard={relayConnectionCard}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isCreateChannelOpen={isCreateChannelOpen}
isHuddleCompanionOpen={isHuddleCompanionOpen}
isPresencePending={presenceSession.isPending}
onAddCommunity={(community) => {
const id = communitiesHook.addCommunity({
...community,
pubkey:
community.pubkey ?? identityQuery.data?.pubkey,
});
handleSwitchCommunity(id);
}}
onAddCommunityOpenChange={
addCommunityDialog.onOpenChange
}
onNewMessage={goNewMessage}
onBackgroundClick={requestFocusedThreadClose}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={handleRemoveCommunity}
onSwitchCommunity={handleSwitchCommunity}
onCreateAgent={() => requestOpenCreateAgent()}
selfPresenceStatus={presenceSession.currentStatus}
communities={communitiesHook.communities}
onCreateChannel={handleCreateChannel}
onCreateForum={handleCreateForum}
onHideDm={handleHideDm}
onHuddleEnded={handleHuddleEnded}
onMarkAllChannelsRead={markAllChannelsRead}
onMarkChannelRead={markChannelRead}
onMarkChannelUnread={markChannelUnread}
onBrowseChannels={handleOpenBrowseChannels}
onOpenDm={async ({ pubkeys }) => {
const directMessage =
await openDmMutation.mutateAsync({
pubkeys,
});
await goChannel(directMessage.id);
}}
onSelectAgents={() => void goAgents()}
onSelectChannel={handleSidebarChannelSelect}
onOpenSearchResult={handleOpenSearchResult}
searchChannels={channels}
searchFocusRequests={[
searchFocusRequest,
scopeSearchFocusRequest,
]}
onSelectHome={() => void goHome()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({
text: "",
emoji: "",
})
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
] ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
previewActivityChannelIds={unreadThreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={starChannel}
onUnstarChannel={unstarChannel}
/>
) : null}
<AppShellChannelSurface
hasCommunityRail={hasCommunityRail}
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
terminal={<TerminalBootstrap {...terminalContext} />}
>
<Outlet />
</AppShellChannelSurface>
{!isHuddleRoom ? (
<RelayConnectionOverlay
card={relayConnectionCard}
errorMessage={channelsErrorMessage}
hasCommunityRail={hasCommunityRail}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>
) : null}
</div>
)}
<RequestedAgentCreateDialogs />
<AgentManagementDialogs />
<AppShellOverlays
activeChannel={managedChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isCreatingBrowseChannel={
createChannelMutation.isPending ||
createForumMutation.isPending
}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseChannelCreate={handleBrowseChannelCreate}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
if (!open) {
setManagedChannelId(null);
}
}}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
setManagedChannelId(null);
void goHome({ replace: true });
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
relayUrl={communitiesHook.activeCommunity?.relayUrl}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
</AppProfilePanelProvider>
</SidebarProvider>
</AppHuddleShell>
</AppShellProvider>
</ChannelNavigationProvider>
</PreventSleepProvider>
);
}