diff --git a/docs/shell-design.md b/docs/shell-design.md
index 48f47b03f..e83f9c841 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 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
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 d4d22f81e..86b19a075 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. 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
+ 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/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs
index 98fd08707..73d616df2 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/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx
index 46b846e34..0ecb0feb9 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";
@@ -25,6 +26,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,
@@ -38,7 +40,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 000000000..b8c22fa71
--- /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.test.tsx b/src/app/shell/BestieNavigation.test.tsx
new file mode 100644
index 000000000..379421b36
--- /dev/null
+++ b/src/app/shell/BestieNavigation.test.tsx
@@ -0,0 +1,455 @@
+// @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("shows the exact choice as Bestie in navigation and search", 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" });
+ 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).getByRole("option", { name: /Pollen.*Bestie/ }),
+ ).toBeInTheDocument();
+ await userEvent.clear(search);
+ await userEvent.type(search, "Bestie");
+ expect(
+ within(dialog).getByRole("option", { name: /Pollen.*Bestie/ }),
+ ).toBeInTheDocument();
+ act(() => {
+ setDefaultAgent(scope, fizz);
+ });
+ expect(
+ within(dialog).getByRole("option", { name: /Fizz.*Bestie/ }),
+ ).toBeInTheDocument();
+ 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();
+ 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: "Renamed" })).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
new file mode 100644
index 000000000..7eb7b7a2c
--- /dev/null
+++ b/src/app/shell/BestieNavigation.tsx
@@ -0,0 +1,132 @@
+import { useContext, useSyncExternalStore, type ReactNode } from "react";
+import { agentAvatars } from "../../features/agents/avatar-packs";
+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 { pagePresentation } from "./presentation";
+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 enabled =
+ pages.some((page) => page.key === BESTIE_PAGE) &&
+ !!viewer &&
+ !!scope?.endsWith(`:${viewer}`);
+ const active = enabled && connection.status === "ready";
+ const saved = useSavedAgent(session, scope ?? "");
+ const choices = useAgentChoices(session, active, 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, promoted);
+ 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,
+ detail: "Bestie",
+ 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 (
+
+
+
+
+
+ );
+}
+
+/** Decorate Bestie's navigation and search destination with its exact choice. */
+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 cb598ed00..c0d4a9a8e 100644
--- a/src/app/shell/PageNavigation.tsx
+++ b/src/app/shell/PageNavigation.tsx
@@ -8,7 +8,13 @@ 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 { pagePresentation } from "./presentation";
+import { Avatar } from "../../shared/design-system/ui/Avatar";
+import {
+ BestieUnreadBadge,
+ BestieUnreadLabel,
+ usePagePresentation,
+} from "./BestieNavigation";
+import { BESTIE_PAGE } from "./bestie-context";
export function PageNavigation({
pages,
@@ -23,6 +29,7 @@ export function PageNavigation({
onSelect(key: string, options?: Pick): void;
placement?: "sidebar" | "topbar" | "toolbar";
}) {
+ const presentation = usePagePresentation();
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 +61,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 +72,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/PageSearch.tsx b/src/app/shell/PageSearch.tsx
index 77920764d..ff46c3b74 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 90b896688..8155cb646 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 10c634c5f..f9b6205ce 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
new file mode 100644
index 000000000..b487c4a5c
--- /dev/null
+++ b/src/app/shell/bestie-context.ts
@@ -0,0 +1,20 @@
+import { createContext, useContext } from "react";
+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(
+ 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/agents/BestieChoice.tsx b/src/bundled/agents/BestieChoice.tsx
index 00079b53d..51aeae03f 100644
--- a/src/bundled/agents/BestieChoice.tsx
+++ b/src/bundled/agents/BestieChoice.tsx
@@ -20,6 +20,16 @@ export function onBestieMade(listener: (pubkey: string) => void) {
return () => void made.delete(listener);
}
+/** Saves `pubkey` as the viewer's Bestie, then tells `onBestieMade`. */
+export async function chooseBestie(
+ session: RelaySession,
+ scope: string,
+ pubkey: string,
+) {
+ await chooseDefaultAgent(session, scope, pubkey);
+ for (const listener of made) listener(pubkey);
+}
+
/** Why only some agents can be made Bestie: a remote one isn't set up for
* it yet. */
export const BESTIE_LOCAL_ONLY =
@@ -48,9 +58,8 @@ export function useBestieChoice(session: RelaySession, scope: string) {
const choice = ++latest.current;
setError("");
setSavingBestie(true);
- chooseDefaultAgent(session, scope, pubkey)
+ chooseBestie(session, scope, pubkey)
.then(() => {
- for (const listener of made) listener(pubkey);
if (choice === latest.current) setReplacement(undefined);
})
.catch((failure: Error) => {
diff --git a/src/bundled/bestie/ChooseBestie.test.tsx b/src/bundled/bestie/ChooseBestie.test.tsx
new file mode 100644
index 000000000..2caeb733b
--- /dev/null
+++ b/src/bundled/bestie/ChooseBestie.test.tsx
@@ -0,0 +1,117 @@
+// @vitest-environment jsdom
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, expect, it, vi } from "vitest";
+import type { Agent, Agents } from "../../features/agents/service";
+import type { RelaySession } from "../../features/relay/session";
+import { onBestieMade } from "../agents/BestieChoice";
+import { BestieChooser } from "./ChooseBestie";
+
+// The New agent dialog creates hosted Cara at once.
+vi.mock("../agents/AgentCreateDialog", () => ({
+ AgentCreateDialog: ({ onCreated }: { onCreated(agent: unknown): void }) => (
+
+ onCreated({ pubkey: "c".repeat(64), name: "Cara", place: "hosted" })
+ }
+ >
+ Create Cara
+
+ ),
+}));
+
+afterEach(() => {
+ cleanup();
+ localStorage.clear();
+});
+
+const OWNER = "f".repeat(64);
+const SCOPE = `https://relay.example:${OWNER}`;
+const member = (name: string, digit: string, place = "here") =>
+ ({ pubkey: digit.repeat(64), name, place }) as unknown as Agent;
+const ada = member("Ada", "a");
+const cara = member("Cara", "c", "hosted");
+
+/** A community where Ada runs on this device and Cara is hosted. */
+function community() {
+ const choice = (agent: Agent) => ({
+ pubkey: agent.pubkey,
+ name: agent.name,
+ managed: true,
+ });
+ const choices = {
+ status: "ready",
+ complete: true,
+ pending: false,
+ identities: [ada, cara].map(choice),
+ selectable: [ada, cara].map(choice),
+ archives: { status: "unavailable", archived: [] },
+ };
+ const mainAgent = {
+ read: vi.fn(async () => undefined),
+ write: vi.fn(async (_agent: string | null) => {}),
+ };
+ const session = {
+ mainAgent,
+ agentChoices: {
+ snapshot: () => choices,
+ subscribe: () => () => {},
+ retain: () => () => {},
+ ensure: () => {},
+ },
+ } as unknown as RelaySession;
+ const snapshot = { agents: [ada, cara], running: [] };
+ const types: never[] = [];
+ const agents = {
+ snapshot: () => snapshot,
+ subscribe: () => () => {},
+ types: () => types,
+ } as unknown as Agents;
+ return { session, agents, mainAgent };
+}
+
+it("offers only agents on this device, and says why", async () => {
+ const user = userEvent.setup();
+ const { session, agents } = community();
+ render( );
+ await user.click(screen.getByRole("button", { name: "Choose your bestie" }));
+ expect(
+ screen.getByText("Only agents on this device can be your Bestie for now."),
+ ).toBeVisible();
+ await user.click(screen.getByRole("combobox", { name: "Your agents" }));
+ expect(await screen.findByRole("option", { name: "Ada" })).toBeVisible();
+ expect(screen.queryByRole("option", { name: "Cara" })).toBeNull();
+});
+
+it("saves the agent chosen here as Bestie, and tells the plugin", async () => {
+ const user = userEvent.setup();
+ const { session, agents, mainAgent } = community();
+ const made = vi.fn();
+ const stop = onBestieMade(made);
+ render( );
+ await user.click(screen.getByRole("button", { name: "Choose your bestie" }));
+ await user.click(screen.getByRole("combobox", { name: "Your agents" }));
+ await user.click(await screen.findByRole("option", { name: "Ada" }));
+ await vi.waitFor(() =>
+ expect(mainAgent.write).toHaveBeenCalledWith(ada.pubkey),
+ );
+ await vi.waitFor(() => expect(made).toHaveBeenCalledWith(ada.pubkey));
+ stop();
+});
+
+it("says a hosted agent created here can't be Bestie yet", async () => {
+ const user = userEvent.setup();
+ const { session, agents, mainAgent } = community();
+ render( );
+ await user.click(screen.getByRole("button", { name: "Choose your bestie" }));
+ await user.click(screen.getByRole("button", { name: "New agent" }));
+ await user.click(screen.getByRole("button", { name: "Create Cara" }));
+ expect(
+ await screen.findByText(
+ "Cara was created. Only agents on this device can be your Bestie for now.",
+ ),
+ ).toBeVisible();
+ expect(mainAgent.write).not.toHaveBeenCalled();
+});
diff --git a/src/bundled/bestie/ChooseBestie.tsx b/src/bundled/bestie/ChooseBestie.tsx
new file mode 100644
index 000000000..5d9010477
--- /dev/null
+++ b/src/bundled/bestie/ChooseBestie.tsx
@@ -0,0 +1,162 @@
+import { useState } from "react";
+import { useAgentChoices } from "../../features/agents/use-choices";
+import { useAgents } from "../../features/agents/react";
+import type { Agents } from "../../features/agents/service";
+import type { RelaySession } from "../../features/relay/session";
+import { Button } from "../../shared/design-system/ui/Button";
+import { Select } from "../../shared/design-system/ui/Select";
+import { AgentCreateDialog } from "../agents/AgentCreateDialog";
+import { BESTIE_LOCAL_ONLY, chooseBestie } from "../agents/BestieChoice";
+
+export type BestieChooserProps = {
+ session: RelaySession;
+ scope: string;
+ community?: string | undefined;
+ agents: Agents;
+};
+
+/** Picks or creates the viewer's Bestie, where there is none to replace: on
+ * the empty Bestie page, and in place of one that is no longer here. */
+export function BestieChooser({
+ session,
+ scope,
+ community,
+ agents,
+}: BestieChooserProps) {
+ const choices = useAgentChoices(session, true, true);
+ const { snapshot, types } = useAgents(agents);
+ const [selecting, setSelecting] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ // Only an agent on this device can be Bestie for now: a remote one isn't
+ // set up for it yet.
+ const here = (pubkey: string) =>
+ agents
+ .snapshot()
+ .agents.some(
+ (agent) => agent.pubkey === pubkey && agent.place === "here",
+ );
+ const local = choices.selectable.filter((agent) =>
+ snapshot.agents.some(
+ (each) => each.pubkey === agent.pubkey && each.place === "here",
+ ),
+ );
+ 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;
+ }
+ if (!here(pubkey)) {
+ setError(BESTIE_LOCAL_ONLY);
+ return;
+ }
+ setSaving(true);
+ setError("");
+ try {
+ await chooseBestie(session, scope, pubkey);
+ } catch (reason) {
+ setError(reason instanceof Error ? reason.message : String(reason));
+ } finally {
+ setSaving(false);
+ }
+ };
+ return (
+ <>
+ {selecting ? (
+
+
({
+ value: agent.pubkey,
+ label: agent.managedName ?? agent.name,
+ })),
+ },
+ ]}
+ onValueChange={(pubkey) => void choose(pubkey)}
+ />
+ {BESTIE_LOCAL_ONLY}
+ setCreating(true)}
+ >
+ New agent
+
+ {choices.error && (
+ <>
+
+ {choices.error}
+
+ {
+ setError("");
+ void session.agentChoices
+ .refresh(true, { fresh: true })
+ .catch((reason) =>
+ setError(
+ reason instanceof Error
+ ? reason.message
+ : String(reason),
+ ),
+ );
+ }}
+ >
+ Try again
+
+ >
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ ) : (
+ setSelecting(true)}>
+ Choose your bestie
+
+ )}
+ {creating && (
+ setCreating(false)}
+ onCreated={(agent) => {
+ setCreating(false);
+ if (agent.place !== "here") {
+ setError(
+ `${agent.name || "Your agent"} was created. ${BESTIE_LOCAL_ONLY}`,
+ );
+ return;
+ }
+ 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.module.css b/src/bundled/bestie/PinnedBestie.module.css
new file mode 100644
index 000000000..623f822cd
--- /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 000000000..ba41b23de
--- /dev/null
+++ b/src/bundled/bestie/PinnedBestie.test.tsx
@@ -0,0 +1,647 @@
+// @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 {
+ 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 {
+ 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 type { Agents } from "../../features/agents/service";
+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.
+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 };
+}
+// 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,
+ 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();
+ let ineligibleSnapshot: ReturnType | undefined;
+ const agentChoices = {
+ ...choices,
+ snapshot: () => ineligibleSnapshot ?? choices.snapshot(),
+ };
+ 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,
+ messages: { ...owner.session.messages, send },
+ };
+ 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(listener) {
+ relayListeners.add(listener);
+ return () => relayListeners.delete(listener);
+ },
+ retry: retryConnection,
+ disconnect() {},
+ async clearCache() {},
+ };
+ const navigator = {
+ open: vi.fn(async () => ({ status: "opened" as const })),
+ dismiss: vi.fn(),
+ } as unknown as Navigation;
+ 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,
+ navigator,
+ 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;
+ },
+ choose: (pubkey: string) => setDefaultAgent(scope, pubkey),
+ element: ({
+ renderedNavigator = navigator,
+ navigation,
+ }: {
+ renderedNavigator?: Navigation | undefined;
+ navigation?: PageNavigation | undefined;
+ } = {}) => (
+
+
+
+ ),
+ rootId: root.id,
+ 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("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 every 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();
+
+ 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(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);
+});
+
+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();
+});
+
+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);
+});
+
+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
new file mode 100644
index 000000000..0d17e21e1
--- /dev/null
+++ b/src/bundled/bestie/PinnedBestie.tsx
@@ -0,0 +1,428 @@
+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 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 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";
+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";
+import { BestieChooser } from "./ChooseBestie";
+import { bestieBaseTarget, bestieRoute, bestieThreadTarget } from "./routes";
+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. */
+export function PinnedBestie({
+ relay,
+ agents,
+ host,
+ extensions,
+ navigator,
+ navigation,
+}: {
+ relay: RelayData;
+ agents: Agents;
+ host: ConversationHostServices;
+ extensions: ConversationExtensions;
+ navigator: Navigation;
+ navigation?: PageNavigation | undefined;
+}) {
+ 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 known = choices.identities.find((choice) => choice.pubkey === saved);
+ const agent = active
+ ? choices.selectable.find((choice) => choice.pubkey === saved)
+ : undefined;
+ const channels = useChannelList(session.channels);
+ 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
+
+ ) : (
+
+ )
+ }
+ />
+ );
+ } 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}
+
+
+
+ );
+}
+
+function DirectMessage({
+ session,
+ scope,
+ host,
+ extensions,
+ navigator,
+ navigation,
+ agent,
+ channel,
+}: {
+ session: RelaySession;
+ scope: string;
+ host: ConversationHostServices;
+ extensions: ConversationExtensions;
+ navigator: Navigation;
+ navigation?: PageNavigation | undefined;
+ 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 (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)
+ return (
+
+ );
+ if (error || attempt > 0)
+ return (
+ retry((value) => value + 1)}>
+ Try again
+
+ }
+ />
+ );
+ return (
+
+
+
+ );
+}
+
+function BestieConversation({
+ session,
+ scope,
+ host,
+ extensions,
+ navigator,
+ navigation,
+ channel,
+ bestie,
+}: {
+ session: RelaySession;
+ scope: string;
+ host: ConversationHostServices;
+ extensions: ConversationExtensions;
+ navigator: Navigation;
+ navigation?: PageNavigation | undefined;
+ channel: ChannelSummary;
+ bestie: string;
+}) {
+ const [sent, setSent] = 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) {
+ const base = bestieBaseTarget(navigation.target);
+ navigator.dismiss(
+ (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);
+ };
+ return (
+
+
+ {thread ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ );
+}
+
+function Notice({
+ title,
+ description,
+ action,
+ announce,
+}: {
+ title: string;
+ description: string;
+ action?: React.ReactNode;
+ announce?: "alert" | "status" | undefined;
+}) {
+ return (
+
+ }
+ title={title}
+ description={
+ announce ? {description} : description
+ }
+ action={action}
+ />
+
+ );
+}
diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md
index 0c5bec67e..b256ba8de 100644
--- a/src/bundled/bestie/README.md
+++ b/src/bundled/bestie/README.md
@@ -1,14 +1,33 @@
# 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
-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.
+### 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
+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.
+
+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.
### The Bestie tab
@@ -66,10 +85,21 @@ 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**.
----
+### Choosing a Bestie
-## Changing Bestie's instructions
+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 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. 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. Deleting the
+Bestie saves none to the account, after reading the account again to check it
+still holds that agent, so the page shows **Choose your bestie** rather than a
+Bestie that is gone.
-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.
+Builderlab no longer owns a separate Bestie setup, agent, Solo chat, or
+selection path.
diff --git a/src/bundled/bestie/chat.test.ts b/src/bundled/bestie/chat.test.ts
new file mode 100644
index 000000000..822711806
--- /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 000000000..f7b2baf14
--- /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.test.tsx b/src/bundled/bestie/index.test.tsx
index 10718e751..88126578e 100644
--- a/src/bundled/bestie/index.test.tsx
+++ b/src/bundled/bestie/index.test.tsx
@@ -1,14 +1,11 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
-import { 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 { bestieFixture, cleanups, HOME } from "./testing";
+import { deferred } from "../builderlab/test-helpers";
+import { bestieFixture, cleanups } from "./testing";
const native = vi.hoisted(() => ({ isTauri: () => true, invoke: vi.fn() }));
vi.mock("@tauri-apps/api/core", () => native);
@@ -20,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();
@@ -42,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>) => {
@@ -72,167 +35,51 @@ 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("sets up Bestie on the model the owner chose and hands focus to its conversation", 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",
+it("waits for the account choice before showing the chooser", async () => {
+ const choice = deferred();
+ const h = await bestieFixture(native.invoke, {
+ mainAgent: {
+ read: () => choice.promise,
+ write: async () => {},
+ },
});
- 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(Elsewhere );
renderPage(h);
- await userEvent.click(
- await screen.findByRole("button", { name: "Set up Bestie" }),
+ expect(await screen.findByRole("status")).toHaveTextContent(
+ "Loading your 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" }),
- );
+ screen.queryByRole("button", { name: "Choose your bestie" }),
+ ).not.toBeInTheDocument();
+ await act(async () => choice.resolve(undefined));
expect(
- await screen.findByRole("region", { name: "Bestie conversation" }),
- ).toBeInTheDocument();
- await waitFor(() => expect(composer()).toHaveFocus());
+ await screen.findByRole("button", { name: "Choose your bestie" }),
+ ).toBeVisible();
});
-it("keeps a retry on the page after updating Bestie's instructions fails", async () => {
+it("replaces hosted setup with the manual Bestie chooser", 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",
- }),
- );
+ await screen.findByRole("heading", { name: "Choose your bestie" }),
+ ).toBeVisible();
expect(
- await screen.findByText(/ask your Builderlab workspace admin/),
- ).toBeInTheDocument();
+ 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: /Bestie's instructions/ }),
+ screen.queryByRole("button", { name: "Set up Bestie" }),
).not.toBeInTheDocument();
});
-it("focuses the Model field when setup asks for a model", 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" }),
- );
- 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(Elsewhere );
- 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 7b2d838d5..eb425f9fb 100644
--- a/src/bundled/bestie/index.tsx
+++ b/src/bundled/bestie/index.tsx
@@ -1,37 +1,26 @@
-import {
- useCallback,
- useEffect,
- useRef,
- useState,
- useSyncExternalStore,
- type ReactNode,
-} from "react";
-import { BestieIcon } from "../../shared/design-system/icons";
+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 { 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 { 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 { 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";
export const inject = [
"pages",
@@ -42,33 +31,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.
@@ -87,35 +49,21 @@ export const apply: PluginModule["apply"] = (ctx) => {
title: "Bestie",
layout: "workspace",
primary: true,
- component: () => {
- const current = useSyncExternalStore(
- source.subscribe,
- source.snapshot,
- source.snapshot,
- );
- return (
-
- );
- },
+ route: { version: 1, validate: (params) => !!bestieRoute(params) },
+ component: ({ navigation: pageNavigation }) => (
+
+ ),
});
};
-export function BestiePage({
- bestie,
- navigation,
- extensions,
- host,
-}: {
- bestie?: RemoteBestie | undefined;
- navigation: Navigation;
- extensions: ConversationExtensions;
- host: ConversationHostServices;
-}) {
+function BestieStatus({ children }: { children: ReactNode }) {
return (
@@ -128,361 +76,94 @@ export function BestiePage({
/>
}
/>
- {bestie ? (
-
- ) : (
-
-
- Bestie runs on Builderlab. Turn on the Builderlab plugin in the
- Buzz desktop app to set it up.
-
-
- void navigation.open({
- version: 1,
- kind: "settings",
- section: "plugins",
- })
- }
- >
- Plugin settings
-
-
- )}
+ {children}
);
}
-/** 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,
+function BestieDestination({
+ relay,
+ ...props
}: {
- bestie: RemoteBestie;
+ relay: RelayData;
+ navigation: Navigation;
extensions: ConversationExtensions;
host: ConversationHostServices;
+ agents: Agents;
+ pageNavigation?: PageNavigation | undefined;
}) {
- 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)
+ 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 (
- <>
-
-
- {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") && (
-
void bestie.setup()}
- >
- {state.status === "error"
- ? "Retry updating Bestie's instructions"
- : "Update Bestie's instructions"}
-
- )}
-
- Bestie's instructions
-
- {bestieInstructions(record.owner, record.channelId)}
-
-
-
-
+
+ {connection.status === "connecting"
+ ? "Connecting to your Bestie…"
+ : (connection.error ??
+ "Choose a community to continue with your Bestie.")}
+
+ {connection.status === "error" && (
+ Retry connection
+ )}
+
+ );
+ if (!saved.read || saved.error)
+ return (
+
+
-
-
- >
+ {saved.error ?? "Loading your Bestie…"}
+
+ {saved.error && Try again }
+
);
+ return saved.agent ? (
+
+ ) : (
+
+ );
+}
+
+function ChooseBestie(props: BestieChooserProps) {
return (
-
-
- {state.message}
+
+ 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.
- {complete ? (
- <>
- {homeError && (
-
- {homeError}
-
- )}
- act(checkHome)}>
- Open Bestie conversation
-
- >
- ) : 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) => (
- act(() => bestie.setup(agent.pubkey))}
- >
- Use {agent.name} · {agent.pubkey.slice(0, 12)}
-
- ))
- ) : (
- <>
- act(() => bestie.setup())}
- >
- {state.status === "error"
- ? "Retry Bestie setup"
- : "Set up Bestie"}
-
- {state.status === "error" && state.canCreateNew && (
-
- act(() => bestie.setup(undefined, { createNew: true }))
- }
- >
- Create a new Bestie
-
- )}
- >
- )}
-
- >
- )}
-
+
+
);
}
-/** 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();
- }, []);
+function Intro({ children }: { children: ReactNode }) {
return (
-
-
({
- value: choice.id,
- label: choice.displayName,
- })),
- },
- ]}
- onValueChange={(value) => {
- setModelId(value);
- setReasoningEffort("");
- }}
- />
- ({
- value,
- label: value,
- })),
- },
- ]}
- onValueChange={setReasoningEffort}
- />
-
-
onChoose({ modelId, reasoningEffort })}
- >
- Continue setup
-
+
+
+
+ {children}
);
diff --git a/src/bundled/bestie/routes.test.ts b/src/bundled/bestie/routes.test.ts
new file mode 100644
index 000000000..9212d156a
--- /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 000000000..4e7770063
--- /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;
+}
diff --git a/src/bundled/bestie/testing.ts b/src/bundled/bestie/testing.ts
index f76a12807..09c740e7c 100644
--- a/src/bundled/bestie/testing.ts
+++ b/src/bundled/bestie/testing.ts
@@ -69,7 +69,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();
@@ -274,6 +282,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: {
@@ -291,6 +305,7 @@ export async function bestieFixture(invoke: Mock) {
);
};
let owner = start();
+ const temporarySessions: ReturnType[] = [];
let snapshot: RelaySnapshot = {
status: "ready",
generation: 1,
@@ -411,6 +426,7 @@ export async function bestieFixture(invoke: Mock) {
await runtime.dispose();
await root.fiber.dispose();
owner.dispose();
+ for (const temporary of temporarySessions) temporary.dispose();
await closed.promise;
listeners.clear();
});
@@ -449,6 +465,16 @@ export async function bestieFixture(invoke: Mock) {
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;
},
diff --git a/src/bundled/channels/useHiddenDms.ts b/src/bundled/channels/useHiddenDms.ts
index 195d4b426..81d397bbc 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.test.tsx b/src/features/channel-navigation/ChannelInventory.test.tsx
index 8c3fd9519..a65fb6631 100644
--- a/src/features/channel-navigation/ChannelInventory.test.tsx
+++ b/src/features/channel-navigation/ChannelInventory.test.tsx
@@ -43,6 +43,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(),
@@ -1873,3 +1880,150 @@ 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();
+ // Source failure removes its candidates but does not release an explicit
+ // account choice whose exact verified DM still exists.
+ act(() => {
+ 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();
+ expect(h.session.channels.list().channels).toHaveLength(4);
+ expect(readView(scope, "hidden-dms", [])).toEqual([]);
+ } finally {
+ lifetime.abort();
+ library.dispose();
+ }
+});
diff --git a/src/features/channel-navigation/ChannelInventory.tsx b/src/features/channel-navigation/ChannelInventory.tsx
index ab201eab2..ad00702d4 100644
--- a/src/features/channel-navigation/ChannelInventory.tsx
+++ b/src/features/channel-navigation/ChannelInventory.tsx
@@ -95,6 +95,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;
@@ -170,6 +171,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,
@@ -247,6 +255,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/relay/direct-messages.test.ts b/src/features/relay/direct-messages.test.ts
index 511fea85c..db33d3679 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 8eb9c0d33..57500f7fe 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,
};
},
diff --git a/src/features/solo/default-agent.test.tsx b/src/features/solo/default-agent.test.tsx
index 3c22eb2f8..90eb17c17 100644
--- a/src/features/solo/default-agent.test.tsx
+++ b/src/features/solo/default-agent.test.tsx
@@ -14,6 +14,7 @@ import {
savedAgentStore,
setDefaultAgent,
useDefaultAgent,
+ useSavedAgentState,
} from "./default-agent";
afterEach(() => {
@@ -578,6 +579,134 @@ 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"}
+
+ Retry
+
+ >
+ );
+ }
+ 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("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));
@@ -779,3 +908,96 @@ it("a choice in another community doesn't keep a failed removal from coming back
"b".repeat(64),
);
});
+
+it("saves none when the account's Bestie is deleted, if the account still holds it", async () => {
+ const { account, session } = accountSession("b".repeat(64));
+ const store = savedAgentStore(session, "deleted");
+ const stop = store.subscribe(() => {});
+ await act(async () => account.resolve());
+ expect(store.snapshot()).toBe("b".repeat(64));
+ forgetDefaultAgent("b".repeat(64));
+ // The account is read again before none is saved.
+ expect(account.write).not.toHaveBeenCalled();
+ await act(async () => account.resolve());
+ await vi.waitFor(() => expect(account.write).toHaveBeenLastCalledWith(null));
+ expect(account.saved()).toBeUndefined();
+ expect(store.snapshot()).toBeUndefined();
+ expect(store.known()).toBe(true);
+ stop();
+});
+
+it("keeps a Bestie another device chose before the deleted one was cleared", async () => {
+ const { account, session } = accountSession("b".repeat(64));
+ const stop = savedAgentStore(session, "moved").subscribe(() => {});
+ await act(async () => account.resolve());
+ // Another device saves First meanwhile.
+ await account.write("a".repeat(64));
+ account.write.mockClear();
+ forgetDefaultAgent("b".repeat(64));
+ await act(async () => account.resolve());
+ expect(account.write).not.toHaveBeenCalled();
+ expect(account.saved()).toBe("a".repeat(64));
+ stop();
+});
+
+it("keeps a choice made here while a deleted Bestie's account is read", async () => {
+ const { account, session } = accountSession("b".repeat(64));
+ const stop = savedAgentStore(session, "meanwhile").subscribe(() => {});
+ await act(async () => account.resolve());
+ forgetDefaultAgent("b".repeat(64));
+ await act(() => chooseDefaultAgent(session, "meanwhile", "a".repeat(64)));
+ // The read begun before the choice still finds Second.
+ await act(async () => account.resolve());
+ expect(account.write.mock.calls).toEqual([["a".repeat(64)]]);
+ expect(account.saved()).toBe("a".repeat(64));
+ stop();
+});
+
+it("says the choice is known again after clearing a deleted Bestie fails", async () => {
+ const { account, session } = accountSession("b".repeat(64));
+ const store = savedAgentStore(session, "failed-clear");
+ const heard = vi.fn(() => store.known());
+ const stop = store.subscribe(() => heard());
+ await act(async () => account.resolve());
+ account.write.mockRejectedValueOnce(new Error("offline"));
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ forgetDefaultAgent("b".repeat(64));
+ await act(async () => account.resolve());
+ await vi.waitFor(() => expect(warn).toHaveBeenCalled());
+ // The last thing listeners heard is that the account's choice is known.
+ expect(heard.mock.results.at(-1)?.value).toBe(true);
+ expect(account.saved()).toBe("b".repeat(64));
+ stop();
+});
+
+it("saves none when the account is read after its Bestie was deleted here", async () => {
+ const { account, session } = accountSession("b".repeat(64));
+ const store = savedAgentStore(session, "deleted-unread");
+ const stop = store.subscribe(() => {});
+ // Deleted while the account's first read is pending.
+ forgetDefaultAgent("b".repeat(64));
+ await act(async () => account.resolve());
+ // It isn't cached on this device again, and the account is read once more.
+ expect(readView("deleted-unread", "solo:default-agent", null)).toBeNull();
+ await act(async () => account.resolve());
+ await vi.waitFor(() => expect(account.write).toHaveBeenLastCalledWith(null));
+ expect(store.snapshot()).toBeUndefined();
+ stop();
+});
+
+it("falls back to a saved choice, not an older read, when a later choice fails", async () => {
+ const { account, session } = accountSession("b".repeat(64));
+ const store = savedAgentStore(session, "fallback");
+ const stop = store.subscribe(() => {});
+ // A choice saved while the first read is pending.
+ await act(() => chooseDefaultAgent(session, "fallback", "a".repeat(64)));
+ await act(async () => account.resolve());
+ account.write.mockRejectedValueOnce(new Error("offline"));
+ await act(() =>
+ expect(
+ chooseDefaultAgent(session, "fallback", "c".repeat(64)),
+ ).rejects.toThrow(),
+ );
+ expect(store.snapshot()).toBe("a".repeat(64));
+ stop();
+});
diff --git a/src/features/solo/default-agent.ts b/src/features/solo/default-agent.ts
index 93dcf065d..f97073e0e 100644
--- a/src/features/solo/default-agent.ts
+++ b/src/features/solo/default-agent.ts
@@ -16,17 +16,38 @@ 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.
+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();
+const readErrors = new WeakMap();
+const readValues = new WeakMap();
+const readFailures = new WeakMap();
+const failedChoices = new WeakSet();
// Accounts whose saved choice is known: read before any choice here, or
// confirmed by a save here, and not while a save is pending.
const known = new WeakSet();
const saving = new WeakMap();
+// Accounts read this session, so a deleted agent can be cleared from them.
+const accounts = new Set>();
+const tracked = new WeakSet();
+// Agents deleted here while an account was unread, or its read had failed, so
+// a read that still finds one saves none instead of adopting it.
+const deleted = 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();
}
/** Saves `agent`, or none, to the account; its choice is known once saved. */
async function save(account: Account, agent: string | null) {
@@ -36,9 +57,13 @@ async function save(account: Account, agent: string | null) {
await account.write(agent);
// Known with its value, before it stops counting as pending.
saved.set(account, agent ?? undefined);
+ // A failed choice after this falls back to it, not to an older read.
+ readValues.set(account, agent ?? undefined);
known.add(account);
} finally {
saving.set(account, (saving.get(account) ?? 1) - 1);
+ // Known again, or not, once it stops counting as pending.
+ notifySaved();
}
}
// Explicit choices on this device by scope, so a failed one gives way to a
@@ -57,7 +82,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. */
@@ -83,21 +108,39 @@ export async function chooseDefaultAgent(
pubkey: string,
) {
const account = session.mainAgent;
- if (account) explicit.add(account);
+ if (account) {
+ explicit.add(account);
+ }
turns.set(scope, (turns.get(scope) ?? 0) + 1);
// 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 save(account, pubkey);
} catch {
- // What was known before it is again.
- remember(account, saved.get(account));
+ 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);
}
@@ -113,6 +156,7 @@ export async function clearDefaultAgent(session: RelaySession, scope: string) {
const previous = chosenAgent(scope);
cache(scope, null);
if (!account) return;
+ advance(account);
try {
await save(account, null);
} catch {
@@ -133,7 +177,39 @@ 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();
+ // An account that saved it as Bestie saves none, so it isn't left pointing
+ // at an agent that is gone.
+ for (const ref of accounts) {
+ const account = ref.deref();
+ if (!account) accounts.delete(ref);
+ else if (!saved.has(account) || readFailures.has(account))
+ deleted.set(account, (deleted.get(account) ?? new Set()).add(pubkey));
+ else if (saved.get(account) === pubkey) clearLater(account, pubkey);
+ }
+ notifySaved();
+}
+
+function clearLater(account: Account, pubkey: string) {
+ clearDeleted(account, pubkey).catch((error) =>
+ console.warn("The deleted Bestie wasn't cleared from your account", error),
+ );
+}
+/** Saves none to an account whose Bestie was deleted, only if the account
+ * still holds that agent when read again: another device may have chosen
+ * another since, and a choice made here meanwhile wins too. */
+async function clearDeleted(account: Account, pubkey: string) {
+ const started = revision(account);
+ if (saving.get(account)) return;
+ const current = await account.read();
+ if (
+ current !== pubkey ||
+ revision(account) !== started ||
+ saved.get(account) !== pubkey
+ )
+ return;
+ advance(account);
+ await save(account, null);
+ remember(account, undefined);
}
/** Adopts the account's saved choice once per session. Settles even when the
@@ -141,17 +217,49 @@ export function forgetDefaultAgent(pubkey: string) {
function adopt(account: Account, scope: string) {
let pending = adopted.get(account);
if (!pending) {
+ if (!tracked.has(account)) {
+ tracked.add(account);
+ accounts.add(new WeakRef(account));
+ }
+ const started = revision(account);
pending = account.read().then(
(agent) => {
+ // A save that finished meanwhile is newer than this read.
+ if (started === revision(account) || !readValues.has(account))
+ 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);
+ // An agent deleted here before the read finished is gone.
+ const gone = agent !== undefined && !!deleted.get(account)?.has(agent);
+ if (adopts && agent !== undefined && !gone) cache(scope, agent);
// A choice made meanwhile is known only once it is saved.
if (adopts) known.add(account);
- remember(account, adopts ? agent : saved.get(account));
+ remember(account, adopts ? agent : (saved.get(account) ?? agent));
+ if (adopts && gone) clearLater(account, agent);
},
- () => {
+ (cause) => {
adopted.delete(account);
- remember(account, saved.get(account));
+ const error =
+ cause instanceof Error
+ ? cause.message
+ : "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();
},
);
adopted.set(account, pending);
@@ -159,28 +267,56 @@ 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(() => {
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,
+ errorSnapshot,
+ errorSnapshot,
);
- return { read, agent };
+ return {
+ read,
+ agent,
+ error,
+ retry: () => {
+ if (!account) return;
+ readErrors.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);
+ },
+ };
+}
+
+/** 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, error: undefined, retry: () => {} };
}
/** 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;
}
/** `useSavedAgent` outside React. Subscribing starts the account's
@@ -218,6 +354,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) => {
@@ -230,7 +367,8 @@ export function useChosenAgent(scope: string) {
},
[scope],
),
- () => chosenAgent(scope),
+ snapshot,
+ snapshot,
);
}
diff --git a/tests/browser/bestie-choose.spec.mjs b/tests/browser/bestie-choose.spec.mjs
new file mode 100644
index 000000000..7824218b5
--- /dev/null
+++ b/tests/browser/bestie-choose.spec.mjs
@@ -0,0 +1,30 @@
+import { test, expect } from "./source-fixture.mjs";
+
+// With no saved Bestie, and in place of one that is no longer here, the Bestie
+// page offers only agents on this device, and says why.
+for (const [variant, query, title] of [
+ ["empty", "", { role: "heading", name: "Choose your bestie" }],
+ ["gone", "?gone", { text: "Zed is no longer here" }],
+]) {
+ test(`The ${variant} Bestie page offers only agents on this device`, async ({
+ page,
+ }) => {
+ await page.goto(`/tests/fixtures/bestie-choose.html${query}`);
+ await expect(
+ title.role
+ ? page.getByRole(title.role, { name: title.name })
+ : page.getByText(title.text, { exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Choose your bestie" }).click();
+ await expect(
+ page.getByText("Only agents on this device can be your Bestie for now."),
+ ).toBeVisible();
+ await page.getByRole("combobox", { name: "Your agents" }).click();
+ await expect(page.getByRole("option", { name: "Ada" })).toBeVisible();
+ await expect(page.getByRole("option", { name: "Cara" })).toHaveCount(0);
+ if (process.env.BESTIE_SHOTS)
+ await page.screenshot({
+ path: `${process.env.BESTIE_SHOTS}/choose-${variant}.png`,
+ });
+ });
+}
diff --git a/tests/browser/plugin-defaults.spec.mjs b/tests/browser/plugin-defaults.spec.mjs
index 3d720f303..986f8b5f7 100644
--- a/tests/browser/plugin-defaults.spec.mjs
+++ b/tests/browser/plugin-defaults.spec.mjs
@@ -52,7 +52,7 @@ test("Bestie opts in without changing the other bundled defaults and survives re
await expect(destination).toBeVisible();
await destination.click();
await expect(
- page.getByRole("heading", { name: "Meet your Bestie" }),
+ page.getByRole("heading", { name: "Choose your bestie", exact: true }),
).toBeVisible();
await plugins(page);
await bestie.click();
diff --git a/tests/browser/sidenav-polish.spec.mjs b/tests/browser/sidenav-polish.spec.mjs
index 1135f118f..17573a25a 100644
--- a/tests/browser/sidenav-polish.spec.mjs
+++ b/tests/browser/sidenav-polish.spec.mjs
@@ -307,7 +307,7 @@ test("top bar swaps its pages for an overflow menu when narrow", async ({
await expect(search).toBeFocused();
await openPage(page, "Bestie");
await expect(
- page.getByRole("heading", { name: "Meet your Bestie" }),
+ page.getByRole("heading", { name: "Choose your bestie", exact: true }),
).toBeVisible();
});
diff --git a/tests/fixtures/bestie-choose.html b/tests/fixtures/bestie-choose.html
new file mode 100644
index 000000000..40e1f00c5
--- /dev/null
+++ b/tests/fixtures/bestie-choose.html
@@ -0,0 +1,2 @@
+
+Choose your bestie fixture
diff --git a/tests/fixtures/bestie-choose.tsx b/tests/fixtures/bestie-choose.tsx
new file mode 100644
index 000000000..30d2604c2
--- /dev/null
+++ b/tests/fixtures/bestie-choose.tsx
@@ -0,0 +1,99 @@
+// The production Bestie page with only the host data boundary replaced: the
+// account has no saved Bestie, so the page shows Choose your bestie. `?gone`
+// saves Zed, who is no longer here, so the chooser shows in his place.
+import { createRoot } from "react-dom/client";
+import type { ComponentType } from "react";
+import * as bestie from "../../src/bundled/bestie/index";
+import type { RelayData } from "../../src/features/relay/service";
+import "../../src/shared/styles/globals.css";
+
+const OWNER = "f".repeat(64);
+const ADA = "a".repeat(64);
+const CARA = "c".repeat(64);
+const ZED = "e".repeat(64);
+const gone = new URLSearchParams(location.search).has("gone");
+const choice = (pubkey: string, name: string) => ({
+ pubkey,
+ name,
+ managed: true,
+});
+const choices = {
+ status: "ready",
+ complete: true,
+ pending: false,
+ identities: [choice(ADA, "Ada"), choice(CARA, "Cara"), choice(ZED, "Zed")],
+ // Zed is no longer in the community.
+ selectable: [choice(ADA, "Ada"), choice(CARA, "Cara")],
+ archives: { status: "ready", archived: [], readAt: 1 },
+};
+const listeners = new Set<() => void>();
+const list = { status: "ready", channels: [] };
+let record: string | undefined = gone ? ZED : undefined;
+const connection = {
+ status: "ready",
+ origin: "https://relay.example",
+ viewer: OWNER,
+ scope: `https://relay.example:${OWNER}`,
+ session: {
+ viewer: OWNER,
+ channels: {
+ list: () => list,
+ subscribeList: () => () => {},
+ ensureList: () => () => {},
+ },
+ mainAgent: {
+ read: async () => record,
+ write: async (agent: string | null) => {
+ record = agent ?? undefined;
+ },
+ },
+ agentChoices: {
+ snapshot: () => choices,
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => void listeners.delete(listener);
+ },
+ retain: () => () => {},
+ ensure: () => {},
+ refresh: async () => {},
+ },
+ },
+};
+const relay = {
+ snapshot: () => connection,
+ subscribe: () => () => {},
+ retry: () => {},
+} as unknown as RelayData;
+// Ada runs on this device; Cara is hosted.
+const snapshot = {
+ agents: [
+ { pubkey: ADA, name: "Ada", place: "here" },
+ { pubkey: CARA, name: "Cara", place: "hosted" },
+ ],
+ running: [],
+};
+const types: never[] = [];
+let Page: ComponentType> | undefined;
+const ctx = {
+ relay,
+ navigation: {},
+ conversation: {},
+ agents: {
+ registerTab: () => {},
+ snapshot: () => snapshot,
+ subscribe: () => () => {},
+ types: () => types,
+ },
+ // The plugin's background effects aren't part of the page.
+ effect: () => {},
+ pages: {
+ register: (page: { component: typeof Page }) => {
+ Page = page.component;
+ },
+ },
+};
+(bestie.apply as (ctx: unknown) => void)(ctx);
+if (!Page) throw new Error("The Bestie plugin registered no page");
+const root = document.getElementById("root");
+if (!root) throw new Error("Missing fixture root");
+createRoot(root).render( );