From 82fc4798b4652c991012c9f7654e9e5ec90d4344 Mon Sep 17 00:00:00 2001 From: Riley Crane Date: Thu, 29 Oct 2026 19:56:44 -0700 Subject: [PATCH 1/3] feat(bestie): list and change the private channels Bestie can read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bestie tab's private channels row becomes its own group: the private channels you share with Bestie, each with Remove, plus Add to a channel… and Add to all my private channels, most recently active first. Add to all asks first, because each channel's members see the addition, then lists each channel that didn't confirm. "Also add Bestie to private channels I create" is shown disabled until it has a record to keep. Adds use session.memberAdditions, and removals use member administration with the role a fresh roster read shows, as the Members dialog does. Public channels need nothing, since agents read them already. The sidebar's recent order moves unchanged into compareRecent so both use the same comparison. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Riley Crane --- src/bundled/bestie/BestieTab.test.tsx | 13 +- src/bundled/bestie/BestieTab.tsx | 6 +- src/bundled/bestie/PrivateChannels.test.tsx | 148 +++++++++++++ src/bundled/bestie/PrivateChannels.tsx | 226 ++++++++++++++++++++ src/bundled/bestie/README.md | 9 +- src/bundled/channels/sidebar-sections.ts | 48 +++-- 6 files changed, 421 insertions(+), 29 deletions(-) create mode 100644 src/bundled/bestie/PrivateChannels.test.tsx create mode 100644 src/bundled/bestie/PrivateChannels.tsx diff --git a/src/bundled/bestie/BestieTab.test.tsx b/src/bundled/bestie/BestieTab.test.tsx index 95c51d0f78..7f4a155e51 100644 --- a/src/bundled/bestie/BestieTab.test.tsx +++ b/src/bundled/bestie/BestieTab.test.tsx @@ -16,6 +16,7 @@ afterEach(() => { const OWNER = "f".repeat(64); const SCOPE = `https://relay.example:${OWNER}`; +const NO_CHANNELS = { channels: [] }; const member = (name: string, digit: string): Agent => ({ key: digit.repeat(64), @@ -64,6 +65,12 @@ function community(saved: string | undefined, agents = [ada, bea]) { status: "ready", scope: SCOPE, session: { + viewer: OWNER, + channels: { + list: () => NO_CHANNELS, + subscribeList: () => () => {}, + ensureList: () => () => {}, + }, mainAgent, agentChoices: { snapshot: () => choices, @@ -111,7 +118,7 @@ it("shows on the saved Bestie only, and moves when Change picks another", async expect(screen.getByText("This is your Bestie")).toBeVisible(); // A type without instructions has nothing to switch. expect(screen.getByText("Remembers you")).toBeVisible(); - expect(screen.queryByRole("switch")).toBeNull(); + expect(screen.queryByRole("switch", { name: "Remembers you" })).toBeNull(); changed.mockClear(); await user.click(screen.getByRole("button", { name: "Change" })); // Change offers every other agent you can make Bestie. @@ -250,7 +257,7 @@ it("says why a save failed and lets the switch be tried again", async () => { "Connect to the community to save", ); expect(remembers).not.toBeChecked(); - expect(remembers).toBeEnabled(); + expect(remembers).not.toHaveAttribute("aria-disabled", "true"); // A config with no instructions yet gets the section alone. expect(save).toHaveBeenCalledWith(ada.key, { config: { instructions: withFeature("", memory, true) }, @@ -272,7 +279,7 @@ it("offers no switch while the agent's instructions can't be read here", () => { } />, ); - expect(screen.queryByRole("switch")).toBeNull(); + expect(screen.queryByRole("switch", { name: "Remembers you" })).toBeNull(); expect( screen.getByText("This agent's instructions could not be read"), ).toBeVisible(); diff --git a/src/bundled/bestie/BestieTab.tsx b/src/bundled/bestie/BestieTab.tsx index d935a610ff..1bfdce44fc 100644 --- a/src/bundled/bestie/BestieTab.tsx +++ b/src/bundled/bestie/BestieTab.tsx @@ -32,6 +32,7 @@ import { restoredFeature, withFeature, } from "./features"; +import { PrivateChannels } from "./PrivateChannels"; type TabAgents = Pick; @@ -169,11 +170,8 @@ function BestieTab({ title="Check-ins" subtitle="Every hour. Not set up yet." /> - + {bestie.view} ); diff --git a/src/bundled/bestie/PrivateChannels.test.tsx b/src/bundled/bestie/PrivateChannels.test.tsx new file mode 100644 index 0000000000..709a35f9ba --- /dev/null +++ b/src/bundled/bestie/PrivateChannels.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { RelaySession } from "../../features/relay/session"; +import { PrivateChannels, privateChannels } from "./PrivateChannels"; + +afterEach(cleanup); + +const YOU = "f".repeat(64); +const BESTIE = "b".repeat(64); +const OTHER = "c".repeat(64); +const channel = ( + name: string, + members: readonly string[], + extra: Partial = {}, +): ChannelSummary => ({ + id: `id-${name}`, + name, + channelType: "stream", + private: true, + members, + ...extra, +}); + +it("keeps only your private channels, split by Bestie's membership, most recent first", () => { + const { joined, others } = privateChannels( + [ + channel("quiet", [YOU]), + channel("busy", [YOU], { lastActivityAt: 20 }), + channel("older", [YOU], { lastActivityAt: 10 }), + channel("shared", [YOU, BESTIE]), + channel("forum", [YOU], { channelType: "forum" }), + { + id: "id-public", + name: "public", + channelType: "stream", + members: [YOU], + }, + channel("not-yours", [OTHER, BESTIE]), + channel("dm", [YOU, BESTIE], { channelType: "dm" }), + channel("solo", [YOU, BESTIE], { channelType: "solo" }), + channel("archived", [YOU], { archived: true }), + channel("huddle", [YOU], { huddle: true }), + ], + YOU, + BESTIE, + ); + expect(joined.map(({ name }) => name)).toEqual(["shared"]); + expect(others.map(({ name }) => name)).toEqual([ + "busy", + "older", + "forum", + "quiet", + ]); +}); + +function session(channels: ChannelSummary[]) { + const roles: Record = { [BESTIE]: "bot" }; + const add = vi.fn(async (id: string) => { + if (id === "id-beta") throw new Error("Not confirmed. Try again."); + }); + const run = vi.fn(async () => {}); + const list = { channels }; + const value = { + viewer: YOU, + outbox: { supports: () => true }, + channels: { + list: () => list, + subscribeList: () => () => {}, + ensureList: () => () => {}, + }, + memberAdditions: { add }, + memberAdministration: { + refresh: vi.fn(async () => {}), + run, + snapshot: vi.fn(() => + run.mock.calls.length + ? { + status: "ready", + authority: { roles: {} }, + operation: { + change: { pubkey: BESTIE, role: "remove" }, + status: "confirmed", + }, + } + : { status: "ready", authority: { roles } }, + ), + }, + } as unknown as RelaySession; + return { value, add, run }; +} + +it("removes Bestie from a channel it is in, as the Members dialog does", async () => { + const user = userEvent.setup(); + const { value, run } = session([channel("shared", [YOU, BESTIE])]); + render(); + await user.click( + screen.getByRole("button", { name: "Remove Bestie from #shared" }), + ); + expect(run).toHaveBeenCalledWith("id-shared", { + pubkey: BESTIE, + expectedRole: "bot", + role: "remove", + }); + expect(screen.queryByRole("alert")).toBeNull(); + // Creating channels with Bestie is shown, not offered yet. + expect( + screen.getByRole("switch", { + name: "Also add Bestie to private channels I create", + }), + ).toHaveAttribute("aria-disabled", "true"); +}); + +it("adds Bestie to one channel, or to all after asking, and reports what didn't confirm", async () => { + const user = userEvent.setup(); + const { value, add } = session([ + channel("alpha", [YOU], { lastActivityAt: 5 }), + channel("beta", [YOU], { lastActivityAt: 9 }), + ]); + render(); + expect(screen.getByText(/Not in any of your private channels/)).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Add to a channel…" })); + const items = await screen.findAllByRole("menuitem"); + expect(items.map((item) => item.textContent)).toEqual(["#beta", "#alpha"]); + await user.click(screen.getByRole("menuitem", { name: "#alpha" })); + expect(add).toHaveBeenCalledWith("id-alpha", BESTIE); + + add.mockClear(); + await user.click( + screen.getByRole("button", { name: "Add to all my private channels" }), + ); + const ask = await screen.findByRole("alertdialog", { + name: "Add Bestie to 2 private channels?", + }); + expect(add).not.toHaveBeenCalled(); + await user.click(within(ask).getByRole("button", { name: "Add to all" })); + expect(add.mock.calls).toEqual([ + ["id-beta", BESTIE], + ["id-alpha", BESTIE], + ]); + expect(await screen.findByRole("alert")).toHaveTextContent( + "#beta: Not confirmed. Try again.", + ); +}); diff --git a/src/bundled/bestie/PrivateChannels.tsx b/src/bundled/bestie/PrivateChannels.tsx new file mode 100644 index 0000000000..92ba21b480 --- /dev/null +++ b/src/bundled/bestie/PrivateChannels.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { canAddMembers } from "../../features/channel-members/members"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { useChannelList } from "../../features/relay/react"; +import type { RelaySession } from "../../features/relay/session"; +import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; +import { Button } from "../../shared/design-system/ui/Button"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; +import { + MenuItem, + MenuPopup, + MenuRoot, + MenuTrigger, +} from "../../shared/design-system/ui/Menu"; +import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow"; +import { compareRecent } from "../channels/sidebar-sections"; + +/** The private channels `viewer` is in, most recent first: those `bestie` is + * in too, and the rest. DMs, Solo chats, huddle rooms and system channels + * are not ones to add an agent to. */ +export function privateChannels( + channels: readonly ChannelSummary[], + viewer: string, + bestie: string, +) { + const yours = channels + .filter( + (channel) => + !channel.archived && + !channel.huddle && + (channel.channelType === "stream" || channel.channelType === "forum") && + channel.private && + channel.members?.includes(viewer), + ) + .sort(compareRecent); + return { + joined: yours.filter((channel) => channel.members?.includes(bestie)), + others: yours.filter((channel) => !channel.members?.includes(bestie)), + }; +} + +/** Removes `pubkey` from one channel as the Members dialog does, resolving + * only once a fresh roster read confirms it. */ +async function removeMember( + session: RelaySession, + channelId: string, + pubkey: string, +) { + const admin = session.memberAdministration; + await admin.refresh(channelId); + const read = admin.snapshot(channelId); + if (read.status !== "ready") + throw new Error(read.error ?? "Member roles are unavailable. Try again."); + const expectedRole = read.authority.roles[pubkey]; + // Already gone: nothing to send. + if (!expectedRole) return; + await admin.run(channelId, { pubkey, expectedRole, role: "remove" }); + const { operation, error } = admin.snapshot(channelId); + if (operation?.change.pubkey !== pubkey || operation.status !== "confirmed") + throw new Error(error ?? "The removal isn't confirmed. Try again."); +} + +const message = (error: unknown) => + error instanceof Error ? error.message : String(error); + +/** The private channels Bestie reads because it is a member. */ +export function PrivateChannels({ + session, + bestie, +}: { + session: RelaySession; + bestie: string; +}) { + const list = useChannelList(session.channels); + const { joined, others } = privateChannels( + list.channels, + session.viewer ?? "", + bestie, + ); + const addable = others.filter((channel) => canAddMembers(session, channel)); + const [busy, setBusy] = useState>(new Set()); + const [failed, setFailed] = useState< + Readonly>> + >({}); + const [confirming, setConfirming] = useState(false); + + /** Runs `change` on each channel at once and keeps why each one failed. */ + const run = async ( + channels: readonly ChannelSummary[], + change: (id: string) => Promise, + ) => { + const ids = channels.map((channel) => channel.id); + setBusy((current) => new Set([...current, ...ids])); + setFailed((current) => + Object.fromEntries( + Object.entries(current).filter(([id]) => !ids.includes(id)), + ), + ); + const results = await Promise.allSettled(ids.map(change)); + setBusy( + (current) => new Set([...current].filter((id) => !ids.includes(id))), + ); + setFailed((current) => ({ + ...current, + ...Object.fromEntries( + results.flatMap((result, index) => { + const channel = channels[index]; + return result.status === "rejected" && channel + ? [ + [ + channel.id, + { name: channel.name, error: message(result.reason) }, + ], + ] + : []; + }), + ), + })); + }; + const add = (channels: readonly ChannelSummary[]) => + run(channels, (id) => session.memberAdditions.add(id, bestie)); + const remove = (channel: ChannelSummary) => + run([channel], (id) => removeMember(session, id, bestie)); + const adding = addable.some((channel) => busy.has(channel.id)); + const problems = Object.entries(failed); + + return ( + <> + + + Add to a channel…} + /> + + {addable.map((channel) => ( + void add([channel])} + > + #{channel.name} + + ))} + + + + + } + /> + + {joined.map((channel) => ( + void remove(channel)} + > + Remove + + } + /> + ))} + {!joined.length && ( + + )} + + + {problems.length > 0 && ( +
+ {problems.map(([id, { name, error }]) => ( +

+ #{name}: {error} +

+ ))} +
+ )} + {confirming && ( + setConfirming(false)} + actions={ + <> + + + + } + /> + )} + + ); +} diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 55028ca18a..af38771e95 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -27,7 +27,14 @@ it never adds a second one. **Restore** puts back the original section in place of its two markers and what lies between them, when they still show where it starts and ends; otherwise fix its markers in Instructions. Off deletes no memory, and the agent's core memory is still in every prompt; -clear memory in the Memory tab. The tab's other settings are not set up yet. +clear memory in the Memory tab. + +**Private channels Bestie can read** lists the private channels you share with +Bestie, each with **Remove**. **Add to a channel…** and **Add to all my +private channels** add it to the private channels you are in, most recently +active first; each channel's members see that you added it. Public channels +need nothing: agents read them already. Check-ins and adding Bestie to new +channels you create are not set up yet. --- diff --git a/src/bundled/channels/sidebar-sections.ts b/src/bundled/channels/sidebar-sections.ts index 9e184c7745..46b728ef02 100644 --- a/src/bundled/channels/sidebar-sections.ts +++ b/src/bundled/channels/sidebar-sections.ts @@ -29,6 +29,30 @@ export function orderContributedSidebarSections( .map(({ section }) => section); } +function compareName(a: ChannelSummary, b: ChannelSummary) { + return a.name.toLowerCase() < b.name.toLowerCase() + ? -1 + : a.name.toLowerCase() > b.name.toLowerCase() + ? 1 + : a.id.localeCompare(b.id); +} + +/** The sidebar's "recent" order: newest activity first, channels without known + * activity after, then by name. */ +export function compareRecent(a: ChannelSummary, b: ChannelSummary) { + if ( + a.lastActivityAt !== undefined && + b.lastActivityAt !== undefined && + a.lastActivityAt !== b.lastActivityAt + ) + return b.lastActivityAt - a.lastActivityAt; + if (a.lastActivityAt !== undefined && b.lastActivityAt === undefined) + return -1; + if (a.lastActivityAt === undefined && b.lastActivityAt !== undefined) + return 1; + return compareName(a, b); +} + /** Preferences only arrange the supplied authorized roster; they never add channels. */ export function sidebarSections( channels: readonly T[], @@ -51,28 +75,10 @@ export function sidebarSections( const groups = preferences?.sections ?? []; const ids = new Set(groups.map((group) => group.id)); const assignment = (id: string) => preferences?.assignments[id]; - const compareName = (a: ChannelSummary, b: ChannelSummary) => - a.name.toLowerCase() < b.name.toLowerCase() - ? -1 - : a.name.toLowerCase() > b.name.toLowerCase() - ? 1 - : a.id.localeCompare(b.id); const sort = (rows: readonly ChannelSummary[], key: string) => - [...rows].sort((a, b) => { - if (preferences?.sort?.[key] === "recent") { - if ( - a.lastActivityAt !== undefined && - b.lastActivityAt !== undefined && - a.lastActivityAt !== b.lastActivityAt - ) - return b.lastActivityAt - a.lastActivityAt; - if (a.lastActivityAt !== undefined && b.lastActivityAt === undefined) - return -1; - if (a.lastActivityAt === undefined && b.lastActivityAt !== undefined) - return 1; - } - return compareName(a, b); - }); + [...rows].sort( + preferences?.sort?.[key] === "recent" ? compareRecent : compareName, + ); return [ { key: "starred", From 373e720abd43e4e1eb80f8d707135482cb58ecc2 Mon Sep 17 00:00:00 2001 From: Riley Crane Date: Thu, 29 Oct 2026 20:02:16 -0700 Subject: [PATCH 2/3] fix(bestie): add to private channels one at a time and keep reports current Add to all now adds Bestie to one channel at a time, as adding several agents does, so a long list doesn't burst the relay. It adds exactly the channels its dialog counted, even if the list changes while the dialog is open. A failure stops showing once the channel is as the change wanted. Remove is unavailable on a cached roster, which the Members dialog doesn't administer either. Co-authored-by: eggboy <3986e5374b8c11a274f4a53fa9a68e1e18b53e712f8aa5218add2485bf5a2b09@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Riley Crane --- src/bundled/bestie/PrivateChannels.test.tsx | 28 ++++++++- src/bundled/bestie/PrivateChannels.tsx | 70 +++++++++++---------- 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/bundled/bestie/PrivateChannels.test.tsx b/src/bundled/bestie/PrivateChannels.test.tsx index 709a35f9ba..c16c3cada7 100644 --- a/src/bundled/bestie/PrivateChannels.test.tsx +++ b/src/bundled/bestie/PrivateChannels.test.tsx @@ -57,7 +57,7 @@ it("keeps only your private channels, split by Bestie's membership, most recent ]); }); -function session(channels: ChannelSummary[]) { +function session(channels: ChannelSummary[], removal = "confirmed") { const roles: Record = { [BESTIE]: "bot" }; const add = vi.fn(async (id: string) => { if (id === "id-beta") throw new Error("Not confirmed. Try again."); @@ -83,8 +83,11 @@ function session(channels: ChannelSummary[]) { authority: { roles: {} }, operation: { change: { pubkey: BESTIE, role: "remove" }, - status: "confirmed", + status: removal, }, + ...(removal !== "confirmed" && { + error: "The change may have taken effect. Refresh to check.", + }), } : { status: "ready", authority: { roles } }, ), @@ -146,3 +149,24 @@ it("adds Bestie to one channel, or to all after asking, and reports what didn't "#beta: Not confirmed. Try again.", ); }); + +it("says when a removal isn't confirmed, and offers none from a cached roster", async () => { + const user = userEvent.setup(); + const { value } = session( + [ + channel("shared", [YOU, BESTIE]), + channel("offline", [YOU, BESTIE], { cached: true }), + ], + "uncertain", + ); + render(); + expect( + screen.getByRole("button", { name: "Remove Bestie from #offline" }), + ).toBeDisabled(); + await user.click( + screen.getByRole("button", { name: "Remove Bestie from #shared" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "#shared: The change may have taken effect. Refresh to check.", + ); +}); diff --git a/src/bundled/bestie/PrivateChannels.tsx b/src/bundled/bestie/PrivateChannels.tsx index 92ba21b480..3a56ebe669 100644 --- a/src/bundled/bestie/PrivateChannels.tsx +++ b/src/bundled/bestie/PrivateChannels.tsx @@ -81,50 +81,51 @@ export function PrivateChannels({ ); const addable = others.filter((channel) => canAddMembers(session, channel)); const [busy, setBusy] = useState>(new Set()); + /** Why each channel's last change failed, and whether it was to join. */ const [failed, setFailed] = useState< - Readonly>> + Readonly< + Record> + > >({}); - const [confirming, setConfirming] = useState(false); + /** The channels Add to all asked about. */ + const [confirming, setConfirming] = useState(); - /** Runs `change` on each channel at once and keeps why each one failed. */ + /** Runs `change` on each channel in turn, as adding several agents does, + * and keeps why each one failed. */ const run = async ( channels: readonly ChannelSummary[], + join: boolean, change: (id: string) => Promise, ) => { - const ids = channels.map((channel) => channel.id); + const ids = new Set(channels.map((channel) => channel.id)); setBusy((current) => new Set([...current, ...ids])); setFailed((current) => Object.fromEntries( - Object.entries(current).filter(([id]) => !ids.includes(id)), + Object.entries(current).filter(([id]) => !ids.has(id)), ), ); - const results = await Promise.allSettled(ids.map(change)); - setBusy( - (current) => new Set([...current].filter((id) => !ids.includes(id))), - ); - setFailed((current) => ({ - ...current, - ...Object.fromEntries( - results.flatMap((result, index) => { - const channel = channels[index]; - return result.status === "rejected" && channel - ? [ - [ - channel.id, - { name: channel.name, error: message(result.reason) }, - ], - ] - : []; - }), - ), - })); + for (const { id, name } of channels) { + let error: string | undefined; + try { + await change(id); + } catch (cause) { + error = message(cause); + } + setBusy((current) => new Set([...current].filter((key) => key !== id))); + if (error !== undefined) + setFailed((current) => ({ ...current, [id]: { name, error, join } })); + } }; const add = (channels: readonly ChannelSummary[]) => - run(channels, (id) => session.memberAdditions.add(id, bestie)); + run(channels, true, (id) => session.memberAdditions.add(id, bestie)); const remove = (channel: ChannelSummary) => - run([channel], (id) => removeMember(session, id, bestie)); + run([channel], false, (id) => removeMember(session, id, bestie)); const adding = addable.some((channel) => busy.has(channel.id)); - const problems = Object.entries(failed); + // A failure stops showing once the channel is as the change wanted anyway. + const inBestie = new Set(joined.map((channel) => channel.id)); + const problems = Object.entries(failed).filter( + ([id, { join }]) => inBestie.has(id) !== join, + ); return ( <> @@ -154,7 +155,7 @@ export function PrivateChannels({ variant="outline" disabled={!addable.length} loading={adding} - onClick={() => setConfirming(true)} + onClick={() => setConfirming(addable)} > Add to all my private channels @@ -170,6 +171,7 @@ export function PrivateChannels({ + } - /> - - {addable.map((channel) => ( - void add([channel])} - > - #{channel.name} - - ))} - - - - - } /> {joined.map((channel) => ( @@ -186,6 +157,55 @@ export function PrivateChannels({ subtitle="Add Bestie to the ones you want it to catch you up on." /> )} + {/* Each action has a row of its own, so its button can wrap under + its title in a narrow panel instead of widening the tab. */} + {addable.length > 0 && ( + <> + ( + + + Choose… + + } + /> + + {addable.map((channel) => ( + void add([channel])} + > + #{channel.name} + + ))} + + + )} + /> + ( + + )} + /> + + )}