From 38174f511926d2a906c203988b409ccf4d6143ff Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Wed, 26 Aug 2026 18:07:03 -0400 Subject: [PATCH 1/5] feat(desktop): add floating Bestie chat Co-authored-by: Codex Signed-off-by: Arjun Mahanti --- desktop/src/app/AppShell.tsx | 6 + desktop/src/app/AppTopChrome.tsx | 10 + .../messages/ui/BestieChatPopover.tsx | 288 ++++++++++++++++++ .../settings/ui/KeyboardShortcutsCard.tsx | 6 +- .../shared/lib/keyboard-shortcuts.test.mjs | 24 ++ desktop/src/shared/lib/keyboard-shortcuts.ts | 24 +- desktop/tests/e2e/bestie-sidebar.spec.ts | 46 +++ 7 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 desktop/src/features/messages/ui/BestieChatPopover.tsx create mode 100644 desktop/src/shared/lib/keyboard-shortcuts.test.mjs diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 0ce76b0cf53..fd2758848db 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -8,10 +8,12 @@ import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays"; import { AppShellChannelSurface } from "@/app/AppShellChannelSurface"; import { AppHuddleShell } from "@/app/AppHuddleShell"; import { AppTopChrome } from "@/app/AppTopChrome"; +import { BestieChatPopover } from "@/features/messages/ui/BestieChatPopover"; import { type TerminalContextOverride, TerminalContextOverrideProvider, } from "@/app/TerminalContextOverrideContext"; +import { useFeatureEnabled } from "@/shared/features"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; @@ -106,6 +108,7 @@ import { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlay import { LazySettingsScreen } from "@/app/LazySettingsScreen"; const EMPTY_CHANNELS: Channel[] = []; export function AppShell() { + const bestieEnabled = useFeatureEnabled("bestie"); useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); @@ -783,6 +786,9 @@ export function AppShell() { hasCommunityRail={hasCommunityRail} onGoBack={goBack} onGoForward={goForward} + trailingContent={ + bestieEnabled ? : null + } /> ) : null} {settingsOpen ? ( diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index 35b5e4b2093..ef54d559e9f 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,14 @@ export function AppTopChrome({ data-tauri-drag-region id="app-top-chrome-content" /> + {trailingContent ? ( +
+ {trailingContent} +
+ ) : null} ); } diff --git a/desktop/src/features/messages/ui/BestieChatPopover.tsx b/desktop/src/features/messages/ui/BestieChatPopover.tsx new file mode 100644 index 00000000000..645f61e3fcb --- /dev/null +++ b/desktop/src/features/messages/ui/BestieChatPopover.tsx @@ -0,0 +1,288 @@ +import { Loader2 } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; + +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { pickBestieAgent } from "@/features/agents/lib/bestie"; +import { useOpenDmMutation } from "@/features/channels/hooks"; +import { useCommunities } from "@/features/communities/useCommunities"; +import { + useChannelMessagesQuery, + useChannelSubscription, + useSendMessageMutation, +} from "@/features/messages/hooks"; +import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; +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, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +export function BestieChatPopover() { + 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 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; + + 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; + setOpen(false); + setChannel(null); + }, [conversationScope]); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + if (!bestie) return; + setOpen(nextOpen); + positionedScrollRef.current = false; + stickToBottomRef.current = true; + const requestId = ++openRequestRef.current; + if (!nextOpen) { + setChannel(null); + return; + } + + 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); + toast.error(`Couldn't open ${bestie.name}`); + }); + }, + [activeCommunity?.relayUrl, bestie, currentPubkey, openDmMutation], + ); + + 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 = + openDmMutation.isPending || (channel && messagesQuery.isLoading); + const isSending = sendMessageMutation.isPending; + + return ( + + + + + + + + + Message {bestie.name} ({getPlatformKeysById("open-bestie")}) + + + + +
+
+ +

+ {bestie.name} +

+
+ +
{ + const element = event.currentTarget; + stickToBottomRef.current = + element.scrollHeight - + element.scrollTop - + element.clientHeight < + 64; + }} + ref={scrollRef} + > + {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/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/tests/e2e/bestie-sidebar.spec.ts b/desktop/tests/e2e/bestie-sidebar.spec.ts index 4a449954248..fae109304c7 100644 --- a/desktop/tests/e2e/bestie-sidebar.spec.ts +++ b/desktop/tests/e2e/bestie-sidebar.spec.ts @@ -82,6 +82,52 @@ 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("Meta+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("Meta+1"); + await expect(popover).toBeVisible(); + await expect(popover).toContainText("Bestie"); + await page.keyboard.press("Meta+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.", + ); }); test("a delayed Bestie open is scoped to its rendered community and signer", async ({ From a859f097406982c5e19900b7abd514a3afe6d8d2 Mon Sep 17 00:00:00 2001 From: Arjun Mahanti Date: Wed, 26 Aug 2026 20:35:12 -0400 Subject: [PATCH 2/5] fix(desktop): make Bestie chat lifecycle-safe Co-authored-by: Codex Signed-off-by: Arjun Mahanti --- desktop/src/app/AppShell.tsx | 8 +- desktop/src/app/AppTopChrome.tsx | 15 +- desktop/src/features/messages/hooks.ts | 92 +++------- .../channelLiveSubscriptionRegistry.test.mjs | 97 +++++++++++ .../lib/channelLiveSubscriptionRegistry.ts | 102 +++++++++++ .../messages/ui/BestieChatPopover.tsx | 162 ++++++++++++------ desktop/src/shared/api/relayClient.ts | 19 +- .../api/visibleChannelOwnership.test.mjs | 32 ++++ .../src/shared/api/visibleChannelOwnership.ts | 21 +++ desktop/src/testing/e2eBridge.ts | 16 +- desktop/tests/e2e/bestie-sidebar.spec.ts | 140 +++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 12 files changed, 566 insertions(+), 140 deletions(-) create mode 100644 desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.test.mjs create mode 100644 desktop/src/features/messages/lib/channelLiveSubscriptionRegistry.ts create mode 100644 desktop/src/shared/api/visibleChannelOwnership.test.mjs create mode 100644 desktop/src/shared/api/visibleChannelOwnership.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index fd2758848db..18a7f097f02 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -786,9 +786,11 @@ export function AppShell() { hasCommunityRail={hasCommunityRail} onGoBack={goBack} onGoForward={goForward} - trailingContent={ - bestieEnabled ? : null - } + /> + ) : null} + {bestieEnabled ? ( + ) : null} {settingsOpen ? ( diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx index ef54d559e9f..175802a39d6 100644 --- a/desktop/src/app/AppTopChrome.tsx +++ b/desktop/src/app/AppTopChrome.tsx @@ -162,14 +162,13 @@ export function AppTopChrome({ data-tauri-drag-region id="app-top-chrome-content" /> - {trailingContent ? ( -
- {trailingContent} -
- ) : null} +
+ {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 index 645f61e3fcb..b90870cbee5 100644 --- a/desktop/src/features/messages/ui/BestieChatPopover.tsx +++ b/desktop/src/features/messages/ui/BestieChatPopover.tsx @@ -1,10 +1,12 @@ 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, @@ -12,6 +14,7 @@ import { 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"; @@ -22,10 +25,10 @@ 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, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -export function BestieChatPopover() { +export function BestieChatPopover({ showTrigger }: { showTrigger: boolean }) { const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); @@ -34,6 +37,10 @@ export function BestieChatPopover() { 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); @@ -95,6 +102,30 @@ export function BestieChatPopover() { ], ); 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; @@ -112,38 +143,49 @@ export function BestieChatPopover() { 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; - const requestId = ++openRequestRef.current; - if (!nextOpen) { - setChannel(null); + if (nextOpen) { + openConversation(); return; } - - 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); - toast.error(`Couldn't open ${bestie.name}`); - }); + openRequestRef.current += 1; + setChannel(null); + setOpenError(null); }, - [activeCommunity?.relayUrl, bestie, currentPubkey, openDmMutation], + [bestie, openConversation], ); const handleBestieShortcut = React.useEffectEvent((event: KeyboardEvent) => { @@ -184,36 +226,50 @@ export function BestieChatPopover() { }); }; const isLoading = - openDmMutation.isPending || (channel && messagesQuery.isLoading); + !openError && + (openDmMutation.isPending || (channel && messagesQuery.isLoading)); const isSending = sendMessageMutation.isPending; return ( - - - - - - - - Message {bestie.name} ({getPlatformKeysById("open-bestie")}) - - + {portalTarget ? ( + createPortal( + +
+ + + + + + Message {bestie.name} ({getPlatformKeysById("open-bestie")}) + + +
+
, + portalTarget, + ) + ) : ( + + + + )} - {isLoading ? ( + {openError ? ( +
+

{openError}

+ +
+ ) : isLoading ? (
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/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 fae109304c7..7b5ab0e1495 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, }) => { @@ -128,6 +134,140 @@ test("the app-level avatar and command shortcut share one Bestie conversation", await expect(popover.getByTestId("message-row").last()).toContainText( "Keep this decision close at hand.", ); + + await page.keyboard.press("Meta+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("Meta+1"); + await expect(popover).toBeVisible(); + await page.keyboard.press("Meta+1"); + await expect(popover).toBeHidden(); + + await page.goto("/#/settings"); + await expect(trigger).toHaveCount(0); + await page.keyboard.press("Meta+1"); + await expect(popover).toBeVisible(); + await page.keyboard.press("Meta+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. */ From 3a863927075d7751f72eb296719cf678fa7508c9 Mon Sep 17 00:00:00 2001 From: Fizz Date: Fri, 28 Aug 2026 10:44:14 -0400 Subject: [PATCH 3/5] fix(desktop): use the platform shortcut in Bestie E2E On-behalf-of: mahanti Signed-off-by: Fizz Co-authored-by: Codex --- desktop/tests/e2e/bestie-sidebar.spec.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/desktop/tests/e2e/bestie-sidebar.spec.ts b/desktop/tests/e2e/bestie-sidebar.spec.ts index 7b5ab0e1495..e4082225323 100644 --- a/desktop/tests/e2e/bestie-sidebar.spec.ts +++ b/desktop/tests/e2e/bestie-sidebar.spec.ts @@ -89,7 +89,7 @@ test("the disabled Bestie experiment does not mount the sidebar entry", async ({ await expect(page.getByTestId("open-bestie-dm")).toHaveCount(0); await expect(page.getByTestId("open-bestie-panel")).toHaveCount(0); - await page.keyboard.press("Meta+1"); + await page.keyboard.press("ControlOrMeta+1"); await expect(page.getByTestId("bestie-chat-popover")).toHaveCount(0); }); @@ -118,10 +118,10 @@ test("the app-level avatar and command shortcut share one Bestie conversation", ); const popover = page.getByTestId("bestie-chat-popover"); - await page.keyboard.press("Meta+1"); + await page.keyboard.press("ControlOrMeta+1"); await expect(popover).toBeVisible(); await expect(popover).toContainText("Bestie"); - await page.keyboard.press("Meta+1"); + await page.keyboard.press("ControlOrMeta+1"); await expect(popover).toBeHidden(); await trigger.click(); await expect(popover).toBeVisible(); @@ -135,23 +135,23 @@ test("the app-level avatar and command shortcut share one Bestie conversation", "Keep this decision close at hand.", ); - await page.keyboard.press("Meta+1"); + 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("Meta+1"); + await page.keyboard.press("ControlOrMeta+1"); await expect(popover).toBeVisible(); - await page.keyboard.press("Meta+1"); + await page.keyboard.press("ControlOrMeta+1"); await expect(popover).toBeHidden(); await page.goto("/#/settings"); await expect(trigger).toHaveCount(0); - await page.keyboard.press("Meta+1"); + await page.keyboard.press("ControlOrMeta+1"); await expect(popover).toBeVisible(); - await page.keyboard.press("Meta+1"); + await page.keyboard.press("ControlOrMeta+1"); await expect(popover).toBeHidden(); }); From f91e8009b05b3964954d06e4ce617ac578c0584a Mon Sep 17 00:00:00 2001 From: Fizz Date: Fri, 28 Aug 2026 11:02:24 -0400 Subject: [PATCH 4/5] fix(desktop): preserve hidden DM resurfacing On-behalf-of: mahanti Signed-off-by: Fizz Co-authored-by: Codex --- desktop/src/app/AppShell.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 18a7f097f02..ccc16202c14 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -35,6 +35,7 @@ import { useChannelsQuery, useCreateChannelMutation, useHideDmMutation, + useOpenDmMutation, } from "@/features/channels/hooks"; import { useDmResurfaceFromMessages } from "@/features/channels/useDmResurfaceFromMessages"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; @@ -507,6 +508,7 @@ export function AppShell() { const createChannelMutation = useCreateChannelMutation(), createForumMutation = useCreateChannelMutation(); const { applyCanvas, applyAgents } = useApplyTemplate(); + const openDmMutation = useOpenDmMutation(); const openDm = useScopedOpenDmNavigation({ goChannel, relayUrl: communitiesHook.activeCommunity?.relayUrl, From 06ad43e6c0c14e388ac14bc2718794ac6977ff05 Mon Sep 17 00:00:00 2001 From: Fizz Date: Fri, 28 Aug 2026 11:36:32 -0400 Subject: [PATCH 5/5] refactor(desktop): isolate Bestie app popover On-behalf-of: mahanti Signed-off-by: Fizz Co-authored-by: Codex --- desktop/src/app/AppBestiePopover.tsx | 7 +++++++ desktop/src/app/AppShell.tsx | 10 ++-------- 2 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 desktop/src/app/AppBestiePopover.tsx 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 ccc16202c14..dd8c54ae484 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -7,13 +7,12 @@ 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 { BestieChatPopover } from "@/features/messages/ui/BestieChatPopover"; import { type TerminalContextOverride, TerminalContextOverrideProvider, } from "@/app/TerminalContextOverrideContext"; -import { useFeatureEnabled } from "@/shared/features"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions"; @@ -109,7 +108,6 @@ import { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlay import { LazySettingsScreen } from "@/app/LazySettingsScreen"; const EMPTY_CHANNELS: Channel[] = []; export function AppShell() { - const bestieEnabled = useFeatureEnabled("bestie"); useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); @@ -790,11 +788,7 @@ export function AppShell() { onGoForward={goForward} /> ) : null} - {bestieEnabled ? ( - - ) : null} +