From d8ebeb7d2f69b2b8610aa02af59de6c5767dc2fd Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 15:14:57 -0400 Subject: [PATCH 01/21] feat(bestie): pin the saved Bestie conversation Signed-off-by: Clay Delk --- src/app/shell/AppShell.tsx | 15 +- src/app/shell/BestieNavigation.module.css | 7 + src/app/shell/BestieNavigation.tsx | 112 ++++++++ src/app/shell/PageNavigation.tsx | 44 +++- src/app/shell/bestie-context.ts | 19 ++ src/bundled/bestie/PinnedBestie.tsx | 240 ++++++++++++++++++ src/bundled/bestie/chat.test.ts | 91 +++++++ src/bundled/bestie/chat.ts | 35 +++ src/bundled/bestie/index.tsx | 52 +++- src/bundled/channels/useHiddenDms.ts | 2 +- .../channel-navigation/ChannelInventory.tsx | 9 + src/features/solo/default-agent.ts | 12 +- 12 files changed, 626 insertions(+), 12 deletions(-) create mode 100644 src/app/shell/BestieNavigation.module.css create mode 100644 src/app/shell/BestieNavigation.tsx create mode 100644 src/app/shell/bestie-context.ts create mode 100644 src/bundled/bestie/PinnedBestie.tsx create mode 100644 src/bundled/bestie/chat.test.ts create mode 100644 src/bundled/bestie/chat.ts diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index 939cabbe73..9d2ba78af9 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -4,6 +4,7 @@ import { useMemo, useRef, useState, + type ComponentProps, type ReactNode, type Ref, } from "react"; @@ -24,6 +25,7 @@ import type { Appearance } from "../../shared/theme/service"; import { PageSearch, type SearchServices } from "./PageSearch"; import { headerPageSelection, pagePlacement, orderPages } from "./presentation"; import { PageNavigation } from "./PageNavigation"; +import { BestieNavigationProvider } from "./BestieNavigation"; import type { NavigationOpenOptions } from "../../features/navigation/controller"; import { PopoverRoot, @@ -37,7 +39,18 @@ import { hasIntegratedWindowControls, WindowControls } from "./WindowControls"; const macDesktop = isTauri() && /Mac/i.test(navigator.platform); const titleBarDragProps = macDesktop ? macTitleBarDragHandlers : {}; -export function AppShell({ +export function AppShell(props: ComponentProps) { + return ( + + + + ); +} + +function AppShellContent({ pages, selected, navigationAttempt, diff --git a/src/app/shell/BestieNavigation.module.css b/src/app/shell/BestieNavigation.module.css new file mode 100644 index 0000000000..b8c22fa711 --- /dev/null +++ b/src/app/shell/BestieNavigation.module.css @@ -0,0 +1,7 @@ +.label[data-bestie-unread] { + color: var(--text-navigation-unread); + font-weight: var(--type-weight-semibold); +} +[data-selected] .label[data-bestie-unread] { + color: var(--text-row-selected); +} diff --git a/src/app/shell/BestieNavigation.tsx b/src/app/shell/BestieNavigation.tsx new file mode 100644 index 0000000000..d4624ae8cd --- /dev/null +++ b/src/app/shell/BestieNavigation.tsx @@ -0,0 +1,112 @@ +import { useContext, useSyncExternalStore, type ReactNode } from "react"; +import { bestieChat } from "../../bundled/bestie/chat"; +import { + UnreadBadge, + useUnreadState, +} from "../../bundled/channels/UnreadBadge"; +import channelStyles from "../../bundled/channels/Channels.module.css"; +import type { RelaySession } from "../../features/relay/session"; +import { useAgentChoices } from "../../features/agents/use-choices"; +import type { RegisteredPage } from "../../features/pages/service"; +import { useRelayConnection } from "../../features/relay/react"; +import type { RelayData } from "../../features/relay/service"; +import { useSavedAgent } from "../../features/solo/default-agent"; +import { useAvatarPreview } from "../../features/profiles/use-avatar-preview"; +import styles from "./BestieNavigation.module.css"; +import { + BESTIE_PAGE, + BestieNavigationContext, + BestieConversationContext, + type BestieMark, +} from "./bestie-context"; + +/** Shell projection of the account-saved Bestie and its existing exact DM. */ +export function BestieNavigationProvider({ + pages, + relay, + children, +}: { + pages: readonly RegisteredPage[]; + relay: RelayData; + children: ReactNode; +}) { + const connection = useRelayConnection(relay); + const { viewer, scope, session } = connection; + const active = + pages.some((page) => page.key === BESTIE_PAGE) && + connection.status === "ready" && + !!viewer && + !!scope?.endsWith(`:${viewer}`); + const saved = useSavedAgent(session, scope ?? ""); + const choices = useAgentChoices(session, active, active); + const agent = active + ? choices.selectable.find((item) => item.pubkey === saved) + : undefined; + const channels = useSyncExternalStore( + session.channels.subscribeList, + session.channels.list, + ); + const channel = bestieChat(channels.channels, viewer, agent?.pubkey); + const avatar = useAvatarPreview( + agent?.avatar ?? "", + connection.origin, + "small", + session.media, + ); + const mark: BestieMark | undefined = agent + ? { + label: agent.managedName ?? agent.name, + avatar: { src: avatar, shape: "squircle" }, + } + : undefined; + return ( + + + {children} + + + ); +} + +export function BestieUnreadLabel({ label }: { label: string }) { + const conversation = useContext(BestieConversationContext); + return conversation ? : label; +} +function UnreadLabel({ + session, + channelId, + label, +}: { + session: RelaySession; + channelId: string; + label: string; +}) { + const { ordinary } = useUnreadState(session, channelId, true); + return ( + + {label} + + ); +} + +export function BestieUnreadBadge({ pageKey }: { pageKey: string }) { + const conversation = useContext(BestieConversationContext); + if (pageKey !== BESTIE_PAGE || !conversation) return null; + return ( + + + + + + ); +} + +export function useBestieMark() { + return useContext(BestieNavigationContext); +} diff --git a/src/app/shell/PageNavigation.tsx b/src/app/shell/PageNavigation.tsx index cb598ed00d..5ea77cfb2d 100644 --- a/src/app/shell/PageNavigation.tsx +++ b/src/app/shell/PageNavigation.tsx @@ -8,7 +8,14 @@ import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { Tabs } from "../../shared/design-system/ui/Tabs"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { PageIcon } from "./PageIcon"; +import { Avatar } from "../../shared/design-system/ui/Avatar"; import { pagePresentation } from "./presentation"; +import { + BestieUnreadBadge, + BestieUnreadLabel, + useBestieMark, +} from "./BestieNavigation"; +import { BESTIE_PAGE } from "./bestie-context"; export function PageNavigation({ pages, @@ -23,6 +30,11 @@ export function PageNavigation({ onSelect(key: string, options?: Pick): void; placement?: "sidebar" | "topbar" | "toolbar"; }) { + const bestie = useBestieMark(); + const presentation = (page: RegisteredPage) => ({ + ...pagePresentation(page), + ...(page.key === BESTIE_PAGE && bestie ? bestie : undefined), + }); if (placement === "topbar") { return ( { const Badge = page.badge; - const { icon, image } = pagePresentation(page); + const { icon, image } = presentation(page); return { value: page.key, - label: pagePresentation(page).label, + label: presentation(page).label, icon: ( ), @@ -54,7 +66,7 @@ export function PageNavigation({ ); } return pages.map((page) => { - const { label, icon, image } = pagePresentation(page); + const { label, icon, image, avatar } = presentation(page); const Badge = page.badge; const badge = Badge && ( @@ -65,7 +77,16 @@ export function PageNavigation({ onSelect(page.key); document.getElementById("main-content")?.focus({ preventScroll: true }); }; - const pageIcon = ( + const pageIcon = avatar ? ( + + ) : ( : label + } title={label} variant="row" - trailing={badge} + trailing={ + page.key === BESTIE_PAGE ? ( + <> + {badge} + + + ) : ( + badge + ) + } icon={ placement === "sidebar" ? ( {pageIcon} diff --git a/src/app/shell/bestie-context.ts b/src/app/shell/bestie-context.ts new file mode 100644 index 0000000000..885dce46c1 --- /dev/null +++ b/src/app/shell/bestie-context.ts @@ -0,0 +1,19 @@ +import { createContext, useContext } from "react"; +import type { RelaySession } from "../../features/relay/session"; + +export const BESTIE_PAGE = "buzz.bestie/bestie"; +export type BestieMark = { + label: string; + avatar: { src: string | undefined; shape: "squircle" }; +}; +export const BestieNavigationContext = createContext( + undefined, +); +export const BestieConversationContext = createContext< + { session: RelaySession; channelId: string } | undefined +>(undefined); + +export function useBestieConversation(session: RelaySession) { + const conversation = useContext(BestieConversationContext); + return conversation?.session === session ? conversation.channelId : undefined; +} diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx new file mode 100644 index 0000000000..ca3c7391ee --- /dev/null +++ b/src/bundled/bestie/PinnedBestie.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from "react"; +import { AgentPortrait } from "../agents/AgentCard"; +import { ChannelBody } from "../channels/ChannelBody"; +import { + ConversationChannel, + ConversationComposer, + ConversationThread, +} from "../../features/conversation/Conversation"; +import type { ConversationExtensions } from "../../features/conversation/contracts"; +import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; +import { messageViewKey } from "../../features/messages/view-key"; +import { useAgentChoices } from "../../features/agents/use-choices"; +import { useChannelList, useRelayConnection } from "../../features/relay/react"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { useSavedAgent } from "../../features/solo/default-agent"; +import { SoloColumn } from "../../features/solo/SoloPresentation"; +import { RobotIcon } from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; +import { EmptyState } from "../../shared/design-system/ui/EmptyState"; +import { FullPageSurface } from "../../shared/design-system/ui/FullPageSurface"; +import { LoadingRegion } from "../../shared/design-system/ui/LoadingRegion"; +import { + PanelHeader, + PanelHeaderLabel, +} from "../../shared/design-system/ui/PanelHeader"; +import { bestieChat } from "./chat"; + +/** Account-saved Besties use their canonical verified DM. No saved choice means + * the caller keeps Riley's hosted setup page. */ +export function PinnedBestie({ + relay, + host, + extensions, +}: { + relay: RelayData; + host: ConversationHostServices; + extensions: ConversationExtensions; +}) { + const connection = useRelayConnection(relay); + const { session, scope, viewer } = connection; + const saved = useSavedAgent(session, scope ?? ""); + const active = + connection.status === "ready" && + !!scope && + !!viewer && + scope.endsWith(`:${viewer}`); + const choices = useAgentChoices(session, active, active); + const agent = active + ? choices.selectable.find((choice) => choice.pubkey === saved) + : undefined; + const channels = useChannelList(session.channels); + if (!agent || !scope) return null; + const channel = bestieChat(channels.channels, viewer, agent.pubkey); + const name = agent.managedName ?? agent.name; + return ( +
+ +
+ + } + /> + } + /> + +
+
+
+ ); +} + +function DirectMessage({ + session, + scope, + host, + extensions, + agent, + channel, +}: { + session: RelaySession; + scope: string; + host: ConversationHostServices; + extensions: ConversationExtensions; + agent: { pubkey: string; name: string }; + channel: ChannelSummary | undefined; +}) { + const [attempt, retry] = useState(0); + const [error, setError] = useState(); + // biome-ignore lint/correctness/useExhaustiveDependencies: Retry deliberately starts a fresh open attempt. + useEffect(() => { + if (channel) return; + const controller = new AbortController(); + setError(undefined); + void session.directMessages + .open([agent.pubkey], controller.signal) + .catch((reason) => { + if (!controller.signal.aborted) + setError( + reason instanceof Error + ? reason.message + : "Buzz couldn’t open this conversation.", + ); + }); + return () => controller.abort(); + }, [session, agent.pubkey, channel, attempt]); + if (channel) + return ( + + ); + if (error) + return ( + retry((value) => value + 1)}>Try again + } + /> + ); + return ( + + + + ); +} + +function BestieConversation({ + session, + scope, + host, + extensions, + channel, + bestie, +}: { + session: RelaySession; + scope: string; + host: ConversationHostServices; + extensions: ConversationExtensions; + channel: ChannelSummary; + bestie: string; +}) { + const [sent, setSent] = useState(); + const [thread, setThread] = useState(); + const [composerFocus, focusComposer] = useState(0); + return ( + +
+ {thread ? ( + { + setThread(undefined); + focusComposer((value) => value + 1); + }} + /> + ) : ( + + + + + )} +
+
+ ); +} + +function Notice({ + title, + description, + action, +}: { + title: string; + description: string; + action?: React.ReactNode; +}) { + return ( +
+ } + title={title} + description={description} + action={action} + /> +
+ ); +} diff --git a/src/bundled/bestie/chat.test.ts b/src/bundled/bestie/chat.test.ts new file mode 100644 index 0000000000..8227118061 --- /dev/null +++ b/src/bundled/bestie/chat.test.ts @@ -0,0 +1,91 @@ +import { expect, it } from "vitest"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { bestieChat } from "./chat"; + +const viewer = "a".repeat(64), + bestie = "b".repeat(64), + other = "c".repeat(64); +const chat = ( + id: string, + overrides: Partial = {}, +): ChannelSummary => ({ + id, + name: id, + channelType: "dm", + creator: viewer, + members: [viewer, bestie], + lastActivityAt: 10, + ...overrides, +}); +/** Absent metadata, as the relay leaves it: not a field set to undefined. */ +const without = ( + channel: ChannelSummary, + key: keyof ChannelSummary, +): ChannelSummary => { + const { [key]: _absent, ...rest } = channel; + return rest as ChannelSummary; +}; + +it("finds no chat without a viewer, a Bestie, or a matching chat", () => { + expect(bestieChat([chat("one")], undefined, bestie)).toBeUndefined(); + expect(bestieChat([chat("one")], viewer, undefined)).toBeUndefined(); + expect(bestieChat([], viewer, bestie)).toBeUndefined(); + expect(bestieChat([chat("one")], viewer, bestie)?.id).toBe("one"); +}); + +it("takes the most recently active chat, and never reorders on a tie", () => { + const channels = [ + chat("older", { lastActivityAt: 5 }), + chat("newest", { lastActivityAt: 30 }), + chat("recent", { lastActivityAt: 20 }), + ]; + expect(bestieChat(channels, viewer, bestie)?.id).toBe("newest"); + const tied = [ + chat("second", { lastActivityAt: 7, createdAt: 1 }), + chat("first", { lastActivityAt: 7, createdAt: 4 }), + ]; + expect(bestieChat(tied, viewer, bestie)?.id).toBe("first"); + // Activity may be unknown; creation still orders those chats. + const unknown = [ + without(chat("old", { createdAt: 2 }), "lastActivityAt"), + without(chat("new", { createdAt: 9 }), "lastActivityAt"), + ]; + expect(bestieChat(unknown, viewer, bestie)?.id).toBe("new"); +}); + +it("only counts verified unarchived DMs the viewer belongs to", () => { + const wrong: readonly ChannelSummary[] = [ + chat("archived", { archived: true }), + chat("stream", { channelType: "stream" }), + without(chat("unclassified"), "channelType"), + chat("Solo history", { channelType: "solo" }), + chat("cached", { cached: true }), + chat("other people", { members: [bestie, other] }), + ]; + for (const channel of wrong) + expect(bestieChat([channel], viewer, bestie)).toBeUndefined(); + expect( + bestieChat([...wrong, chat("mine", { creator: other })], viewer, bestie) + ?.id, + ).toBe("mine"); +}); + +it("requires the Bestie to be the one other member", () => { + const wrong: readonly ChannelSummary[] = [ + chat("with a person", { members: [viewer, bestie, other] }), + chat("another agent", { members: [viewer, other] }), + chat("alone", { members: [viewer] }), + // An unknown roster is not evidence of a match. + without(chat("unknown roster"), "members"), + ]; + for (const channel of wrong) + expect(bestieChat([channel], viewer, bestie)).toBeUndefined(); + // A roster that repeats a member still names one other member. + expect( + bestieChat( + [chat("dup", { members: [viewer, bestie, bestie, viewer] })], + viewer, + bestie, + )?.id, + ).toBe("dup"); +}); diff --git a/src/bundled/bestie/chat.ts b/src/bundled/bestie/chat.ts new file mode 100644 index 0000000000..f7b2baf146 --- /dev/null +++ b/src/bundled/bestie/chat.ts @@ -0,0 +1,35 @@ +import type { ChannelSummary } from "../../features/relay/contracts"; + +/** The verified one-to-one DM with this Bestie. Solo chats stay separate. */ +export function bestieChat( + channels: readonly ChannelSummary[], + viewer: string | undefined, + bestie: string | undefined, +) { + if (!viewer || !bestie) return; + return channels + .filter( + (channel) => + channel.channelType === "dm" && + !channel.cached && + !channel.archived && + channel.members?.includes(viewer) && + onlyOther(channel.members, viewer) === bestie, + ) + .sort( + (a, b) => + recency(b) - recency(a) || + (b.createdAt ?? 0) - (a.createdAt ?? 0) || + a.id.localeCompare(b.id), + ) + .at(0); +} + +const recency = (channel: ChannelSummary) => + channel.lastActivityAt ?? channel.createdAt ?? 0; + +function onlyOther(members: readonly string[] | undefined, viewer: string) { + if (!members) return; + const others = [...new Set(members)].filter((key) => key !== viewer); + return others.length === 1 ? others[0] : undefined; +} diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index 497f0857b8..65d6e31eeb 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -29,6 +29,10 @@ import { type RemoteBestie, } from "./setup"; import type { RemoteAgentModel } from "../builderlab/agents/client"; +import { useRelayConnection } from "../../features/relay/react"; +import { useSavedAgentState } from "../../features/solo/default-agent"; +import type { RelayData } from "../../features/relay/service"; +import { PinnedBestie } from "./PinnedBestie"; export const inject = [ "pages", @@ -78,8 +82,9 @@ export const apply: PluginModule["apply"] = (ctx) => { source.snapshot, ); return ( - { }); }; +function BestieDestination({ + relay, + ...props +}: { + bestie?: RemoteBestie | undefined; + relay: RelayData; + navigation: Navigation; + extensions: ConversationExtensions; + host: ConversationHostServices; +}) { + const connection = useRelayConnection(relay); + const saved = useSavedAgentState(connection.session, connection.scope ?? ""); + if (!saved.read) + return ( +
+ +
+ } + /> + } + /> + +

+ Loading your Bestie… +

+
+
+
+
+ ); + return saved.agent ? ( + + ) : ( + + ); +} + export function BestiePage({ bestie, navigation, diff --git a/src/bundled/channels/useHiddenDms.ts b/src/bundled/channels/useHiddenDms.ts index 195d4b4260..81d397bbc6 100644 --- a/src/bundled/channels/useHiddenDms.ts +++ b/src/bundled/channels/useHiddenDms.ts @@ -243,5 +243,5 @@ export function useHiddenDms( return () => controller.abort(); }, [hiddenKey, session, dmRosterKey, show, update]); - return { hiddenIds: new Set(hidden.map((entry) => entry.id)), hide }; + return { hiddenIds: new Set(hidden.map((entry) => entry.id)), hide, show }; } diff --git a/src/features/channel-navigation/ChannelInventory.tsx b/src/features/channel-navigation/ChannelInventory.tsx index 3dbed1d359..1bad4cd263 100644 --- a/src/features/channel-navigation/ChannelInventory.tsx +++ b/src/features/channel-navigation/ChannelInventory.tsx @@ -94,6 +94,7 @@ import { import { splitPartition } from "../relay/partition"; import styles from "../../bundled/channels/Channels.module.css"; import { avatarMedia } from "../../shared/avatar-source"; +import { useBestieConversation } from "../../app/shell/bestie-context"; type Inventory = { sidebar: ReturnType; @@ -167,6 +168,13 @@ export function ChannelInventory({ queries.channelKit.snapshot, ); const hiddenDms = useHiddenDms(scope, queries, list); + const promotedDm = useBestieConversation(queries); + // Promotion is presentation, not hiding. Clear an earlier local removal so + // releasing this destination restores the ordinary DM row. + const showDm = hiddenDms.show; + useEffect(() => { + if (promotedDm) showDm([promotedDm]); + }, [promotedDm, showDm]); const lifecycle = queries.channelLifecycle; const dmVisibility = useSyncExternalStore( lifecycle.subscribe, @@ -244,6 +252,7 @@ export function ChannelInventory({ const preparingDm = composingMessage ? handoff?.preparingDm : undefined; const sidebarChannels = channels.filter( (channel) => + channel.id !== promotedDm && !channel.huddle && !channel.metadataPending && inTeamInventory(channel) && diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts index 3a0fddb74e..c32b2173f7 100644 --- a/src/features/solo/default-agent.ts +++ b/src/features/solo/default-agent.ts @@ -125,12 +125,18 @@ function useAdopted(session: RelaySession, scope: string) { return { read, agent }; } +/** The account-saved choice and whether its first read settled. Without an + * account capability, the local explicit choice is already authoritative. */ +export function useSavedAgentState(session: RelaySession, scope: string) { + const local = useChosenAgent(scope); + const account = useAdopted(session, scope); + return session.mainAgent ? account : { read: true, agent: local }; +} + /** The agent saved as main on the viewer's account, when it has one; without * an account record, the agent saved on this device. */ export function useSavedAgent(session: RelaySession, scope: string) { - const local = useChosenAgent(scope); - const account = useAdopted(session, scope); - return session.mainAgent ? account.agent : local; + return useSavedAgentState(session, scope).agent; } function chosenAgent(scope: string) { From 3340bed8a35a2d87825a97e9b9725b393013f1c3 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 15:22:40 -0400 Subject: [PATCH 02/21] fix(bestie): preserve pinned conversation states Signed-off-by: Clay Delk --- src/app/shell/BestieNavigation.test.tsx | 444 ++++++++++++++++++ src/app/shell/BestieNavigation.tsx | 18 +- src/app/shell/PageNavigation.tsx | 9 +- src/app/shell/PageSearch.tsx | 13 +- src/app/shell/bestie-context.ts | 1 + src/bundled/bestie/PinnedBestie.module.css | 5 + src/bundled/bestie/PinnedBestie.test.tsx | 388 +++++++++++++++ src/bundled/bestie/PinnedBestie.tsx | 177 +++++-- src/bundled/bestie/index.test.tsx | 43 +- src/bundled/bestie/testing.ts | 16 +- .../ChannelInventory.test.tsx | 146 ++++++ 11 files changed, 1212 insertions(+), 48 deletions(-) create mode 100644 src/app/shell/BestieNavigation.test.tsx create mode 100644 src/bundled/bestie/PinnedBestie.module.css create mode 100644 src/bundled/bestie/PinnedBestie.test.tsx diff --git a/src/app/shell/BestieNavigation.test.tsx b/src/app/shell/BestieNavigation.test.tsx new file mode 100644 index 0000000000..046a5ab1a7 --- /dev/null +++ b/src/app/shell/BestieNavigation.test.tsx @@ -0,0 +1,444 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { StrictMode, useEffect, useState } from "react"; +import { + act, + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { agentAvatars } from "../../features/agents/avatar-packs"; +import { createAgentChoices } from "../../features/agents/choices"; +import { createAgentLibrary } from "../../features/agents/library"; +import { localAgentsFixture } from "../../features/agents/local-testing"; +import type { RegisteredPage } from "../../features/pages/service"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; +import { createRelaySession } from "../../features/relay/session"; +import { + keypair, + message, + metadata, + roster, +} from "../../features/relay/testing"; +import type { LiveCallbacks } from "../../features/relay/live"; +import type { ReadJournal } from "../../features/relay/read-state-storage"; +import type { ReadStateSigning } from "../../features/relay/read-state-host"; +import { + decodeReadState, + signReadState, + // @ts-expect-error Test the production Node codec with disposable identities. +} from "../../../browser-host/read-state.mjs"; +import { + forgetDefaultAgent, + setDefaultAgent, +} from "../../features/solo/default-agent"; +import { BestieNavigationProvider } from "./BestieNavigation"; +import { PageNavigation } from "./PageNavigation"; +import { PrimarySidebar } from "./PrimarySidebar"; +import { ChannelNavigationProvider } from "../../features/channel-navigation/ChannelNavigationState"; +import type { Navigation } from "../../features/navigation/controller"; +import { PageSearch } from "./PageSearch"; + +beforeEach(() => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { + configurable: true, + value: vi.fn(), + }); +}); +const disposals: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const dispose of disposals.splice(0)) dispose(); + localStorage.clear(); + vi.unstubAllGlobals(); + Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); +}); +const viewer = "a".repeat(64), + pollen = "b".repeat(64), + fizz = "c".repeat(64); +const portrait = (() => { + const asset = agentAvatars[0]; + if (!asset) throw new Error("Expected bundled agent artwork"); + return asset; +})(); +const scope = `https://example.test:${viewer}`; +const page: RegisteredPage = { + id: "bestie", + key: "buzz.bestie/bestie", + pluginId: "buzz.bestie", + revision: "bundled", + title: "Bestie", + primary: true, + component: () => null, +}; + +async function fixture() { + const owner = createRelaySession(null); + const libraryRead = vi.fn(async () => ({ + definitions: [], + identities: [{ pubkey: fizz, name: "Fizz" }], + })); + const library = createAgentLibrary(libraryRead); + const native = localAgentsFixture([ + { + pubkey: pollen, + name: "Pollen", + picture: portrait.url, + owner: viewer, + relayUrl: "wss://example.test", + }, + ]); + const lifetime = new AbortController(); + const choices = createAgentChoices({ + scope, + library: library.queries, + native, + signal: lifetime.signal, + }); + await choices.refresh(false); + const session = { ...owner.session, viewer, agentChoices: choices }; + let snapshot: RelaySnapshot = { + status: "ready", + generation: 1, + viewer, + scope, + session, + }; + const listeners = new Set<() => void>(); + const relay: RelayData = { + snapshot: () => snapshot, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + retry() {}, + disconnect() {}, + async clearCache() {}, + }; + disposals.push(() => { + lifetime.abort(); + library.dispose(); + owner.dispose(); + }); + return { + relay, + native, + libraryRead, + update(patch: Partial) { + snapshot = { ...snapshot, ...patch }; + for (const listener of listeners) listener(); + }, + }; +} + +it("updates the mounted sidebar and search from the exact shared choice, including replacement and missing artwork", async () => { + const f = await fixture(); + const select = vi.fn(); + render( + + + + + + , + ); + const nav = screen.getByRole("navigation", { name: "Pages" }); + expect(within(nav).getByRole("button", { name: "Bestie" })).toBeVisible(); + act(() => { + setDefaultAgent(scope, pollen); + }); + const pollenRow = await within(nav).findByRole("button", { name: "Pollen" }); + expect(pollenRow.querySelector("img")).toHaveAttribute( + "src", + portrait.preview, + ); + expect(pollenRow.querySelector("[data-avatar-shape]")).toHaveAttribute( + "data-avatar-shape", + "squircle", + ); + expect(pollenRow.querySelector("[data-status]")).toBeNull(); + await userEvent.click(screen.getByRole("button", { name: "Search Buzz" })); + const dialog = screen.getByRole("dialog", { name: "Search Buzz" }); + expect( + within(dialog).getByRole("option", { name: "Pollen" }).querySelector("img"), + ).toHaveAttribute("src", portrait.preview); + const search = within(dialog).getByRole("combobox", { name: "Search Buzz" }); + await userEvent.type(search, "Bestie"); + expect( + within(dialog).getByRole("option", { name: "Pollen" }), + ).toBeInTheDocument(); + act(() => { + setDefaultAgent(scope, fizz); + }); + expect( + within(dialog).getByRole("option", { name: "Fizz" }), + ).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("option", { name: "Fizz" })); + expect(select).toHaveBeenCalledWith(page.key); + const fizzRow = await within(nav).findByRole("button", { name: "Fizz" }); + expect(fizzRow.querySelector("img")).toBeNull(); + expect(fizzRow.querySelector("[data-avatar-shape]")).toHaveTextContent("F"); + act(() => { + forgetDefaultAgent(fizz); + }); + expect( + within(nav).getByRole("button", { name: "Bestie" }), + ).toBeInTheDocument(); + act(() => { + setDefaultAgent(scope, "d".repeat(64)); + }); + expect( + within(nav).getByRole("button", { name: "Bestie" }), + ).toBeInTheDocument(); +}); + +it("follows roster changes and community replacement without remounting content or leaking the old identity", async () => { + const f = await fixture(); + setDefaultAgent(scope, pollen); + const mounts = vi.fn(), + unmounts = vi.fn(); + function Content() { + const [text, setText] = useState(""); + useEffect(() => { + mounts(); + return unmounts; + }, []); + return ( + setText(event.target.value)} + /> + ); + } + render( + + {}} /> + + , + ); + await screen.findByRole("button", { name: "Pollen" }); + await userEvent.type( + screen.getByRole("textbox", { name: "Draft" }), + "keep this", + ); + act(() => { + f.native.set({ + agents: f.native + .snapshot() + .agents.map((agent) => ({ ...agent, name: "Renamed" })), + }); + }); + expect(screen.getByRole("button", { name: "Renamed" })).toBeInTheDocument(); + act(() => { + f.update({ status: "connecting", generation: 2 }); + }); + expect(screen.getByRole("button", { name: "Bestie" })).toBeInTheDocument(); + act(() => { + f.update({ status: "ready", scope: `https://other.test:${viewer}` }); + }); + expect(screen.getByRole("button", { name: "Bestie" })).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: "Draft" })).toHaveValue( + "keep this", + ); + expect(mounts).toHaveBeenCalledTimes(1); + expect(unmounts).not.toHaveBeenCalled(); +}); + +it("does not demand legacy inventory when the Bestie page is disabled", async () => { + const f = await fixture(); + render( + + content + , + ); + await waitFor(() => expect(screen.getByText("content")).toBeInTheDocument()); + expect(f.libraryRead).not.toHaveBeenCalled(); +}); + +it("shows the promoted DM's shared unread pip, clears when read, and follows replacement and deletion", async () => { + const f = await fixture(); + const me = keypair(), + relay = keypair(), + agent = keypair(); + const first = "11111111-1111-4111-8111-111111111111", + second = "22222222-2222-4222-8222-222222222222"; + const discovery = [ + metadata(relay, first, "Pollen DM", undefined, [["t", "dm"]]), + roster(relay, first, [me.pubkey, pollen]), + metadata(relay, second, "Fizz DM", undefined, [["t", "dm"]]), + roster(relay, second, [me.pubkey, fizz]), + ]; + let live: LiveCallbacks | undefined; + let journal: ReadJournal | undefined; + const owner = createRelaySession( + { + viewer: me.pubkey, + relayAuthor: relay.pubkey, + readState: { + decode: async (events) => decodeReadState(events, me.secret), + sign: async (intent: ReadStateSigning) => + signReadState(intent, me.secret), + publish: async () => {}, + }, + media: () => undefined, + query: async (filters) => + filters.some((filter) => + filter.kinds?.some((kind) => kind === 39000 || kind === 39002), + ) + ? discovery + : [], + subscribe(callbacks) { + live = callbacks; + return { update() {}, retry() {}, dispose() {} }; + }, + }, + { + readPublisherLock: async (_signal, work) => work(), + readStateStorage: { + async update(change) { + journal = change(journal); + return journal; + }, + close() {}, + }, + }, + ); + disposals.push(() => owner.dispose()); + const liveState = { + ...owner.session.live.snapshot(), + roster: { state: "verified" as const }, + }; + const session = { + ...owner.session, + live: { ...owner.session.live, snapshot: () => liveState }, + agentChoices: f.relay.snapshot().session.agentChoices, + }; + const currentScope = `https://example.test:${me.pubkey}`; + f.update({ session, scope: currentScope, viewer: me.pubkey }); + live?.receive(discovery); + setDefaultAgent(currentScope, pollen); + const navigator = { + open: vi.fn().mockResolvedValue({ status: "opened" }), + subscribe: () => () => {}, + snapshot: () => ({ attempt: { id: "bestie-visit" } }), + } as unknown as Navigation; + // Like AppShell's memoized navigation: the provider can update without + // recreating the sidebar element. Context must reach its retained consumers. + const sidebar = ( + + + {}} + /> + + + ); + const tree = () => ( + + + {sidebar} + + + ); + const view = render(tree()); + const row = () => screen.getByRole("button", { name: /^Pollen/ }); + const dmRow = () => document.querySelector(`[data-channel-id="${first}"]`); + expect(row().querySelector("[data-channel-priority]")).toBeNull(); + expect(row().querySelector("[data-bestie-unread]")).toBeNull(); + await waitFor(() => + expect( + document.querySelector(`[data-channel-id="${second}"]`), + ).not.toBeNull(), + ); + expect(dmRow()).toBeNull(); + const root = message(me, first, "Question", 1_700_000_004); + act(() => { + live?.receive([ + root, + message(agent, first, "Thread reply", 1_700_000_005, [ + ["e", root.id, "", "reply"], + ]), + ]); + }); + await waitFor(() => + expect(row().querySelector("[data-channel-activity]")).not.toBeNull(), + ); + expect( + within(row()).getByRole("img", { name: /1 observed unread messages/ }), + ).toBeInTheDocument(); + expect(row().querySelector("[data-channel-priority]")).toBeNull(); + expect(row().querySelector("[data-bestie-unread]")).toHaveAttribute( + "data-bestie-unread", + "true", + ); + expect(dmRow()).toBeNull(); + view.rerender(tree()); + expect(dmRow()).toBeNull(); + await act(async () => { + await session.unread.markChannelRead(first); + }); + expect(row().querySelector("[data-channel-priority]")).toBeNull(); + expect(row().querySelector("[data-bestie-unread]")).toBeNull(); + await act(async () => { + await session.unread.markUnreadLocal({ kind: "channel", channelId: first }); + }); + expect( + within(row()).getByRole("img", { name: /Marked unread/ }), + ).toBeInTheDocument(); + act(() => { + setDefaultAgent(currentScope, fizz); + }); + const replacement = screen.getByRole("button", { name: "Fizz" }); + expect(replacement.querySelector("[data-channel-priority]")).toBeNull(); + act(() => { + live?.receive([message(agent, first, "Old bestie message", 1_700_000_006)]); + }); + expect(replacement.querySelector("[data-channel-priority]")).toBeNull(); + act(() => { + live?.receive([ + message(agent, second, "New bestie message", 1_700_000_007), + ]); + }); + await waitFor(() => + expect(replacement.querySelector("[data-channel-priority]")).not.toBeNull(), + ); + act(() => { + forgetDefaultAgent(fizz); + }); + expect( + screen + .getByRole("button", { name: "Bestie" }) + .querySelector("[data-channel-priority]"), + ).toBeNull(); +}); diff --git a/src/app/shell/BestieNavigation.tsx b/src/app/shell/BestieNavigation.tsx index d4624ae8cd..f0dfd584b7 100644 --- a/src/app/shell/BestieNavigation.tsx +++ b/src/app/shell/BestieNavigation.tsx @@ -1,4 +1,5 @@ import { useContext, useSyncExternalStore, type ReactNode } from "react"; +import { agentAvatars } from "../../features/agents/avatar-packs"; import { bestieChat } from "../../bundled/bestie/chat"; import { UnreadBadge, @@ -11,6 +12,7 @@ import type { RegisteredPage } from "../../features/pages/service"; import { useRelayConnection } from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; import { useSavedAgent } from "../../features/solo/default-agent"; +import { pagePresentation } from "./presentation"; import { useAvatarPreview } from "../../features/profiles/use-avatar-preview"; import styles from "./BestieNavigation.module.css"; import { @@ -47,15 +49,18 @@ export function BestieNavigationProvider({ session.channels.list, ); const channel = bestieChat(channels.channels, viewer, agent?.pubkey); - const avatar = useAvatarPreview( - agent?.avatar ?? "", + const pack = agentAvatars.find((item) => item.url === agent?.avatar); + const preview = useAvatarPreview( + pack ? "" : (agent?.avatar ?? ""), connection.origin, "small", session.media, ); + const avatar = pack?.preview ?? preview; const mark: BestieMark | undefined = agent ? { label: agent.managedName ?? agent.name, + searchLabel: "Bestie", avatar: { src: avatar, shape: "squircle" }, } : undefined; @@ -107,6 +112,11 @@ export function BestieUnreadBadge({ pageKey }: { pageKey: string }) { ); } -export function useBestieMark() { - return useContext(BestieNavigationContext); +/** One presentation for sidebar and page search, without mutating registration. */ +export function usePagePresentation() { + const bestie = useContext(BestieNavigationContext); + return (page: RegisteredPage) => ({ + ...pagePresentation(page), + ...(page.key === BESTIE_PAGE ? bestie : undefined), + }); } diff --git a/src/app/shell/PageNavigation.tsx b/src/app/shell/PageNavigation.tsx index 5ea77cfb2d..c0d4a9a8ef 100644 --- a/src/app/shell/PageNavigation.tsx +++ b/src/app/shell/PageNavigation.tsx @@ -9,11 +9,10 @@ import { Tabs } from "../../shared/design-system/ui/Tabs"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { PageIcon } from "./PageIcon"; import { Avatar } from "../../shared/design-system/ui/Avatar"; -import { pagePresentation } from "./presentation"; import { BestieUnreadBadge, BestieUnreadLabel, - useBestieMark, + usePagePresentation, } from "./BestieNavigation"; import { BESTIE_PAGE } from "./bestie-context"; @@ -30,11 +29,7 @@ export function PageNavigation({ onSelect(key: string, options?: Pick): void; placement?: "sidebar" | "topbar" | "toolbar"; }) { - const bestie = useBestieMark(); - const presentation = (page: RegisteredPage) => ({ - ...pagePresentation(page), - ...(page.key === BESTIE_PAGE && bestie ? bestie : undefined), - }); + const presentation = usePagePresentation(); if (placement === "topbar") { return ( void; services?: SearchServices | undefined; }) { + const pagePresentation = usePagePresentation(); const [open, setOpen] = useState(false); const [contentPresent, setContentPresent] = useState(false); const [query, setQuery] = useState(""); @@ -146,6 +144,11 @@ export function PageSearch({ .flatMap((page) => { if (!needle) return [{ page, rank: 0 }]; const match = matchName(page.label, needle); + const alias = + "searchLabel" in page && typeof page.searchLabel === "string" + ? matchName(page.searchLabel, needle) + : undefined; + if (!match && alias) return [{ page, rank: alias.rank }]; return match ? [{ page: { ...page, matches: match.positions }, rank: match.rank }] : []; diff --git a/src/app/shell/bestie-context.ts b/src/app/shell/bestie-context.ts index 885dce46c1..bb3ac245b3 100644 --- a/src/app/shell/bestie-context.ts +++ b/src/app/shell/bestie-context.ts @@ -4,6 +4,7 @@ import type { RelaySession } from "../../features/relay/session"; export const BESTIE_PAGE = "buzz.bestie/bestie"; export type BestieMark = { label: string; + searchLabel: "Bestie"; avatar: { src: string | undefined; shape: "squircle" }; }; export const BestieNavigationContext = createContext( diff --git a/src/bundled/bestie/PinnedBestie.module.css b/src/bundled/bestie/PinnedBestie.module.css new file mode 100644 index 0000000000..623f822cd8 --- /dev/null +++ b/src/bundled/bestie/PinnedBestie.module.css @@ -0,0 +1,5 @@ +/* The Solo column and composer use this existing named query container to + * adapt their width inside the Bestie workspace. */ +.work { + container: solo-conversation / inline-size; +} diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx new file mode 100644 index 0000000000..ab9c3bd442 --- /dev/null +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -0,0 +1,388 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { afterEach, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; +import { composerDOMFixture } from "../../features/messages/composer-testing"; +import { createAgentChoices } from "../../features/agents/choices"; +import { createAgentLibrary } from "../../features/agents/library"; +import { localAgentsFixture } from "../../features/agents/local-testing"; +import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; +import type { RelayData } from "../../features/relay/service"; +import { createRelaySession } from "../../features/relay/session"; +import { + keypair, + message, + summary, + bounds, + metadata, + roster, + signed, +} from "../../features/relay/testing"; +import type { RelayEvent } from "../../features/relay/events"; +import { setDefaultAgent } from "../../features/solo/default-agent"; +import { readView, writeView } from "../../shared/view-state"; +import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import { PinnedBestie } from "./PinnedBestie"; + +// jsdom has no virtualized layout. Keep the real rows, thread reader and React +// lifecycle; model only Virtua's imperative layout boundary, not scroll behavior. +vi.mock("virtua", async () => { + const { forwardRef, useImperativeHandle } = await import("react"); + return { + Virtualizer: forwardRef(function Virtualizer( + { children }: { children: ReactNode }, + ref, + ) { + useImperativeHandle(ref, () => ({ + cache: undefined, + findItemIndex: () => 0, + getItemOffset: () => 0, + scrollOffset: 0, + scrollSize: 0, + viewportSize: 0, + scrollTo() {}, + scrollToIndex() {}, + })); + return
{children}
; + }), + }; +}); + +composerDOMFixture(); +const disposals: (() => void)[] = []; +afterEach(() => { + cleanup(); + for (const dispose of disposals.splice(0)) dispose(); + localStorage.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const pollen = "b".repeat(64), + fizz = "c".repeat(64), + libraryOnly = "e".repeat(64); +const pollenDM = "11111111-1111-4111-8111-111111111111"; +const fizzDM = "22222222-2222-4222-8222-222222222222"; +const libraryDM = "33333333-3333-4333-8333-333333333333"; +const empty: readonly never[] = []; +const contribution = { snapshot: () => empty, subscribe: () => () => {} }; +const extensions = { tools: contribution, inline: contribution }; +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +async function fixture({ + failed = false, + localFailed = false, + hold = false, + existing = false, + badRoster = false, + withReply = false, +} = {}) { + // The timeline waits for a measurable host before mounting its virtualizer. + vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800); + vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + const viewer = keypair(), + relayKey = keypair(); + const scope = `https://test.example:${viewer.pubkey}`; + const discovery = new Map(); + function dm( + agent: string, + id = agent === pollen ? pollenDM : agent === fizz ? fizzDM : libraryDM, + ) { + discovery.set(id, [ + metadata( + relayKey, + id, + agent === pollen ? "Pollen" : agent === fizz ? "Fizz" : "Library only", + undefined, + [["t", "dm"]], + ), + roster(relayKey, id, [viewer.pubkey, badRoster ? libraryOnly : agent]), + ]); + return id; + } + const root = message(viewer, pollenDM, "Are you there?", 1_700_000_000); + const response = message( + keypair(), + pollenDM, + "Yep, here. What’s up?", + 1_700_000_001, + [["e", root.id, "", "reply"]], + ); + const gate = deferred(); + if (existing) dm(pollen); + const open = vi.fn(async (keys: readonly string[], _signal: AbortSignal) => { + if (hold && keys[0] === pollen) await gate.promise; + const key = keys[0]; + if (!key) throw new Error("Fixture needs an exact recipient"); + return dm(key); + }); + const owner = createRelaySession( + { + viewer: viewer.pubkey, + relayAuthor: relayKey.pubkey, + scope: "https://test.example", + media: () => undefined, + query: async (filters) => { + if ( + filters.some((f) => + f.kinds?.some((kind) => kind === 39000 || kind === 39002), + ) + ) + return [...discovery.values()].flat(); + if (withReply && filters.some((f) => f.top_level)) + return [ + root, + summary(relayKey, pollenDM, root.id, { + reply_count: 1, + participants: [response.pubkey], + }), + bounds(relayKey, pollenDM, "head", { + has_more: false, + next_cursor: null, + }), + ]; + if ( + withReply && + filters.some( + (f) => f.ids?.includes(root.id) || f["#e"]?.includes(root.id), + ) + ) + return [root, response]; + return []; + }, + openDirectMessage: open, + writer: { + kinds: [9, 9000], + sign: async (event) => signed(viewer, event), + publish: async () => {}, + }, + }, + { outboxStorage: { load: () => [], save() {} } }, + ); + let unavailable = failed; + const read = vi.fn(async () => { + if (unavailable) throw new Error("Fixture inventory unavailable"); + return { + definitions: [], + identities: [{ pubkey: libraryOnly, name: "Library only" }], + }; + }); + const library = createAgentLibrary(read); + const native = localAgentsFixture( + [ + { + pubkey: pollen, + name: "Pollen", + owner: viewer.pubkey, + relayUrl: "wss://test.example", + }, + { + pubkey: fizz, + name: "Fizz", + owner: viewer.pubkey, + relayUrl: "wss://test.example", + }, + ], + localFailed ? "error" : "ready", + ); + if (localFailed) + native.set({ status: "error", error: "Local agents unavailable" }); + const lifetime = new AbortController(); + const choices = createAgentChoices({ + scope, + library: library.queries, + native, + signal: lifetime.signal, + }); + await choices.refresh(); + native.onRefresh(() => native.set({ status: "ready" })); + disposals.push(() => { + lifetime.abort(); + library.dispose(); + owner.dispose(); + }); + const send = vi.fn(() => "d".repeat(64)); + const session = { + ...owner.session, + agentChoices: choices, + messages: { ...owner.session.messages, send }, + }; + const snapshot = { + status: "ready" as const, + generation: 1, + scope, + viewer: viewer.pubkey, + session, + }; + const relay: RelayData = { + snapshot: () => snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + async clearCache() {}, + }; + const host = { + relay, + panels: { + snapshot: () => empty, + subscribe: () => () => {}, + resolve: () => undefined, + }, + navigation: { open: vi.fn() }, + } as unknown as ConversationHostServices; + return { + viewer: viewer.pubkey, + scope, + session, + send, + read, + open, + gate, + native, + recover: () => { + unavailable = false; + }, + choose: (pubkey: string) => setDefaultAgent(scope, pubkey), + mount: () => + render( + + + , + { reactStrictMode: true }, + ), + }; +} + +it("opens the actual verified DM and sends plain text without a seeded removable mention", async () => { + const f = await fixture(); + f.choose(pollen); + const user = userEvent.setup(); + f.mount(); + const input = await screen.findByRole("textbox", { name: "Message Pollen" }); + expect(f.open).toHaveBeenCalledWith([pollen], expect.any(AbortSignal)); + expect( + f.session.channels.list().channels.find((c) => c.id === pollenDM), + ).toMatchObject({ + channelType: "dm", + members: expect.arrayContaining([pollen]), + }); + expect(input).not.toHaveTextContent("@Pollen"); + expect( + screen.queryByRole("button", { name: /Remove mention/ }), + ).not.toBeInTheDocument(); + await user.type(input, "Morning"); + await user.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => + expect(f.send).toHaveBeenCalledWith(pollenDM, "Morning", [], []), + ); + expect(input).not.toHaveTextContent("@Pollen"); + expect( + screen.queryByRole("button", { name: /Remove mention/ }), + ).not.toBeInTheDocument(); +}); + +it("reuses an existing DM and its ordinary draft after leaving and reopening Bestie", async () => { + const f = await fixture({ existing: true }); + writeView(f.scope, `draft:${pollenDM}`, "Existing DM draft"); + f.choose(pollen); + const view = f.mount(); + expect( + await screen.findByRole("textbox", { name: "Message Pollen" }), + ).toHaveTextContent("Existing DM draft"); + expect(f.open).not.toHaveBeenCalled(); + view.unmount(); + f.mount(); + expect( + await screen.findByRole("textbox", { name: "Message Pollen" }), + ).toHaveTextContent("Existing DM draft"); + expect(f.open).not.toHaveBeenCalled(); +}); + +it("follows a bestie changed elsewhere without carrying the old DM draft or modifying the Solo draft", async () => { + const f = await fixture(); + const user = userEvent.setup(); + writeView(f.scope, "solo:new-draft", "Untouched Solo notes"); + f.choose(pollen); + f.mount(); + await user.type( + await screen.findByRole("textbox", { name: "Message Pollen" }), + "Keep these notes", + ); + expect( + screen.queryByRole("button", { name: /Change.*bestie/i }), + ).not.toBeInTheDocument(); + act(() => { + f.choose(fizz); + }); + expect( + await screen.findByRole("textbox", { name: "Message Fizz" }), + ).not.toHaveTextContent("Keep these notes"); + act(() => { + f.choose(pollen); + }); + expect( + await screen.findByRole("textbox", { name: "Message Pollen" }), + ).toHaveTextContent("Keep these notes"); + expect(readView(f.scope, "solo:new-draft", "")).toBe("Untouched Solo notes"); +}); + +it("does not display a stale open after the selection changes, and aborts it on unmount", async () => { + const f = await fixture({ hold: true }); + f.choose(pollen); + const view = f.mount(); + await waitFor(() => expect(f.open).toHaveBeenCalled()); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + act(() => { + f.choose(fizz); + }); + await screen.findByRole("textbox", { name: "Message Fizz" }); + await act(async () => { + f.gate.resolve(); + }); + expect( + screen.queryByRole("textbox", { name: "Message Pollen" }), + ).not.toBeInTheDocument(); + expect( + f.open.mock.calls + .filter(([keys]) => keys[0] === pollen) + .every(([, signal]) => signal.aborted), + ).toBe(true); + view.unmount(); +}); + +it("reports an open failure and retries through the same DM owner", async () => { + const f = await fixture(); + f.open.mockRejectedValueOnce(new Error("DM temporarily unavailable")); + // StrictMode abandons its initial effect; both initial opens must fail. + f.open.mockRejectedValueOnce(new Error("DM temporarily unavailable")); + f.choose(pollen); + f.mount(); + await screen.findByText("DM temporarily unavailable"); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByRole("textbox", { name: "Message Pollen" }); +}); + +it("never renders a DM whose signed membership does not match the chosen agent", async () => { + const f = await fixture({ badRoster: true }); + f.choose(pollen); + f.mount(); + await screen.findByText( + "The conversation’s participants could not be confirmed. Try again.", + ); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); +}); diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx index ca3c7391ee..882b04da01 100644 --- a/src/bundled/bestie/PinnedBestie.tsx +++ b/src/bundled/bestie/PinnedBestie.tsx @@ -26,6 +26,7 @@ import { PanelHeaderLabel, } from "../../shared/design-system/ui/PanelHeader"; import { bestieChat } from "./chat"; +import styles from "./PinnedBestie.module.css"; /** Account-saved Besties use their canonical verified DM. No saved choice means * the caller keeps Riley's hosted setup page. */ @@ -47,39 +48,145 @@ export function PinnedBestie({ !!viewer && scope.endsWith(`:${viewer}`); const choices = useAgentChoices(session, active, active); + const known = choices.identities.find((choice) => choice.pubkey === saved); const agent = active ? choices.selectable.find((choice) => choice.pubkey === saved) : undefined; const channels = useChannelList(session.channels); - if (!agent || !scope) return null; - const channel = bestieChat(channels.channels, viewer, agent.pubkey); - const name = agent.managedName ?? agent.name; + const identity = agent ?? known; + const name = identity ? (identity.managedName ?? identity.name) : "Bestie"; + const header = ( + + ) : ( + + ) + } + /> + } + /> + ); + let content: React.ReactNode; + if (!active || !scope) { + content = ( + Retry connection + ) : undefined + } + /> + ); + } else if (!agent) { + const pending = + choices.pending || + choices.archives.status === "idle" || + choices.archives.status === "loading"; + content = + pending && !choices.error ? ( + + + + ) : ( + void session.agentChoices.refresh()}> + Try again + + ) : undefined + } + /> + ); + } else { + const channel = bestieChat(channels.channels, viewer, agent.pubkey); + content = channel ? ( + + ) : channels.status === "error" || channels.status === "unavailable" ? ( + + session.channels.refreshList + ? session.channels.refreshList() + : session.channels.ensureList() + } + > + Try again + + } + /> + ) : channels.status !== "ready" ? ( + + + + ) : ( + + ); + } return (
- - } - /> - } - /> - + {header} + {content}
@@ -108,16 +215,26 @@ function DirectMessage({ if (channel) return; const controller = new AbortController(); setError(undefined); - void session.directMessages - .open([agent.pubkey], controller.signal) - .catch((reason) => { + void (async () => { + try { + if ( + !session.agentChoices + .snapshot() + .selectable.some((choice) => choice.pubkey === agent.pubkey) + ) + throw new Error( + "This agent is no longer available in this community.", + ); + await session.directMessages.open([agent.pubkey], controller.signal); + } catch (reason) { if (!controller.signal.aborted) setError( reason instanceof Error ? reason.message : "Buzz couldn’t open this conversation.", ); - }); + } + })(); return () => controller.abort(); }, [session, agent.pubkey, channel, attempt]); if (channel) @@ -185,7 +302,7 @@ function BestieConversation({ >
{thread ? ( diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx index 10718e751b..c2d5d01141 100644 --- a/src/bundled/bestie/index.test.tsx +++ b/src/bundled/bestie/index.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { composerDOMFixture } from "../../features/messages/composer-testing"; @@ -8,6 +8,7 @@ import type { ConversationExtensions } from "../../features/conversation/contrac import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; import type { Navigation } from "../../features/navigation/controller"; import { BestiePage } from "./index"; +import { deferred } from "../builderlab/test-helpers"; import { bestieFixture, cleanups, HOME } from "./testing"; const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); @@ -81,6 +82,46 @@ const chooseModel = async () => { }; const composer = () => screen.getByRole("textbox", { name: "Message #Bestie" }); +it("waits for the account choice before deciding whether to show setup", async () => { + const choice = deferred(); + const h = await bestieFixture(native.invoke, { + mainAgent: { + read: () => choice.promise, + write: async () => {}, + }, + }); + renderPage(h); + expect(await screen.findByRole("status")).toHaveTextContent( + "Loading your Bestie…", + ); + expect( + screen.queryByRole("button", { name: "Set up Bestie" }), + ).not.toBeInTheDocument(); + await act(async () => choice.resolve(undefined)); + expect( + await screen.findByRole("button", { name: "Set up Bestie" }), + ).toBeVisible(); +}); + +it("routes an account-saved Bestie to its canonical DM instead of hosted setup", async () => { + const choice = deferred(); + const h = await bestieFixture(native.invoke, { + mainAgent: { + read: () => choice.promise, + write: async () => {}, + }, + }); + await h.bestie.setup(); + renderPage(h); + await act(async () => choice.resolve(h.agent)); + expect( + await screen.findByText("This connection cannot start direct messages."), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Set up Bestie" }), + ).not.toBeInTheDocument(); +}); + it("sets up Bestie on the model the owner chose and hands focus to its conversation", async () => { const h = await bestieFixture(native.invoke); renderPage(h); diff --git a/src/bundled/bestie/testing.ts b/src/bundled/bestie/testing.ts index bb313f5261..fd6eac9326 100644 --- a/src/bundled/bestie/testing.ts +++ b/src/bundled/bestie/testing.ts @@ -68,7 +68,15 @@ type Reply = { status: number; body: unknown }; /** Real Builderlab and Bestie plugins, Agents service, relay session and * Outbox. Only the native bridge (Beekeeper HTTP, login, owner signing) and the * relay wire are simulated. `invoke` is the test file's mocked Tauri invoke. */ -export async function bestieFixture(invoke: Mock) { +export async function bestieFixture( + invoke: Mock, + options: { + mainAgent?: { + read(signal: AbortSignal): Promise; + write(agent: string, signal: AbortSignal): Promise; + }; + } = {}, +) { const identity = keypair(); const authority = keypair(); const remote = keypair(); @@ -273,6 +281,12 @@ export async function bestieFixture(invoke: Mock) { publish, }, workflows: { runs: async () => ({ runs: [], next: null }) }, + ...(options.mainAgent + ? { + readMainAgent: options.mainAgent.read, + writeMainAgent: options.mainAgent.write, + } + : {}), }, { outboxStorage: { diff --git a/src/features/channel-navigation/ChannelInventory.test.tsx b/src/features/channel-navigation/ChannelInventory.test.tsx index d0bdcb095b..ddc0c14cd0 100644 --- a/src/features/channel-navigation/ChannelInventory.test.tsx +++ b/src/features/channel-navigation/ChannelInventory.test.tsx @@ -42,6 +42,13 @@ import { } from "./ChannelNavigationState"; import { readView, writeView } from "../../shared/view-state"; import styles from "../../app/shell/PrimarySidebar.module.css"; +import { BestieNavigationProvider } from "../../app/shell/BestieNavigation"; +import { PageNavigation } from "../../app/shell/PageNavigation"; +import { createAgentChoices } from "../agents/choices"; +import { createAgentLibrary } from "../agents/library"; +import { localAgentsFixture } from "../agents/local-testing"; +import { forgetDefaultAgent, setDefaultAgent } from "../solo/default-agent"; +import type { RegisteredPage } from "../pages/service"; const { rowRender, menuRender } = vi.hoisted(() => ({ rowRender: vi.fn(), @@ -1806,3 +1813,142 @@ it("opening a shared chat lists it once, with the viewer's chats", async () => { expect(rows).toHaveLength(1); expect(rows[0]?.closest("nav")).toHaveAccessibleName("Solo conversations"); }); +it("promotes exactly the Bestie DM, restores prior local removal, and releases it on replacement, deletion and plugin disable", async () => { + const viewer = "a".repeat(64), + pollen = "b".repeat(64), + fizz = "c".repeat(64); + const h = fixture(undefined, "ready", [ + { + id: "pollen-dm", + name: "Pollen DM", + channelType: "dm", + members: [viewer, pollen], + }, + { + id: "fizz-dm", + name: "Fizz DM", + channelType: "dm", + members: [viewer, fizz], + }, + { + id: "group-dm", + name: "Group DM", + channelType: "dm", + members: [viewer, pollen, fizz], + }, + { + id: "namesake", + name: "Pollen namesake", + channelType: "dm", + members: [viewer, "d".repeat(64)], + }, + ]); + const library = createAgentLibrary(async () => ({ + definitions: [], + identities: [], + })); + const native = localAgentsFixture([ + { + pubkey: pollen, + name: "Pollen", + owner: viewer, + relayUrl: "wss://relay.test", + }, + { pubkey: fizz, name: "Fizz", owner: viewer, relayUrl: "wss://relay.test" }, + ]); + const lifetime = new AbortController(); + const choices = createAgentChoices({ + scope: h.session.scope, + library: library.queries, + native, + signal: lifetime.signal, + }); + await choices.refresh(); + h.session.agentChoices = choices; + const page: RegisteredPage = { + id: "bestie", + key: "buzz.bestie/bestie", + pluginId: "buzz.bestie", + revision: "bundled", + title: "Bestie", + primary: true, + component: () => null, + }; + const relay: RelayData = { + snapshot: () => h.snapshot, + subscribe: () => () => {}, + retry() {}, + disconnect() {}, + async clearCache() {}, + }; + const tree = (enabled = true) => ( + + {h.view( + "other-page", + {}} + />, + )} + + ); + const row = (id: string) => + document.querySelector(`[data-channel-id="${id}"]`); + const scope = h.session.scope; + // Previously removed locally, not a relay Hide or an authorization change. + writeView(scope, "hidden-dms", [ + { id: "pollen-dm", baseline: null, knownIds: [] }, + ]); + setDefaultAgent(scope, pollen); + try { + const view = render(tree(), { reactStrictMode: true }); + await screen.findByRole("button", { name: "Pollen" }); + await waitFor(() => expect(row("fizz-dm")).not.toBeNull()); + expect(row("pollen-dm")).toBeNull(); + expect(row("group-dm")).not.toBeNull(); + expect(row("namesake")).not.toBeNull(); + await waitFor(() => expect(readView(scope, "hidden-dms", [])).toEqual([])); + + act(() => { + setDefaultAgent(scope, fizz); + }); + await screen.findByRole("button", { name: "Fizz" }); + expect(row("pollen-dm")).not.toBeNull(); + expect(row("fizz-dm")).toBeNull(); + act(() => { + forgetDefaultAgent(fizz); + }); + expect(screen.getByRole("button", { name: "Bestie" })).toBeInTheDocument(); + expect(row("fizz-dm")).not.toBeNull(); + + act(() => { + setDefaultAgent(scope, pollen); + }); + expect(row("pollen-dm")).toBeNull(); + view.unmount(); + const reopened = render(tree(), { reactStrictMode: true }); + await waitFor(() => expect(row("fizz-dm")).not.toBeNull()); + expect(row("pollen-dm")).toBeNull(); + reopened.rerender(tree(false)); + expect(row("pollen-dm")).not.toBeNull(); + reopened.rerender(tree()); + expect(row("pollen-dm")).toBeNull(); + // A retained name is not enough to suppress a conversation whose exact + // membership or chosen-agent eligibility is no longer established. + h.publish("pollen-dm", { members: [viewer, pollen, fizz] }); + expect(row("pollen-dm")).not.toBeNull(); + h.publish("pollen-dm", { members: [viewer, pollen] }); + expect(row("pollen-dm")).toBeNull(); + act(() => { + native.set({ agents: [] }); + }); + expect(row("pollen-dm")).not.toBeNull(); + expect(screen.getByRole("button", { name: "Bestie" })).toBeInTheDocument(); + expect(h.session.channels.list().channels).toHaveLength(4); + expect(readView(scope, "hidden-dms", [])).toEqual([]); + } finally { + lifetime.abort(); + library.dispose(); + } +}); From ad7e43bec468028f3aa966d955c78e08b56e0b63 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 15:51:27 -0400 Subject: [PATCH 03/21] fix(bestie): keep a stable search identity Signed-off-by: Clay Delk --- src/app/shell/BestieNavigation.test.tsx | 17 +++++++++++------ src/app/shell/BestieNavigation.tsx | 3 +-- src/app/shell/PageSearch.tsx | 13 +++++-------- src/app/shell/bestie-context.ts | 1 - 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/app/shell/BestieNavigation.test.tsx b/src/app/shell/BestieNavigation.test.tsx index 046a5ab1a7..a0071b9fac 100644 --- a/src/app/shell/BestieNavigation.test.tsx +++ b/src/app/shell/BestieNavigation.test.tsx @@ -144,7 +144,7 @@ async function fixture() { }; } -it("updates the mounted sidebar and search from the exact shared choice, including replacement and missing artwork", async () => { +it("updates the mounted sidebar from the exact choice while search keeps Bestie's stable identity", async () => { const f = await fixture(); const select = vi.fn(); render( @@ -179,20 +179,25 @@ it("updates the mounted sidebar and search from the exact shared choice, includi await userEvent.click(screen.getByRole("button", { name: "Search Buzz" })); const dialog = screen.getByRole("dialog", { name: "Search Buzz" }); expect( - within(dialog).getByRole("option", { name: "Pollen" }).querySelector("img"), - ).toHaveAttribute("src", portrait.preview); + within(dialog).getByRole("option", { name: "Bestie" }), + ).toBeInTheDocument(); const search = within(dialog).getByRole("combobox", { name: "Search Buzz" }); + await userEvent.type(search, "Pollen"); + expect( + within(dialog).queryByRole("option", { name: "Pollen" }), + ).not.toBeInTheDocument(); + await userEvent.clear(search); await userEvent.type(search, "Bestie"); expect( - within(dialog).getByRole("option", { name: "Pollen" }), + within(dialog).getByRole("option", { name: "Bestie" }), ).toBeInTheDocument(); act(() => { setDefaultAgent(scope, fizz); }); expect( - within(dialog).getByRole("option", { name: "Fizz" }), + within(dialog).getByRole("option", { name: "Bestie" }), ).toBeInTheDocument(); - await userEvent.click(within(dialog).getByRole("option", { name: "Fizz" })); + await userEvent.click(within(dialog).getByRole("option", { name: "Bestie" })); expect(select).toHaveBeenCalledWith(page.key); const fizzRow = await within(nav).findByRole("button", { name: "Fizz" }); expect(fizzRow.querySelector("img")).toBeNull(); diff --git a/src/app/shell/BestieNavigation.tsx b/src/app/shell/BestieNavigation.tsx index f0dfd584b7..6bde92590c 100644 --- a/src/app/shell/BestieNavigation.tsx +++ b/src/app/shell/BestieNavigation.tsx @@ -60,7 +60,6 @@ export function BestieNavigationProvider({ const mark: BestieMark | undefined = agent ? { label: agent.managedName ?? agent.name, - searchLabel: "Bestie", avatar: { src: avatar, shape: "squircle" }, } : undefined; @@ -112,7 +111,7 @@ export function BestieUnreadBadge({ pageKey }: { pageKey: string }) { ); } -/** One presentation for sidebar and page search, without mutating registration. */ +/** Decorate navigation without changing the page's stable search identity. */ export function usePagePresentation() { const bestie = useContext(BestieNavigationContext); return (page: RegisteredPage) => ({ diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx index d1c5561a88..5691dfe301 100644 --- a/src/app/shell/PageSearch.tsx +++ b/src/app/shell/PageSearch.tsx @@ -1,5 +1,4 @@ import { useSearchSettings } from "./SearchSettings"; -import { usePagePresentation } from "./BestieNavigation"; import { useCallback, useEffect, @@ -23,7 +22,11 @@ import type { ShortcutBindingsSnapshot } from "../../features/shortcuts/preferen import type { AppServices } from "../services"; import { HOST_SHORTCUT_ORDER } from "../shortcuts"; import { conversationComposerFocusTarget } from "../focus-navigation"; -import { orderPages, shellPresentation } from "./presentation"; +import { + orderPages, + pagePresentation, + shellPresentation, +} from "./presentation"; import { matchName } from "../../features/search/match"; import { SearchChoices, @@ -61,7 +64,6 @@ export function PageSearch({ onSelect: (key: string) => void; services?: SearchServices | undefined; }) { - const pagePresentation = usePagePresentation(); const [open, setOpen] = useState(false); const [contentPresent, setContentPresent] = useState(false); const [query, setQuery] = useState(""); @@ -144,11 +146,6 @@ export function PageSearch({ .flatMap((page) => { if (!needle) return [{ page, rank: 0 }]; const match = matchName(page.label, needle); - const alias = - "searchLabel" in page && typeof page.searchLabel === "string" - ? matchName(page.searchLabel, needle) - : undefined; - if (!match && alias) return [{ page, rank: alias.rank }]; return match ? [{ page: { ...page, matches: match.positions }, rank: match.rank }] : []; diff --git a/src/app/shell/bestie-context.ts b/src/app/shell/bestie-context.ts index bb3ac245b3..885dce46c1 100644 --- a/src/app/shell/bestie-context.ts +++ b/src/app/shell/bestie-context.ts @@ -4,7 +4,6 @@ import type { RelaySession } from "../../features/relay/session"; export const BESTIE_PAGE = "buzz.bestie/bestie"; export type BestieMark = { label: string; - searchLabel: "Bestie"; avatar: { src: string | undefined; shape: "squircle" }; }; export const BestieNavigationContext = createContext( From 7bdd456537525ce2ecbeef2b50e204c38e299fdd Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 16:16:58 -0400 Subject: [PATCH 04/21] test(bestie): cover pinned recovery states Signed-off-by: Clay Delk --- docs/shell-design.md | 7 +- docs/solo/README.md | 25 +++++-- src/bundled/bestie/PinnedBestie.test.tsx | 94 ++++++++++++++++++++++-- src/bundled/bestie/README.md | 53 ++++++++++--- 4 files changed, 154 insertions(+), 25 deletions(-) diff --git a/docs/shell-design.md b/docs/shell-design.md index 48f47b03f6..acdfeb306e 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -254,7 +254,12 @@ avatar. Search, sidebar and history controls use unfilled ghost icon buttons wit Their hover fills remain translucent over the colored backdrop. Pressed fills are slightly stronger; icons keep full opacity. Plugin launchers use the shared glass style. -Bestie is available in the Messages sidebar when its plugin is enabled. +Bestie is available in the Messages sidebar when its plugin is enabled. An +account-saved Bestie decorates that row with the agent's identity and promotes +its exact one-to-one DM there; Search Buzz keeps the stable **Bestie** page label +rather than indexing the page under the agent's name. See +[`solo/README.md`](solo/README.md) and the bundled Bestie README for selection, +DM promotion and hosted-setup fallback ownership. `ProfileButton.tsx` uses the selected community's profile when connected and local defaults in Personal space. Its anchored account menu groups profile and status controls above Appearance, Plugins and Settings. The avatar inside the diff --git a/docs/solo/README.md b/docs/solo/README.md index d4d22f81e7..9c80732b53 100644 --- a/docs/solo/README.md +++ b/docs/solo/README.md @@ -130,12 +130,25 @@ behavior on the label, so a rename touches copy only. archive read has settled. A chosen agent missing for another reason is stood in for without replacing the choice. Until an agent is explicitly saved as the bestie, including for an automatic pick, a stand-in or a failed save, **Make my - bestie** stays offered. Nothing is picked until the account record has been read - and its archive read has settled, so a new chat is never tagged for a stale - pick. A new chat tags the current default once each time it is shown; the tag - isn't saved until the draft is edited, so showing an untouched new chat again - uses the current default. Archive rereads, failed rereads and reconnects keep - the last known archives, so a known deletion is never forgotten and a live + bestie** stays offered. When the Bestie plugin is enabled, the explicit + account-saved choice also owns its pinned sidebar destination; automatic and + stand-in defaults do not. The pinned destination reuses the newest verified + one-to-one DM whose only other member is that exact agent, opening one through + the shared DM service only when none exists. It is an ordinary DM with the same + history, draft, threads and unread state—not a Solo chat or a second message + store. While promoted, that DM is omitted from the ordinary Messages inventory; + replacing or deleting the Bestie, disabling the plugin, or losing the exact + roster match restores the ordinary row. The pinned navigation row adopts the + agent's name and avatar, while global search keeps the stable **Bestie** page + identity so an agent-name search does not duplicate the existing Direct + messages result. + + Nothing is picked until the account record has been read and its archive read + has settled, so a new chat is never tagged for a stale pick. A new chat tags the + current default once each time it is shown; the tag isn't saved until the draft + is edited, so showing an untouched new chat again uses the current default. + Archive rereads, failed rereads and reconnects keep the last known archives, so + a known deletion is never forgotten and a live choice never disappears. Follow-ups keep all exact agents notified by the previous send, just like Team threads, including the shared Remember mentioned agents preference. diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx index ab9c3bd442..90bc6a9fe3 100644 --- a/src/bundled/bestie/PinnedBestie.test.tsx +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -9,7 +9,7 @@ import { createAgentChoices } from "../../features/agents/choices"; import { createAgentLibrary } from "../../features/agents/library"; import { localAgentsFixture } from "../../features/agents/local-testing"; import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; -import type { RelayData } from "../../features/relay/service"; +import type { RelayData, RelaySnapshot } from "../../features/relay/service"; import { createRelaySession } from "../../features/relay/session"; import { keypair, @@ -209,6 +209,11 @@ async function fixture({ signal: lifetime.signal, }); await choices.refresh(); + let ineligibleSnapshot: ReturnType | undefined; + const agentChoices = { + ...choices, + snapshot: () => ineligibleSnapshot ?? choices.snapshot(), + }; native.onRefresh(() => native.set({ status: "ready" })); disposals.push(() => { lifetime.abort(); @@ -218,20 +223,25 @@ async function fixture({ const send = vi.fn(() => "d".repeat(64)); const session = { ...owner.session, - agentChoices: choices, + agentChoices, messages: { ...owner.session.messages, send }, }; - const snapshot = { + let snapshot = { status: "ready" as const, generation: 1, scope, viewer: viewer.pubkey, session, - }; + } as RelaySnapshot; + const relayListeners = new Set<() => void>(); + const retryConnection = vi.fn(); const relay: RelayData = { snapshot: () => snapshot, - subscribe: () => () => {}, - retry() {}, + subscribe(listener) { + relayListeners.add(listener); + return () => relayListeners.delete(listener); + }, + retry: retryConnection, disconnect() {}, async clearCache() {}, }; @@ -253,6 +263,20 @@ async function fixture({ open, gate, native, + makePollenIneligible() { + const current = choices.snapshot(); + ineligibleSnapshot = { + ...current, + selectable: current.selectable.filter( + (choice) => choice.pubkey !== pollen, + ), + }; + }, + retryConnection, + updateRelay(patch: Partial) { + snapshot = { ...snapshot, ...patch } as RelaySnapshot; + for (const listener of relayListeners) listener(); + }, recover: () => { unavailable = false; }, @@ -386,3 +410,61 @@ it("never renders a DM whose signed membership does not match the chosen agent", ); expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); }); + +it("keeps the saved Bestie visible through reconnect and offers connection recovery", async () => { + const f = await fixture({ existing: true }); + f.choose(pollen); + f.mount(); + await screen.findByRole("textbox", { name: "Message Pollen" }); + act(() => f.updateRelay({ status: "connecting" })); + expect(screen.getByRole("heading", { name: "Pollen" })).toBeVisible(); + expect(screen.getByText("Connecting…")).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Set up Bestie" }), + ).not.toBeInTheDocument(); + act(() => + f.updateRelay({ status: "error", error: "Community temporarily offline" }), + ); + expect(screen.getByText("Community temporarily offline")).toBeVisible(); + await userEvent.click( + screen.getByRole("button", { name: "Retry connection" }), + ); + expect(f.retryConnection).toHaveBeenCalledOnce(); +}); + +it("recovers a saved agent missing from a partial inventory through refresh", async () => { + const f = await fixture({ localFailed: true }); + f.choose(pollen); + f.mount(); + expect(await screen.findByText("Local agents unavailable")).toBeVisible(); + expect(f.session.agentChoices.snapshot()).toMatchObject({ + status: "ready", + complete: false, + error: "Local agents unavailable", + }); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + await screen.findByRole("textbox", { name: "Message Pollen" }); + expect(f.session.agentChoices.snapshot()).toMatchObject({ + status: "ready", + complete: true, + }); + expect(f.session.agentChoices.snapshot()).not.toHaveProperty("error"); +}); + +it("rechecks eligibility before retrying a failed DM open", async () => { + const f = await fixture(); + f.open.mockRejectedValueOnce(new Error("DM temporarily unavailable")); + f.open.mockRejectedValueOnce(new Error("DM temporarily unavailable")); + f.choose(pollen); + f.mount(); + await screen.findByText("DM temporarily unavailable"); + const attempts = f.open.mock.calls.length; + f.makePollenIneligible(); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + expect( + await screen.findByText( + "This agent is no longer available in this community.", + ), + ).toBeVisible(); + expect(f.open).toHaveBeenCalledTimes(attempts); +}); diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 171a0fea47..12048f1d2a 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -1,19 +1,48 @@ # Bestie -Your personal companion in Buzz. Bestie runs as a hosted agent on Builderlab -and chats with you on the Bestie page, in a private channel only the two of you -belong to. +Bestie is the account-saved agent pinned in Buzz's left navigation. The +selection is the private `main-agent` record shared with Solo's default-agent +owner; this plugin adds presentation and a conversation destination, not another +selection store. -## Getting started +## Pinned conversation -Open **Bestie** and choose **Set up Bestie**. Setup signs you in to Builderlab, -creates your hosted Bestie, gives it its instructions, creates your private -channel and sends a first message on your behalf so Bestie introduces itself. +When the account has a saved eligible Bestie, the sidebar row adopts that +agent's name and avatar and opens their canonical direct message. The canonical +conversation is the newest verified, unarchived, non-cached DM whose roster +contains the viewer and exactly that one other public key. A Solo chat, group DM, +namesake, unknown roster or archived/cached channel never qualifies. If no exact +DM exists, the page asks the shared direct-message service to open one after +rechecking that the agent remains selectable. ---- +The page uses the ordinary conversation owners for history, drafts, sending, +threads and unread state. While that exact DM is promoted, Messages omits its +ordinary row and clears any old device-local hidden-DM intent. Replacing or +deleting the Bestie, disabling the plugin, changing communities, or losing the +exact roster match releases the promotion and restores the ordinary DM row. -## Changing Bestie's instructions +Navigation presentation and search identity intentionally differ: the pinned +row shows the agent's name and avatar, while Search Buzz continues to list the +page as **Bestie**. Searching the agent's name therefore keeps the same New chat, +chat and Direct messages results as every other agent instead of adding a second +route to the same DM. -Bestie's instructions live in `prompt.ts`. When they differ from the ones a -set-up Bestie has, the Bestie page offers **Update Bestie's instructions**. -**Bestie's instructions** on that page shows the current text. +## Hosted setup fallback + +The account choice is read before the page decides what to show. With a saved +Bestie, reconnecting, inventory failures and missing-agent states remain on the +pinned destination with their own recovery; they never fall through to setup. +When the account read settles with no saved Bestie, Riley's existing Builderlab +setup remains the page: it creates a hosted agent, gives it the instructions in +`prompt.ts`, creates its private Solo home and sends the introduction. + +Builderlab setup does **not** yet write that agent to `main-agent` or hand off to +the canonical DM. Connecting hosted setup to the shared selection owner is a +separate follow-up; until then, setup and an explicitly account-saved Bestie are +parallel entry paths. + +## Changing hosted Bestie's instructions + +When `prompt.ts` differs from the instructions on a set-up hosted Bestie, the +setup page offers **Update Bestie's instructions**. **Bestie's instructions** on +that page shows the current text. From e6891be20638eca070fdcabee3218d28a09a9f56 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 16:20:32 -0400 Subject: [PATCH 05/21] fix(bestie): retain DM promotion through inventory failure Signed-off-by: Clay Delk --- src/app/shell/BestieNavigation.test.tsx | 2 +- src/app/shell/BestieNavigation.tsx | 18 ++++++++++++++---- .../ChannelInventory.test.tsx | 10 +++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/app/shell/BestieNavigation.test.tsx b/src/app/shell/BestieNavigation.test.tsx index a0071b9fac..bf4a4b7635 100644 --- a/src/app/shell/BestieNavigation.test.tsx +++ b/src/app/shell/BestieNavigation.test.tsx @@ -257,7 +257,7 @@ it("follows roster changes and community replacement without remounting content act(() => { f.update({ status: "connecting", generation: 2 }); }); - expect(screen.getByRole("button", { name: "Bestie" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Renamed" })).toBeInTheDocument(); act(() => { f.update({ status: "ready", scope: `https://other.test:${viewer}` }); }); diff --git a/src/app/shell/BestieNavigation.tsx b/src/app/shell/BestieNavigation.tsx index 6bde92590c..089ea0298e 100644 --- a/src/app/shell/BestieNavigation.tsx +++ b/src/app/shell/BestieNavigation.tsx @@ -34,21 +34,31 @@ export function BestieNavigationProvider({ }) { const connection = useRelayConnection(relay); const { viewer, scope, session } = connection; - const active = + const enabled = pages.some((page) => page.key === BESTIE_PAGE) && - connection.status === "ready" && !!viewer && !!scope?.endsWith(`:${viewer}`); + const active = enabled && connection.status === "ready"; const saved = useSavedAgent(session, scope ?? ""); const choices = useAgentChoices(session, active, active); - const agent = active + const agent = enabled ? choices.selectable.find((item) => item.pubkey === saved) : undefined; + // A failed source contributes no stale identity row, but the account choice + // and exact verified DM still establish promotion. Complete evidence that the + // agent is gone, or known archive evidence, releases it. + const promoted = + enabled && + saved && + !choices.archives.archived.includes(saved) && + (agent || choices.status !== "ready" || !choices.complete) + ? saved + : undefined; const channels = useSyncExternalStore( session.channels.subscribeList, session.channels.list, ); - const channel = bestieChat(channels.channels, viewer, agent?.pubkey); + const channel = bestieChat(channels.channels, viewer, promoted); const pack = agentAvatars.find((item) => item.url === agent?.avatar); const preview = useAvatarPreview( pack ? "" : (agent?.avatar ?? ""), diff --git a/src/features/channel-navigation/ChannelInventory.test.tsx b/src/features/channel-navigation/ChannelInventory.test.tsx index ddc0c14cd0..8abfd621fd 100644 --- a/src/features/channel-navigation/ChannelInventory.test.tsx +++ b/src/features/channel-navigation/ChannelInventory.test.tsx @@ -1940,8 +1940,16 @@ it("promotes exactly the Bestie DM, restores prior local removal, and releases i expect(row("pollen-dm")).not.toBeNull(); h.publish("pollen-dm", { members: [viewer, pollen] }); expect(row("pollen-dm")).toBeNull(); + // Source failure removes its candidates but does not release an explicit + // account choice whose exact verified DM still exists. act(() => { - native.set({ agents: [] }); + native.set({ status: "error", agents: [], error: "Agents unavailable" }); + }); + expect(row("pollen-dm")).toBeNull(); + expect(screen.getByRole("button", { name: "Bestie" })).toBeInTheDocument(); + // A settled successful inventory proving the saved agent is gone releases it. + act(() => { + native.set({ status: "ready", agents: [] }); }); expect(row("pollen-dm")).not.toBeNull(); expect(screen.getByRole("button", { name: "Bestie" })).toBeInTheDocument(); From 2f877dba9ddd79632bccfbe7503f65604f98f1e4 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 16:45:55 -0400 Subject: [PATCH 06/21] fix(bestie): recover account choice reads Signed-off-by: Clay Delk --- src/bundled/bestie/README.md | 7 ++-- src/bundled/bestie/index.test.tsx | 32 ++++++++++++++++++ src/bundled/bestie/index.tsx | 10 ++++-- src/features/solo/default-agent.ts | 54 +++++++++++++++++++++++------- 4 files changed, 84 insertions(+), 19 deletions(-) diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 12048f1d2a..61ed5a2fcb 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -32,9 +32,10 @@ route to the same DM. The account choice is read before the page decides what to show. With a saved Bestie, reconnecting, inventory failures and missing-agent states remain on the pinned destination with their own recovery; they never fall through to setup. -When the account read settles with no saved Bestie, Riley's existing Builderlab -setup remains the page: it creates a hosted agent, gives it the instructions in -`prompt.ts`, creates its private Solo home and sends the introduction. +Only a successful account read with no saved Bestie shows Riley's existing +Builderlab setup. A failed read remains on the Bestie page with Retry. Setup +creates a hosted agent, gives it the instructions in `prompt.ts`, creates its +private Solo home and sends the introduction. Builderlab setup does **not** yet write that agent to `main-agent` or hand off to the canonical DM. Connecting hosted setup to the shared selection owner is a diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx index c2d5d01141..5f095d493f 100644 --- a/src/bundled/bestie/index.test.tsx +++ b/src/bundled/bestie/index.test.tsx @@ -103,6 +103,38 @@ it("waits for the account choice before deciding whether to show setup", async ( ).toBeVisible(); }); +it("keeps setup hidden when the account choice read fails and retries it", async () => { + const first = deferred(); + const second = deferred(); + let reads = 0; + const h = await bestieFixture(native.invoke, { + mainAgent: { + read: () => (reads++ ? second.promise : first.promise), + write: async () => {}, + }, + }); + await h.bestie.setup(); + renderPage(h); + await act(async () => first.reject(new Error("Account temporarily offline"))); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Account temporarily offline", + ); + expect( + screen.queryByRole("button", { name: "Set up Bestie" }), + ).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByRole("status")).toHaveTextContent( + "Loading your Bestie…", + ); + await act(async () => second.resolve(h.agent)); + expect( + await screen.findByText("This connection cannot start direct messages."), + ).toBeVisible(); + expect( + screen.queryByRole("button", { name: "Set up Bestie" }), + ).not.toBeInTheDocument(); +}); + it("routes an account-saved Bestie to its canonical DM instead of hosted setup", async () => { const choice = deferred(); const h = await bestieFixture(native.invoke, { diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index 65d6e31eeb..ff1afdbb40 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -106,7 +106,7 @@ function BestieDestination({ }) { const connection = useRelayConnection(relay); const saved = useSavedAgentState(connection.session, connection.scope ?? ""); - if (!saved.read) + if (!saved.read || saved.error) return (
@@ -120,9 +120,13 @@ function BestieDestination({ } /> -

- Loading your Bestie… +

+ {saved.error ?? "Loading your Bestie…"}

+ {saved.error && }
diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts index c32b2173f7..d94a92fc6f 100644 --- a/src/features/solo/default-agent.ts +++ b/src/features/solo/default-agent.ts @@ -16,13 +16,18 @@ import { const KEY = "solo:default-agent"; type Account = NonNullable; const adopted = new WeakMap>(); -// The account's saved choice once read: undefined when never saved or -// unreadable. +// The account's saved choice once read: undefined only when a successful read +// found no record. Read failures are tracked separately. const saved = new WeakMap(); +const readErrors = new WeakMap(); const listeners = new Set<() => void>(); +function notifySaved() { + for (const listener of listeners) listener(); +} function remember(account: Account, agent: string | undefined) { saved.set(account, agent); - for (const listener of listeners) listener(); + readErrors.delete(account); + notifySaved(); } function subscribeSaved(listener: () => void) { listeners.add(listener); @@ -37,7 +42,7 @@ function cache(scope: string, agent: string | null) { const saves = writeView(scope, KEY, agent); if (saves) unsaved.delete(scope); else unsaved.set(scope, agent); - for (const listener of listeners) listener(); + notifySaved(); return saves; } /** Makes an agent the one new Berd messages are tagged for, on this device. */ @@ -85,7 +90,7 @@ export function forgetDefaultAgent(pubkey: string) { clearViewValue(KEY, pubkey); for (const [scope, agent] of unsaved) if (agent === pubkey) unsaved.delete(scope); - for (const listener of listeners) listener(); + notifySaved(); } /** Adopts the account's saved choice once per session. Settles even when the @@ -99,9 +104,17 @@ function adopt(account: Account, scope: string) { if (adopts && agent !== undefined) cache(scope, agent); remember(account, adopts ? agent : saved.get(account)); }, - () => { + (cause) => { adopted.delete(account); - remember(account, saved.get(account)); + readErrors.set( + account, + cause instanceof Error + ? cause.message + : "Your Bestie couldn't be read from your account.", + ); + // Solo still settles and can choose a device fallback while offline. + saved.set(account, saved.get(account)); + notifySaved(); }, ); adopted.set(account, pending); @@ -109,8 +122,7 @@ function adopt(account: Account, scope: string) { return pending; } -/** Whether the account's saved choice has been read (or couldn't be), and - * what it is. */ +/** Whether the account read settled, its saved choice, and recoverable failure. */ function useAdopted(session: RelaySession, scope: string) { const account = session.mainAgent; useEffect(() => { @@ -122,15 +134,31 @@ function useAdopted(session: RelaySession, scope: string) { const agent = useSyncExternalStore(subscribeSaved, () => account ? saved.get(account) : undefined, ); - return { read, agent }; + const error = useSyncExternalStore(subscribeSaved, () => + account ? readErrors.get(account) : undefined, + ); + return { + read, + agent, + error, + retry: () => { + if (!account) return; + readErrors.delete(account); + saved.delete(account); + notifySaved(); + void adopt(account, scope); + }, + }; } -/** The account-saved choice and whether its first read settled. Without an - * account capability, the local explicit choice is already authoritative. */ +/** The account-saved choice and its read state. Without an account capability, + * the local explicit choice is already authoritative. */ export function useSavedAgentState(session: RelaySession, scope: string) { const local = useChosenAgent(scope); const account = useAdopted(session, scope); - return session.mainAgent ? account : { read: true, agent: local }; + return session.mainAgent + ? account + : { read: true, agent: local, error: undefined, retry: () => {} }; } /** The agent saved as main on the viewer's account, when it has one; without From 744b808424b1aea3781670dc580db57c03da2eef Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 16:50:52 -0400 Subject: [PATCH 07/21] fix(bestie): support server-rendered selection Signed-off-by: Clay Delk --- src/bundled/bestie/README.md | 8 +++++--- src/features/solo/default-agent.ts | 23 +++++++++++++++-------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 61ed5a2fcb..1afb10aee9 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -5,7 +5,9 @@ selection is the private `main-agent` record shared with Solo's default-agent owner; this plugin adds presentation and a conversation destination, not another selection store. -## Pinned conversation +## Getting started + +### Pinned conversation When the account has a saved eligible Bestie, the sidebar row adopts that agent's name and avatar and opens their canonical direct message. The canonical @@ -27,7 +29,7 @@ page as **Bestie**. Searching the agent's name therefore keeps the same New chat chat and Direct messages results as every other agent instead of adding a second route to the same DM. -## Hosted setup fallback +### Hosted setup fallback The account choice is read before the page decides what to show. With a saved Bestie, reconnecting, inventory failures and missing-agent states remain on the @@ -42,7 +44,7 @@ the canonical DM. Connecting hosted setup to the shared selection owner is a separate follow-up; until then, setup and an explicitly account-saved Bestie are parallel entry paths. -## Changing hosted Bestie's instructions +### Changing hosted Bestie's instructions When `prompt.ts` differs from the instructions on a set-up hosted Bestie, the setup page offers **Update Bestie's instructions**. **Bestie's instructions** on diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts index d94a92fc6f..ee9af5918b 100644 --- a/src/features/solo/default-agent.ts +++ b/src/features/solo/default-agent.ts @@ -128,14 +128,19 @@ function useAdopted(session: RelaySession, scope: string) { useEffect(() => { if (account) void adopt(account, scope); }, [account, scope]); - const read = useSyncExternalStore(subscribeSaved, () => - account ? saved.has(account) : true, + const readSnapshot = () => (account ? saved.has(account) : true); + const agentSnapshot = () => (account ? saved.get(account) : undefined); + const errorSnapshot = () => (account ? readErrors.get(account) : undefined); + const read = useSyncExternalStore(subscribeSaved, readSnapshot, readSnapshot); + const agent = useSyncExternalStore( + subscribeSaved, + agentSnapshot, + agentSnapshot, ); - const agent = useSyncExternalStore(subscribeSaved, () => - account ? saved.get(account) : undefined, - ); - const error = useSyncExternalStore(subscribeSaved, () => - account ? readErrors.get(account) : undefined, + const error = useSyncExternalStore( + subscribeSaved, + errorSnapshot, + errorSnapshot, ); return { read, @@ -176,6 +181,7 @@ function chosenAgent(scope: string) { /** The agent explicitly saved as main, whether or not it can be tagged now. */ export function useChosenAgent(scope: string) { + const snapshot = useCallback(() => chosenAgent(scope), [scope]); return useSyncExternalStore( useCallback( (notify) => { @@ -188,7 +194,8 @@ export function useChosenAgent(scope: string) { }, [scope], ), - () => chosenAgent(scope), + snapshot, + snapshot, ); } From 63e189646c3ec6b9d70a2a9e63d9b9ce0abf246c Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 29 Oct 2026 17:27:59 -0400 Subject: [PATCH 08/21] fix(bestie): identify pinned search results Show the saved Bestie identity in page search while preserving Bestie as a searchable detail. Carry display owner metadata through directory results so agent namesakes use readable owner qualifiers before key suffixes. Co-authored-by: Buzzsaw Signed-off-by: Clay Delk --- docs/shell-design.md | 4 +- docs/solo/README.md | 6 +- src/app/shell/BestieNavigation.test.tsx | 24 ++++--- src/app/shell/BestieNavigation.tsx | 3 +- src/app/shell/PageSearch.tsx | 29 +++++--- src/app/shell/SearchResults.test.tsx | 80 ++++++++++++++++++++++ src/app/shell/SearchResults.tsx | 4 ++ src/app/shell/bestie-context.ts | 1 + src/bundled/bestie/README.md | 10 +-- src/features/relay/direct-messages.test.ts | 17 +++-- src/features/relay/direct-messages.ts | 17 +++-- 11 files changed, 155 insertions(+), 40 deletions(-) diff --git a/docs/shell-design.md b/docs/shell-design.md index acdfeb306e..e83f9c8411 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -256,8 +256,8 @@ Pressed fills are slightly stronger; icons keep full opacity. Plugin launchers use the shared glass style. Bestie is available in the Messages sidebar when its plugin is enabled. An account-saved Bestie decorates that row with the agent's identity and promotes -its exact one-to-one DM there; Search Buzz keeps the stable **Bestie** page label -rather than indexing the page under the agent's name. See +its exact one-to-one DM there. Search Buzz shows the same agent identity with +**Bestie** as supporting text and matches either name. See [`solo/README.md`](solo/README.md) and the bundled Bestie README for selection, DM promotion and hosted-setup fallback ownership. `ProfileButton.tsx` uses the selected community's profile when connected and diff --git a/docs/solo/README.md b/docs/solo/README.md index 9c80732b53..86b19a075c 100644 --- a/docs/solo/README.md +++ b/docs/solo/README.md @@ -139,9 +139,9 @@ behavior on the label, so a rename touches copy only. store. While promoted, that DM is omitted from the ordinary Messages inventory; replacing or deleting the Bestie, disabling the plugin, or losing the exact roster match restores the ordinary row. The pinned navigation row adopts the - agent's name and avatar, while global search keeps the stable **Bestie** page - identity so an agent-name search does not duplicate the existing Direct - messages result. + agent's name and avatar. Global search shows the same identity with **Bestie** + as supporting text and matches either name; the separate agent result remains + a **New chat** action rather than another route to the pinned DM. Nothing is picked until the account record has been read and its archive read has settled, so a new chat is never tagged for a stale pick. A new chat tags the diff --git a/src/app/shell/BestieNavigation.test.tsx b/src/app/shell/BestieNavigation.test.tsx index bf4a4b7635..379421b364 100644 --- a/src/app/shell/BestieNavigation.test.tsx +++ b/src/app/shell/BestieNavigation.test.tsx @@ -144,7 +144,7 @@ async function fixture() { }; } -it("updates the mounted sidebar from the exact choice while search keeps Bestie's stable identity", async () => { +it("shows the exact choice as Bestie in navigation and search", async () => { const f = await fixture(); const select = vi.fn(); render( @@ -178,26 +178,32 @@ it("updates the mounted sidebar from the exact choice while search keeps Bestie' expect(pollenRow.querySelector("[data-status]")).toBeNull(); await userEvent.click(screen.getByRole("button", { name: "Search Buzz" })); const dialog = screen.getByRole("dialog", { name: "Search Buzz" }); - expect( - within(dialog).getByRole("option", { name: "Bestie" }), - ).toBeInTheDocument(); + const pollenSearch = within(dialog).getByRole("option", { + name: /Pollen.*Bestie/, + }); + expect(pollenSearch.querySelector("img")).toHaveAttribute( + "src", + portrait.preview, + ); const search = within(dialog).getByRole("combobox", { name: "Search Buzz" }); await userEvent.type(search, "Pollen"); expect( - within(dialog).queryByRole("option", { name: "Pollen" }), - ).not.toBeInTheDocument(); + within(dialog).getByRole("option", { name: /Pollen.*Bestie/ }), + ).toBeInTheDocument(); await userEvent.clear(search); await userEvent.type(search, "Bestie"); expect( - within(dialog).getByRole("option", { name: "Bestie" }), + within(dialog).getByRole("option", { name: /Pollen.*Bestie/ }), ).toBeInTheDocument(); act(() => { setDefaultAgent(scope, fizz); }); expect( - within(dialog).getByRole("option", { name: "Bestie" }), + within(dialog).getByRole("option", { name: /Fizz.*Bestie/ }), ).toBeInTheDocument(); - await userEvent.click(within(dialog).getByRole("option", { name: "Bestie" })); + await userEvent.click( + within(dialog).getByRole("option", { name: /Fizz.*Bestie/ }), + ); expect(select).toHaveBeenCalledWith(page.key); const fizzRow = await within(nav).findByRole("button", { name: "Fizz" }); expect(fizzRow.querySelector("img")).toBeNull(); diff --git a/src/app/shell/BestieNavigation.tsx b/src/app/shell/BestieNavigation.tsx index 089ea0298e..7eb7b7a2c9 100644 --- a/src/app/shell/BestieNavigation.tsx +++ b/src/app/shell/BestieNavigation.tsx @@ -70,6 +70,7 @@ export function BestieNavigationProvider({ const mark: BestieMark | undefined = agent ? { label: agent.managedName ?? agent.name, + detail: "Bestie", avatar: { src: avatar, shape: "squircle" }, } : undefined; @@ -121,7 +122,7 @@ export function BestieUnreadBadge({ pageKey }: { pageKey: string }) { ); } -/** Decorate navigation without changing the page's stable search identity. */ +/** Decorate Bestie's navigation and search destination with its exact choice. */ export function usePagePresentation() { const bestie = useContext(BestieNavigationContext); return (page: RegisteredPage) => ({ diff --git a/src/app/shell/PageSearch.tsx b/src/app/shell/PageSearch.tsx index 5691dfe301..0627f5c5cb 100644 --- a/src/app/shell/PageSearch.tsx +++ b/src/app/shell/PageSearch.tsx @@ -1,4 +1,5 @@ import { useSearchSettings } from "./SearchSettings"; +import { usePagePresentation } from "./BestieNavigation"; import { useCallback, useEffect, @@ -22,11 +23,7 @@ import type { ShortcutBindingsSnapshot } from "../../features/shortcuts/preferen import type { AppServices } from "../services"; import { HOST_SHORTCUT_ORDER } from "../shortcuts"; import { conversationComposerFocusTarget } from "../focus-navigation"; -import { - orderPages, - pagePresentation, - shellPresentation, -} from "./presentation"; +import { orderPages, shellPresentation } from "./presentation"; import { matchName } from "../../features/search/match"; import { SearchChoices, @@ -64,6 +61,7 @@ export function PageSearch({ onSelect: (key: string) => void; services?: SearchServices | undefined; }) { + const pagePresentation = usePagePresentation(); const [open, setOpen] = useState(false); const [contentPresent, setContentPresent] = useState(false); const [query, setQuery] = useState(""); @@ -145,9 +143,24 @@ export function PageSearch({ ] .flatMap((page) => { if (!needle) return [{ page, rank: 0 }]; - const match = matchName(page.label, needle); - return match - ? [{ page: { ...page, matches: match.positions }, rank: match.rank }] + const labelMatch = matchName(page.label, needle); + const detailMatch = page.detail + ? matchName(page.detail, needle) + : undefined; + const rank = Math.min( + labelMatch?.rank ?? Infinity, + detailMatch?.rank ?? Infinity, + ); + return Number.isFinite(rank) + ? [ + { + page: { + ...page, + ...(labelMatch ? { matches: labelMatch.positions } : {}), + }, + rank, + }, + ] : []; }) .sort((a, b) => a.rank - b.rank) diff --git a/src/app/shell/SearchResults.test.tsx b/src/app/shell/SearchResults.test.tsx index 90b8966880..8155cb646c 100644 --- a/src/app/shell/SearchResults.test.tsx +++ b/src/app/shell/SearchResults.test.tsx @@ -3293,6 +3293,7 @@ function peopleSession( name: string; username?: string; isAgent?: true; + ownerPubkey?: string; }[] = [], agents: readonly { pubkey: string; name: string; managed: boolean }[] = [], ) { @@ -3614,6 +3615,85 @@ it("tells uncached directory namesakes apart before a DM opens", async () => { owner.dispose(); } }); + +it("uses readable owners before key suffixes for agent namesakes", async () => { + const relay = keypair(), + viewer = keypair(), + first = keypair(), + second = keypair(), + secondOwner = keypair(); + // The default naming policy, as the bundled plugin registers it. + const directory = createAgentDirectory(); + const owner = createRelaySession( + { + ...scriptedTransport(viewer.pubkey, relay.pubkey).transport, + async query(filters) { + return filters.some((filter) => filter.kinds?.includes(0)) + ? [profile(secondOwner, { display_name: "Morgan" })] + : []; + }, + }, + { + identityNames: { + bind: (source) => + bindNames(source, { + snapshot: () => [directory], + subscribe: () => () => {}, + }), + } as IdentityNames, + }, + ); + const t = peopleSession( + owner.session, + [ + { + pubkey: first.pubkey, + name: "Namesake", + isAgent: true, + ownerPubkey: viewer.pubkey, + }, + { + pubkey: second.pubkey, + name: "Namesake", + isAgent: true, + ownerPubkey: secondOwner.pubkey, + }, + ], + [ + { pubkey: first.pubkey, name: "Namesake", managed: true }, + { pubkey: second.pubkey, name: "Namesake", managed: true }, + ], + ); + try { + render( + {}} + input={createRef()} + pages={[]} + openConversation={() => {}} + />, + ); + const group = await screen.findByRole("group", { name: "Agents" }); + await waitFor(() => + expect(within(group).getAllByRole("option")).toHaveLength(2), + ); + expect( + await within(group).findByRole("option", { + name: /^Morgan’s Namesake/, + }), + ).toBeVisible(); + expect( + within(group).getByRole("option", { + name: /^NamesakeStart a direct message$/, + }), + ).toBeVisible(); + } finally { + cleanup(); + owner.dispose(); + } +}); it.each([true, false])( "leads with a new chat for the viewer's agents, then their chats, then their DMs (Solo=%s)", async (solo) => { diff --git a/src/app/shell/SearchResults.tsx b/src/app/shell/SearchResults.tsx index 7676621c97..0a8bd8bde3 100644 --- a/src/app/shell/SearchResults.tsx +++ b/src/app/shell/SearchResults.tsx @@ -495,6 +495,9 @@ export function SearchResults({ message.authorId, ...(message.workflowOwnerId ? [message.workflowOwnerId] : []), ]), + ...personDirectory.people.flatMap((person) => + person.ownerPubkey ? [person.ownerPubkey] : [], + ), ]), ] .sort() @@ -582,6 +585,7 @@ export function SearchResults({ pubkey: person.pubkey, name: person.name, ...(person.isAgent ? { isAgent: true } : {}), + ...(person.ownerPubkey ? { ownerPubkey: person.ownerPubkey } : {}), })); for (const { pubkey, source, agent } of eligible) { const dm = relationships.dm(pubkey); diff --git a/src/app/shell/bestie-context.ts b/src/app/shell/bestie-context.ts index 885dce46c1..b487c4a5c1 100644 --- a/src/app/shell/bestie-context.ts +++ b/src/app/shell/bestie-context.ts @@ -4,6 +4,7 @@ import type { RelaySession } from "../../features/relay/session"; export const BESTIE_PAGE = "buzz.bestie/bestie"; export type BestieMark = { label: string; + detail: "Bestie"; avatar: { src: string | undefined; shape: "squircle" }; }; export const BestieNavigationContext = createContext( diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 1afb10aee9..0edf4b0f65 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -23,11 +23,11 @@ ordinary row and clears any old device-local hidden-DM intent. Replacing or deleting the Bestie, disabling the plugin, changing communities, or losing the exact roster match releases the promotion and restores the ordinary DM row. -Navigation presentation and search identity intentionally differ: the pinned -row shows the agent's name and avatar, while Search Buzz continues to list the -page as **Bestie**. Searching the agent's name therefore keeps the same New chat, -chat and Direct messages results as every other agent instead of adding a second -route to the same DM. +The pinned row and Search Buzz destination show the agent's name and avatar. +Search adds **Bestie** as supporting text and matches either name, so the pinned +DM remains recognizable without presenting a placeholder identity. Agent rows +remain separate **New chat** actions; namesakes stay distinct by exact public key +and use readable owner labels before abbreviated-key fallbacks. ### Hosted setup fallback diff --git a/src/features/relay/direct-messages.test.ts b/src/features/relay/direct-messages.test.ts index 511fea85c4..db33d36799 100644 --- a/src/features/relay/direct-messages.test.ts +++ b/src/features/relay/direct-messages.test.ts @@ -11,6 +11,7 @@ function setup( ) { const viewer = keypair(), other = keypair(), + agentOwner = keypair(), relay = keypair(), stranger = keypair(); const authority = options.untrusted ? stranger : relay; @@ -32,11 +33,15 @@ function setup( if (filters.some((f) => f.kinds?.includes(0))) return [ profile(viewer, { name: "Me" }), - profile(other, { - name: "Other", - picture: "https://example.com/avatar.png", - is_agent: true, - about: "Unused biography", + signed(other, { + kind: 0, + content: JSON.stringify({ + name: "Other", + picture: "https://example.com/avatar.png", + is_agent: true, + about: "Unused biography", + }), + tags: [["auth", agentOwner.pubkey, "", "a".repeat(128)]], }), ]; return options.lagging && @@ -65,6 +70,7 @@ function setup( owner, viewer, other, + agentOwner, query, publish, openDirectMessage, @@ -186,6 +192,7 @@ it("reads paginated verified profiles, excludes the viewer, and cancels with the name: "Other", picture: "https://example.com/avatar.png", isAgent: true, + ownerPubkey: t.agentOwner.pubkey, }, ], hasMore: false, diff --git a/src/features/relay/direct-messages.ts b/src/features/relay/direct-messages.ts index 8eb9c0d337..57500f7fe0 100644 --- a/src/features/relay/direct-messages.ts +++ b/src/features/relay/direct-messages.ts @@ -67,13 +67,16 @@ export function createDirectMessages( return { people: [...foldProfiles(events)] .filter(([pubkey]) => pubkey !== transport?.viewer) - .map(([pubkey, { name, username, picture, isAgent }]) => ({ - pubkey, - name, - ...(username ? { username } : {}), - ...(picture ? { picture } : {}), - ...(isAgent ? { isAgent } : {}), - })), + .map( + ([pubkey, { name, username, picture, isAgent, ownerPubkey }]) => ({ + pubkey, + name, + ...(username ? { username } : {}), + ...(picture ? { picture } : {}), + ...(isAgent ? { isAgent } : {}), + ...(ownerPubkey ? { ownerPubkey } : {}), + }), + ), hasMore: events.length >= limit, }; }, From 7dca86dd3413f2be16b013986bdd7d1968bae6e1 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 30 Oct 2026 09:03:55 -0400 Subject: [PATCH 09/21] fix(bestie): wait for an account-scoped session Signed-off-by: Clay Delk --- src/bundled/bestie/index.test.tsx | 12 ++++++ src/bundled/bestie/index.tsx | 70 +++++++++++++++++++++---------- src/bundled/bestie/testing.ts | 12 ++++++ 3 files changed, 71 insertions(+), 23 deletions(-) diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx index 5f095d493f..c021f673bc 100644 --- a/src/bundled/bestie/index.test.tsx +++ b/src/bundled/bestie/index.test.tsx @@ -135,6 +135,18 @@ it("keeps setup hidden when the account choice read fails and retries it", async ).not.toBeInTheDocument(); }); +it("keeps a replacement connecting session out of hosted setup", async () => { + const h = await bestieFixture(native.invoke); + renderPage(h); + act(() => h.replaceWithConnectingSession()); + expect(await screen.findByRole("status")).toHaveTextContent( + "Connecting to your Bestie…", + ); + expect( + screen.queryByRole("button", { name: "Set up Bestie" }), + ).not.toBeInTheDocument(); +}); + it("routes an account-saved Bestie to its canonical DM instead of hosted setup", async () => { const choice = deferred(); const h = await bestieFixture(native.invoke, { diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index ff1afdbb40..f5bef0a6a7 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -94,6 +94,26 @@ export const apply: PluginModule["apply"] = (ctx) => { }); }; +function BestieStatus({ children }: { children: ReactNode }) { + return ( +
+ +
+ } + /> + } + /> + {children} +
+
+
+ ); +} + function BestieDestination({ relay, ...props @@ -106,31 +126,35 @@ function BestieDestination({ }) { const connection = useRelayConnection(relay); const saved = useSavedAgentState(connection.session, connection.scope ?? ""); + const established = + connection.status === "ready" && + !!connection.viewer && + !!connection.scope?.endsWith(`:${connection.viewer}`); + if (!established && !saved.agent) + return ( + +

+ {connection.status === "connecting" + ? "Connecting to your Bestie…" + : (connection.error ?? + "Choose a community to continue with your Bestie.")} +

+ {connection.status === "error" && ( + + )} +
+ ); if (!saved.read || saved.error) return ( -
- -
- } - /> - } - /> - -

- {saved.error ?? "Loading your Bestie…"} -

- {saved.error && } -
-
-
-
+ +

+ {saved.error ?? "Loading your Bestie…"} +

+ {saved.error && } +
); return saved.agent ? ( [] = []; let snapshot: RelaySnapshot = { status: "ready", generation: 1, @@ -423,6 +424,7 @@ export async function bestieFixture( await runtime.dispose(); await root.fiber.dispose(); owner.dispose(); + for (const temporary of temporarySessions) temporary.dispose(); await closed.promise; listeners.clear(); }); @@ -461,6 +463,16 @@ export async function bestieFixture( signInCanceled = true; }, session: () => owner.session, + replaceWithConnectingSession() { + const temporary = createRelaySession(null); + temporarySessions.push(temporary); + snapshot = { + status: "connecting", + generation: snapshot.generation + 1, + session: temporary.session, + }; + notify(); + }, ignoreCreateMembers() { createMembers = false; }, From bf0df384583c6f768056f1bd61776dc04cd31f4b Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 30 Oct 2026 09:05:55 -0400 Subject: [PATCH 10/21] fix(solo): fence stale Bestie account reads Signed-off-by: Clay Delk --- src/features/solo/default-agent.test.tsx | 42 ++++++++++++++++++++++++ src/features/solo/default-agent.ts | 21 ++++++++++-- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/features/solo/default-agent.test.tsx b/src/features/solo/default-agent.test.tsx index 104c00a4aa..645fc93b75 100644 --- a/src/features/solo/default-agent.test.tsx +++ b/src/features/solo/default-agent.test.tsx @@ -12,6 +12,7 @@ import { restoreDefaultAgent, setDefaultAgent, useDefaultAgent, + useSavedAgentState, } from "./default-agent"; afterEach(() => { @@ -576,6 +577,47 @@ it("keeps a live choice adopted on retry through later archive reads", async () expect(screen.getByRole("status").textContent).toBe("Second"); }); +it("ignores an older read failure after a newer choice and preserves it through retry", async () => { + const pending = () => { + let resolve!: (value: string | undefined) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; + }; + const first = pending(); + const second = pending(); + let reads = 0; + const account = { + read: vi.fn(() => (reads++ ? second.promise : first.promise)), + write: vi.fn(async () => {}), + }; + const session = { mainAgent: account } as unknown as RelaySession; + function Selection() { + const state = useSavedAgentState(session, "stale-failure"); + return ( + <> + {state.agent ?? "none"} + {state.error ?? "none"} + + + ); + } + render(); + await act(() => chooseDefaultAgent(session, "stale-failure", "a".repeat(64))); + await act(async () => first.reject(new Error("old read failed"))); + expect(screen.getByLabelText("agent")).toHaveTextContent("a".repeat(64)); + expect(screen.getByLabelText("error")).toHaveTextContent("none"); + await act(async () => screen.getByRole("button", { name: "Retry" }).click()); + await act(async () => second.resolve("a".repeat(64))); + expect(screen.getByLabelText("agent")).toHaveTextContent("a".repeat(64)); + expect(screen.getByLabelText("error")).toHaveTextContent("none"); +}); + it("a choice made while the account is read wins, even when already cached", async () => { const { account, session, Selection } = accountSession("b".repeat(64)); setDefaultAgent("account", "a".repeat(64)); diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts index ee9af5918b..85a27fc741 100644 --- a/src/features/solo/default-agent.ts +++ b/src/features/solo/default-agent.ts @@ -16,6 +16,13 @@ import { const KEY = "solo:default-agent"; type Account = NonNullable; const adopted = new WeakMap>(); +const revisions = new WeakMap(); +const revision = (account: Account) => revisions.get(account) ?? 0; +const advance = (account: Account) => { + const next = revision(account) + 1; + revisions.set(account, next); + return next; +}; // The account's saved choice once read: undefined only when a successful read // found no record. Read failures are tracked separately. const saved = new WeakMap(); @@ -68,7 +75,10 @@ export async function chooseDefaultAgent( pubkey: string, ) { const account = session.mainAgent; - if (account) explicit.add(account); + if (account) { + explicit.add(account); + advance(account); + } // A choice this device can't cache still applies here, so it is still saved // to the account. setDefaultAgent(scope, pubkey); @@ -98,14 +108,17 @@ export function forgetDefaultAgent(pubkey: string) { function adopt(account: Account, scope: string) { let pending = adopted.get(account); if (!pending) { + const started = revision(account); pending = account.read().then( (agent) => { + if (started !== revision(account)) return; const adopts = !explicit.has(account); if (adopts && agent !== undefined) cache(scope, agent); - remember(account, adopts ? agent : saved.get(account)); + remember(account, adopts ? agent : (saved.get(account) ?? agent)); }, (cause) => { adopted.delete(account); + if (started !== revision(account)) return; readErrors.set( account, cause instanceof Error @@ -149,7 +162,9 @@ function useAdopted(session: RelaySession, scope: string) { retry: () => { if (!account) return; readErrors.delete(account); - saved.delete(account); + advance(account); + // Keep a newer confirmed choice visible while its account record is reread. + if (saved.get(account) === undefined) saved.delete(account); notifySaved(); void adopt(account, scope); }, From 83c339f1fba36db349ae1e141b0f2e1030ded340 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 30 Oct 2026 09:59:11 -0400 Subject: [PATCH 11/21] fix(bestie): route thread navigation through history Co-authored-by: Buzzsaw Signed-off-by: Clay Delk --- src/bundled/bestie/PinnedBestie.test.tsx | 108 ++++++++++++++++++++++- src/bundled/bestie/PinnedBestie.tsx | 59 +++++++++++-- src/bundled/bestie/index.tsx | 9 +- src/bundled/bestie/routes.test.ts | 30 +++++++ src/bundled/bestie/routes.ts | 36 ++++++++ 5 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 src/bundled/bestie/routes.test.ts create mode 100644 src/bundled/bestie/routes.ts diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx index 90bc6a9fe3..1f26654e0f 100644 --- a/src/bundled/bestie/PinnedBestie.test.tsx +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -9,6 +9,12 @@ import { createAgentChoices } from "../../features/agents/choices"; import { createAgentLibrary } from "../../features/agents/library"; import { localAgentsFixture } from "../../features/agents/local-testing"; import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; +import { + createNavigationController, + type Navigation, +} from "../../features/navigation/controller"; +import { createMemoryHistory } from "../../features/navigation/history"; +import type { PageNavigation } from "../../features/navigation/service"; import type { RelayData, RelaySnapshot } from "../../features/relay/service"; import { createRelaySession } from "../../features/relay/session"; import { @@ -25,6 +31,7 @@ import { setDefaultAgent } from "../../features/solo/default-agent"; import { readView, writeView } from "../../shared/view-state"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; import { PinnedBestie } from "./PinnedBestie"; +import { bestieRoute } from "./routes"; // jsdom has no virtualized layout. Keep the real rows, thread reader and React // lifecycle; model only Virtua's imperative layout boundary, not scroll behavior. @@ -245,6 +252,10 @@ async function fixture({ disconnect() {}, async clearCache() {}, }; + const navigator = { + open: vi.fn(async () => ({ status: "opened" as const })), + dismiss: vi.fn(), + } as unknown as Navigation; const host = { relay, panels: { @@ -263,6 +274,7 @@ async function fixture({ open, gate, native, + navigator, makePollenIneligible() { const current = choices.snapshot(); ineligibleSnapshot = { @@ -281,10 +293,33 @@ async function fixture({ unavailable = false; }, choose: (pubkey: string) => setDefaultAgent(scope, pubkey), + element: ({ + renderedNavigator = navigator, + navigation, + }: { + renderedNavigator?: Navigation | undefined; + navigation?: PageNavigation | undefined; + } = {}) => ( + + + + ), + rootId: root.id, mount: () => render( - + , { reactStrictMode: true }, ), @@ -401,6 +436,77 @@ it("reports an open failure and retries through the same DM owner", async () => await screen.findByRole("textbox", { name: "Message Pollen" }); }); +it("restores Bestie threads with Back and Forward and retires an explicitly closed visit", async () => { + const f = await fixture({ existing: true, withReply: true }); + f.choose(pollen); + const previous = { + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + } as const; + const bestie = { + version: 1, + kind: "page", + pluginId: "buzz.bestie", + pageId: "bestie", + } as const; + const controller = createNavigationController(createMemoryHistory(previous)); + disposals.push(controller.dispose); + const navigator = controller.navigation; + void navigator.open(bestie); + const pageNavigation = (): PageNavigation => { + const { entry, attempt } = navigator.snapshot(); + const request: PageNavigation = { + entryId: entry.id, + target: entry.target, + signal: attempt.signal, + ...(attempt.focus ? { focus: attempt.focus } : {}), + forSession: () => request, + resolve: () => false, + complete: () => true, + }; + return request; + }; + const view = render( + f.element({ renderedNavigator: navigator, navigation: pageNavigation() }), + { reactStrictMode: true }, + ); + const rerender = () => + view.rerender( + f.element({ renderedNavigator: navigator, navigation: pageNavigation() }), + ); + + await userEvent.click( + await screen.findByRole("button", { name: "View thread: 1 reply" }), + ); + const threadTarget = navigator.snapshot().entry.target; + expect( + threadTarget.kind === "page" + ? bestieRoute(threadTarget.route?.params) + : undefined, + ).toEqual({ messageId: f.rootId, threadRootId: f.rootId }); + rerender(); + expect(await screen.findByLabelText("Thread")).toBeVisible(); + + act(() => navigator.back()); + rerender(); + expect( + await screen.findByRole("textbox", { name: "Message Pollen" }), + ).toBeVisible(); + act(() => navigator.forward()); + rerender(); + expect(await screen.findByLabelText("Thread")).toBeVisible(); + + await userEvent.click(screen.getByRole("button", { name: "Close thread" })); + rerender(); + expect(navigator.snapshot().entry.target).toEqual(bestie); + act(() => navigator.back()); + expect(navigator.snapshot().entry.target).toEqual(previous); + act(() => navigator.forward()); + expect(navigator.snapshot().entry.target).toEqual(bestie); +}); + it("never renders a DM whose signed membership does not match the chosen agent", async () => { const f = await fixture({ badRoster: true }); f.choose(pollen); diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx index 882b04da01..7ee794d0eb 100644 --- a/src/bundled/bestie/PinnedBestie.tsx +++ b/src/bundled/bestie/PinnedBestie.tsx @@ -8,6 +8,8 @@ import { } from "../../features/conversation/Conversation"; import type { ConversationExtensions } from "../../features/conversation/contracts"; import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; +import type { Navigation } from "../../features/navigation/controller"; +import type { PageNavigation } from "../../features/navigation/service"; import { messageViewKey } from "../../features/messages/view-key"; import { useAgentChoices } from "../../features/agents/use-choices"; import { useChannelList, useRelayConnection } from "../../features/relay/react"; @@ -26,6 +28,7 @@ import { PanelHeaderLabel, } from "../../shared/design-system/ui/PanelHeader"; import { bestieChat } from "./chat"; +import { bestieBaseTarget, bestieRoute, bestieThreadTarget } from "./routes"; import styles from "./PinnedBestie.module.css"; /** Account-saved Besties use their canonical verified DM. No saved choice means @@ -34,10 +37,14 @@ export function PinnedBestie({ relay, host, extensions, + navigator, + navigation, }: { relay: RelayData; host: ConversationHostServices; extensions: ConversationExtensions; + navigator: Navigation; + navigation?: PageNavigation | undefined; }) { const connection = useRelayConnection(relay); const { session, scope, viewer } = connection; @@ -140,6 +147,8 @@ export function PinnedBestie({ scope={scope} host={host} extensions={extensions} + navigator={navigator} + navigation={navigation} agent={{ pubkey: agent.pubkey, name }} channel={channel} /> @@ -176,6 +185,8 @@ export function PinnedBestie({ scope={scope} host={host} extensions={extensions} + navigator={navigator} + navigation={navigation} agent={{ pubkey: agent.pubkey, name }} channel={undefined} /> @@ -198,6 +209,8 @@ function DirectMessage({ scope, host, extensions, + navigator, + navigation, agent, channel, }: { @@ -205,6 +218,8 @@ function DirectMessage({ scope: string; host: ConversationHostServices; extensions: ConversationExtensions; + navigator: Navigation; + navigation?: PageNavigation | undefined; agent: { pubkey: string; name: string }; channel: ChannelSummary | undefined; }) { @@ -245,6 +260,8 @@ function DirectMessage({ scope={scope} host={host} extensions={extensions} + navigator={navigator} + navigation={navigation} channel={channel} bestie={agent.name} /> @@ -277,6 +294,8 @@ function BestieConversation({ scope, host, extensions, + navigator, + navigation, channel, bestie, }: { @@ -284,12 +303,42 @@ function BestieConversation({ scope: string; host: ConversationHostServices; extensions: ConversationExtensions; + navigator: Navigation; + navigation?: PageNavigation | undefined; channel: ChannelSummary; bestie: string; }) { const [sent, setSent] = useState(); - const [thread, setThread] = useState(); + const routed = + navigation?.target.kind === "page" + ? bestieRoute(navigation.target.route?.params) + : undefined; + const [localThread, setLocalThread] = useState(); + const thread = routed?.messageId ?? localThread; const [composerFocus, focusComposer] = useState(0); + const ownsCurrentVisit = + !!navigation && + !navigation.signal.aborted && + navigator.snapshot().entry.id === navigation.entryId; + const openThread = (messageId: string, threadRootId: string) => { + if (!navigation) { + setLocalThread(messageId); + return; + } + if (navigation.target.kind !== "page" || !ownsCurrentVisit) return; + void navigator.open( + bestieThreadTarget(navigation.target, { messageId, threadRootId }), + ); + }; + const closeThread = () => { + if (navigation?.target.kind === "page" && routed && ownsCurrentVisit) { + navigator.dismiss( + [navigation.entryId], + bestieBaseTarget(navigation.target), + ); + } else if (!navigation) setLocalThread(undefined); + focusComposer((value) => value + 1); + }; return ( { - setThread(undefined); - focusComposer((value) => value + 1); - }} + navigation={routed ? navigation : undefined} + close={closeThread} /> ) : ( @@ -321,7 +368,7 @@ function BestieConversation({ activityLane={!channel.cached} revealMessageId={sent} marksRead - onOpenThread={setThread} + onOpenThread={openThread} /> { title: "Bestie", layout: "workspace", primary: true, - component: () => { + route: { version: 1, validate: (params) => !!bestieRoute(params) }, + component: ({ navigation: pageNavigation }) => { const current = useSyncExternalStore( source.subscribe, source.snapshot, @@ -88,6 +91,7 @@ export const apply: PluginModule["apply"] = (ctx) => { navigation={ctx.navigation} extensions={ctx.conversation} host={ctx} + pageNavigation={pageNavigation} /> ); }, @@ -123,6 +127,7 @@ function BestieDestination({ navigation: Navigation; extensions: ConversationExtensions; host: ConversationHostServices; + pageNavigation?: PageNavigation | undefined; }) { const connection = useRelayConnection(relay); const saved = useSavedAgentState(connection.session, connection.scope ?? ""); @@ -161,6 +166,8 @@ function BestieDestination({ relay={relay} host={props.host} extensions={props.extensions} + navigator={props.navigation} + navigation={props.pageNavigation} /> ) : ( diff --git a/src/bundled/bestie/routes.test.ts b/src/bundled/bestie/routes.test.ts new file mode 100644 index 0000000000..9212d156ac --- /dev/null +++ b/src/bundled/bestie/routes.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { bestieBaseTarget, bestieRoute, bestieThreadTarget } from "./routes"; + +const page = { + version: 1, + kind: "page", + pluginId: "buzz.bestie", + pageId: "bestie", +} as const; +const route = { + messageId: "a".repeat(64), + threadRootId: "b".repeat(64), +}; + +it("round-trips an exact Bestie thread route", () => { + const target = bestieThreadTarget(page, route); + expect(bestieRoute(target.route?.params)).toEqual(route); + expect(bestieBaseTarget(target)).toEqual(page); +}); + +it.each([ + undefined, + {}, + { messageId: route.messageId }, + { ...route, extra: true }, + { ...route, messageId: "not-an-event" }, + { ...route, threadRootId: "A".repeat(64) }, +])("rejects an invalid Bestie thread route", (value) => { + expect(bestieRoute(value)).toBeUndefined(); +}); diff --git a/src/bundled/bestie/routes.ts b/src/bundled/bestie/routes.ts new file mode 100644 index 0000000000..4e77700630 --- /dev/null +++ b/src/bundled/bestie/routes.ts @@ -0,0 +1,36 @@ +import type { JsonValue, OpenTarget } from "../../features/navigation/targets"; + +type BestiePageTarget = Extract; +export type BestieRoute = { messageId: string; threadRootId: string }; +const hex = /^[0-9a-f]{64}$/; + +export function bestieRoute( + value: JsonValue | undefined, +): BestieRoute | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + const route = value as Readonly>; + if ( + Object.keys(route).length !== 2 || + typeof route.messageId !== "string" || + !hex.test(route.messageId) || + typeof route.threadRootId !== "string" || + !hex.test(route.threadRootId) + ) + return; + return { messageId: route.messageId, threadRootId: route.threadRootId }; +} + +export function bestieThreadTarget( + target: BestiePageTarget, + route: BestieRoute, +): BestiePageTarget { + return { + ...target, + route: { version: 1, params: { ...route } }, + }; +} + +export function bestieBaseTarget(target: BestiePageTarget): BestiePageTarget { + const { route: _route, ...base } = target; + return base; +} From 64a999b9ed689fcd19fc5b7a3b3919bb020bb3dd Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 30 Oct 2026 09:59:56 -0400 Subject: [PATCH 12/21] fix(bestie): preserve focus across DM retries Co-authored-by: Buzzsaw Signed-off-by: Clay Delk --- src/bundled/bestie/PinnedBestie.test.tsx | 27 ++++++++++++++++++++++++ src/bundled/bestie/PinnedBestie.tsx | 15 +++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx index 1f26654e0f..cbab65525b 100644 --- a/src/bundled/bestie/PinnedBestie.test.tsx +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -436,6 +436,33 @@ it("reports an open failure and retries through the same DM owner", async () => await screen.findByRole("textbox", { name: "Message Pollen" }); }); +it("keeps a failed DM retry focused and announces another failure", async () => { + const f = await fixture(); + f.open.mockRejectedValueOnce(new Error("DM temporarily unavailable")); + // StrictMode abandons its initial effect; both initial opens must fail. + f.open.mockRejectedValueOnce(new Error("DM temporarily unavailable")); + let rejectRetry!: (reason: unknown) => void; + f.open.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectRetry = reject; + }), + ); + f.choose(pollen); + f.mount(); + await screen.findByText("DM temporarily unavailable"); + const retry = screen.getByRole("button", { name: "Try again" }); + await userEvent.click(retry); + expect(retry).toHaveFocus(); + await waitFor(() => expect(retry).toHaveAttribute("aria-busy", "true")); + await act(async () => rejectRetry(new Error("DM still unavailable"))); + expect(await screen.findByRole("alert")).toHaveTextContent( + "DM still unavailable", + ); + expect(screen.getByRole("button", { name: "Try again" })).toBe(retry); + expect(retry).toHaveFocus(); +}); + it("restores Bestie threads with Back and Forward and retires an explicitly closed visit", async () => { const f = await fixture({ existing: true, withReply: true }); f.choose(pollen); diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx index 7ee794d0eb..e1d91ed89d 100644 --- a/src/bundled/bestie/PinnedBestie.tsx +++ b/src/bundled/bestie/PinnedBestie.tsx @@ -266,13 +266,16 @@ function DirectMessage({ bestie={agent.name} /> ); - if (error) + if (error || attempt > 0) return ( retry((value) => value + 1)}>Try again + } /> ); @@ -386,17 +389,21 @@ function Notice({ title, description, action, + announce, }: { title: string; description: string; action?: React.ReactNode; + announce?: "alert" | "status" | undefined; }) { return (
} title={title} - description={description} + description={ + announce ? {description} : description + } action={action} />
From c716a3896a936da9cf81cbeee25ea1b55febe8b1 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 30 Oct 2026 11:08:18 -0400 Subject: [PATCH 13/21] fix(solo): settle failed first Bestie saves Signed-off-by: Clay Delk --- src/features/solo/default-agent.test.tsx | 87 ++++++++++++++++++++++++ src/features/solo/default-agent.ts | 46 +++++++++++-- 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/features/solo/default-agent.test.tsx b/src/features/solo/default-agent.test.tsx index 645fc93b75..c54d2c27d5 100644 --- a/src/features/solo/default-agent.test.tsx +++ b/src/features/solo/default-agent.test.tsx @@ -618,6 +618,93 @@ it("ignores an older read failure after a newer choice and preserves it through expect(screen.getByLabelText("error")).toHaveTextContent("none"); }); +it("settles a first account read after its attempted choice fails", async () => { + let resolve!: (value: string | undefined) => void; + const read = new Promise((done) => { + resolve = done; + }); + const account = { + read: vi.fn(() => read), + write: vi.fn(async () => { + throw new Error("write failed"); + }), + }; + const session = { mainAgent: account } as unknown as RelaySession; + function Selection() { + const state = useSavedAgentState(session, "failed-first-choice"); + return ( + <> + {String(state.read)} + {state.agent ?? "none"} + {state.error ?? "none"} + + ); + } + const view = render(); + await act(async () => {}); + await expect( + chooseDefaultAgent(session, "failed-first-choice", "a".repeat(64)), + ).rejects.toThrow( + "Your bestie couldn't be saved to your account. Try again.", + ); + await act(async () => resolve("b".repeat(64))); + expect(screen.getByLabelText("read")).toHaveTextContent("true"); + expect(screen.getByLabelText("agent")).toHaveTextContent("b".repeat(64)); + expect(screen.getByLabelText("error")).toHaveTextContent("none"); + view.unmount(); + render(); + expect(screen.getByLabelText("read")).toHaveTextContent("true"); + expect(screen.getByLabelText("agent")).toHaveTextContent("b".repeat(64)); +}); + +it("recovers a failed first account read after its attempted choice fails", async () => { + const pending = () => { + let resolve!: (value: string | undefined) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; + }; + const first = pending(); + const second = pending(); + let reads = 0; + const account = { + read: vi.fn(() => (reads++ ? second.promise : first.promise)), + write: vi.fn(async () => { + throw new Error("write failed"); + }), + }; + const session = { mainAgent: account } as unknown as RelaySession; + function Selection() { + const state = useSavedAgentState(session, "failed-first-read"); + return ( + <> + {String(state.read)} + {state.agent ?? "none"} + {state.error ?? "none"} + + ); + } + const view = render(); + await act(async () => {}); + await expect( + chooseDefaultAgent(session, "failed-first-read", "a".repeat(64)), + ).rejects.toThrow( + "Your bestie couldn't be saved to your account. Try again.", + ); + await act(async () => first.reject(new Error("account offline"))); + expect(screen.getByLabelText("read")).toHaveTextContent("true"); + expect(screen.getByLabelText("error")).toHaveTextContent("account offline"); + view.unmount(); + render(); + await act(async () => second.resolve("b".repeat(64))); + expect(screen.getByLabelText("read")).toHaveTextContent("true"); + expect(screen.getByLabelText("agent")).toHaveTextContent("b".repeat(64)); + expect(screen.getByLabelText("error")).toHaveTextContent("none"); +}); + it("a choice made while the account is read wins, even when already cached", async () => { const { account, session, Selection } = accountSession("b".repeat(64)); setDefaultAgent("account", "a".repeat(64)); diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts index 85a27fc741..682fa3d89e 100644 --- a/src/features/solo/default-agent.ts +++ b/src/features/solo/default-agent.ts @@ -27,6 +27,9 @@ const advance = (account: Account) => { // found no record. Read failures are tracked separately. const saved = new WeakMap(); const readErrors = new WeakMap(); +const readValues = new WeakMap(); +const readFailures = new WeakMap(); +const failedChoices = new WeakSet(); const listeners = new Set<() => void>(); function notifySaved() { for (const listener of listeners) listener(); @@ -77,19 +80,36 @@ export async function chooseDefaultAgent( const account = session.mainAgent; if (account) { explicit.add(account); - advance(account); } // A choice this device can't cache still applies here, so it is still saved // to the account. setDefaultAgent(scope, pubkey); if (!account) return; + const attempt = advance(account); try { await account.write(pubkey); } catch { + if (revision(account) === attempt) { + if (readValues.has(account)) { + remember(account, readValues.get(account)); + } else if (readFailures.has(account)) { + adopted.delete(account); + readErrors.set( + account, + readFailures.get(account) ?? + "Your Bestie couldn't be read from your account.", + ); + saved.set(account, saved.get(account)); + notifySaved(); + } else { + failedChoices.add(account); + } + } throw new Error( "Your bestie couldn't be saved to your account. Try again.", ); } + failedChoices.delete(account); remember(account, pubkey); } @@ -111,20 +131,32 @@ function adopt(account: Account, scope: string) { const started = revision(account); pending = account.read().then( (agent) => { - if (started !== revision(account)) return; + readValues.set(account, agent); + readFailures.delete(account); + if (started !== revision(account)) { + if (failedChoices.delete(account)) remember(account, agent); + return; + } const adopts = !explicit.has(account); if (adopts && agent !== undefined) cache(scope, agent); remember(account, adopts ? agent : (saved.get(account) ?? agent)); }, (cause) => { adopted.delete(account); - if (started !== revision(account)) return; - readErrors.set( - account, + const error = cause instanceof Error ? cause.message - : "Your Bestie couldn't be read from your account.", - ); + : "Your Bestie couldn't be read from your account."; + readFailures.set(account, error); + if (started !== revision(account)) { + if (failedChoices.delete(account)) { + readErrors.set(account, error); + saved.set(account, saved.get(account)); + notifySaved(); + } + return; + } + readErrors.set(account, error); // Solo still settles and can choose a device fallback while offline. saved.set(account, saved.get(account)); notifySaved(); From a0f4350294c7e0e34a24ac3e72c497c6a9ad7db8 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 30 Oct 2026 11:08:22 -0400 Subject: [PATCH 14/21] fix(bestie): retire repeated thread visits Signed-off-by: Clay Delk --- src/bundled/bestie/PinnedBestie.test.tsx | 22 ++++++++++++++++++++-- src/bundled/bestie/PinnedBestie.tsx | 11 +++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx index cbab65525b..4473f3c1d2 100644 --- a/src/bundled/bestie/PinnedBestie.test.tsx +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -463,7 +463,7 @@ it("keeps a failed DM retry focused and announces another failure", async () => expect(retry).toHaveFocus(); }); -it("restores Bestie threads with Back and Forward and retires an explicitly closed visit", async () => { +it("restores Bestie threads with Back and Forward and retires every explicitly closed visit", async () => { const f = await fixture({ existing: true, withReply: true }); f.choose(pollen); const previous = { @@ -525,11 +525,29 @@ it("restores Bestie threads with Back and Forward and retires an explicitly clos rerender(); expect(await screen.findByLabelText("Thread")).toBeVisible(); + const other = { + version: 1, + kind: "page", + pluginId: "buzz.agents", + pageId: "agents", + } as const; + void navigator.open(other); + rerender(); + void navigator.open(bestie); + rerender(); + void navigator.open(threadTarget); + rerender(); + expect(await screen.findByLabelText("Thread")).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Close thread" })); rerender(); expect(navigator.snapshot().entry.target).toEqual(bestie); act(() => navigator.back()); - expect(navigator.snapshot().entry.target).toEqual(previous); + expect(navigator.snapshot().entry.target).toEqual(other); + act(() => navigator.back()); + expect(navigator.snapshot().entry.target).toEqual(bestie); + act(() => navigator.forward()); + expect(navigator.snapshot().entry.target).toEqual(other); act(() => navigator.forward()); expect(navigator.snapshot().entry.target).toEqual(bestie); }); diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx index e1d91ed89d..fe348b39cf 100644 --- a/src/bundled/bestie/PinnedBestie.tsx +++ b/src/bundled/bestie/PinnedBestie.tsx @@ -10,6 +10,7 @@ import type { ConversationExtensions } from "../../features/conversation/contrac import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; import type { Navigation } from "../../features/navigation/controller"; import type { PageNavigation } from "../../features/navigation/service"; +import { targetKey } from "../../features/navigation/targets"; import { messageViewKey } from "../../features/messages/view-key"; import { useAgentChoices } from "../../features/agents/use-choices"; import { useChannelList, useRelayConnection } from "../../features/relay/react"; @@ -335,9 +336,15 @@ function BestieConversation({ }; const closeThread = () => { if (navigation?.target.kind === "page" && routed && ownsCurrentVisit) { + const base = bestieBaseTarget(navigation.target); navigator.dismiss( - [navigation.entryId], - bestieBaseTarget(navigation.target), + (target) => + target.kind === "page" && + targetKey(bestieBaseTarget(target)) === targetKey(base) && + bestieRoute(target.route?.params)?.messageId === routed.messageId && + bestieRoute(target.route?.params)?.threadRootId === + routed.threadRootId, + base, ); } else if (!navigation) setLocalThread(undefined); focusComposer((value) => value + 1); From f78963029edf1ba3df3b1d31632c1aae6a882228 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 30 Oct 2026 13:06:48 -0400 Subject: [PATCH 15/21] feat(bestie): choose an existing agent or create one Signed-off-by: Clay Delk --- src/app/pages.integration.test.mjs | 2 +- src/bundled/bestie/README.md | 26 +- src/bundled/bestie/index.test.tsx | 272 +------------- src/bundled/bestie/index.tsx | 576 ++++++++--------------------- 4 files changed, 175 insertions(+), 701 deletions(-) diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 98fd08707f..73d616df24 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -322,7 +322,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", assert.equal(bestiePage.primary, true); assert.match( renderToStaticMarkup(createElement(bestiePage.component)), - /Meet your Bestie/, + /Choose a community to continue with your Bestie\./, ); await services.plugins.change("disable", "buzz.bestie"); assert.equal( diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 0edf4b0f65..4aea2875a6 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -29,23 +29,15 @@ DM remains recognizable without presenting a placeholder identity. Agent rows remain separate **New chat** actions; namesakes stay distinct by exact public key and use readable owner labels before abbreviated-key fallbacks. -### Hosted setup fallback +### Choosing a Bestie The account choice is read before the page decides what to show. With a saved Bestie, reconnecting, inventory failures and missing-agent states remain on the -pinned destination with their own recovery; they never fall through to setup. -Only a successful account read with no saved Bestie shows Riley's existing -Builderlab setup. A failed read remains on the Bestie page with Retry. Setup -creates a hosted agent, gives it the instructions in `prompt.ts`, creates its -private Solo home and sends the introduction. - -Builderlab setup does **not** yet write that agent to `main-agent` or hand off to -the canonical DM. Connecting hosted setup to the shared selection owner is a -separate follow-up; until then, setup and an explicitly account-saved Bestie are -parallel entry paths. - -### Changing hosted Bestie's instructions - -When `prompt.ts` differs from the instructions on a set-up hosted Bestie, the -setup page offers **Update Bestie's instructions**. **Bestie's instructions** on -that page shows the current text. +pinned destination with their own recovery; they never fall through to the +chooser. A successful account read with no saved Bestie shows **Choose your +bestie**, where the owner can select an eligible existing agent or open the +shared **New agent** dialog. Choosing or creating an agent saves it through +`main-agent`, then hands off to the canonical DM. + +Builderlab no longer owns a separate Bestie setup, agent, Solo chat, or +selection path. diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx index c021f673bc..88126578e9 100644 --- a/src/bundled/bestie/index.test.tsx +++ b/src/bundled/bestie/index.test.tsx @@ -1,15 +1,11 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { composerDOMFixture } from "../../features/messages/composer-testing"; -import type { ConversationExtensions } from "../../features/conversation/contracts"; -import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; -import type { Navigation } from "../../features/navigation/controller"; -import { BestiePage } from "./index"; import { deferred } from "../builderlab/test-helpers"; -import { bestieFixture, cleanups, HOME } from "./testing"; +import { bestieFixture, cleanups } from "./testing"; const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); vi.mock("@tauri-apps/api/core", () => native); @@ -21,20 +17,9 @@ vi.stubGlobal( disconnect() {} }, ); -HTMLElement.prototype.scrollIntoView = vi.fn(); beforeEach(() => { vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); - Object.defineProperty(navigator, "platform", { - configurable: true, - value: "MacIntel", - }); - let next = 0; - const randomUUID = crypto.randomUUID.bind(crypto); - vi.spyOn(crypto, "randomUUID").mockImplementation(() => - next++ === 0 ? HOME : randomUUID(), - ); - vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(async () => { cleanup(); @@ -43,29 +28,6 @@ afterEach(async () => { native.invoke.mockReset(); vi.restoreAllMocks(); vi.unstubAllEnvs(); - Reflect.deleteProperty(navigator, "platform"); -}); - -it("keeps Bestie's page with a route to plugin settings while Builderlab is off", async () => { - const open = vi.fn(async () => ({ status: "opened" })); - render( - , - ); - expect(screen.getByRole("status")).toHaveTextContent( - "Turn on the Builderlab plugin", - ); - await userEvent.click( - screen.getByRole("button", { name: "Plugin settings" }), - ); - expect(open).toHaveBeenCalledWith({ - version: 1, - kind: "settings", - section: "plugins", - }); }); const renderPage = (h: Awaited>) => { @@ -73,16 +35,8 @@ const renderPage = (h: Awaited>) => { if (!Page) throw new Error("Missing Bestie page"); return render(); }; -const chooseModel = async () => { - const user = userEvent.setup(); - await user.click(await screen.findByRole("combobox", { name: "Model" })); - await user.click(await screen.findByRole("option", { name: "Model A" })); - await user.click(screen.getByRole("combobox", { name: "Reasoning effort" })); - await user.click(await screen.findByRole("option", { name: "high" })); -}; -const composer = () => screen.getByRole("textbox", { name: "Message #Bestie" }); -it("waits for the account choice before deciding whether to show setup", async () => { +it("waits for the account choice before showing the chooser", async () => { const choice = deferred(); const h = await bestieFixture(native.invoke, { mainAgent: { @@ -95,229 +49,37 @@ it("waits for the account choice before deciding whether to show setup", async ( "Loading your Bestie…", ); expect( - screen.queryByRole("button", { name: "Set up Bestie" }), + screen.queryByRole("button", { name: "Choose your bestie" }), ).not.toBeInTheDocument(); await act(async () => choice.resolve(undefined)); expect( - await screen.findByRole("button", { name: "Set up Bestie" }), - ).toBeVisible(); -}); - -it("keeps setup hidden when the account choice read fails and retries it", async () => { - const first = deferred(); - const second = deferred(); - let reads = 0; - const h = await bestieFixture(native.invoke, { - mainAgent: { - read: () => (reads++ ? second.promise : first.promise), - write: async () => {}, - }, - }); - await h.bestie.setup(); - renderPage(h); - await act(async () => first.reject(new Error("Account temporarily offline"))); - expect(await screen.findByRole("alert")).toHaveTextContent( - "Account temporarily offline", - ); - expect( - screen.queryByRole("button", { name: "Set up Bestie" }), - ).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Try again" })); - expect(await screen.findByRole("status")).toHaveTextContent( - "Loading your Bestie…", - ); - await act(async () => second.resolve(h.agent)); - expect( - await screen.findByText("This connection cannot start direct messages."), + await screen.findByRole("button", { name: "Choose your bestie" }), ).toBeVisible(); - expect( - screen.queryByRole("button", { name: "Set up Bestie" }), - ).not.toBeInTheDocument(); }); -it("keeps a replacement connecting session out of hosted setup", async () => { +it("replaces hosted setup with the manual Bestie chooser", async () => { const h = await bestieFixture(native.invoke); renderPage(h); - act(() => h.replaceWithConnectingSession()); - expect(await screen.findByRole("status")).toHaveTextContent( - "Connecting to your Bestie…", - ); expect( - screen.queryByRole("button", { name: "Set up Bestie" }), - ).not.toBeInTheDocument(); -}); - -it("routes an account-saved Bestie to its canonical DM instead of hosted setup", async () => { - const choice = deferred(); - const h = await bestieFixture(native.invoke, { - mainAgent: { - read: () => choice.promise, - write: async () => {}, - }, - }); - await h.bestie.setup(); - renderPage(h); - await act(async () => choice.resolve(h.agent)); + await screen.findByRole("heading", { name: "Choose your bestie" }), + ).toBeVisible(); expect( - await screen.findByText("This connection cannot start direct messages."), + screen.getByText( + "Select or create an agent to be your bestie in Buzz. They’ll get special placement and extra capabilities throughout the app.", + ), ).toBeVisible(); expect( screen.queryByRole("button", { name: "Set up Bestie" }), ).not.toBeInTheDocument(); }); -it("sets up Bestie on the model the owner chose and hands focus to its conversation", async () => { +it("opens the existing new-agent dialog from the chooser", async () => { const h = await bestieFixture(native.invoke); renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - // Builderlab offers no default model, so nothing is made before a choice. - const proceed = await screen.findByRole("button", { - name: "Continue setup", - }); - expect(proceed).toBeDisabled(); - expect(h.requests("register-agent")).toHaveLength(0); - await chooseModel(); - await userEvent.click(proceed); - expect( - await screen.findByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await waitFor(() => expect(composer()).toHaveFocus()); - expect(h.open).not.toHaveBeenCalled(); - expect(h.requests("register-agent")).toHaveLength(1); - expect(h.rows[0]).toMatchObject({ - model_id: "model-a", - reasoning_effort: "high", - }); -}); - -it("leaves focus where the owner moved it while setup ran", async () => { - const h = await bestieFixture(native.invoke); - render(); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - await chooseModel(); - const gate = h.holdRequest("register-agent"); - await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); - screen.getByRole("button", { name: "Elsewhere" }).focus(); - gate.resolve(); - expect( - await screen.findByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await waitFor(() => expect(composer()).toBeEnabled()); - expect(screen.getByRole("button", { name: "Elsewhere" })).toHaveFocus(); -}); - -it("opens the conversation with focus after a failed home read is retried", async () => { - const h = await bestieFixture(native.invoke); - await h.bestie.setup(); - const stranger = "56".repeat(32); - h.addMember(stranger); - // The page's own setup reads the record when its agents change. - await h.agents.refresh(); - renderPage(h); - expect( - await screen.findByText(/must be private and contain only you and Bestie/), - ).toBeInTheDocument(); - h.removeMember(stranger); - await userEvent.click( - screen.getByRole("button", { name: "Open Bestie conversation" }), - ); - expect( - await screen.findByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await waitFor(() => expect(composer()).toHaveFocus()); -}); - -it("keeps a retry on the page after updating Bestie's instructions fails", async () => { - const h = await bestieFixture(native.invoke); - await h.bestie.setup(); - // As if prompt.ts changed after this Bestie was set up. - const key = Object.keys(localStorage).find((name) => - name.startsWith("buzz.remote-bestie.v2:"), - ); - if (!key) throw new Error("Missing Bestie record"); - localStorage.setItem( - key, - JSON.stringify({ - ...JSON.parse(localStorage.getItem(key) ?? "{}"), - promptVersion: "remote-bestie-1", - }), - ); - // The page's own setup reads the record when its agents change. - await h.agents.refresh(); - renderPage(h); - h.reply("update-agent", 500, {}); - await userEvent.click( - await screen.findByRole("button", { name: "Update Bestie's instructions" }), - ); - // The failure stays beside the conversation with its retry. - await screen.findByRole("button", { - name: "Retry updating Bestie's instructions", - }); - expect( - screen.getByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await userEvent.click( - screen.getByRole("button", { - name: "Retry updating Bestie's instructions", - }), - ); - expect( - await screen.findByText(/ask your Builderlab workspace admin/), - ).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /Bestie's instructions/ }), - ).not.toBeInTheDocument(); -}); - -it("focuses the Model field when setup asks for a model", async () => { - const h = await bestieFixture(native.invoke); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - const model = await screen.findByRole("combobox", { name: "Model" }); - await waitFor(() => expect(model).toHaveFocus()); -}); - -it("keeps focus on the setup button when submitting the model fails", async () => { - const h = await bestieFixture(native.invoke); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - await chooseModel(); - const gate = h.holdRequest("register-agent"); - h.reply("register-agent", 500, {}); - await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); - // The chooser is gone while the submission runs, but focus has a home. - const busy = await screen.findByRole("button", { name: "Set up Bestie" }); - await waitFor(() => expect(busy).toHaveFocus()); - gate.resolve(); - const retry = await screen.findByRole("button", { - name: "Retry Bestie setup", - }); - expect(retry).toHaveFocus(); -}); - -it("does not pull focus back from where the owner moved it during a failed model submission", async () => { - const h = await bestieFixture(native.invoke); - render(); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), + const user = userEvent.setup(); + await user.click( + await screen.findByRole("button", { name: "Choose your bestie" }), ); - await chooseModel(); - const gate = h.holdRequest("register-agent"); - h.reply("register-agent", 500, {}); - await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); - await screen.findByRole("button", { name: "Set up Bestie" }); - screen.getByRole("button", { name: "Elsewhere" }).focus(); - gate.resolve(); - await screen.findByRole("button", { name: "Retry Bestie setup" }); - expect(screen.getByRole("button", { name: "Elsewhere" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "New agent" })); + expect(screen.getByRole("dialog", { name: "Create agent" })).toBeVisible(); }); diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index c73ab6531e..03fa284e62 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -1,38 +1,28 @@ -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; -import { BestieIcon } from "../../shared/design-system/icons"; +import { useState, type ReactNode } from "react"; import type { PluginModule } from "../../plugins/api"; import type { Navigation } from "../../features/navigation/controller"; import type { PageNavigation } from "../../features/navigation/service"; +import { useAgentChoices } from "../../features/agents/use-choices"; +import { useAgents } from "../../features/agents/react"; +import { + chooseDefaultAgent, + useSavedAgentState, +} from "../../features/solo/default-agent"; +import type { ConversationExtensions } from "../../features/conversation/contracts"; +import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; +import type { Agents } from "../../features/agents/service"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { useRelayConnection } from "../../features/relay/react"; +import { BestieIcon } from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; import { FullPageSurface } from "../../shared/design-system/ui/FullPageSurface"; import { PanelHeader, PanelHeaderLabel, } from "../../shared/design-system/ui/PanelHeader"; -import { Button } from "../../shared/design-system/ui/Button"; import { Select } from "../../shared/design-system/ui/Select"; -import type { ConversationExtensions } from "../../features/conversation/contracts"; -import type {} from "../builderlab/service"; -import { ChannelPreview } from "../inbox/ChannelPreview"; -import { ConversationChannel } from "../../features/conversation/Conversation"; -import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; -import type { ChannelSummary } from "../../features/relay/contracts"; -import { bestieInstructions } from "./prompt"; -import { - createRemoteBestie, - type ModelChoice, - type RemoteBestie, -} from "./setup"; -import type { RemoteAgentModel } from "../builderlab/agents/client"; -import { useRelayConnection } from "../../features/relay/react"; -import { useSavedAgentState } from "../../features/solo/default-agent"; -import type { RelayData } from "../../features/relay/service"; +import { AgentCreateDialog } from "../agents/AgentCreateDialog"; import { PinnedBestie } from "./PinnedBestie"; import { bestieRoute } from "./routes"; @@ -45,56 +35,22 @@ export const inject = [ "panels", ]; export const apply: PluginModule["apply"] = (ctx) => { - let bestie: RemoteBestie | undefined; - const listeners = new Set<() => void>(); - const notify = () => { - for (const listener of listeners) listener(); - }; - const source = { - snapshot: () => bestie, - subscribe(listener: () => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; - // Keep the page available while Builderlab is off or unconfigured. Cordis - // owns the consumer's lifetime as the hosted type appears or disappears. - ctx.inject(["builderlab"], (child) => { - const current = createRemoteBestie(child.builderlab, ctx.agents, ctx.relay); - bestie = current; - notify(); - child.effect(() => () => { - current.dispose(); - bestie = undefined; - notify(); - }); - }); - ctx.effect(() => () => listeners.clear()); ctx.pages.register({ id: "bestie", title: "Bestie", layout: "workspace", primary: true, route: { version: 1, validate: (params) => !!bestieRoute(params) }, - component: ({ navigation: pageNavigation }) => { - const current = useSyncExternalStore( - source.subscribe, - source.snapshot, - source.snapshot, - ); - return ( - - ); - }, + component: ({ navigation: pageNavigation }) => ( + + ), }); }; @@ -122,11 +78,11 @@ function BestieDestination({ relay, ...props }: { - bestie?: RemoteBestie | undefined; relay: RelayData; navigation: Navigation; extensions: ConversationExtensions; host: ConversationHostServices; + agents: Agents; pageNavigation?: PageNavigation | undefined; }) { const connection = useRelayConnection(relay); @@ -170,389 +126,153 @@ function BestieDestination({ navigation={props.pageNavigation} /> ) : ( - + ); } -export function BestiePage({ - bestie, - navigation, - extensions, - host, +function ChooseBestie({ + session, + scope, + community, + ...props }: { - bestie?: RemoteBestie | undefined; - navigation: Navigation; - extensions: ConversationExtensions; + session: RelaySession; + scope: string; + community?: string | undefined; host: ConversationHostServices; + agents: Agents; }) { + const choices = useAgentChoices(session, true, true); + const { types } = useAgents(props.agents); + const [selecting, setSelecting] = useState(false); + const [creating, setCreating] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const choose = async (pubkey: string) => { + if ( + !session.agentChoices + .snapshot() + .selectable.some((agent) => agent.pubkey === pubkey) + ) { + setError("That agent is no longer available. Choose another."); + return; + } + setSaving(true); + setError(""); + try { + await chooseDefaultAgent(session, scope, pubkey); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSaving(false); + } + }; return ( -
- -
- } - /> + +

Choose your bestie

+

+ Select or create an agent to be your bestie in Buzz. They’ll get special + placement and extra capabilities throughout the app. +

+ {selecting ? ( +
+ ({ - value: choice.id, - label: choice.displayName, - })), - }, - ]} - onValueChange={(value) => { - setModelId(value); - setReasoningEffort(""); - }} - /> - + +
+ } + /> } - disabled={saving || choices.pending || !choices.selectable.length} - groups={[ - { - label: "Your agents", - options: choices.selectable.map((agent) => ({ - value: agent.pubkey, - label: agent.managedName ?? agent.name, - })), - }, - ]} - onValueChange={(pubkey) => void choose(pubkey)} /> - - {choices.error && ( - <> -

- {choices.error} + {bestie ? ( + + ) : ( + +

+ Bestie runs on Builderlab. Turn on the Builderlab plugin in the + Buzz desktop app to set it up.

- - )} - {error && ( -

- {error} -

+ )}
- ) : ( - - )} - {creating && ( - setCreating(false)} - onCreated={(agent) => { - setCreating(false); - void session.agentChoices - .refresh(true, { fresh: true }) - .then(() => choose(agent.pubkey)) - .catch((reason) => - setError( - reason instanceof Error ? reason.message : String(reason), - ), - ); - }} - /> - )} - +
+
); } +/** Keyboard focus is free to move when nothing holds it, as when the control + * that had it unmounts, or when it sits inside the setup surface. Focus the + * owner moved elsewhere is theirs and stays put. */ +const focusIsFree = () => + typeof document !== "undefined" && + (!document.activeElement || + document.activeElement === document.body || + !!document.activeElement.closest("[data-bestie-intro]")); + function Intro({ children }: { children: ReactNode }) { return (
+

Meet your Bestie

{children}
); } + +function Setup({ + bestie, + extensions, + host, +}: { + bestie: RemoteBestie; + extensions: ConversationExtensions; + host: ConversationHostServices; +}) { + const state = useSyncExternalStore( + bestie.subscribe, + bestie.snapshot, + bestie.snapshot, + ); + const { record, connection } = state; + const [home, setHome] = + useState>(); + const [homeError, setHomeError] = useState(""); + const lifetime = useRef(undefined); + // A setup action the owner started hands focus to the conversation it + // opens, unless they moved focus elsewhere while it ran. + const handoff = useRef(false); + const act = (action: () => unknown) => { + handoff.current = true; + void action(); + }; + useEffect(() => { + const controller = new AbortController(); + lifetime.current = controller; + return () => controller.abort(); + }, []); + const complete = + !!record?.complete && + !!record.agent && + ["ready", "idle", "error", "busy"].includes(state.status); + // Show the conversation here only after its roster is still just you and Bestie. + const checkHome = useCallback(async () => { + const controller = lifetime.current; + const agent = record?.agent; + if ( + !record?.complete || + !agent || + !connection || + !controller || + controller.signal.aborted + ) + return; + const active = () => + !controller.signal.aborted && + bestie.snapshot().connection === connection && + bestie.snapshot().record === record; + try { + const details = await connection.session.channelDetails.load( + record.channelId, + controller.signal, + ); + if (!active()) return; + const channel = await connection.session.channelWrites.refreshMembership( + record.channelId, + ); + if (!active()) return; + if ( + details.visibility !== "private" || + channel.channelType !== "solo" || + channel.archived || + !channel.members?.includes(record.owner) || + !channel.members.includes(agent) || + channel.members.some( + (member) => member !== record.owner && member !== agent, + ) + ) + throw new Error( + "Bestie's channel must be private and contain only you and Bestie. Review its members before continuing.", + ); + setHomeError(""); + setHome( + connection.session.channels + .list() + .channels.find((row) => row.id === record.channelId) ?? { + id: record.channelId, + name: "Bestie", + channelType: "solo", + }, + ); + } catch (error) { + if (!active()) return; + setHome(undefined); + setHomeError( + error instanceof Error + ? error.message + : "Could not open Bestie's channel. Try again.", + ); + } + }, [bestie, connection, record]); + useEffect(() => { + if (complete) void checkHome(); + }, [checkHome, complete]); + const open = complete && connection && home?.id === record.channelId; + // Read while the finished action may still hold focus, before it unmounts. + const focusConversation = !!open && handoff.current && focusIsFree(); + useEffect(() => { + if (open) handoff.current = false; + }, [open]); + // Moving between setup steps unmounts the control that had focus. The setup + // button stays mounted through busy and error, so it keeps focus from there. + // biome-ignore lint/correctness/useExhaustiveDependencies: Each setup step change is the trigger. + useEffect(() => { + if (!handoff.current || !focusIsFree()) return; + document + .querySelector("[data-bestie-intro] [data-bestie-focus]") + ?.focus(); + }, [state.status]); + if (open) + return ( + <> +
+

+ {state.status === "error" || state.status === "busy" + ? state.message + : state.instructionsChanged + ? "Bestie's instructions have changed since setup." + : "If Bestie doesn't reply, ask your Builderlab workspace admin to allow this community to use inference."} +

+ {(state.instructionsChanged || state.status === "busy") && ( + + )} +
+ Bestie's instructions +
+              {bestieInstructions(record.owner, record.channelId)}
+            
+
+
+ + + + + ); + return ( + +

+ {state.message} +

+ {complete ? ( + <> + {homeError && ( +

+ {homeError} +

+ )} + + + ) : state.status === "unavailable" ? null : ( + <> +

+ Setup signs you in to Builderlab, asks which model Bestie runs on, + creates or reuses your hosted agent named Bestie, replaces its + instructions, creates a private channel and invites it. If you + already customized Bestie's instructions, save a copy before + continuing. +

+
+ {state.status === "choose-model" ? ( + act(() => bestie.chooseModel(model))} + /> + ) : state.status === "choose-agent" ? ( + state.candidates?.map((agent) => ( + + )) + ) : ( + <> + + {state.status === "error" && state.canCreateNew && ( + + )} + + )} +
+ + )} +
+ ); +} + +/** Builderlab runs an agent only on a model and reasoning effort its owner + * chose from the catalog; nothing is preselected. */ +function ModelChooser({ + models, + onChoose, +}: { + models: readonly RemoteAgentModel[]; + onChoose: (model: ModelChoice) => void; +}) { + const [modelId, setModelId] = useState(""); + const [reasoningEffort, setReasoningEffort] = useState(""); + const model = models.find((choice) => choice.id === modelId); + const supported = + !!model?.supportedReasoningEfforts.includes(reasoningEffort); + const chooser = useRef(null); + // The previous step's button unmounted, so the first field takes focus. + useEffect(() => { + if (focusIsFree()) + chooser.current?.querySelector("[role=combobox]")?.focus(); + }, []); + return ( +
+ ({ + value, + label: value, + })), + }, + ]} + onValueChange={setReasoningEffort} + /> +
+ +
+
+ ); +} From eb45012e520d95eff74a538552e4048a53c7fb59 Mon Sep 17 00:00:00 2001 From: Riley Crane Date: Fri, 30 Oct 2026 15:05:59 -0700 Subject: [PATCH 17/21] feat(bestie): choose an existing agent or create one Ports Clay's Choose your bestie from #1935's pre-revert head f78963029: with no saved Bestie, the Bestie page offers the viewer's eligible agents and the shared New agent dialog in place of Builderlab's hosted setup. The plugin keeps registering the stack's Bestie tabs, check-in cleanup and onBestieMade alongside the page. Co-authored-by: Clay Delk Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Riley Crane --- src/app/pages.integration.test.mjs | 2 +- src/bundled/bestie/README.md | 26 +- src/bundled/bestie/index.test.tsx | 272 +------------- src/bundled/bestie/index.tsx | 576 ++++++++--------------------- 4 files changed, 175 insertions(+), 701 deletions(-) diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index 98fd08707f..73d616df24 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -322,7 +322,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", assert.equal(bestiePage.primary, true); assert.match( renderToStaticMarkup(createElement(bestiePage.component)), - /Meet your Bestie/, + /Choose a community to continue with your Bestie\./, ); await services.plugins.change("disable", "buzz.bestie"); assert.equal( diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 3cb7fdd535..5f85521154 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -85,23 +85,15 @@ saw it as Bestie, while it is in one of your private channels. The tab has the same switches, **Remove from all** for its channels, and **Make my Bestie again**. -### Hosted setup fallback +### Choosing a Bestie The account choice is read before the page decides what to show. With a saved Bestie, reconnecting, inventory failures and missing-agent states remain on the -pinned destination with their own recovery; they never fall through to setup. -Only a successful account read with no saved Bestie shows Riley's existing -Builderlab setup. A failed read remains on the Bestie page with Retry. Setup -creates a hosted agent, gives it the instructions in `prompt.ts`, creates its -private Solo home and sends the introduction. - -Builderlab setup does **not** yet write that agent to `main-agent` or hand off to -the canonical DM. Connecting hosted setup to the shared selection owner is a -separate follow-up; until then, setup and an explicitly account-saved Bestie are -parallel entry paths. - -### Changing hosted Bestie's instructions - -When `prompt.ts` differs from the instructions on a set-up hosted Bestie, the -setup page offers **Update Bestie's instructions**. **Bestie's instructions** on -that page shows the current text. +pinned destination with their own recovery; they never fall through to the +chooser. A successful account read with no saved Bestie shows **Choose your +bestie**, where the owner can select an eligible existing agent or open the +shared **New agent** dialog. Choosing or creating an agent saves it through +`main-agent`, then hands off to the canonical DM. + +Builderlab no longer owns a separate Bestie setup, agent, Solo chat, or +selection path. diff --git a/src/bundled/bestie/index.test.tsx b/src/bundled/bestie/index.test.tsx index c021f673bc..88126578e9 100644 --- a/src/bundled/bestie/index.test.tsx +++ b/src/bundled/bestie/index.test.tsx @@ -1,15 +1,11 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, cleanup, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { composerDOMFixture } from "../../features/messages/composer-testing"; -import type { ConversationExtensions } from "../../features/conversation/contracts"; -import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; -import type { Navigation } from "../../features/navigation/controller"; -import { BestiePage } from "./index"; import { deferred } from "../builderlab/test-helpers"; -import { bestieFixture, cleanups, HOME } from "./testing"; +import { bestieFixture, cleanups } from "./testing"; const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() })); vi.mock("@tauri-apps/api/core", () => native); @@ -21,20 +17,9 @@ vi.stubGlobal( disconnect() {} }, ); -HTMLElement.prototype.scrollIntoView = vi.fn(); beforeEach(() => { vi.stubEnv("VITE_BUZZ_BUILDERLAB_URL", "https://builderlab.example"); - Object.defineProperty(navigator, "platform", { - configurable: true, - value: "MacIntel", - }); - let next = 0; - const randomUUID = crypto.randomUUID.bind(crypto); - vi.spyOn(crypto, "randomUUID").mockImplementation(() => - next++ === 0 ? HOME : randomUUID(), - ); - vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(async () => { cleanup(); @@ -43,29 +28,6 @@ afterEach(async () => { native.invoke.mockReset(); vi.restoreAllMocks(); vi.unstubAllEnvs(); - Reflect.deleteProperty(navigator, "platform"); -}); - -it("keeps Bestie's page with a route to plugin settings while Builderlab is off", async () => { - const open = vi.fn(async () => ({ status: "opened" })); - render( - , - ); - expect(screen.getByRole("status")).toHaveTextContent( - "Turn on the Builderlab plugin", - ); - await userEvent.click( - screen.getByRole("button", { name: "Plugin settings" }), - ); - expect(open).toHaveBeenCalledWith({ - version: 1, - kind: "settings", - section: "plugins", - }); }); const renderPage = (h: Awaited>) => { @@ -73,16 +35,8 @@ const renderPage = (h: Awaited>) => { if (!Page) throw new Error("Missing Bestie page"); return render(); }; -const chooseModel = async () => { - const user = userEvent.setup(); - await user.click(await screen.findByRole("combobox", { name: "Model" })); - await user.click(await screen.findByRole("option", { name: "Model A" })); - await user.click(screen.getByRole("combobox", { name: "Reasoning effort" })); - await user.click(await screen.findByRole("option", { name: "high" })); -}; -const composer = () => screen.getByRole("textbox", { name: "Message #Bestie" }); -it("waits for the account choice before deciding whether to show setup", async () => { +it("waits for the account choice before showing the chooser", async () => { const choice = deferred(); const h = await bestieFixture(native.invoke, { mainAgent: { @@ -95,229 +49,37 @@ it("waits for the account choice before deciding whether to show setup", async ( "Loading your Bestie…", ); expect( - screen.queryByRole("button", { name: "Set up Bestie" }), + screen.queryByRole("button", { name: "Choose your bestie" }), ).not.toBeInTheDocument(); await act(async () => choice.resolve(undefined)); expect( - await screen.findByRole("button", { name: "Set up Bestie" }), - ).toBeVisible(); -}); - -it("keeps setup hidden when the account choice read fails and retries it", async () => { - const first = deferred(); - const second = deferred(); - let reads = 0; - const h = await bestieFixture(native.invoke, { - mainAgent: { - read: () => (reads++ ? second.promise : first.promise), - write: async () => {}, - }, - }); - await h.bestie.setup(); - renderPage(h); - await act(async () => first.reject(new Error("Account temporarily offline"))); - expect(await screen.findByRole("alert")).toHaveTextContent( - "Account temporarily offline", - ); - expect( - screen.queryByRole("button", { name: "Set up Bestie" }), - ).not.toBeInTheDocument(); - await userEvent.click(screen.getByRole("button", { name: "Try again" })); - expect(await screen.findByRole("status")).toHaveTextContent( - "Loading your Bestie…", - ); - await act(async () => second.resolve(h.agent)); - expect( - await screen.findByText("This connection cannot start direct messages."), + await screen.findByRole("button", { name: "Choose your bestie" }), ).toBeVisible(); - expect( - screen.queryByRole("button", { name: "Set up Bestie" }), - ).not.toBeInTheDocument(); }); -it("keeps a replacement connecting session out of hosted setup", async () => { +it("replaces hosted setup with the manual Bestie chooser", async () => { const h = await bestieFixture(native.invoke); renderPage(h); - act(() => h.replaceWithConnectingSession()); - expect(await screen.findByRole("status")).toHaveTextContent( - "Connecting to your Bestie…", - ); expect( - screen.queryByRole("button", { name: "Set up Bestie" }), - ).not.toBeInTheDocument(); -}); - -it("routes an account-saved Bestie to its canonical DM instead of hosted setup", async () => { - const choice = deferred(); - const h = await bestieFixture(native.invoke, { - mainAgent: { - read: () => choice.promise, - write: async () => {}, - }, - }); - await h.bestie.setup(); - renderPage(h); - await act(async () => choice.resolve(h.agent)); + await screen.findByRole("heading", { name: "Choose your bestie" }), + ).toBeVisible(); expect( - await screen.findByText("This connection cannot start direct messages."), + screen.getByText( + "Select or create an agent to be your bestie in Buzz. They’ll get special placement and extra capabilities throughout the app.", + ), ).toBeVisible(); expect( screen.queryByRole("button", { name: "Set up Bestie" }), ).not.toBeInTheDocument(); }); -it("sets up Bestie on the model the owner chose and hands focus to its conversation", async () => { +it("opens the existing new-agent dialog from the chooser", async () => { const h = await bestieFixture(native.invoke); renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - // Builderlab offers no default model, so nothing is made before a choice. - const proceed = await screen.findByRole("button", { - name: "Continue setup", - }); - expect(proceed).toBeDisabled(); - expect(h.requests("register-agent")).toHaveLength(0); - await chooseModel(); - await userEvent.click(proceed); - expect( - await screen.findByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await waitFor(() => expect(composer()).toHaveFocus()); - expect(h.open).not.toHaveBeenCalled(); - expect(h.requests("register-agent")).toHaveLength(1); - expect(h.rows[0]).toMatchObject({ - model_id: "model-a", - reasoning_effort: "high", - }); -}); - -it("leaves focus where the owner moved it while setup ran", async () => { - const h = await bestieFixture(native.invoke); - render(); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - await chooseModel(); - const gate = h.holdRequest("register-agent"); - await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); - screen.getByRole("button", { name: "Elsewhere" }).focus(); - gate.resolve(); - expect( - await screen.findByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await waitFor(() => expect(composer()).toBeEnabled()); - expect(screen.getByRole("button", { name: "Elsewhere" })).toHaveFocus(); -}); - -it("opens the conversation with focus after a failed home read is retried", async () => { - const h = await bestieFixture(native.invoke); - await h.bestie.setup(); - const stranger = "56".repeat(32); - h.addMember(stranger); - // The page's own setup reads the record when its agents change. - await h.agents.refresh(); - renderPage(h); - expect( - await screen.findByText(/must be private and contain only you and Bestie/), - ).toBeInTheDocument(); - h.removeMember(stranger); - await userEvent.click( - screen.getByRole("button", { name: "Open Bestie conversation" }), - ); - expect( - await screen.findByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await waitFor(() => expect(composer()).toHaveFocus()); -}); - -it("keeps a retry on the page after updating Bestie's instructions fails", async () => { - const h = await bestieFixture(native.invoke); - await h.bestie.setup(); - // As if prompt.ts changed after this Bestie was set up. - const key = Object.keys(localStorage).find((name) => - name.startsWith("buzz.remote-bestie.v2:"), - ); - if (!key) throw new Error("Missing Bestie record"); - localStorage.setItem( - key, - JSON.stringify({ - ...JSON.parse(localStorage.getItem(key) ?? "{}"), - promptVersion: "remote-bestie-1", - }), - ); - // The page's own setup reads the record when its agents change. - await h.agents.refresh(); - renderPage(h); - h.reply("update-agent", 500, {}); - await userEvent.click( - await screen.findByRole("button", { name: "Update Bestie's instructions" }), - ); - // The failure stays beside the conversation with its retry. - await screen.findByRole("button", { - name: "Retry updating Bestie's instructions", - }); - expect( - screen.getByRole("region", { name: "Bestie conversation" }), - ).toBeInTheDocument(); - await userEvent.click( - screen.getByRole("button", { - name: "Retry updating Bestie's instructions", - }), - ); - expect( - await screen.findByText(/ask your Builderlab workspace admin/), - ).toBeInTheDocument(); - expect( - screen.queryByRole("button", { name: /Bestie's instructions/ }), - ).not.toBeInTheDocument(); -}); - -it("focuses the Model field when setup asks for a model", async () => { - const h = await bestieFixture(native.invoke); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - const model = await screen.findByRole("combobox", { name: "Model" }); - await waitFor(() => expect(model).toHaveFocus()); -}); - -it("keeps focus on the setup button when submitting the model fails", async () => { - const h = await bestieFixture(native.invoke); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), - ); - await chooseModel(); - const gate = h.holdRequest("register-agent"); - h.reply("register-agent", 500, {}); - await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); - // The chooser is gone while the submission runs, but focus has a home. - const busy = await screen.findByRole("button", { name: "Set up Bestie" }); - await waitFor(() => expect(busy).toHaveFocus()); - gate.resolve(); - const retry = await screen.findByRole("button", { - name: "Retry Bestie setup", - }); - expect(retry).toHaveFocus(); -}); - -it("does not pull focus back from where the owner moved it during a failed model submission", async () => { - const h = await bestieFixture(native.invoke); - render(); - renderPage(h); - await userEvent.click( - await screen.findByRole("button", { name: "Set up Bestie" }), + const user = userEvent.setup(); + await user.click( + await screen.findByRole("button", { name: "Choose your bestie" }), ); - await chooseModel(); - const gate = h.holdRequest("register-agent"); - h.reply("register-agent", 500, {}); - await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); - await screen.findByRole("button", { name: "Set up Bestie" }); - screen.getByRole("button", { name: "Elsewhere" }).focus(); - gate.resolve(); - await screen.findByRole("button", { name: "Retry Bestie setup" }); - expect(screen.getByRole("button", { name: "Elsewhere" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "New agent" })); + expect(screen.getByRole("dialog", { name: "Create agent" })).toBeVisible(); }); diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index ede5262b9f..698f7b45e1 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -1,41 +1,31 @@ -import { - useCallback, - useEffect, - useRef, - useState, - useSyncExternalStore, - type ReactNode, -} from "react"; -import { BestieIcon } from "../../shared/design-system/icons"; +import { useState, type ReactNode } from "react"; import type { PluginModule } from "../../plugins/api"; import type { Navigation } from "../../features/navigation/controller"; import type { PageNavigation } from "../../features/navigation/service"; +import { useAgentChoices } from "../../features/agents/use-choices"; +import { useAgents } from "../../features/agents/react"; +import { + chooseDefaultAgent, + useSavedAgentState, +} from "../../features/solo/default-agent"; +import type { ConversationExtensions } from "../../features/conversation/contracts"; +import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; +import type { Agents } from "../../features/agents/service"; +import type { RelayData } from "../../features/relay/service"; +import type { RelaySession } from "../../features/relay/session"; +import { useRelayConnection } from "../../features/relay/react"; +import { BestieIcon } from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; import { FullPageSurface } from "../../shared/design-system/ui/FullPageSurface"; import { PanelHeader, PanelHeaderLabel, } from "../../shared/design-system/ui/PanelHeader"; -import { Button } from "../../shared/design-system/ui/Button"; import { Select } from "../../shared/design-system/ui/Select"; -import type { ConversationExtensions } from "../../features/conversation/contracts"; -import type {} from "../builderlab/service"; -import { ChannelPreview } from "../inbox/ChannelPreview"; -import { ConversationChannel } from "../../features/conversation/Conversation"; -import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; -import type { ChannelSummary } from "../../features/relay/contracts"; -import { bestieInstructions } from "./prompt"; -import { - createRemoteBestie, - type ModelChoice, - type RemoteBestie, -} from "./setup"; -import type { RemoteAgentModel } from "../builderlab/agents/client"; +import { AgentCreateDialog } from "../agents/AgentCreateDialog"; import { onBestieMade } from "../agents/BestieChoice"; import { bestieTab, formerBestieTab } from "./BestieTab"; import { rememberNewBestie, stopFormerCheckIns } from "./former"; -import { useRelayConnection } from "../../features/relay/react"; -import { useSavedAgentState } from "../../features/solo/default-agent"; -import type { RelayData } from "../../features/relay/service"; import { PinnedBestie } from "./PinnedBestie"; import { bestieRoute } from "./routes"; @@ -48,33 +38,6 @@ export const inject = [ "panels", ]; export const apply: PluginModule["apply"] = (ctx) => { - let bestie: RemoteBestie | undefined; - const listeners = new Set<() => void>(); - const notify = () => { - for (const listener of listeners) listener(); - }; - const source = { - snapshot: () => bestie, - subscribe(listener: () => void) { - listeners.add(listener); - return () => { - listeners.delete(listener); - }; - }, - }; - // Keep the page available while Builderlab is off or unconfigured. Cordis - // owns the consumer's lifetime as the hosted type appears or disappears. - ctx.inject(["builderlab"], (child) => { - const current = createRemoteBestie(child.builderlab, ctx.agents, ctx.relay); - bestie = current; - notify(); - child.effect(() => () => { - current.dispose(); - bestie = undefined; - notify(); - }); - }); - ctx.effect(() => () => listeners.clear()); ctx.agents.registerTab(bestieTab(ctx.relay, ctx.agents)); ctx.agents.registerTab(formerBestieTab(ctx.relay, ctx.agents)); // Whoever is Bestie, on any device, only it checks in. @@ -94,23 +57,16 @@ export const apply: PluginModule["apply"] = (ctx) => { layout: "workspace", primary: true, route: { version: 1, validate: (params) => !!bestieRoute(params) }, - component: ({ navigation: pageNavigation }) => { - const current = useSyncExternalStore( - source.subscribe, - source.snapshot, - source.snapshot, - ); - return ( - - ); - }, + component: ({ navigation: pageNavigation }) => ( + + ), }); }; @@ -138,11 +94,11 @@ function BestieDestination({ relay, ...props }: { - bestie?: RemoteBestie | undefined; relay: RelayData; navigation: Navigation; extensions: ConversationExtensions; host: ConversationHostServices; + agents: Agents; pageNavigation?: PageNavigation | undefined; }) { const connection = useRelayConnection(relay); @@ -186,389 +142,153 @@ function BestieDestination({ navigation={props.pageNavigation} /> ) : ( - + ); } -export function BestiePage({ - bestie, - navigation, - extensions, - host, +function ChooseBestie({ + session, + scope, + community, + ...props }: { - bestie?: RemoteBestie | undefined; - navigation: Navigation; - extensions: ConversationExtensions; + session: RelaySession; + scope: string; + community?: string | undefined; host: ConversationHostServices; + agents: Agents; }) { + const choices = useAgentChoices(session, true, true); + const { types } = useAgents(props.agents); + const [selecting, setSelecting] = useState(false); + const [creating, setCreating] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const choose = async (pubkey: string) => { + if ( + !session.agentChoices + .snapshot() + .selectable.some((agent) => agent.pubkey === pubkey) + ) { + setError("That agent is no longer available. Choose another."); + return; + } + setSaving(true); + setError(""); + try { + await chooseDefaultAgent(session, scope, pubkey); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + } finally { + setSaving(false); + } + }; return ( -
- -
- } - /> + +

Choose your bestie

+

+ Select or create an agent to be your bestie in Buzz. They’ll get special + placement and extra capabilities throughout the app. +

+ {selecting ? ( +
+ ({ - value: choice.id, - label: choice.displayName, - })), - }, - ]} - onValueChange={(value) => { - setModelId(value); - setReasoningEffort(""); - }} - /> - ({ + value: agent.pubkey, + label: agent.managedName ?? agent.name, + })), + }, + ]} + onValueChange={(pubkey) => void choose(pubkey)} + /> +

{BESTIE_LOCAL_ONLY}

+ + {choices.error && ( + <> +

+ {choices.error} +

+ + + )} + {error && ( +

+ {error} +

+ )} +
+ ) : ( + + )} + {creating && ( + setCreating(false)} + onCreated={(agent) => { + setCreating(false); + void session.agentChoices + .refresh(true, { fresh: true }) + .then(() => choose(agent.pubkey)) + .catch((reason) => + setError( + reason instanceof Error ? reason.message : String(reason), + ), + ); + }} + /> + )} + + ); +} diff --git a/src/bundled/bestie/PinnedBestie.test.tsx b/src/bundled/bestie/PinnedBestie.test.tsx index 4473f3c1d2..ba41b23de7 100644 --- a/src/bundled/bestie/PinnedBestie.test.tsx +++ b/src/bundled/bestie/PinnedBestie.test.tsx @@ -30,6 +30,7 @@ import type { RelayEvent } from "../../features/relay/events"; import { setDefaultAgent } from "../../features/solo/default-agent"; import { readView, writeView } from "../../shared/view-state"; import { ToastProvider } from "../../shared/design-system/ui/Toast"; +import type { Agents } from "../../features/agents/service"; import { PinnedBestie } from "./PinnedBestie"; import { bestieRoute } from "./routes"; @@ -82,6 +83,15 @@ function deferred() { }); return { promise, resolve }; } +// No agent runs on this device in these tests; the chooser lists none. +const noAgents = { agents: [], running: [] }; +const noTypes: never[] = []; +const agents = { + snapshot: () => noAgents, + subscribe: () => () => {}, + types: () => noTypes, +} as unknown as Agents; + async function fixture({ failed = false, localFailed = false, @@ -303,6 +313,7 @@ async function fixture({ { ).toBeVisible(); expect(f.open).toHaveBeenCalledTimes(attempts); }); + +it("offers the chooser in place of a Bestie that is no longer here", async () => { + const f = await fixture(); + f.makePollenIneligible(); + f.choose(pollen); + f.mount(); + expect(await screen.findByText("Pollen is no longer here")).toBeVisible(); + expect( + screen.getByText("Choose another agent to be your bestie."), + ).toBeVisible(); + expect( + screen.getByRole("button", { name: "Choose your bestie" }), + ).toBeVisible(); +}); diff --git a/src/bundled/bestie/PinnedBestie.tsx b/src/bundled/bestie/PinnedBestie.tsx index fe348b39cf..0d17e21e1b 100644 --- a/src/bundled/bestie/PinnedBestie.tsx +++ b/src/bundled/bestie/PinnedBestie.tsx @@ -13,6 +13,7 @@ import type { PageNavigation } from "../../features/navigation/service"; import { targetKey } from "../../features/navigation/targets"; import { messageViewKey } from "../../features/messages/view-key"; import { useAgentChoices } from "../../features/agents/use-choices"; +import type { Agents } from "../../features/agents/service"; import { useChannelList, useRelayConnection } from "../../features/relay/react"; import type { ChannelSummary } from "../../features/relay/contracts"; import type { RelayData } from "../../features/relay/service"; @@ -29,6 +30,7 @@ import { PanelHeaderLabel, } from "../../shared/design-system/ui/PanelHeader"; import { bestieChat } from "./chat"; +import { BestieChooser } from "./ChooseBestie"; import { bestieBaseTarget, bestieRoute, bestieThreadTarget } from "./routes"; import styles from "./PinnedBestie.module.css"; @@ -36,12 +38,14 @@ import styles from "./PinnedBestie.module.css"; * the caller keeps Riley's hosted setup page. */ export function PinnedBestie({ relay, + agents, host, extensions, navigator, navigation, }: { relay: RelayData; + agents: Agents; host: ConversationHostServices; extensions: ConversationExtensions; navigator: Navigation; @@ -127,15 +131,21 @@ export function PinnedBestie({ : `${name} is no longer here` } description={ - choices.error ?? - "Choose another bestie from an eligible agent’s actions menu." + choices.error ?? "Choose another agent to be your bestie." } action={ choices.error ? ( - ) : undefined + ) : ( + + ) } /> ); diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 5f85521154..85860b4b3d 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -92,8 +92,11 @@ Bestie, reconnecting, inventory failures and missing-agent states remain on the pinned destination with their own recovery; they never fall through to the chooser. A successful account read with no saved Bestie shows **Choose your bestie**, where the owner can select an eligible existing agent or open the -shared **New agent** dialog. Choosing or creating an agent saves it through -`main-agent`, then hands off to the canonical DM. +shared **New agent** dialog. Only agents on this device are offered for now, +and the chooser says so. Choosing or creating an agent saves it through +`main-agent`, then hands off to the canonical DM; like **Make my bestie**, a +choice here starts the agent with Remembers you on. When the saved Bestie is +no longer here, the page shows the same chooser in its place. Builderlab no longer owns a separate Bestie setup, agent, Solo chat, or selection path. diff --git a/src/bundled/bestie/index.tsx b/src/bundled/bestie/index.tsx index 698f7b45e1..eb425f9fb7 100644 --- a/src/bundled/bestie/index.tsx +++ b/src/bundled/bestie/index.tsx @@ -1,18 +1,12 @@ -import { useState, type ReactNode } from "react"; +import type { ReactNode } from "react"; import type { PluginModule } from "../../plugins/api"; import type { Navigation } from "../../features/navigation/controller"; import type { PageNavigation } from "../../features/navigation/service"; -import { useAgentChoices } from "../../features/agents/use-choices"; -import { useAgents } from "../../features/agents/react"; -import { - chooseDefaultAgent, - useSavedAgentState, -} from "../../features/solo/default-agent"; +import { useSavedAgentState } from "../../features/solo/default-agent"; import type { ConversationExtensions } from "../../features/conversation/contracts"; import type { ConversationHostServices } from "../../features/conversation/ConversationHost"; import type { Agents } from "../../features/agents/service"; import type { RelayData } from "../../features/relay/service"; -import type { RelaySession } from "../../features/relay/session"; import { useRelayConnection } from "../../features/relay/react"; import { BestieIcon } from "../../shared/design-system/icons"; import { Button } from "../../shared/design-system/ui/Button"; @@ -21,11 +15,10 @@ import { PanelHeader, PanelHeaderLabel, } from "../../shared/design-system/ui/PanelHeader"; -import { Select } from "../../shared/design-system/ui/Select"; -import { AgentCreateDialog } from "../agents/AgentCreateDialog"; import { onBestieMade } from "../agents/BestieChoice"; import { bestieTab, formerBestieTab } from "./BestieTab"; import { rememberNewBestie, stopFormerCheckIns } from "./former"; +import { BestieChooser, type BestieChooserProps } from "./ChooseBestie"; import { PinnedBestie } from "./PinnedBestie"; import { bestieRoute } from "./routes"; @@ -136,6 +129,7 @@ function BestieDestination({ return saved.agent ? ( { - if ( - !session.agentChoices - .snapshot() - .selectable.some((agent) => agent.pubkey === pubkey) - ) { - setError("That agent is no longer available. Choose another."); - return; - } - setSaving(true); - setError(""); - try { - await chooseDefaultAgent(session, scope, pubkey); - } catch (reason) { - setError(reason instanceof Error ? reason.message : String(reason)); - } finally { - setSaving(false); - } - }; +function ChooseBestie(props: BestieChooserProps) { return (

Choose your bestie

@@ -195,89 +153,7 @@ function ChooseBestie({ Select or create an agent to be your bestie in Buzz. They’ll get special placement and extra capabilities throughout the app.

- {selecting ? ( -
-