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 }) => ( + + ), +})); + +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: choice.id, - label: choice.displayName, - })), - }, - ]} - onValueChange={(value) => { - setModelId(value); - setReasoningEffort(""); - }} - /> -