diff --git a/desktop/src/app/AppBestiePopover.tsx b/desktop/src/app/AppBestiePopover.tsx
new file mode 100644
index 00000000000..d07fb0f9864
--- /dev/null
+++ b/desktop/src/app/AppBestiePopover.tsx
@@ -0,0 +1,7 @@
+import { BestieChatPopover } from "@/features/messages/ui/BestieChatPopover";
+import { useFeatureEnabled } from "@/shared/features";
+
+export function AppBestiePopover({ hidden }: { hidden: boolean }) {
+ const bestieEnabled = useFeatureEnabled("bestie");
+ return bestieEnabled ? : null;
+}
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index f72e9578e77..dd8c54ae484 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -7,6 +7,7 @@ import { AppShellProvider } from "@/app/AppShellContext";
import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
import { AppHuddleShell } from "@/app/AppHuddleShell";
+import { AppBestiePopover } from "@/app/AppBestiePopover";
import { AppTopChrome } from "@/app/AppTopChrome";
import {
type TerminalContextOverride,
@@ -787,6 +788,7 @@ export function AppShell() {
onGoForward={goForward}
/>
) : null}
+
{settingsOpen ? (
diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx
index 35b5e4b2093..175802a39d6 100644
--- a/desktop/src/app/AppTopChrome.tsx
+++ b/desktop/src/app/AppTopChrome.tsx
@@ -15,6 +15,7 @@ type AppTopChromeProps = {
onGoBack: () => void;
onGoForward: () => void;
hasCommunityRail?: boolean;
+ trailingContent?: React.ReactNode;
};
// Fixed px on purpose (button box + glyph): these controls sit beside the
@@ -58,6 +59,7 @@ export function AppTopChrome({
onGoBack,
onGoForward,
hasCommunityRail = false,
+ trailingContent,
}: AppTopChromeProps) {
const topChromeRef = React.useRef(null);
const isFullscreen = useIsFullscreen();
@@ -160,6 +162,13 @@ export function AppTopChrome({
data-tauri-drag-region
id="app-top-chrome-content"
/>
+
+ {trailingContent}
+
);
}
diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts
index d28f2926081..72b33684332 100644
--- a/desktop/src/features/messages/hooks.ts
+++ b/desktop/src/features/messages/hooks.ts
@@ -43,7 +43,7 @@ import {
clearTimeoutState,
recordTimeoutFromRejection,
} from "@/features/moderation/lib/timeoutStore";
-import { relayClient, setVisibleChannel } from "@/shared/api/relayClient";
+import { acquireVisibleChannel, relayClient } from "@/shared/api/relayClient";
import { customEmojiQueryKey } from "@/features/custom-emoji/hooks";
import { channelsQueryKey } from "@/features/channels/hooks";
import { reactionEmojiUrl } from "@/shared/api/customEmoji";
@@ -60,6 +60,7 @@ import type { Channel, Identity, RelayEvent } from "@/shared/api/types";
// Same .mjs the renderer uses, so the cache-update projection can't drift
// from the on-render overlay.
import { applyEditTagOverlay } from "@/features/messages/lib/applyEditTagOverlay.mjs";
+import { createChannelLiveSubscriptionRegistry } from "@/features/messages/lib/channelLiveSubscriptionRegistry";
import {
emptyChannelWindowStore,
mapChannelWindowEvents,
@@ -90,6 +91,25 @@ type MessageQueryContext = {
const CHANNEL_TIMELINE_KINDS = new Set(CHANNEL_TIMELINE_CONTENT_KINDS);
const CHANNEL_AUX_KINDS = new Set(CHANNEL_AUX_EVENT_KINDS);
+const channelSubscriptionRegistries = new WeakMap<
+ QueryClient,
+ ReturnType
+>();
+
+function channelSubscriptionRegistry(queryClient: QueryClient) {
+ const existing = channelSubscriptionRegistries.get(queryClient);
+ if (existing) return existing;
+ const created = createChannelLiveSubscriptionRegistry({
+ onError: (message, channelId, error) =>
+ console.error(message, channelId, error),
+ subscribe: (channelId, onEvent) =>
+ relayClient.subscribeToChannelLive(channelId, onEvent),
+ subscribeToReconnects: (onReconnect) =>
+ relayClient.subscribeToReconnects(onReconnect),
+ });
+ channelSubscriptionRegistries.set(queryClient, created);
+ return created;
+}
export function resolveCachedReplyRootId(
parentEventId: string,
@@ -398,10 +418,7 @@ export function useChannelSubscription(channel: Channel | null) {
// degraded networks.
useEffect(() => {
if (!channelId || channelType === "forum") return;
- setVisibleChannel(channelId);
- return () => {
- setVisibleChannel(null);
- };
+ return acquireVisibleChannel(channelId);
}, [channelId, channelType]);
useEffect(() => {
@@ -409,68 +426,11 @@ export function useChannelSubscription(channel: Channel | null) {
return;
}
- let isDisposed = false;
- let cleanup: (() => Promise) | undefined;
- const disposeReconnectListener = relayClient.subscribeToReconnects(() => {
- void refreshNewestWindow().catch((error) => {
- if (!isDisposed) {
- console.error(
- "Failed to refresh channel window after reconnecting",
- channelId,
- error,
- );
- }
- });
+ return channelSubscriptionRegistry(queryClient).acquire(channelId, {
+ onEvent: appendMessage,
+ refresh: refreshNewestWindow,
});
-
- // The live subscription starts at "now", so it cannot close the gap
- // between the last page snapshot and subscription establishment. Always
- // refresh once subscription setup settles — on success because freshness
- // alone is not proof that no relay events landed in that interval, and on
- // failure because a hydrated channel has no other authoritative fetch:
- // the relay window endpoint may be healthy even when the live socket is
- // not, and the reconnect listener above re-syncs when it recovers.
- const refreshAfterSubscribe = (outcome: string) => {
- if (isDisposed) return;
- void refreshNewestWindow().catch((error) => {
- if (!isDisposed) {
- console.error(
- `Failed to refresh channel window after ${outcome}`,
- channelId,
- error,
- );
- }
- });
- };
- relayClient
- .subscribeToChannelLive(channelId, (event) => {
- if (!isDisposed) {
- appendMessage(event);
- }
- })
- .then(
- (dispose) => {
- if (isDisposed) {
- void dispose();
- return;
- }
- cleanup = dispose;
- refreshAfterSubscribe("subscribing");
- },
- (error) => {
- console.error("Failed to subscribe to channel", channelId, error);
- refreshAfterSubscribe("subscription failure");
- },
- );
-
- return () => {
- isDisposed = true;
- disposeReconnectListener();
- if (cleanup) {
- void cleanup();
- }
- };
- }, [channelId, channelType]);
+ }, [channelId, channelType, queryClient]);
}
export function useSendMessageMutation(
diff --git a/desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.test.mjs b/desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.test.mjs
new file mode 100644
index 00000000000..1574393918e
--- /dev/null
+++ b/desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.test.mjs
@@ -0,0 +1,97 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { createChannelLiveSubscriptionRegistry } from "./channelLiveSubscriptionRegistry.ts";
+
+function createRig() {
+ const subscriptions = new Map();
+ const disposed = [];
+ let subscribeCount = 0;
+ const registry = createChannelLiveSubscriptionRegistry({
+ onError: (_message, _channelId, error) => {
+ throw error;
+ },
+ subscribe: async (channelId, onEvent) => {
+ subscribeCount += 1;
+ subscriptions.set(channelId, onEvent);
+ return () => disposed.push(channelId);
+ },
+ subscribeToReconnects: () => () => {},
+ });
+ return {
+ disposed,
+ registry,
+ subscriptions,
+ subscribeCount: () => subscribeCount,
+ };
+}
+
+const event = { id: "event" };
+
+test("same-channel surfaces share one live subscription when the newer surface closes", async () => {
+ const rig = createRig();
+ const received = [];
+ const releaseMain = rig.registry.acquire("bestie", {
+ onEvent: () => received.push("main"),
+ refresh: async () => {},
+ });
+ const releasePopover = rig.registry.acquire("bestie", {
+ onEvent: () => received.push("popover"),
+ refresh: async () => {},
+ });
+ await Promise.resolve();
+
+ assert.equal(rig.subscribeCount(), 1);
+ rig.subscriptions.get("bestie")(event);
+ releasePopover();
+ rig.subscriptions.get("bestie")(event);
+ assert.deepEqual(received, ["popover", "main"]);
+ assert.deepEqual(rig.disposed, []);
+
+ releaseMain();
+ assert.deepEqual(rig.disposed, ["bestie"]);
+});
+
+test("releasing the older owner leaves the newer same-channel owner active", async () => {
+ const rig = createRig();
+ const received = [];
+ const releaseMain = rig.registry.acquire("bestie", {
+ onEvent: () => received.push("main"),
+ refresh: async () => {},
+ });
+ const releasePopover = rig.registry.acquire("bestie", {
+ onEvent: () => received.push("popover"),
+ refresh: async () => {},
+ });
+ await Promise.resolve();
+
+ releaseMain();
+ rig.subscriptions.get("bestie")(event);
+ assert.deepEqual(received, ["popover"]);
+ assert.deepEqual(rig.disposed, []);
+
+ releasePopover();
+ assert.deepEqual(rig.disposed, ["bestie"]);
+});
+
+test("the first owner receives events emitted before subscription setup settles", async () => {
+ const received = [];
+ const registry = createChannelLiveSubscriptionRegistry({
+ onError: (_message, _channelId, error) => {
+ throw error;
+ },
+ subscribe: async (_channelId, onEvent) => {
+ onEvent(event);
+ return () => {};
+ },
+ subscribeToReconnects: () => () => {},
+ });
+
+ registry.acquire("bestie", {
+ onEvent: () => received.push("bestie"),
+ refresh: async () => {},
+ });
+ await Promise.resolve();
+
+ assert.deepEqual(received, ["bestie"]);
+});
diff --git a/desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.ts b/desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.ts
new file mode 100644
index 00000000000..fe1e79271ae
--- /dev/null
+++ b/desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.ts
@@ -0,0 +1,102 @@
+import type { RelayEvent } from "@/shared/api/types";
+
+type Dispose = () => void | Promise;
+
+type SubscriptionOwner = {
+ onEvent: (event: RelayEvent) => void;
+ refresh: () => Promise;
+};
+
+type RegistryDependencies = {
+ onError: (message: string, channelId: string, error: unknown) => void;
+ subscribe: (
+ channelId: string,
+ onEvent: (event: RelayEvent) => void,
+ ) => Promise;
+ subscribeToReconnects: (onReconnect: () => void) => Dispose;
+};
+
+type SubscriptionEntry = {
+ disposeReconnect: Dispose;
+ disposeSubscription?: Dispose;
+ owners: Map;
+};
+
+export function createChannelLiveSubscriptionRegistry({
+ onError,
+ subscribe,
+ subscribeToReconnects,
+}: RegistryDependencies) {
+ const entries = new Map();
+ const currentOwner = (entry: SubscriptionEntry) =>
+ Array.from(entry.owners.values()).at(-1);
+
+ const refresh = (
+ entry: SubscriptionEntry,
+ channelId: string,
+ outcome: string,
+ ) => {
+ const owner = currentOwner(entry);
+ if (!owner) return;
+ void owner.refresh().catch((error) => {
+ if (entries.get(channelId) === entry) {
+ onError(
+ `Failed to refresh channel window after ${outcome}`,
+ channelId,
+ error,
+ );
+ }
+ });
+ };
+
+ return {
+ acquire(channelId: string, owner: SubscriptionOwner) {
+ const token = Symbol(channelId);
+ let entry = entries.get(channelId);
+ if (!entry) {
+ const owners = new Map([[token, owner]]);
+ entry = {
+ disposeReconnect: subscribeToReconnects(() => {
+ const current = entries.get(channelId);
+ if (current) refresh(current, channelId, "reconnecting");
+ }),
+ owners,
+ };
+ entries.set(channelId, entry);
+ const startedEntry = entry;
+ void subscribe(channelId, (event) => {
+ currentOwner(startedEntry)?.onEvent(event);
+ }).then(
+ (dispose) => {
+ if (entries.get(channelId) !== startedEntry) {
+ void dispose();
+ return;
+ }
+ startedEntry.disposeSubscription = dispose;
+ refresh(startedEntry, channelId, "subscribing");
+ },
+ (error) => {
+ if (entries.get(channelId) !== startedEntry) return;
+ onError("Failed to subscribe to channel", channelId, error);
+ refresh(startedEntry, channelId, "subscription failure");
+ },
+ );
+ } else {
+ entry.owners.set(token, owner);
+ }
+
+ let released = false;
+ return () => {
+ if (released) return;
+ released = true;
+ const current = entries.get(channelId);
+ if (!current) return;
+ current.owners.delete(token);
+ if (current.owners.size > 0) return;
+ entries.delete(channelId);
+ void current.disposeReconnect();
+ if (current.disposeSubscription) void current.disposeSubscription();
+ };
+ },
+ };
+}
diff --git a/desktop/src/features/messages/ui/BestieChatPopover.tsx b/desktop/src/features/messages/ui/BestieChatPopover.tsx
new file mode 100644
index 00000000000..b90870cbee5
--- /dev/null
+++ b/desktop/src/features/messages/ui/BestieChatPopover.tsx
@@ -0,0 +1,354 @@
+import { Loader2 } from "lucide-react";
+import * as React from "react";
+import { createPortal } from "react-dom";
+import { toast } from "sonner";
+
+import { useManagedAgentsQuery } from "@/features/agents/hooks";
+import { pickBestieAgent } from "@/features/agents/lib/bestie";
+import { useOpenDmMutation } from "@/features/channels/hooks";
+import { useChannelOpenReadState } from "@/features/channels/ui/useChannelOpenReadState";
+import { useCommunities } from "@/features/communities/useCommunities";
+import {
+ useChannelMessagesQuery,
+ useChannelSubscription,
+ useSendMessageMutation,
+} from "@/features/messages/hooks";
+import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
+import { getThreadReference } from "@/features/messages/lib/threading";
+import { MessageComposer } from "@/features/messages/ui/MessageComposer";
+import { MessageThreadTranscript } from "@/features/messages/ui/MessageThreadTranscript";
+import { useProfileQuery } from "@/features/profile/hooks";
+import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
+import { useIdentityQuery } from "@/shared/api/hooks";
+import type { Channel } from "@/shared/api/types";
+import { getPlatformKeysById } from "@/shared/lib/keyboard-shortcuts";
+import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
+import { normalizePubkey } from "@/shared/lib/pubkey";
+import { Button } from "@/shared/ui/button";
+import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+
+export function BestieChatPopover({ showTrigger }: { showTrigger: boolean }) {
+ const { activeCommunity } = useCommunities();
+ const identityQuery = useIdentityQuery();
+ const profileQuery = useProfileQuery();
+ const managedAgentsQuery = useManagedAgentsQuery();
+ const openDmMutation = useOpenDmMutation();
+ const sendMessageMutation = useSendMessageMutation(null, identityQuery.data);
+ const [open, setOpen] = React.useState(false);
+ const [channel, setChannel] = React.useState(null);
+ const [openError, setOpenError] = React.useState(null);
+ const [portalTarget, setPortalTarget] = React.useState(
+ null,
+ );
+ const scrollRef = React.useRef(null);
+ const openRequestRef = React.useRef(0);
+ const positionedScrollRef = React.useRef(false);
+ const stickToBottomRef = React.useRef(true);
+
+ const bestie = React.useMemo(
+ () =>
+ pickBestieAgent(managedAgentsQuery.data ?? [], activeCommunity?.relayUrl),
+ [activeCommunity?.relayUrl, managedAgentsQuery.data],
+ );
+ const conversationScope = `${activeCommunity?.relayUrl ?? ""}:${bestie?.pubkey ?? ""}`;
+ const conversationScopeRef = React.useRef(conversationScope);
+
+ useChannelSubscription(channel);
+ const messagesQuery = useChannelMessagesQuery(channel);
+ const currentPubkey = identityQuery.data?.pubkey ?? null;
+ const profiles = React.useMemo(() => {
+ if (!bestie) return undefined;
+ return {
+ [normalizePubkey(bestie.pubkey)]: {
+ avatarUrl: bestie.avatarUrl,
+ displayName: bestie.name,
+ isAgent: true,
+ name: bestie.name,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+ ...(currentPubkey
+ ? {
+ [normalizePubkey(currentPubkey)]: {
+ avatarUrl: profileQuery.data?.avatarUrl ?? null,
+ displayName: "You",
+ isAgent: false,
+ name: null,
+ nip05Handle: null,
+ ownerPubkey: null,
+ },
+ }
+ : {}),
+ };
+ }, [bestie, currentPubkey, profileQuery.data?.avatarUrl]);
+ const messages = React.useMemo(
+ () =>
+ channel
+ ? formatTimelineMessages(
+ messagesQuery.data ?? [],
+ channel,
+ currentPubkey ?? undefined,
+ profileQuery.data?.avatarUrl ?? null,
+ profiles,
+ )
+ : [],
+ [
+ channel,
+ currentPubkey,
+ messagesQuery.data,
+ profileQuery.data?.avatarUrl,
+ profiles,
+ ],
+ );
+ const lastMessageId = messages.at(-1)?.id ?? null;
+ const latestTopLevelMessage = React.useMemo(() => {
+ const rawMessages = messagesQuery.data;
+ if (!rawMessages) return null;
+ for (let index = rawMessages.length - 1; index >= 0; index -= 1) {
+ if (getThreadReference(rawMessages[index].tags).parentId === null) {
+ return rawMessages[index];
+ }
+ }
+ return null;
+ }, [messagesQuery.data]);
+ const activeReadAt = latestTopLevelMessage
+ ? new Date(latestTopLevelMessage.created_at * 1_000).toISOString()
+ : null;
+ useChannelOpenReadState(
+ open ? (channel?.id ?? null) : null,
+ channel?.isMember,
+ activeReadAt,
+ );
+
+ React.useLayoutEffect(() => {
+ setPortalTarget(
+ showTrigger ? document.getElementById("app-top-chrome-trailing") : null,
+ );
+ }, [showTrigger]);
+
+ React.useEffect(() => {
+ if (!lastMessageId) return;
+ const scrollElement = scrollRef.current;
+ if (!scrollElement) return;
+ if (!positionedScrollRef.current || stickToBottomRef.current) {
+ scrollElement.scrollTo({
+ behavior: positionedScrollRef.current ? "smooth" : "auto",
+ top: scrollElement.scrollHeight,
+ });
+ }
+ positionedScrollRef.current = true;
+ }, [lastMessageId]);
+
+ React.useEffect(() => {
+ if (conversationScopeRef.current === conversationScope) return;
+ conversationScopeRef.current = conversationScope;
+ openRequestRef.current += 1;
+ setOpen(false);
+ setChannel(null);
+ setOpenError(null);
+ }, [conversationScope]);
+
+ const openConversation = React.useCallback(() => {
+ if (!bestie) return;
+ const requestId = ++openRequestRef.current;
+ setChannel(null);
+ setOpenError(null);
+ void openDmMutation
+ .mutateAsync({
+ pubkeys: [bestie.pubkey],
+ expectedRelayUrl: activeCommunity?.relayUrl,
+ expectedSignerPubkey: currentPubkey ?? undefined,
+ })
+ .then((openedChannel) => {
+ if (openRequestRef.current === requestId) setChannel(openedChannel);
+ })
+ .catch((error) => {
+ if (openRequestRef.current !== requestId) return;
+ console.error("Failed to open Bestie conversation", error);
+ setOpenError(`Couldn't load your conversation with ${bestie.name}.`);
+ toast.error(`Couldn't open ${bestie.name}`);
+ });
+ }, [activeCommunity?.relayUrl, bestie, currentPubkey, openDmMutation]);
+
+ const handleOpenChange = React.useCallback(
+ (nextOpen: boolean) => {
+ if (!bestie) return;
+ setOpen(nextOpen);
+ positionedScrollRef.current = false;
+ stickToBottomRef.current = true;
+ if (nextOpen) {
+ openConversation();
+ return;
+ }
+ openRequestRef.current += 1;
+ setChannel(null);
+ setOpenError(null);
+ },
+ [bestie, openConversation],
+ );
+
+ const handleBestieShortcut = React.useEffectEvent((event: KeyboardEvent) => {
+ if (
+ !bestie ||
+ !hasPrimaryShortcutModifier(event) ||
+ event.altKey ||
+ event.shiftKey ||
+ event.repeat ||
+ event.defaultPrevented ||
+ event.code !== "Digit1"
+ ) {
+ return;
+ }
+ event.preventDefault();
+ handleOpenChange(!open);
+ });
+
+ React.useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => handleBestieShortcut(event);
+ window.addEventListener("keydown", onKeyDown, { capture: true });
+ return () => window.removeEventListener("keydown", onKeyDown, true);
+ }, []);
+
+ if (!bestie) return null;
+
+ const submit = async (
+ content: string,
+ mentionPubkeys: string[],
+ mediaTags?: string[][],
+ ) => {
+ if (!channel) return;
+ await sendMessageMutation.mutateAsync({
+ content,
+ mediaTags,
+ mentionPubkeys,
+ targetChannel: channel,
+ });
+ };
+ const isLoading =
+ !openError &&
+ (openDmMutation.isPending || (channel && messagesQuery.isLoading));
+ const isSending = sendMessageMutation.isPending;
+
+ return (
+
+ {portalTarget ? (
+ createPortal(
+
+
+
+
+
+
+
+ Message {bestie.name} ({getPlatformKeysById("open-bestie")})
+
+
+
+ ,
+ portalTarget,
+ )
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
{
+ const element = event.currentTarget;
+ stickToBottomRef.current =
+ element.scrollHeight -
+ element.scrollTop -
+ element.clientHeight <
+ 64;
+ }}
+ ref={scrollRef}
+ >
+ {openError ? (
+
+
{openError}
+
+
+ ) : isLoading ? (
+
+
+
+ ) : messages.length > 0 && channel ? (
+
+ ) : (
+
+
+ Your messages with {bestie.name} will show up here.
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx
index c9c12b94216..cd358b20b5a 100644
--- a/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx
+++ b/desktop/src/features/settings/ui/KeyboardShortcutsCard.tsx
@@ -3,6 +3,7 @@ import {
getPlatformKeys,
type KeyboardShortcut,
} from "@/shared/lib/keyboard-shortcuts";
+import { useFeatureEnabled } from "@/shared/features";
import {
SettingsOptionGroup,
SettingsOptionGroupList,
@@ -33,7 +34,10 @@ function KeyCombo({ shortcut }: { shortcut: KeyboardShortcut }) {
}
export function KeyboardShortcutsCard() {
- const categories = getShortcutsByCategory();
+ const bestieEnabled = useFeatureEnabled("bestie");
+ const categories = getShortcutsByCategory(
+ bestieEnabled ? new Set(["bestie"]) : undefined,
+ );
return (
diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts
index 9cf4c134473..5449dc983f4 100644
--- a/desktop/src/shared/api/relayClient.ts
+++ b/desktop/src/shared/api/relayClient.ts
@@ -1,16 +1,13 @@
import { RelayClient } from "@/shared/api/relayClientSession";
+import { createVisibleChannelOwnership } from "@/shared/api/visibleChannelOwnership";
export const relayClient = new RelayClient();
+const visibleChannelOwnership = createVisibleChannelOwnership((channelId) =>
+ relayClient.setVisibleChannelId(channelId),
+);
-/**
- * Notify the relay client which channel is currently visible in the UI.
- *
- * On reconnect, subscriptions for the visible channel are sent in the first
- * replay batch so the user sees their active channel recover before others
- * on degraded networks.
- *
- * Call with `null` when the user navigates away from a channel view.
- */
-export function setVisibleChannel(id: string | null): void {
- relayClient.setVisibleChannelId(id);
+/** Keep reconnect priority on the newest surface without letting an older
+ * surface's cleanup clear a channel that remains visible elsewhere. */
+export function acquireVisibleChannel(id: string): () => void {
+ return visibleChannelOwnership.acquire(id);
}
diff --git a/desktop/src/shared/api/visibleChannelOwnership.test.mjs b/desktop/src/shared/api/visibleChannelOwnership.test.mjs
new file mode 100644
index 00000000000..43d3beba8f3
--- /dev/null
+++ b/desktop/src/shared/api/visibleChannelOwnership.test.mjs
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { createVisibleChannelOwnership } from "./visibleChannelOwnership.ts";
+
+test("releasing a newer surface restores the still-visible older surface", () => {
+ const visible = [];
+ const ownership = createVisibleChannelOwnership((channelId) =>
+ visible.push(channelId),
+ );
+
+ const releaseMain = ownership.acquire("main");
+ const releaseBestie = ownership.acquire("bestie");
+ releaseBestie();
+ releaseMain();
+
+ assert.deepEqual(visible, ["main", "bestie", "main", null]);
+});
+
+test("same-channel consumers cannot clear each other's visible marker", () => {
+ const visible = [];
+ const ownership = createVisibleChannelOwnership((channelId) =>
+ visible.push(channelId),
+ );
+
+ const releaseMain = ownership.acquire("bestie");
+ const releasePopover = ownership.acquire("bestie");
+ releaseMain();
+ releasePopover();
+
+ assert.deepEqual(visible, ["bestie", "bestie", "bestie", null]);
+});
diff --git a/desktop/src/shared/api/visibleChannelOwnership.ts b/desktop/src/shared/api/visibleChannelOwnership.ts
new file mode 100644
index 00000000000..8ea3a145e14
--- /dev/null
+++ b/desktop/src/shared/api/visibleChannelOwnership.ts
@@ -0,0 +1,21 @@
+export function createVisibleChannelOwnership(
+ setVisibleChannel: (channelId: string | null) => void,
+) {
+ const owners = new Map();
+
+ return {
+ acquire(channelId: string) {
+ const token = Symbol(channelId);
+ owners.set(token, channelId);
+ setVisibleChannel(channelId);
+ let released = false;
+
+ return () => {
+ if (released) return;
+ released = true;
+ owners.delete(token);
+ setVisibleChannel(Array.from(owners.values()).at(-1) ?? null);
+ };
+ },
+ };
+}
diff --git a/desktop/src/shared/lib/keyboard-shortcuts.test.mjs b/desktop/src/shared/lib/keyboard-shortcuts.test.mjs
new file mode 100644
index 00000000000..8cdccd4ad46
--- /dev/null
+++ b/desktop/src/shared/lib/keyboard-shortcuts.test.mjs
@@ -0,0 +1,24 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { getShortcutsByCategory } from "./keyboard-shortcuts.ts";
+
+function shortcutIds(categories) {
+ return [...categories.values()].flat().map((shortcut) => shortcut.id);
+}
+
+test("feature-owned shortcuts stay out of the default catalog", () => {
+ assert.equal(
+ shortcutIds(getShortcutsByCategory()).includes("open-bestie"),
+ false,
+ );
+});
+
+test("the Bestie shortcut appears only when its experiment is effective", () => {
+ assert.equal(
+ shortcutIds(getShortcutsByCategory(new Set(["bestie"]))).includes(
+ "open-bestie",
+ ),
+ true,
+ );
+});
diff --git a/desktop/src/shared/lib/keyboard-shortcuts.ts b/desktop/src/shared/lib/keyboard-shortcuts.ts
index d8e1550549f..fea7a61c1e2 100644
--- a/desktop/src/shared/lib/keyboard-shortcuts.ts
+++ b/desktop/src/shared/lib/keyboard-shortcuts.ts
@@ -19,6 +19,7 @@ export type KeyboardShortcut = {
keys: string;
keysWindows: string;
category: ShortcutCategory;
+ requiredFeature?: string;
};
export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
@@ -95,6 +96,15 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
keysWindows: "Shift+Ctrl+A",
category: "Navigation",
},
+ {
+ id: "open-bestie",
+ label: "Open Bestie",
+ description: "Open or close the floating Bestie conversation",
+ keys: "⌘1",
+ keysWindows: "Ctrl+1",
+ category: "Navigation",
+ requiredFeature: "bestie",
+ },
{
id: "toggle-sidebar",
label: "Toggle sidebar",
@@ -256,15 +266,19 @@ const CATEGORY_ORDER: ShortcutCategory[] = [
"Zoom",
];
-export function getShortcutsByCategory(): Map<
- ShortcutCategory,
- KeyboardShortcut[]
-> {
+export function getShortcutsByCategory(
+ enabledFeatures: ReadonlySet = new Set(),
+): Map {
const map = new Map();
for (const cat of CATEGORY_ORDER) {
map.set(
cat,
- KEYBOARD_SHORTCUTS.filter((s) => s.category === cat),
+ KEYBOARD_SHORTCUTS.filter(
+ (shortcut) =>
+ shortcut.category === cat &&
+ (!shortcut.requiredFeature ||
+ enabledFeatures.has(shortcut.requiredFeature)),
+ ),
);
}
return map;
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index c620c577648..c278d78ae2b 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -353,6 +353,8 @@ type E2eConfig = {
/** Reject `clear_pending_navigation_deep_links` with this message. */
clearPendingNavigationDeepLinksError?: string;
openDmDelayMs?: number;
+ /** Reject successive `open_dm` calls, then resume. */
+ openDmErrors?: (string | null)[];
sendMessageDelayMs?: number;
/** Delay (ms) for `start_managed_agent` so e2e tests can switch the
* community mid-startup and observe the fail-closed scope check. */
@@ -1201,7 +1203,8 @@ declare global {
kind: number;
}) => boolean;
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
- channelName: string;
+ channelId?: string;
+ channelName?: string;
content: string;
parentEventId?: string | null;
pubkey?: string;
@@ -6983,6 +6986,8 @@ async function handleOpenDm(
// active community/identity.
assertExpectedRelayScope(args.expectedRelayUrl, config);
assertExpectedSigner(args.expectedSignerPubkey, config);
+ const openError = config?.mock?.openDmErrors?.shift();
+ if (openError) throw new Error(openError);
const normalizedPubkeys = normalizeParticipantPubkeys(args.pubkeys);
if (normalizedPubkeys.length === 0) {
@@ -10859,6 +10864,7 @@ export function maybeInstallE2eTauriMocks() {
await emitMockHuddleState();
};
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ = ({
+ channelId,
channelName,
content,
parentEventId,
@@ -10870,11 +10876,13 @@ export function maybeInstallE2eTauriMocks() {
pending,
id,
}) => {
- const channel = mockChannels.find(
- (candidate) => candidate.name === channelName,
+ const channel = mockChannels.find((candidate) =>
+ channelId ? candidate.id === channelId : candidate.name === channelName,
);
if (!channel) {
- throw new Error(`Mock channel ${channelName} not found.`);
+ throw new Error(
+ `Mock channel ${channelId ?? channelName ?? ""} not found.`,
+ );
}
return emitMockChannelMessage(
diff --git a/desktop/tests/e2e/bestie-sidebar.spec.ts b/desktop/tests/e2e/bestie-sidebar.spec.ts
index 4a449954248..e4082225323 100644
--- a/desktop/tests/e2e/bestie-sidebar.spec.ts
+++ b/desktop/tests/e2e/bestie-sidebar.spec.ts
@@ -45,6 +45,12 @@ async function seedCommunities(
);
}
+function channelIdFromUrl(url: string) {
+ const channelId = new URL(url).hash.match(/^#\/channels\/([^?]+)/)?.[1];
+ if (!channelId) throw new Error(`Expected a channel route, got ${url}`);
+ return channelId;
+}
+
test("the enabled Bestie experiment adds a direct-message entry below Agents", async ({
page,
}) => {
@@ -82,6 +88,186 @@ test("the disabled Bestie experiment does not mount the sidebar entry", async ({
await page.goto("/");
await expect(page.getByTestId("open-bestie-dm")).toHaveCount(0);
+ await expect(page.getByTestId("open-bestie-panel")).toHaveCount(0);
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(page.getByTestId("bestie-chat-popover")).toHaveCount(0);
+});
+
+test("the app-level avatar and command shortcut share one Bestie conversation", async ({
+ page,
+}) => {
+ await installMockBridge(page, { managedAgents: [bestie] });
+ await page.setViewportSize({ width: 1000, height: 760 });
+ await page.goto("/");
+
+ const trigger = page.getByTestId("open-bestie-panel");
+ const topChrome = page.getByTestId("app-top-chrome");
+ await expect(trigger).toBeVisible();
+ await expect(trigger).toHaveAccessibleName("Open Bestie chat");
+
+ const [triggerBox, chromeBox] = await Promise.all([
+ trigger.boundingBox(),
+ topChrome.boundingBox(),
+ ]);
+ expect(triggerBox).not.toBeNull();
+ expect(chromeBox).not.toBeNull();
+ expect(triggerBox?.x).toBeGreaterThan((chromeBox?.x ?? 0) + 800);
+ expect(triggerBox?.y).toBeGreaterThanOrEqual(chromeBox?.y ?? 0);
+ expect((triggerBox?.y ?? 0) + (triggerBox?.height ?? 0)).toBeLessThanOrEqual(
+ (chromeBox?.y ?? 0) + (chromeBox?.height ?? 0),
+ );
+
+ const popover = page.getByTestId("bestie-chat-popover");
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(popover).toBeVisible();
+ await expect(popover).toContainText("Bestie");
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(popover).toBeHidden();
+ await trigger.click();
+ await expect(popover).toBeVisible();
+
+ const composer = popover.getByTestId("message-composer");
+ const editor = composer.locator('[contenteditable="true"]');
+ await expect(editor).toBeEditable();
+ await editor.fill("Keep this decision close at hand.");
+ await composer.getByRole("button", { name: "Send" }).click();
+ await expect(popover.getByTestId("message-row").last()).toContainText(
+ "Keep this decision close at hand.",
+ );
+
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(popover).toBeHidden();
+ await page.getByTestId("channel-general").click();
+ const channelEditor = page
+ .getByTestId("message-composer")
+ .locator('[contenteditable="true"]');
+ await channelEditor.focus();
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(popover).toBeVisible();
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(popover).toBeHidden();
+
+ await page.goto("/#/settings");
+ await expect(trigger).toHaveCount(0);
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(popover).toBeVisible();
+ await page.keyboard.press("ControlOrMeta+1");
+ await expect(popover).toBeHidden();
+});
+
+test("a failed Bestie open stays actionable and Retry restores real history", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ managedAgents: [bestie],
+ openDmErrors: [null, "relay offline", null],
+ });
+ await page.goto("/");
+
+ await page.getByTestId("open-bestie-dm").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("Bestie");
+ const composer = page.getByTestId("message-composer");
+ await composer.locator('[contenteditable="true"]').fill("Existing history");
+ await composer.getByRole("button", { name: "Send" }).click();
+ await expect(page.getByTestId("message-row").last()).toContainText(
+ "Existing history",
+ );
+ await page.getByTestId("channel-general").click();
+
+ await page.getByTestId("open-bestie-panel").click();
+ const popover = page.getByTestId("bestie-chat-popover");
+ await expect(popover.getByRole("alert")).toContainText(
+ "Couldn't load your conversation with Bestie.",
+ );
+ await expect(popover.locator('[contenteditable="true"]')).toHaveCount(0);
+
+ await popover.getByRole("button", { name: "Retry" }).click();
+ await expect(popover.getByTestId("bestie-chat-transcript")).toContainText(
+ "Existing history",
+ );
+ await expect(popover.locator('[contenteditable="true"]')).toBeEditable();
+});
+
+test("viewing an unread Bestie DM in the popover advances its canonical read marker", async ({
+ page,
+}) => {
+ await installMockBridge(page, { managedAgents: [bestie] });
+ await page.goto("/");
+
+ await page.getByTestId("open-bestie-dm").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("Bestie");
+ const bestieChannelId = channelIdFromUrl(page.url());
+ await page.getByTestId("channel-general").click();
+ const createdAt = Math.floor(Date.now() / 1_000) + 1;
+ await page.evaluate(
+ ({ channelId, content, createdAt, pubkey }) =>
+ window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
+ channelId,
+ content,
+ createdAt,
+ pubkey,
+ }),
+ {
+ channelId: bestieChannelId,
+ content: "Unread Bestie guidance",
+ createdAt,
+ pubkey: BESTIE_PUBKEY,
+ },
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(
+ (channelId) =>
+ window.__BUZZ_E2E_COMMAND_LOG__?.some(
+ (entry) =>
+ entry.command === "observed_unread_ingest" &&
+ (
+ entry.payload as {
+ request?: { events?: Array<{ channelId?: string }> };
+ }
+ )?.request?.events?.some(
+ (event) => event.channelId === channelId,
+ ),
+ ),
+ bestieChannelId,
+ ),
+ )
+ .toBe(true);
+ await page.evaluate(() => {
+ window.__BUZZ_E2E_COMMAND_LOG__ = [];
+ });
+
+ await page.getByTestId("open-bestie-panel").click();
+ const popover = page.getByTestId("bestie-chat-popover");
+ await expect(popover.getByTestId("bestie-chat-transcript")).toContainText(
+ "Unread Bestie guidance",
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(
+ ({ channelId, readAt }) =>
+ window.__BUZZ_E2E_COMMAND_LOG__?.some(
+ (entry) =>
+ entry.command === "observed_unread_ingest" &&
+ (
+ entry.payload as {
+ request?: {
+ markers?: Array<{
+ contextId?: string;
+ readAt?: number | null;
+ }>;
+ };
+ }
+ )?.request?.markers?.some(
+ (marker) =>
+ marker.contextId === channelId &&
+ (marker.readAt ?? 0) >= readAt,
+ ),
+ ),
+ { channelId: bestieChannelId, readAt: createdAt },
+ ),
+ )
+ .toBe(true);
});
test("a delayed Bestie open is scoped to its rendered community and signer", async ({
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 284214a5493..9d2f905de3a 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -284,6 +284,8 @@ type MockBridgeOptions = {
/** Reject `clear_pending_navigation_deep_links` with this message. */
clearPendingNavigationDeepLinksError?: string;
openDmDelayMs?: number;
+ /** Reject successive `open_dm` calls, then resume. */
+ openDmErrors?: (string | null)[];
sendMessageDelayMs?: number;
/** Delay (ms) for `start_managed_agent` so e2e tests can switch the
* community mid-startup and observe the fail-closed scope check. */