From fd1f59d87156eeaac0e76af23bb59e23a01dab4d Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 12 Nov 2026 15:48:03 -0800 Subject: [PATCH 1/2] fix(bestie): show the Bestie's profile picture in navigation The Bestie button read only the agent definition's picture. On a device where the agent isn't local, that inventory row has no picture, so the button fell back to initials while DM rows and messages showed the profile picture. Prefer the published profile picture, use the definition's art only until the profile loads, and keep the bundled poster match on whichever URL wins. --- src/app/shell/BestieNavigation.test.tsx | 51 ++++++++++++++++++++++++- src/app/shell/BestieNavigation.tsx | 29 ++++++++++++-- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/src/app/shell/BestieNavigation.test.tsx b/src/app/shell/BestieNavigation.test.tsx index f7afd25a6e..bd9a02d1c6 100644 --- a/src/app/shell/BestieNavigation.test.tsx +++ b/src/app/shell/BestieNavigation.test.tsx @@ -17,6 +17,8 @@ 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 type { ProfileQueries } from "../../features/relay/profile-directory"; +import type { Profile } from "../../features/relay/contracts"; import { createRelaySession } from "../../features/relay/session"; import { createSidebarPreferencesStore } from "../../features/relay/sidebar-preferences-store"; import { @@ -112,7 +114,20 @@ async function fixture(firstAgent = false) { signal: lifetime.signal, }); await choices.refresh(false); - const session = { ...owner.session, viewer, agentChoices: choices }; + let profileMap: ReadonlyMap = new Map(); + const profileListeners = new Set<() => void>(); + const ensured = vi.fn(async () => {}); + const profiles: ProfileQueries = { + snapshot: () => profileMap, + subscribe(listener) { + profileListeners.add(listener); + return () => { + profileListeners.delete(listener); + }; + }, + ensure: ensured, + }; + const session = { ...owner.session, viewer, agentChoices: choices, profiles }; let snapshot: RelaySnapshot = { status: "ready", generation: 1, @@ -143,6 +158,11 @@ async function fixture(firstAgent = false) { relay, native, libraryRead, + ensured, + publishProfile(pubkey: string, profile: Profile) { + profileMap = new Map([...profileMap, [pubkey, profile]]); + for (const listener of profileListeners) listener(); + }, update(patch: Partial) { snapshot = { ...snapshot, ...patch }; for (const listener of listeners) listener(); @@ -179,6 +199,35 @@ it("shows the creation invitation in the dock, then follows the configured Besti expect(fizzTab).toHaveTextContent("F"); }); +it("shows the Bestie's published profile picture over its agent definition art", async () => { + const f = await fixture(); + const published = agentAvatars[1]; + if (!published) throw new Error("Expected a second bundled avatar"); + setDefaultAgent(scope, pollen); + render( + + {}} + placement="dock" + /> + , + ); + const tab = await screen.findByRole("tab", { name: "Pollen" }); + // The definition's art covers the wait for the profile it asked for. + expect(tab.querySelector("img")).toHaveAttribute("src", portrait.preview); + await waitFor(() => + expect(f.ensured).toHaveBeenCalledWith([pollen], "background"), + ); + act(() => + f.publishProfile(pollen, { name: "Pollen", picture: published.url }), + ); + expect( + screen.getByRole("tab", { name: "Pollen" }).querySelector("img"), + ).toHaveAttribute("src", published.preview); +}); + it("offers first-agent creation, then shows the exact Bestie in navigation and search", async () => { const f = await fixture(true); diff --git a/src/app/shell/BestieNavigation.tsx b/src/app/shell/BestieNavigation.tsx index 27293469ce..603ea2a23b 100644 --- a/src/app/shell/BestieNavigation.tsx +++ b/src/app/shell/BestieNavigation.tsx @@ -1,11 +1,19 @@ -import { useContext, useSyncExternalStore, type ReactNode } from "react"; +import { + useContext, + useEffect, + useSyncExternalStore, + type ReactNode, +} from "react"; import { agentAvatars } from "../../features/agents/avatar-packs"; import { bestieChat } from "../../bundled/bestie/chat"; import { UnreadBadge } from "../../bundled/channels/UnreadBadge"; import channelStyles from "../../bundled/channels/Channels.module.css"; import { useAgentChoices } from "../../features/agents/use-choices"; import type { RegisteredPage } from "../../features/pages/service"; -import { useRelayConnection } from "../../features/relay/react"; +import { + useProfileGeneration, + useRelayConnection, +} from "../../features/relay/react"; import type { RelayData } from "../../features/relay/service"; import { useSavedAgentState } from "../../features/solo/default-agent"; import { pagePresentation } from "./presentation"; @@ -56,9 +64,22 @@ export function BestieNavigationProvider({ session.channels.list, ); const channel = bestieChat(channels.channels, viewer, promoted); - const pack = agentAvatars.find((item) => item.url === agent?.avatar); + // The published profile picture wins, as in DM rows and message avatars; the + // agent definition's art covers only the wait for that profile. + const bestie = agent?.pubkey; + const generation = useProfileGeneration(session.profiles); + useEffect(() => { + void generation; + if (bestie) + void session.profiles.ensure([bestie], "background").catch(() => {}); + }, [session, bestie, generation]); + const profilePicture = useSyncExternalStore(session.profiles.subscribe, () => + bestie ? session.profiles.snapshot().get(bestie)?.picture : undefined, + ); + const picture = profilePicture ?? agent?.avatar; + const pack = agentAvatars.find((item) => item.url === picture); const preview = useAvatarPreview( - pack ? "" : (agent?.avatar ?? ""), + pack ? "" : (picture ?? ""), connection.origin, "small", session.media, From 5191fea38e387975a50f2b81e64ad57b5983b4a8 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 12 Nov 2026 15:51:22 -0800 Subject: [PATCH 2/2] docs(bestie): note the pictureless-profile fallback --- src/app/shell/BestieNavigation.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/shell/BestieNavigation.tsx b/src/app/shell/BestieNavigation.tsx index 603ea2a23b..1f0bcc2d00 100644 --- a/src/app/shell/BestieNavigation.tsx +++ b/src/app/shell/BestieNavigation.tsx @@ -65,7 +65,8 @@ export function BestieNavigationProvider({ ); const channel = bestieChat(channels.channels, viewer, promoted); // The published profile picture wins, as in DM rows and message avatars; the - // agent definition's art covers only the wait for that profile. + // agent definition's art covers the wait for that profile, or a profile + // without a picture (as ChannelMemberAvatars does). const bestie = agent?.pubkey; const generation = useProfileGeneration(session.profiles); useEffect(() => {