Files
buzz/desktop/src/app/AppShell.tsx
Will Pfleger 83aab8cb5c feat(desktop): remove communities locally and stop their agent pairs (#7997)
🤖 Leave contacts the relay before it removes anything locally: it checks
the relay's `/info` for NIP-43 membership support, then waits for the
relay to accept a signed leave request. When a relay has been torn down,
that check fails, Leave throws, and the community stays in the rail.
Removing a community also left its relay's managed agent pairs running,
and they kept reconnecting to it until the app was quit.

**Remove from this device**
- A new community action, **Remove from this device**, sits under
**Leave community** in the profile menu's community actions. It never
contacts the relay. A confirm dialog explains that relay membership
isn't revoked.
- `useCommunityNavigationTransitions` splits the old `removeCommunity`
into `removeCommunityFromDevice` (local-only cleanup and navigation to a
fallback community or the discovery screen) and
`leaveAndRemoveCommunity` (calls `leaveCommunity`, then runs the same
cleanup). Leave's flow hasn't changed.
- When Leave fails, the error now adds: "If the community no longer
exists, use Remove from this device." The error shown when community
discovery state can't be saved no longer says "Membership was removed",
so its wording fits both actions.

**Agent pairs on a removed relay**
- Once the local removal completes, the shared cleanup stops every live
agent pair on that relay, including manually started ones. It matches
relays with `canonicalRelayUrl`. Stopping is best-effort: failures are
logged and the removal still completes. Pairs on other relays aren't
touched, and nothing is stopped if another community still uses the same
relay.
- Both reconcile call sites (`useManagedAgentRuntimeReconciliation` and
`bootstrapManagedAgentRuntimePairs`) now go through
`reconcileConfiguredManagedAgentRuntimes`. A reconcile that is already
running in Rust can't be cancelled, so after it resolves, this helper
stops any pair it started whose relay is no longer in the saved
community list.
- Launch restore runs in the background after `apply_workspace` and can
finish after its community is removed. `apply_workspace` takes a restore
ticket (the current `launch_restore_generation` in `AppState`) before
handing its lock to the restore task. Removing the active community
calls the new `invalidate_launch_restore` command, which bumps that
generation while holding the runtime transition lock.
`restore_managed_agents_on_launch` re-checks its ticket under the same
lock and spawns nothing if it changed. If restore takes the lock first,
its pairs are registered before invalidation returns, and the removal
stop then finds them. Removal never changes `relay_url_override`: the
outgoing relay stays applied until the next `apply_workspace`, exactly
as on `main`, so in-flight work never falls back to the default relay.
Invalidation never takes `workspace_apply_lock`, which restore holds
while waiting on the transition lock, so lock order is unchanged.
- With no relay applied, backend readers fall back to the default relay,
the same state as launch before the first `apply_workspace`. A normal
quit and relaunch doesn't bring a pair back on a removed relay: restore
targets the applied workspace, and the legacy per-record relay pin is
ignored.

**Tests**
- A `community-rail` e2e case makes the mocked relay's membership check
fail, the way an unreachable relay does. It checks that Leave shows the
error and keeps the community, and that Remove from this device then
removes it, switches to the fallback, stops the agent pair on the
removed relay, and leaves the pair on the other relay running.
- Unit tests in `managedAgentRelayCleanup.test.mjs` cover relay
matching, a stop or listing failure not throwing, and the reconcile
fence stopping a pair that started on a relay removed mid-flight.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-10-01 18:43:21 +00:00

1005 lines
40 KiB
TypeScript

import * as React from "react";
import { ProtectedGlobalOverlay } from "@protected-feature-components";
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 {
type TerminalContextOverride,
TerminalContextOverrideProvider,
} from "@/app/TerminalContextOverrideContext";
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 { useDmResurfaceFromMessages } from "@/features/channels/useDmResurfaceFromMessages";
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,
visibleUserStatus,
useUserStatusSubscription,
} from "@/features/user-status/hooks";
import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
import { useArchiveSync } from "@/features/local-archive/useArchiveSync";
import { useArchiveAgentMetricsBridge } from "@/features/local-archive/useArchiveAgentMetricsBridge";
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 { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlayProvider";
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 {
leaveAndRemoveCommunity: handleLeaveCommunity,
removeCommunityFromDevice: removeFromDevice,
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 rawLocationSearchSection = (location.search as { section?: unknown })
.section;
// Migrate the legacy "moderation" token to "relay-admin" (renamed section
// id). useLocation().search is the raw URL query, bypassing route validation,
// so the alias must be applied here too. The "doctor" alias is NOT needed on
// this path: AppShell never carried it on main, and it lives only in
// validateSettingsSearch where route validation rewrites it before rendering.
const locationSearchSection =
rawLocationSearchSection === "moderation"
? "relay-admin"
: rawLocationSearchSection;
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(communitiesHook.activeCommunity?.relayUrl);
// 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);
// The archive batch now persists in Rust, so the agent-metrics invalidation
// signal arrives as a Tauri event rather than an in-process call.
useArchiveAgentMetricsBridge();
// 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 [terminalContextOverride, setTerminalContextOverride] =
React.useState<TerminalContextOverride | null>(null);
const { activeChannel, terminalContext } = useTerminalContext({
channelId: selectedChannelId,
channels,
locationSearch: location.search,
pubkey: identityQuery.data?.pubkey,
relayUrl: communitiesHook.activeCommunity?.relayUrl,
});
const effectiveTerminalContext = terminalContextOverride
? {
...terminalContext,
channelId: terminalContextOverride.channelId,
channelName: terminalContextOverride.channelName,
threadId: null,
}
: terminalContext;
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,
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)),
[followedRootIds, mutedRootIds, participatedRootIds, authoredRootIds],
);
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();
useDmResurfaceFromMessages({
pubkey: identityQuery.data?.pubkey,
relayUrl: communitiesHook.activeCommunity?.relayUrl,
reopen: openDmMutation.mutateAsync,
});
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, query: string) => {
void openSearchHit(hit, { query });
},
[openSearchHit],
);
useAppShellLifecycleEffects({
desktopBadgeEnabled: !isHuddleRoom,
homeBadgeCountExcludingHighPriority,
topLevelUnreadChannelIds,
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>
<AppWorkflowEditorOverlayProvider>
{!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}
onLeaveCommunity={handleLeaveCommunity}
onRemoveCommunityFromDevice={removeFromDevice}
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={setUserStatusMutation.mutate}
onClearUserStatus={() =>
setUserStatusMutation.mutate({
text: "",
emoji: "",
})
}
profile={profileQuery.data}
projectsOverviewActive={
location.pathname === "/projects"
}
selfUserStatus={
deferredPubkey
? (visibleUserStatus(
selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
],
) ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
{...{ highPriorityUnreadChannelIds }}
previewActivityChannelIds={unreadThreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={starChannel}
onUnstarChannel={unstarChannel}
/>
) : null}
<TerminalContextOverrideProvider
onChange={setTerminalContextOverride}
>
<AppShellChannelSurface
hasCommunityRail={hasCommunityRail}
isHuddleRoom={isHuddleRoom}
isHuddleRoomStarting={isHuddleRoomStarting}
mainInsetRef={mainInsetRef}
terminal={
<TerminalBootstrap {...effectiveTerminalContext} />
}
>
<Outlet />
</AppShellChannelSurface>
</TerminalContextOverrideProvider>
{!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}
/>
{!isHuddleRoom ? <ProtectedGlobalOverlay /> : null}
</AppWorkflowEditorOverlayProvider>
</AppProfilePanelProvider>
</SidebarProvider>
</AppHuddleShell>
</AppShellProvider>
</ChannelNavigationProvider>
</PreventSleepProvider>
);
}