From 17c4f678c1d7f0d5618b7ddc2faf0fbbfe4a0f93 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 27 Oct 2026 22:08:13 -0700 Subject: [PATCH 01/17] fix(channels): prioritize this client's agents in member invitations Offer agents configured on this client before typing. Group search results by client agents, people, other community agents and the viewer's remaining agent inventory, preserving archive, membership, paging and Solo rules. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../channels/ChannelMembersDialog.test.tsx | 200 ++++++++++++++++-- src/bundled/channels/ChannelMembersDialog.tsx | 83 ++++++-- 2 files changed, 247 insertions(+), 36 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 12e62cc372..3710034950 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -257,6 +257,157 @@ async function setup( }, }; } +it("offers your agents before typing and adds one without a directory search", async () => { + const t = await setup(); + const yours = await screen.findByRole("region", { + name: "Your agents on this client", + }); + const add = within(yours).getByRole("button", { name: /^Add Fixture agent/ }); + expect(screen.getByRole("searchbox")).toHaveValue(""); + await t.user.click(add); + await screen.findByText("Fixture agent is in the channel."); + expect( + screen.queryByRole("button", { name: /^Add Fixture agent/ }), + ).toBeNull(); + expect( + t.query.mock.calls + .flatMap(([filters]) => filters) + .filter((filter) => filter.search), + ).toEqual([]); +}); + +it("orders matches by client agents, people, other agents, then your non-client agents", async () => { + const t = await setup(); + const other = profile(keypair(), { name: "Fixture", is_agent: true }); + const person = profile(t.person, { name: "Fixture person" }); + const saved = { pubkey: keypair().pubkey, name: "Fixture saved" }; + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [saved], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) ? [other, person] : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + await screen.findByRole("button", { name: /^Add Fixture person/ }); + const results = screen.getByRole("region", { name: "Not in this channel" }); + const rows = within(results).getAllByRole("button", { name: /^Add / }); + const sections = [ + "Your agents on this client", + "People", + "Other agents", + "Your other agents", + ]; + expect(rows).toHaveLength(4); + expect( + sections.map((name) => + within(within(results).getByRole("region", { name })).getByRole( + "button", + { name: /^Add / }, + ), + ), + ).toEqual(rows); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Fixture agent/), + expect.stringMatching(/^Add Fixture person/), + expect.stringMatching(/^Add Fixture \(/), + expect.stringMatching(/^Add Fixture saved/), + ]); + expect( + screen.queryByRole("checkbox", { name: "Include other agents" }), + ).toBeNull(); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("keeps library-only agents out of the default list and the client group", async () => { + const t = await setup(); + const old = profile(keypair(), { name: "Fixture old agent", is_agent: true }); + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [t.agent, { pubkey: old.pubkey, name: "Fixture old agent" }], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + }); + const yours = screen.getByRole("region", { + name: "Your agents on this client", + }); + expect(within(yours).getAllByRole("button", { name: /^Add / })).toHaveLength( + 1, + ); + expect( + screen.queryByRole("button", { name: /^Add Fixture old agent/ }), + ).toBeNull(); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) ? [old] : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + await within( + await screen.findByRole("region", { name: "Your other agents" }), + ).findByRole("button", { name: /^Add Fixture old agent/ }); + expect(within(yours).getAllByRole("button", { name: /^Add / })).toHaveLength( + 1, + ); + await t.user.clear(screen.getByRole("searchbox")); + await act(async () => t.control.set({ agents: [] })); + expect( + screen.queryByRole("region", { name: "Your agents on this client" }), + ).toBeNull(); + expect(screen.queryByRole("button", { name: /^Add Fixture/ })).toBeNull(); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("does not promote saved agents after a client inventory failure and recovers", async () => { + const t = await setup(); + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [t.agent], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + t.control.set({ status: "error", error: "Client inventory offline" }); + }); + await screen.findByText("Some agents could not load."); + expect( + screen.queryByRole("region", { name: "Your agents on this client" }), + ).toBeNull(); + await act(async () => t.control.set({ status: "ready" })); + expect( + within( + screen.getByRole("region", { name: "Your agents on this client" }), + ).getByRole("button", { name: /^Add Fixture agent/ }), + ).toBeVisible(); + expect(screen.queryByText("Some agents could not load.")).toBeNull(); +}); + +it("keeps archived agents out of the default invitations", async () => { + const t = await setup("stream", false, true, undefined, undefined, { + archivedAgent: true, + }); + await vi.waitFor(() => + expect(t.session.archives.snapshot().status).toBe("ready"), + ); + expect( + screen.queryByRole("region", { name: "Your agents on this client" }), + ).toBeNull(); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + await vi.waitFor(() => + expect( + screen.getByRole("button", { name: "Refresh member data" }), + ).toHaveAttribute("aria-busy", "false"), + ); + expect( + screen.queryByRole("button", { name: /^Add Fixture agent/ }), + ).toBeNull(); +}); + it("searches outside the roster, prevents double submission, and moves confirmed additions into Members", async () => { const t = await setup(); const add = await t.search(); @@ -305,12 +456,8 @@ it("ranks a matching agent of yours by name and keeps the highlight on its row a .getByRole("region", { name: "Not in this channel" }) .querySelector("[data-highlighted]"); expect(highlighted()).toContainElement(morgan); - t.readAgentLibrary.mockResolvedValue({ - definitions: [], - identities: [agent], - }); await act(async () => { - await t.session.agentChoices.refresh(); + t.control.set({ agents: [{ ...t.agent, ...agent }] }); }); const rows = within( screen.getByRole("region", { name: "Not in this channel" }), @@ -355,7 +502,7 @@ it("DMs are view-only and never search outside their members", async () => { }); it("Escape closes the dialog and restores focus to its header button", async () => { const t = await setup(); - expect(screen.getByRole("searchbox")).toHaveFocus(); + await vi.waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus()); await t.user.keyboard("{Escape}"); await vi.waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), @@ -1083,6 +1230,8 @@ it("retries ownership admission and failed owner names through the shared refres canOpenLink: () => true, onOpenLink: () => true, }); + // Leave no default invitation view to release a slot during this budget test. + await act(async () => t.control.set({ agents: [] })); const manager = keypair(); const agent = managedProfile(t.person, manager, 1800000000); const original = t.query.getMockImplementation(); @@ -1148,6 +1297,8 @@ async function pagedInvitations( const local = Array.from({ length: localCount }, (_, index) => ({ pubkey: keypair().pubkey, name: `Helper local ${index}`, + owner: t.viewer.pubkey, + relayUrl: t.agent.relayUrl, })); const firstRemote = remote[0]; if (!firstRemote) throw new Error("Missing first remote fixture"); @@ -1160,6 +1311,12 @@ async function pagedInvitations( }); await step(() => act(async () => { + t.control.set({ + agents: [ + ...local, + { ...t.agent, pubkey: firstRemote.pubkey, name: "Helper remote 0" }, + ], + }); await t.session.agentChoices.refresh(); }), ); @@ -1217,10 +1374,10 @@ it("pages combined local and relay invitations without losing matches", async ({ const firstPage = rows(); expect(firstPage).toHaveLength(30); expect( - firstPage.every((row) => - row.getAttribute("aria-label")?.startsWith("Add Helper remote"), - ), - ).toBe(true); + within( + screen.getByRole("region", { name: "Your agents on this client" }), + ).getAllByRole("button", { name: /^Add / }), + ).toEqual(firstPage); expect(pages()).toEqual([1]); for (const count of [60, 90, 95]) { await more(); @@ -1266,8 +1423,11 @@ it("resets combined invitation paging on refresh or query change", async ({ expect(rows()).toHaveLength(30); await search(""); expect( - screen.queryByRole("region", { name: "Not in this channel" }), - ).toBeNull(); + within( + screen.getByRole("region", { name: "Your agents on this client" }), + ).getAllByRole("button", { name: /^Add / }), + ).toHaveLength(30); + expect(pages()).toEqual([1, 2, 1, 1]); expect(t.publish).not.toHaveBeenCalled(); }); }); @@ -1297,6 +1457,8 @@ it("adds a team's members not in the channel with one button", async () => { snapshot: () => snapshot, } as unknown as Agents; const t = await setup("stream", false, false, undefined, agents); + await screen.findByRole("region", { name: "Your agents on this client" }); + expect(screen.queryByRole("list", { name: "Teams" })).toBeNull(); await t.user.type(screen.getByRole("searchbox"), "Cr"); const teams = await screen.findByRole("list", { name: "Teams" }); expect(within(teams).getByText("Ada, Cy")).toBeInTheDocument(); @@ -1323,15 +1485,13 @@ it("adds a team's members not in the channel with one button", async () => { }); it("offers local agents to add by the shared name rule", async () => { const t = await setup(); - t.readAgentLibrary.mockResolvedValue({ - definitions: [], - identities: [ - { pubkey: keypair().pubkey, name: "José" }, - { pubkey: keypair().pubkey, name: "Honey" }, - ], - }); await act(async () => { - await t.session.agentChoices.refresh(); + t.control.set({ + agents: [ + { ...t.agent, pubkey: keypair().pubkey, name: "José" }, + { ...t.agent, pubkey: keypair().pubkey, name: "Honey" }, + ], + }); }); const query = t.query.getMockImplementation(); if (!query) throw new Error("Missing query fixture"); diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index b1f5f1193d..67a80c9f1c 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -768,13 +768,18 @@ export function ChannelMembersDialog({ const candidates = new Map( search.people.map((person) => [person.pubkey, person]), ); - if (text) + // Default suggestions use this client's agents; searches also include the + // viewer's non-client inventory after people and other community agents. + if (text || (!solo && canAdd)) for (const agent of solo ? canAddSoloAgents ? agentChoices.selectable : [] - : agentChoices.identities) { - if (addable(agent.pubkey, label(agent.pubkey, agent.name), agent.name)) + : agentChoices.selectable.filter((agent) => !!text || agent.managed)) { + if ( + !text || + addable(agent.pubkey, label(agent.pubkey, agent.name), agent.name) + ) candidates.set(agent.pubkey, { pubkey: agent.pubkey, name: agent.name, @@ -782,8 +787,8 @@ export function ChannelMembersDialog({ }); } const needle = text.toLowerCase(); - // The relay ranks each page exact, then prefix, then other. Rank merged - // agents the same way; the stable sort keeps the relay's order within a rank. + // Client-managed agents come first; within each group retain the shared name + // ranking and the relay's order for equal ranks. const candidateRank = (person: { pubkey: string; name: string }) => typedMatch(label(person.pubkey, person.name), needle)?.tier ?? 4; const soloAgents = new Set( @@ -795,6 +800,14 @@ export function ChannelMembersDialog({ known.has(person.pubkey) || profiles.get(person.pubkey)?.isAgent ); + const candidateGroup = (person: { pubkey: string; isAgent?: boolean }) => + known.get(person.pubkey)?.managed + ? 0 + : !agentCandidate(person) + ? 1 + : known.has(person.pubkey) + ? 3 + : 2; const available = [...candidates.values()] .filter( (person) => @@ -807,7 +820,11 @@ export function ChannelMembersDialog({ !(canAddSoloAgents && soloAgents.has(person.pubkey)) ), ) - .sort((a, b) => candidateRank(a) - candidateRank(b)); + .sort( + (a, b) => + (!solo && candidateGroup(a) - candidateGroup(b)) || + candidateRank(a) - candidateRank(b), + ); // Known agents supplement the server page, not its rendering bound. Keep all // matches reachable through the existing More action without mounting them all. const invitationSize = @@ -817,7 +834,22 @@ export function ChannelMembersDialog({ const visibleCandidates = roleFilter === "All" ? available.slice(0, invitationSize) : []; const moreCandidates = available.length > invitationSize; - const offering = !initialLoading && canAdd && roleFilter === "All" && !!text; + const offering = + !initialLoading && + canAdd && + roleFilter === "All" && + (!!text || (!solo && available.length > 0)); + const invitationGroups = [ + "Your agents on this client", + "People", + "Other agents", + "Your other agents", + ].map((name, index) => ({ + name, + people: visibleCandidates.filter( + (person) => !solo && candidateGroup(person) === index, + ), + })); const agentsByMention = offering && solo && @@ -904,7 +936,7 @@ export function ChannelMembersDialog({ const missing = team.members.filter( (key) => teamNames.has(key) && !members.has(key), ); - return missing.length && matchPerson(team.name, query) + return text && missing.length && matchPerson(team.name, query) ? [{ team, missing }] : []; }); @@ -1616,14 +1648,33 @@ export function ChannelMembersDialog({ ))} )} - {visibleCandidates.map((person) => - row( - person.pubkey, - label(person.pubkey, person.name), - true, - person.picture, - person.isAgent, - ), + {(solo + ? [{ name: "", people: visibleCandidates }] + : invitationGroups + ).map((group) => + group.people.length > 0 ? ( +
+ {group.name && ( +

+ {group.name} +

+ )} + {group.people.map((person) => + row( + person.pubkey, + label(person.pubkey, person.name), + true, + person.picture, + person.isAgent, + ), + )} +
+ ) : null, )} {search.loading && (

Date: Tue, 27 Oct 2026 23:04:35 -0700 Subject: [PATCH 02/17] fix(channels): start invitation groups with four options Expand each group independently in batches of four and reset expansion when searching or refreshing. Use secondary Show more buttons and separate the search continuation from group expansion. Clarify agents not set up here. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../channels/ChannelMembersDialog.test.tsx | 200 +++++++++++++----- src/bundled/channels/ChannelMembersDialog.tsx | 98 ++++++--- 2 files changed, 215 insertions(+), 83 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 3710034950..688e15ad1e 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -260,7 +260,7 @@ async function setup( it("offers your agents before typing and adds one without a directory search", async () => { const t = await setup(); const yours = await screen.findByRole("region", { - name: "Your agents on this client", + name: "Your agents", }); const add = within(yours).getByRole("button", { name: /^Add Fixture agent/ }); expect(screen.getByRole("searchbox")).toHaveValue(""); @@ -297,12 +297,7 @@ it("orders matches by client agents, people, other agents, then your non-client await screen.findByRole("button", { name: /^Add Fixture person/ }); const results = screen.getByRole("region", { name: "Not in this channel" }); const rows = within(results).getAllByRole("button", { name: /^Add / }); - const sections = [ - "Your agents on this client", - "People", - "Other agents", - "Your other agents", - ]; + const sections = ["Your agents", "People", "Other agents", "Not set up here"]; expect(rows).toHaveLength(4); expect( sections.map((name) => @@ -324,6 +319,74 @@ it("orders matches by client agents, people, other agents, then your non-client expect(t.publish).not.toHaveBeenCalled(); }); +it("starts every invitation group at four and expands only the selected group", async () => { + const t = await setup(); + const client = Array.from({ length: 5 }, (_, index) => ({ + ...t.agent, + pubkey: keypair().pubkey, + name: `Helper client ${index}`, + })); + const saved = Array.from({ length: 5 }, (_, index) => ({ + pubkey: keypair().pubkey, + name: `Helper saved ${index}`, + })); + const people = Array.from({ length: 5 }, (_, index) => + profile(keypair(), { name: `Helper person ${index}` }), + ); + const other = Array.from({ length: 5 }, (_, index) => + profile(keypair(), { name: `Helper other ${index}`, is_agent: true }), + ); + t.readAgentLibrary.mockResolvedValue({ definitions: [], identities: saved }); + await act(async () => { + t.control.set({ agents: client }); + await t.session.agentChoices.refresh(); + }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? [...other, ...people] + : query(filters), + ); + const groups = ["Your agents", "People", "Other agents", "Not set up here"]; + const rows = (name: string) => + within(screen.getByRole("region", { name })).getAllByRole("button", { + name: /^Add /, + }); + expect(rows(groups[0] ?? "")).toHaveLength(4); + await t.user.type(screen.getByRole("searchbox"), "Helper"); + await screen.findByRole("button", { name: /^Add Helper person 0/ }); + for (const name of groups) { + expect(rows(name)).toHaveLength(4); + expect( + within(screen.getByRole("region", { name })).getByRole("button", { + name: "Show more", + }), + ).toBeVisible(); + } + await t.user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}"); + expect( + screen + .getByRole("region", { name: "Not in this channel" }) + .querySelector("[data-highlighted]"), + ).toContainElement(rows("People")[0] ?? null); + await t.user.click( + within(screen.getByRole("region", { name: "People" })).getByRole("button", { + name: "Show more", + }), + ); + expect(rows("People")).toHaveLength(5); + expect( + within(screen.getByRole("region", { name: "People" })).queryByRole( + "button", + { name: "Show more" }, + ), + ).toBeNull(); + for (const name of groups.filter((name) => name !== "People")) + expect(rows(name)).toHaveLength(4); + expect(t.publish).not.toHaveBeenCalled(); +}); + it("keeps library-only agents out of the default list and the client group", async () => { const t = await setup(); const old = profile(keypair(), { name: "Fixture old agent", is_agent: true }); @@ -335,7 +398,7 @@ it("keeps library-only agents out of the default list and the client group", asy await t.session.agentChoices.refresh(); }); const yours = screen.getByRole("region", { - name: "Your agents on this client", + name: "Your agents", }); expect(within(yours).getAllByRole("button", { name: /^Add / })).toHaveLength( 1, @@ -350,16 +413,14 @@ it("keeps library-only agents out of the default list and the client group", asy ); await t.user.type(screen.getByRole("searchbox"), "Fixture"); await within( - await screen.findByRole("region", { name: "Your other agents" }), + await screen.findByRole("region", { name: "Not set up here" }), ).findByRole("button", { name: /^Add Fixture old agent/ }); expect(within(yours).getAllByRole("button", { name: /^Add / })).toHaveLength( 1, ); await t.user.clear(screen.getByRole("searchbox")); await act(async () => t.control.set({ agents: [] })); - expect( - screen.queryByRole("region", { name: "Your agents on this client" }), - ).toBeNull(); + expect(screen.queryByRole("region", { name: "Your agents" })).toBeNull(); expect(screen.queryByRole("button", { name: /^Add Fixture/ })).toBeNull(); expect(t.publish).not.toHaveBeenCalled(); }); @@ -375,14 +436,13 @@ it("does not promote saved agents after a client inventory failure and recovers" t.control.set({ status: "error", error: "Client inventory offline" }); }); await screen.findByText("Some agents could not load."); - expect( - screen.queryByRole("region", { name: "Your agents on this client" }), - ).toBeNull(); + expect(screen.queryByRole("region", { name: "Your agents" })).toBeNull(); await act(async () => t.control.set({ status: "ready" })); expect( - within( - screen.getByRole("region", { name: "Your agents on this client" }), - ).getByRole("button", { name: /^Add Fixture agent/ }), + within(screen.getByRole("region", { name: "Your agents" })).getByRole( + "button", + { name: /^Add Fixture agent/ }, + ), ).toBeVisible(); expect(screen.queryByText("Some agents could not load.")).toBeNull(); }); @@ -394,9 +454,7 @@ it("keeps archived agents out of the default invitations", async () => { await vi.waitFor(() => expect(t.session.archives.snapshot().status).toBe("ready"), ); - expect( - screen.queryByRole("region", { name: "Your agents on this client" }), - ).toBeNull(); + expect(screen.queryByRole("region", { name: "Your agents" })).toBeNull(); await t.user.type(screen.getByRole("searchbox"), "Fixture"); await vi.waitFor(() => expect( @@ -720,10 +778,17 @@ it("refresh restarts the current directory query from page one instead of mixing : query(filters); }); await t.user.type(screen.getByRole("searchbox"), "Morgan"); - const more = await screen.findByRole("button", { name: "Show more results" }); + const more = await screen.findByRole("button", { name: "Find more matches" }); expect(more).toHaveAttribute("data-size", "sm"); expect(more.parentElement).toHaveClass("flex", "justify-center"); await t.user.click(more); + const people = within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getByRole("region", { name: "People" }); + for (let index = 0; index < 7; index++) + await t.user.click( + within(people).getByRole("button", { name: "Show more" }), + ); await screen.findByText(nameText("Morgan stale")); const refresh = screen.getByRole("button", { name: "Refresh member data" }); await vi.waitFor(() => expect(refresh).toHaveAttribute("aria-busy", "false")); @@ -1351,7 +1416,17 @@ async function pagedInvitations( fireEvent.click(button); }); const more = () => - click(screen.getByRole("button", { name: "Show more results" })); + click(screen.getByRole("button", { name: "Find more matches" })); + const groupRows = (name: string) => + within(screen.getByRole("region", { name })).getAllByRole("button", { + name: /^Add /, + }); + const moreIn = (name: string) => + click( + within(screen.getByRole("region", { name })).getByRole("button", { + name: "Show more", + }), + ); const rows = () => within( screen.getByRole("region", { name: "Not in this channel" }), @@ -1363,27 +1438,35 @@ async function pagedInvitations( .map((filter) => filter.page); await search("Helper"); await idle(); - return { t, refresh, idle, search, click, more, rows, pages }; + return { + t, + refresh, + idle, + search, + click, + more, + moreIn, + groupRows, + rows, + pages, + }; } -it("pages combined local and relay invitations without losing matches", async ({ +it("expands local and relay groups independently without losing paged matches", async ({ signal, }) => { return ownedTask(signal, async (step) => { - const { t, idle, more, rows, pages } = await pagedInvitations(step, 65); - const firstPage = rows(); - expect(firstPage).toHaveLength(30); - expect( - within( - screen.getByRole("region", { name: "Your agents on this client" }), - ).getAllByRole("button", { name: /^Add / }), - ).toEqual(firstPage); + const { t, idle, more, moreIn, groupRows, rows, pages } = + await pagedInvitations(step, 65); + expect(rows()).toHaveLength(8); + expect(groupRows("Your agents")).toHaveLength(4); + expect(groupRows("People")).toHaveLength(4); + for (let index = 0; index < 16; index++) await moreIn("Your agents"); + expect(groupRows("Your agents")).toHaveLength(66); + expect(groupRows("People")).toHaveLength(4); + for (let index = 0; index < 7; index++) await moreIn("People"); + expect(rows()).toHaveLength(95); expect(pages()).toEqual([1]); - for (const count of [60, 90, 95]) { - await more(); - expect(rows()).toHaveLength(count); - expect(pages()).toEqual([1]); - } await more(); await idle(); expect( @@ -1392,20 +1475,31 @@ it("pages combined local and relay invitations without losing matches", async ({ expect(rows()).toHaveLength(96); expect(pages()).toEqual([1, 2]); expect( - screen.queryByRole("button", { name: "Show more results" }), + screen.queryByRole("button", { name: "Find more matches" }), ).toBeNull(); expect(t.publish).not.toHaveBeenCalled(); }); }); -it("resets combined invitation paging on refresh or query change", async ({ +it("resets every invitation group's expansion on refresh or query change", async ({ signal, }) => { return ownedTask(signal, async (step) => { - // 31 local agents: past one page locally, so a reset is observable. - const { t, refresh, idle, search, click, more, rows, pages } = - await pagedInvitations(step, 31); - for (let i = 0; i < 3; i++) await more(); + const { + t, + refresh, + idle, + search, + click, + more, + moreIn, + groupRows, + rows, + pages, + } = await pagedInvitations(step, 31); + for (let index = 0; index < 7; index++) await moreIn("Your agents"); + for (let index = 0; index < 7; index++) await moreIn("People"); + await more(); await idle(); expect( screen.getByRole("button", { name: /^Add Helper final/ }), @@ -1414,19 +1508,17 @@ it("resets combined invitation paging on refresh or query change", async ({ expect(pages()).toEqual([1, 2]); await click(refresh); await idle(); - expect(rows()).toHaveLength(30); + expect(rows()).toHaveLength(8); expect(pages()).toEqual([1, 2, 1]); - await more(); - expect(rows()).toHaveLength(60); + await moreIn("Your agents"); + expect(groupRows("Your agents")).toHaveLength(8); + expect(groupRows("People")).toHaveLength(4); await search("Helper local"); await idle(); - expect(rows()).toHaveLength(30); + expect(rows()).toHaveLength(8); await search(""); - expect( - within( - screen.getByRole("region", { name: "Your agents on this client" }), - ).getAllByRole("button", { name: /^Add / }), - ).toHaveLength(30); + expect(groupRows("Your agents")).toHaveLength(4); + expect(rows()).toHaveLength(4); expect(pages()).toEqual([1, 2, 1, 1]); expect(t.publish).not.toHaveBeenCalled(); }); @@ -1457,7 +1549,7 @@ it("adds a team's members not in the channel with one button", async () => { snapshot: () => snapshot, } as unknown as Agents; const t = await setup("stream", false, false, undefined, agents); - await screen.findByRole("region", { name: "Your agents on this client" }); + await screen.findByRole("region", { name: "Your agents" }); expect(screen.queryByRole("list", { name: "Teams" })).toBeNull(); await t.user.type(screen.getByRole("searchbox"), "Cr"); const teams = await screen.findByRole("list", { name: "Teams" }); diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 67a80c9f1c..b6537d110d 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -70,6 +70,8 @@ import { avatarMedia } from "../../shared/avatar-source"; const typedMatch = (name: string, needle: string) => needle ? matchPerson(name, needle) : undefined; +const INVITATION_GROUP_SIZE = 4; + /** Mounted rows share the same bounded presence owner as message bylines. */ function MemberAvatar({ session, @@ -587,9 +589,10 @@ export function ChannelMembersDialog({ const [invitationPage, setInvitationPage] = useState({ text, size: MEMBER_SEARCH_PAGE_SIZE, + groupSizes: {} as Record, }); if (invitationPage.text !== text) - setInvitationPage({ text, size: MEMBER_SEARCH_PAGE_SIZE }); + setInvitationPage({ text, size: MEMBER_SEARCH_PAGE_SIZE, groupSizes: {} }); // biome-ignore lint/correctness/useExhaustiveDependencies: destination changes retire addition notices. useEffect(() => { const controller = new AbortController(); @@ -831,25 +834,30 @@ export function ChannelMembersDialog({ invitationPage.text === text ? invitationPage.size : MEMBER_SEARCH_PAGE_SIZE; - const visibleCandidates = - roleFilter === "All" ? available.slice(0, invitationSize) : []; - const moreCandidates = available.length > invitationSize; + const moreCandidates = solo && available.length > invitationSize; const offering = !initialLoading && canAdd && roleFilter === "All" && (!!text || (!solo && available.length > 0)); const invitationGroups = [ - "Your agents on this client", + "Your agents", "People", "Other agents", - "Your other agents", - ].map((name, index) => ({ - name, - people: visibleCandidates.filter( - (person) => !solo && candidateGroup(person) === index, - ), - })); + "Not set up here", + ].map((name, index) => { + const people = + !solo && roleFilter === "All" + ? available.filter((person) => candidateGroup(person) === index) + : []; + const size = invitationPage.groupSizes[name] ?? INVITATION_GROUP_SIZE; + return { name, people: people.slice(0, size), more: people.length > size }; + }); + const visibleCandidates = solo + ? roleFilter === "All" + ? available.slice(0, invitationSize) + : [] + : invitationGroups.flatMap((group) => group.people); const agentsByMention = offering && solo && @@ -916,7 +924,7 @@ export function ChannelMembersDialog({ if (solo) share.reloadRoster(); else setRosterReading(true); setRefresh((value) => value + 1); - setInvitationPage({ text, size: MEMBER_SEARCH_PAGE_SIZE }); + setInvitationPage({ text, size: MEMBER_SEARCH_PAGE_SIZE, groupSizes: {} }); void session.memberAdministration.refresh(channelId).catch(() => {}); // Native inventory retains ready data during refresh, so its snapshot alone // does not expose every in-flight read. @@ -1628,7 +1636,7 @@ export function ChannelMembersDialog({ {...highlight.listProps} >

Not in this {place}

@@ -1649,13 +1657,14 @@ export function ChannelMembersDialog({ )} {(solo - ? [{ name: "", people: visibleCandidates }] + ? [{ name: "", people: visibleCandidates, more: false }] : invitationGroups ).map((group) => group.people.length > 0 ? (
{group.name && (

)} - {group.people.map((person) => - row( - person.pubkey, - label(person.pubkey, person.name), - true, - person.picture, - person.isAgent, - ), + {group.name === "Not set up here" && ( +

+ Your agents from other devices or earlier setups. +

+ )} +
    + {group.people.map((person) => + row( + person.pubkey, + label(person.pubkey, person.name), + true, + person.picture, + person.isAgent, + ), + )} +
+ {group.more && ( +
+ +
)}

) : null, @@ -1709,20 +1747,22 @@ export function ChannelMembersDialog({

)} {(moreCandidates || search.more) && ( -
+
)} From 861c308465e8d9316e518bcfbf116da8be2e9878 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 27 Oct 2026 23:14:56 -0700 Subject: [PATCH 03/17] feat(channels): explain agent imports with community scope hints Reuse the Profile community badge beside Your agents to explain imports on this device. Put setup guidance behind an information popover and preserve nested dismissal and focus restoration through the shared popover controls. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../channels/ChannelMembersDialog.test.tsx | 82 ++++++++++++++++++- src/bundled/channels/ChannelMembersDialog.tsx | 68 +++++++++++++-- src/bundled/channels/ChannelsPage.tsx | 7 ++ src/bundled/channels/index.tsx | 2 + src/features/agents/AgentCommunityScope.tsx | 1 + 5 files changed, 153 insertions(+), 7 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 688e15ad1e..0dd31934ea 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom import { SOLO_LABEL } from "../../features/solo/labels"; import type { Agents } from "../../features/agents/service"; +import type { CommunityReader } from "../../features/communities/service"; +import { emptySync } from "../../features/communities/known-communities"; import "@testing-library/jest-dom/vitest"; import { act, @@ -78,7 +80,11 @@ async function setup( onOpenLink(target: string, returnFocus?: HTMLElement): boolean; }, agents?: Agents, - options: { archivedAgent?: boolean; channelCreation?: boolean } = {}, + options: { + archivedAgent?: boolean; + channelCreation?: boolean; + communities?: CommunityReader; + } = {}, ) { const viewer = keypair(), relay = keypair(), @@ -194,6 +200,7 @@ async function setup( @@ -276,6 +283,79 @@ it("offers your agents before typing and adds one without a directory search", a ).toEqual([]); }); +it("explains imported agents with the current community's badge and nested popover", async () => { + const snapshot: ReturnType = { + status: "ready", + relayAvailable: true, + profile: { name: "Carl", picture: "" }, + memberships: [ + { + id: "https://relay.example.test", + name: "Fixture community", + publishedIcon: "https://images.example/community.png", + }, + ], + selected: "https://other.example.test", + sync: emptySync(), + }; + const t = await setup("stream", false, false, undefined, undefined, { + communities: { snapshot: () => snapshot, subscribe: () => () => {} }, + }); + const group = await screen.findByRole("region", { name: "Your agents" }); + const badge = within(group).getByRole("button", { + name: "Agents scope: Fixture community", + }); + expect(badge.querySelector("img")).toHaveAttribute( + "src", + "https://images.example/community.png", + ); + expect(screen.queryByRole("dialog", { name: "Agents scope" })).toBeNull(); + await t.user.click(badge); + expect( + await screen.findByRole("dialog", { name: "Agents scope" }), + ).toHaveTextContent( + "These are your agents imported into this community on this device.", + ); + await t.user.keyboard("{Escape}"); + await vi.waitFor(() => + expect(screen.queryByRole("dialog", { name: "Agents scope" })).toBeNull(), + ); + expect(screen.getByRole("dialog", { name: "Manage members" })).toBeVisible(); + await vi.waitFor(() => expect(badge).toHaveFocus()); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("keeps setup guidance behind its information popover", async () => { + const t = await setup(); + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [{ pubkey: keypair().pubkey, name: "Fixture old agent" }], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + }); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + const group = await screen.findByRole("region", { name: "Not set up here" }); + const hint = "Your agents from other devices or earlier setups."; + expect(within(group).queryByText(hint)).toBeNull(); + const info = within(group).getByRole("button", { + name: "About agents not set up here", + }); + await t.user.click(info); + expect( + await screen.findByRole("dialog", { name: "About agents not set up here" }), + ).toHaveTextContent(hint); + await t.user.keyboard("{Escape}"); + await vi.waitFor(() => + expect( + screen.queryByRole("dialog", { name: "About agents not set up here" }), + ).toBeNull(), + ); + expect(screen.getByRole("dialog", { name: "Manage members" })).toBeVisible(); + await vi.waitFor(() => expect(info).toHaveFocus()); + expect(t.publish).not.toHaveBeenCalled(); +}); + it("orders matches by client agents, people, other agents, then your non-client agents", async () => { const t = await setup(); const other = profile(keypair(), { name: "Fixture", is_agent: true }); diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index b6537d110d..7692f0cb77 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -20,6 +20,10 @@ import type { RelaySession } from "../../features/relay/session"; import type { Navigation } from "../../features/navigation/controller"; import type { Agents, Team } from "../../features/agents/service"; import { useAgentChoices } from "../../features/agents/use-choices"; +import { AgentCommunityScope } from "../../features/agents/AgentCommunityScope"; +import type { CommunityReader } from "../../features/communities/service"; +import { communityPresentation } from "../../features/communities/community-icon"; +import { splitPartition } from "../../features/relay/partition"; import { useIdentityNames } from "../../features/identity-names/react"; import { usePresenceStatus } from "../../features/presence/react"; import { profileTarget } from "../../features/profiles/target"; @@ -51,11 +55,17 @@ import { SearchField } from "../../shared/design-system/ui/SearchField"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { Accordion } from "../../shared/design-system/ui/Accordion"; import { Tooltip } from "../../shared/design-system/ui/Tooltip"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, +} from "../../shared/design-system/ui/Popover"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { ArrowsClockwiseIcon, CircleNotchIcon, HashIcon, + InfoIcon, UsersIcon, XIcon, } from "../../shared/design-system/icons"; @@ -324,6 +334,7 @@ export function ChannelMembersButton({ onOpenConversation, presentation, agents, + communities, navigator, signal, owner, @@ -335,6 +346,7 @@ export function ChannelMembersButton({ /** Someone else's Solo chat names its owner on the Access control. */ owner?: { name: string; picture?: string | undefined } | undefined; agents?: Agents | undefined; + communities?: CommunityReader | undefined; navigator?: Navigation | undefined; signal?: AbortSignal | undefined; presentation?: @@ -372,6 +384,7 @@ export function ChannelMembersButton({ onOpenLink={onOpenLink} onOpenConversation={onOpenConversation} navigator={navigator} + communities={communities} signal={signal} {...(agents ? { agents } : {})} close={() => setOpen(false)} @@ -445,6 +458,7 @@ export function ChannelMembersDialog({ onOpenLink, onOpenConversation, agents, + communities, navigator, signal, }: { @@ -455,6 +469,7 @@ export function ChannelMembersDialog({ title?: string; description?: string; agents?: Agents; + communities?: CommunityReader | undefined; navigator?: Navigation | undefined; /** The visit that opened this dialog; leaving it retires late results. */ signal?: AbortSignal | undefined; @@ -549,6 +564,20 @@ export function ChannelMembersDialog({ session.archives.snapshot, ); const agentChoices = useAgentChoices(session, true, true); + const communitySnapshot = useSyncExternalStore( + communities?.subscribe ?? noTeams, + communities?.snapshot ?? noSnapshot, + ); + const community = useMemo(() => { + const origin = splitPartition(session.scope)?.communityOrigin; + if (!origin || origin === "offline") return undefined; + try { + return communityPresentation(origin, communitySnapshot?.memberships); + } catch { + // An unscoped/offline reader has no community presentation to show. + return undefined; + } + }, [session.scope, communitySnapshot?.memberships]); const agentsSnapshot = useSyncExternalStore( agents?.subscribe ?? noTeams, agents?.snapshot ?? noSnapshot, @@ -1668,16 +1697,43 @@ export function ChannelMembersDialog({ > {group.name && (

{group.name} + {group.name === "Your agents" && ( + + )} + {group.name === "Not set up here" && ( + + + )}

)} - {group.name === "Not set up here" && ( -

- Your agents from other devices or earlier setups. -

- )}
    {group.people.map((person) => row( diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 2b102703e5..c7bae6bb92 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -90,6 +90,7 @@ import { useChannelLabels } from "./useChannelLabels"; import { useComposerSent } from "./useComposerSent"; import { useSidebarPreferences } from "./useSidebarPreferences"; import styles from "./Channels.module.css"; +import type { CommunityReader } from "../../features/communities/service"; import { avatarMedia } from "../../shared/avatar-source"; /** The open Solo conversation's Archive, published for the shortcut while its visit is live. */ @@ -105,11 +106,13 @@ export function ChannelsPage({ navigation, navigator, agents, + communities, archive, }: { extensions?: ConversationExtensions | undefined; /** For adding a whole team of the owner's agents from the members dialog. */ agents?: Agents | undefined; + communities?: CommunityReader | undefined; relay: RelayData; navigation?: PageNavigation | undefined; navigator: Navigation; @@ -158,6 +161,7 @@ export function ChannelsPage({ archive={archive} companion={companion} agents={agents} + communities={communities} /> )} @@ -176,11 +180,13 @@ function ChannelWorkspace({ navigator, viewer, agents, + communities, archive, }: { extensions?: ConversationExtensions | undefined; agents?: Agents | undefined; companion?: ReactNode; + communities?: CommunityReader | undefined; scope: string; navigation?: PageNavigation | undefined; navigator: Navigation; @@ -1544,6 +1550,7 @@ function ChannelWorkspace({ key={current.id} session={queries} channelId={current.id} + communities={communities} close={() => setMembersOpen(false)} trigger={ membersTrigger.current?.isConnected diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx index 158a8fd631..5d7a2ee2e9 100644 --- a/src/bundled/channels/index.tsx +++ b/src/bundled/channels/index.tsx @@ -19,6 +19,7 @@ export const inject = [ "navigation", "settingsCards", "agents", + "communityReader", "shortcuts", ]; export const apply: PluginModule["apply"] = (ctx) => { @@ -111,6 +112,7 @@ export const apply: PluginModule["apply"] = (ctx) => { archive={archive} companion={companion} agents={agents} + communities={ctx.communityReader} /> ), }); diff --git a/src/features/agents/AgentCommunityScope.tsx b/src/features/agents/AgentCommunityScope.tsx index e5440aa405..a197fdda42 100644 --- a/src/features/agents/AgentCommunityScope.tsx +++ b/src/features/agents/AgentCommunityScope.tsx @@ -15,6 +15,7 @@ export type AgentScopeReader = Pick; const noSubscribe = () => () => {}; const descriptions = { + Agents: "These are your agents imported into this community on this device.", Memory: "Memories are specific to this community and aren’t shared with other communities.", Attention: From 79dc1e9e816f1f731bdb36cd13b259b9ed1c46f8 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 27 Oct 2026 23:19:41 -0700 Subject: [PATCH 04/17] fix(channels): label unconfigured agents as your other agents Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../channels/ChannelMembersDialog.test.tsx | 21 ++++++++++++------- src/bundled/channels/ChannelMembersDialog.tsx | 8 +++---- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 0dd31934ea..7b23ed3251 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -335,20 +335,22 @@ it("keeps setup guidance behind its information popover", async () => { await t.session.agentChoices.refresh(); }); await t.user.type(screen.getByRole("searchbox"), "Fixture"); - const group = await screen.findByRole("region", { name: "Not set up here" }); + const group = await screen.findByRole("region", { + name: "Your other agents", + }); const hint = "Your agents from other devices or earlier setups."; expect(within(group).queryByText(hint)).toBeNull(); const info = within(group).getByRole("button", { - name: "About agents not set up here", + name: "About your other agents", }); await t.user.click(info); expect( - await screen.findByRole("dialog", { name: "About agents not set up here" }), + await screen.findByRole("dialog", { name: "About your other agents" }), ).toHaveTextContent(hint); await t.user.keyboard("{Escape}"); await vi.waitFor(() => expect( - screen.queryByRole("dialog", { name: "About agents not set up here" }), + screen.queryByRole("dialog", { name: "About your other agents" }), ).toBeNull(), ); expect(screen.getByRole("dialog", { name: "Manage members" })).toBeVisible(); @@ -377,7 +379,12 @@ it("orders matches by client agents, people, other agents, then your non-client await screen.findByRole("button", { name: /^Add Fixture person/ }); const results = screen.getByRole("region", { name: "Not in this channel" }); const rows = within(results).getAllByRole("button", { name: /^Add / }); - const sections = ["Your agents", "People", "Other agents", "Not set up here"]; + const sections = [ + "Your agents", + "People", + "Other agents", + "Your other agents", + ]; expect(rows).toHaveLength(4); expect( sections.map((name) => @@ -428,7 +435,7 @@ it("starts every invitation group at four and expands only the selected group", ? [...other, ...people] : query(filters), ); - const groups = ["Your agents", "People", "Other agents", "Not set up here"]; + const groups = ["Your agents", "People", "Other agents", "Your other agents"]; const rows = (name: string) => within(screen.getByRole("region", { name })).getAllByRole("button", { name: /^Add /, @@ -493,7 +500,7 @@ it("keeps library-only agents out of the default list and the client group", asy ); await t.user.type(screen.getByRole("searchbox"), "Fixture"); await within( - await screen.findByRole("region", { name: "Not set up here" }), + await screen.findByRole("region", { name: "Your other agents" }), ).findByRole("button", { name: /^Add Fixture old agent/ }); expect(within(yours).getAllByRole("button", { name: /^Add / })).toHaveLength( 1, diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 7692f0cb77..62e8f0c630 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -873,7 +873,7 @@ export function ChannelMembersDialog({ "Your agents", "People", "Other agents", - "Not set up here", + "Your other agents", ].map((name, index) => { const people = !solo && roleFilter === "All" @@ -1706,13 +1706,13 @@ export function ChannelMembersDialog({ scope="Agents" /> )} - {group.name === "Not set up here" && ( + {group.name === "Your other agents" && (

    Your agents from other devices or earlier From 9bcf25e78320ba9c1478cbe2576d4a59368853f9 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Tue, 27 Oct 2026 23:35:14 -0700 Subject: [PATCH 05/17] test(channels): provide community reader in panel lifecycle fixture Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/features/panels/service.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/features/panels/service.test.tsx b/src/features/panels/service.test.tsx index 96b107243f..fa19032f98 100644 --- a/src/features/panels/service.test.tsx +++ b/src/features/panels/service.test.tsx @@ -8,6 +8,7 @@ import { SettingsCardsService } from "../settings/service"; import { ShortcutsService } from "../shortcuts/service"; import { provideRelay } from "../relay/service"; import { provideNavigation } from "../navigation/service"; +import { emptySync } from "../communities/known-communities"; import * as channelsPlugin from "../../bundled/channels"; import * as githubPlugin from "../../bundled/github"; import { flush } from "../relay/testing"; @@ -40,6 +41,17 @@ it("independently unloads panels without removing the page or shared data", asyn addTool: () => {}, addAttentionSource: () => {}, }); + root.provide("communityReader", { + subscribe: () => () => {}, + snapshot: () => ({ + status: "unavailable", + relayAvailable: false, + profile: { name: "", picture: "" }, + memberships: [], + selected: null, + sync: emptySync(), + }), + }); // GitHub reads through gh where the host can run it; here it cannot. root.provide("host", {}); provideNavigation(root); From afd4d960b0a31a02a972ba9c804e32c8539b7b77 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 09:14:04 -0700 Subject: [PATCH 06/17] test(channels): supply community reader to browser fixtures Co-authored-by: Carl Signed-off-by: Taylor Ho --- tests/fixtures/conversation.tsx | 12 ++++++++++++ tests/fixtures/mentions.tsx | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/tests/fixtures/conversation.tsx b/tests/fixtures/conversation.tsx index 46cf5ae56e..37c52de08c 100644 --- a/tests/fixtures/conversation.tsx +++ b/tests/fixtures/conversation.tsx @@ -1,6 +1,7 @@ import { ToastProvider } from "../../src/shared/design-system/ui/Toast"; // Automated boundary proof only. No live broker, persistent messages or real keys. import { Context } from "@deepseek-ai/cordis"; +import { emptySync } from "../../src/features/communities/known-communities"; import { StrictMode, useState, @@ -291,6 +292,17 @@ conversation.tools.subscribe(() => { } }); ctx.provide("relay", relay); +ctx.provide("communityReader", { + subscribe: () => () => {}, + snapshot: () => ({ + status: "unavailable", + relayAvailable: false, + profile: { name: "", picture: "" }, + memberships: [], + selected: null, + sync: emptySync(), + }), +}); // Channels needs Agents for its members/templates; this fixture has no agents. const noAgents = { status: "unavailable", agents: [], running: [], teams: [] }; ctx.provide("agents", { diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx index 6a22bd53a0..eca6b5719c 100644 --- a/tests/fixtures/mentions.tsx +++ b/tests/fixtures/mentions.tsx @@ -5,6 +5,7 @@ import { StrictMode, useState, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { finalizeEvent } from "nostr-tools"; import { Context } from "@deepseek-ai/cordis"; +import { emptySync } from "../../src/features/communities/known-communities"; import { createPluginManager } from "../../src/plugins/manager"; import { PagesService } from "../../src/features/pages/service"; import { PanelsService } from "../../src/features/panels/service"; @@ -369,6 +370,17 @@ if (channels) { provideNavigation(context); // Channels requires Agents for its page/settings, neither mounted here. context.provide("agents", {}); + context.provide("communityReader", { + subscribe: () => () => {}, + snapshot: () => ({ + status: "unavailable", + relayAvailable: false, + profile: { name: "", picture: "" }, + memberships: [], + selected: null, + sync: emptySync(), + }), + }); // Only the composer is mounted; the Channels page never consumes this service. context.provide("relay", { snapshot: () => ({ session: namedSession }), From 8c46704db196ed9c04c925cf2fc3e9a602ac5afe Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 09:14:28 -0700 Subject: [PATCH 07/17] test(channels): avoid repeated accessibility scans when paging Retain the full pagination dataset and all expansion assertions while reusing connected Show more controls. Keep the existing five-second test budget. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../channels/ChannelMembersDialog.test.tsx | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 7b23ed3251..f38635a57a 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -1508,12 +1508,22 @@ async function pagedInvitations( within(screen.getByRole("region", { name })).getAllByRole("button", { name: /^Add /, }); - const moreIn = (name: string) => - click( - within(screen.getByRole("region", { name })).getByRole("button", { - name: "Show more", - }), - ); + // React retains the group's control while it expands. Resolve it once rather + // than recomputing every growing row's accessible name before each click. + const moreButtons = new Map(); + const moreIn = (name: string) => { + let button = moreButtons.get(name); + if (!button?.isConnected) { + button = within(screen.getByRole("region", { name })).getByRole( + "button", + { + name: "Show more", + }, + ); + moreButtons.set(name, button); + } + return click(button); + }; const rows = () => within( screen.getByRole("region", { name: "Not in this channel" }), From febca637b8f6424ca5f6ab525c0974aece6dfc38 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 09:14:56 -0700 Subject: [PATCH 08/17] test(channels): align browser journeys with grouped invitations Scope roster assertions separately from invitation sections and retain the 240-agent workload with four-row expansion. Co-authored-by: Carl Signed-off-by: Taylor Ho --- tests/browser/channel-members-focus.spec.mjs | 25 ++++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/browser/channel-members-focus.spec.mjs b/tests/browser/channel-members-focus.spec.mjs index c2dab9301f..a1fb9914c9 100644 --- a/tests/browser/channel-members-focus.spec.mjs +++ b/tests/browser/channel-members-focus.spec.mjs @@ -277,10 +277,9 @@ test("member identity geometry and separate addition focus", async ({ expect(field.y - header.y - header.height).toBeCloseTo(12, 0); await expect(add).toHaveCSS("font-size", "12px"); expect((await add.boundingBox()).height).toBe(24); - const members = dialog.getByRole("region", { - name: "People", - exact: true, - }); + const members = dialog.locator( + ':scope section[aria-label="Member list"] > section[aria-label="People"]', + ); const others = dialog.getByRole("region", { name: "Not in this channel", exact: true, @@ -290,7 +289,9 @@ test("member identity geometry and separate addition focus", async ({ exact: true, }); await expect(members).toHaveCount(0); - await expect(others.getByRole("heading")).toHaveCSS("position", "sticky"); + await expect( + others.getByRole("heading", { name: "People", exact: true }), + ).toHaveCSS("position", "sticky"); // With no roster matches, search results start at the top without an orphan divider. await expect(others).toHaveCSS("border-top-width", "0px"); expect((await others.boundingBox()).y).toBeCloseTo( @@ -726,14 +727,16 @@ test("large known-agent searches mount only the first invitation page", async ({ body: JSON.stringify({ counts, ...sample }), contentType: "application/json", }); - expect(counts.slice(0, 3)).toEqual([30, 30, 30]); + expect(counts.slice(0, 3)).toEqual([4, 4, 4]); // A random npub may contain "hell"; it cannot contain "hello" (no o in bech32). - expect(counts[3]).toBeLessThanOrEqual(30); + expect(counts[3]).toBeLessThanOrEqual(4); expect(counts.slice(4)).toEqual([0, 0]); await search.fill("helper"); await expect(refresh).toHaveAttribute("aria-busy", "false"); - const more = dialog.getByRole("button", { name: "Show more results" }); - for (const count of [60, 90, 120, 150, 180, 210, 240]) { + const more = dialog + .getByRole("region", { name: "Your other agents", exact: true }) + .getByRole("button", { name: "Show more", exact: true }); + for (let count = 8; count <= 240; count += 4) { await more.click(); await expect( dialog.getByRole("button", { name: /^Add Helper/ }), @@ -1049,7 +1052,9 @@ for (const rejected of [false, true]) { } else { await expect( members - .getByRole("region", { name: "People", exact: true }) + .locator( + 'section[aria-label="Member list"] > section[aria-label="People"]', + ) .getByRole("button", { name: /Open profile for Morgan/ }), ).toHaveCount(0); await expect(page.locator(".buzz-toast")).toHaveCount(0); From ed48963ef74d1b6b9aee904273368d9bf3aa97f4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 09:27:12 -0700 Subject: [PATCH 09/17] fix(channels): reuse shared ranking within invitation groups Use the existing relationship and search-usage rules inside the agreed groups. Remember successful choices while retaining key lookup, stable row callbacks, Solo ordering and four-row expansion. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../channels/ChannelMembersDialog.test.tsx | 155 +++++++++++++++++- src/bundled/channels/ChannelMembersDialog.tsx | 90 +++++++--- 2 files changed, 222 insertions(+), 23 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index f38635a57a..d319b628a8 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -20,7 +20,20 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, afterEach, expect, it, vi } from "vitest"; import { useEffect } from "react"; import { createRelaySession } from "../../features/relay/session"; -import { keypair, profile, roster, signed } from "../../features/relay/testing"; +import { + keypair, + metadata, + profile, + roster, + signed, +} from "../../features/relay/testing"; +import { personKey } from "../../features/search/people-rank"; +import { + pickerText, + readSearchUsage, + recordChoice, + recordVisit, +} from "../../features/search/usage"; import { matchesEvent } from "../../features/relay/projection"; import type { RelayEvent } from "../../features/relay/events"; import { PublishRejected } from "../../features/relay/outbox"; @@ -53,6 +66,7 @@ function ownedTask( } // jsdom lacks scrollIntoView; the search highlight keeps its row in view. beforeEach(() => { + localStorage.clear(); Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: vi.fn(), @@ -221,6 +235,7 @@ async function setup( query, readAgentLibrary, session: owner.session, + relay, person, viewer, delayRoster: () => { @@ -375,6 +390,11 @@ it("orders matches by client agents, people, other agents, then your non-client t.query.mockImplementation(async (filters) => filters.some((filter) => filter.search) ? [other, person] : query(filters), ); + recordChoice( + t.session.scope, + pickerText("channel-members", "fixture"), + personKey(saved.pubkey), + ); await t.user.type(screen.getByRole("searchbox"), "Fixture"); await screen.findByRole("button", { name: /^Add Fixture person/ }); const results = screen.getByRole("region", { name: "Not in this channel" }); @@ -406,6 +426,139 @@ it("orders matches by client agents, people, other agents, then your non-client expect(t.publish).not.toHaveBeenCalled(); }); +async function relationshipInvitations() { + const t = await setup(); + const people = [ + { ...keypair(), name: "Sam A stranger" }, + { ...keypair(), name: "Sam B shared" }, + { ...keypair(), name: "Sam C group" }, + { ...keypair(), name: "Sam D direct" }, + { ...keypair(), name: "Sam E direct" }, + { ...keypair(), name: "Sam" }, + ]; + const key = (index: number) => people[index]?.pubkey ?? ""; + const directId = "22222222-2222-4222-8222-222222222222"; + const otherDirectId = "33333333-3333-4333-8333-333333333333"; + const groupId = "44444444-4444-4444-8444-444444444444"; + const sharedId = "55555555-5555-4555-8555-555555555555"; + const events = [ + metadata(t.relay, directId, "Direct", undefined, [["t", "dm"]]), + roster(t.relay, directId, [t.viewer.pubkey, key(3)]), + metadata(t.relay, otherDirectId, "Other direct", undefined, [["t", "dm"]]), + roster(t.relay, otherDirectId, [t.viewer.pubkey, key(4)]), + metadata(t.relay, groupId, "Group", undefined, [["t", "dm"]]), + roster(t.relay, groupId, [t.viewer.pubkey, key(2), t.person.pubkey]), + metadata(t.relay, sharedId, "Shared"), + roster(t.relay, sharedId, [t.viewer.pubkey, key(1)]), + ]; + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? people.map((person) => profile(person, { name: person.name })) + : [ + ...(await query(filters)), + ...events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ], + ); + await act(async () => t.session.channels.refreshList?.()); + await vi.waitFor(() => + expect( + t.session.channels + .list() + .channels.find((channel) => channel.id === directId)?.participants, + ).toEqual([key(3)]), + ); + const rows = () => + within( + within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getByRole("region", { name: "People" }), + ) + .getAllByRole("button", { name: /^Add / }) + .map((row) => row.getAttribute("aria-label")); + return { ...t, people, key, directId, otherDirectId, rows }; +} + +it("uses shared relationship bands inside People while exact names stay first", async () => { + const t = await relationshipInvitations(); + await t.user.type(screen.getByRole("searchbox"), "Sa"); + await screen.findByRole("button", { name: /^Add Sam D direct/ }); + expect(t.rows()).toEqual([ + expect.stringMatching(/^Add Sam D direct/), + expect.stringMatching(/^Add Sam E direct/), + expect.stringMatching(/^Add Sam C group/), + expect.stringMatching(/^Add Sam B shared/), + ]); + await t.user.type(screen.getByRole("searchbox"), "m"); + await screen.findByRole("button", { name: /^Add Sam \(/ }); + expect(t.rows()).toEqual([ + expect.stringMatching(/^Add Sam \(/), + expect.stringMatching(/^Add Sam D direct/), + expect.stringMatching(/^Add Sam E direct/), + expect.stringMatching(/^Add Sam C group/), + ]); +}); + +it("uses shared DM visit usage and picker-specific earlier choices", async () => { + const t = await relationshipInvitations(); + recordVisit(t.session.scope, `channel:${t.otherDirectId}`); + recordChoice(t.session.scope, pickerText("dm", "sa"), personKey(t.key(0))); + await t.user.type(screen.getByRole("searchbox"), "Sa"); + await screen.findByRole("button", { name: /^Add Sam E direct/ }); + expect(t.rows()[0]).toMatch(/^Add Sam E direct/); + recordChoice( + t.session.scope, + pickerText("channel-members", "sam"), + personKey(t.key(0)), + ); + await t.user.type(screen.getByRole("searchbox"), "m "); + await screen.findByRole("button", { name: /^Add Sam \(/ }); + // Exact still leads; the earlier Add choice precedes the relationship bands. + expect(t.rows()).toEqual([ + expect.stringMatching(/^Add Sam \(/), + expect.stringMatching(/^Add Sam A stranger/), + expect.stringMatching(/^Add Sam E direct/), + expect.stringMatching(/^Add Sam D direct/), + ]); +}); + +it("remembers successful channel additions without learning from failed writes", async () => { + const t = await setup(); + t.fail("Only the owner can add this agent"); + const add = await t.search(); + await t.user.click(add); + await screen.findByText(/Only the owner can add this agent/); + const typed = pickerText("channel-members", "morgan"); + const key = personKey(t.person.pubkey); + expect( + readSearchUsage(t.session.scope).pick(typed, new Set([key])), + ).toBeUndefined(); + t.fail(""); + await t.user.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("Morgan is in the channel."); + expect(readSearchUsage(t.session.scope).pick(typed, new Set([key]))).toBe( + key, + ); +}); + +it("keeps public-key lookup results whose names do not match the key text", async () => { + const t = await setup(); + for (const value of [t.person.pubkey, npubEncode(t.person.pubkey)]) { + fireEvent.change(screen.getByRole("searchbox"), { target: { value } }); + const add = await screen.findByRole("button", { name: /^Add Morgan/ }); + expect(add).toBeEnabled(); + expect( + within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getAllByRole("button", { name: /^Add / }), + ).toEqual([add]); + } + expect(t.publish).not.toHaveBeenCalled(); +}); + it("starts every invitation group at four and expands only the selected group", async () => { const t = await setup(); const client = Array.from({ length: 5 }, (_, index) => ({ diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 62e8f0c630..3f348d5977 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -27,6 +27,15 @@ import { splitPartition } from "../../features/relay/partition"; import { useIdentityNames } from "../../features/identity-names/react"; import { usePresenceStatus } from "../../features/presence/react"; import { profileTarget } from "../../features/profiles/target"; +import { peopleRelationships } from "../../features/profiles/relationships"; +import { personKey } from "../../features/search/people-rank"; +import { + noSearchUsage, + pickerText, + readSearchUsage, + recordChoice, + searchOrder, +} from "../../features/search/usage"; import { matchPerson } from "../../features/search/person-match"; import { useSearchHighlight } from "../../features/search/use-search-highlight"; import styles from "./ChannelMembersDialog.module.css"; @@ -615,6 +624,11 @@ export function ChannelMembersDialog({ : "" : rosterReadError; const text = query.trim(); + // biome-ignore lint/correctness/useExhaustiveDependencies: a new query or explicit refresh rereads stored choice/visit scores. + const usage = useMemo( + () => (solo ? noSearchUsage : readSearchUsage(session.scope)), + [session.scope, solo, text, refresh], + ); const [invitationPage, setInvitationPage] = useState({ text, size: MEMBER_SEARCH_PAGE_SIZE, @@ -819,10 +833,18 @@ export function ChannelMembersDialog({ }); } const needle = text.toLowerCase(); - // Client-managed agents come first; within each group retain the shared name - // ranking and the relay's order for equal ranks. - const candidateRank = (person: { pubkey: string; name: string }) => - typedMatch(label(person.pubkey, person.name), needle)?.tier ?? 4; + const relationships = useMemo( + () => + peopleRelationships( + list.channels, + new Set( + agentChoices.identities + .filter((agent) => agent.managed) + .map((agent) => agent.pubkey), + ), + ), + [list.channels, agentChoices.identities], + ); const soloAgents = new Set( agentChoices.selectable.map((agent) => agent.pubkey), ); @@ -840,23 +862,41 @@ export function ChannelMembersDialog({ : known.has(person.pubkey) ? 3 : 2; - const available = [...candidates.values()] - .filter( - (person) => - !members.has(person.pubkey) && - !archiveHides(session.archives, person.pubkey, session.viewer) && - // A Solo chat offers only the agents its owner can add directly. - !( - solo && - agentCandidate(person) && - !(canAddSoloAgents && soloAgents.has(person.pubkey)) - ), - ) - .sort( - (a, b) => - (!solo && candidateGroup(a) - candidateGroup(b)) || - candidateRank(a) - candidateRank(b), - ); + const available = [...candidates.values()].filter( + (person) => + !members.has(person.pubkey) && + !archiveHides(session.archives, person.pubkey, session.viewer) && + // A Solo chat offers only the agents its owner can add directly. + !( + solo && + agentCandidate(person) && + !(canAddSoloAgents && soloAgents.has(person.pubkey)) + ), + ); + const picked = usage.pick( + pickerText("channel-members", needle), + new Set(available.map((person) => personKey(person.pubkey))), + ); + // Keep the product's outer groups; within each, use New message's shared + // exact/choice/relationship/usage order. Relay ties and key-only matches stay. + const candidateRank = (person: { pubkey: string; name: string }) => { + const rank = + typedMatch(label(person.pubkey, person.name), needle)?.tier ?? 4; + if (solo || !needle) return rank; + const key = personKey(person.pubkey); + const dm = relationships.dm(person.pubkey); + return searchOrder(usage, picked, { + key, + rank, + band: relationships.rank(person.pubkey), + usageKeys: [key, ...(dm ? [`channel:${dm}`] : [])], + }); + }; + available.sort( + (a, b) => + (!solo && candidateGroup(a) - candidateGroup(b)) || + candidateRank(a) - candidateRank(b), + ); // Known agents supplement the server page, not its rendering bound. Keep all // matches reachable through the existing More action without mounting them all. const invitationSize = @@ -1001,6 +1041,11 @@ export function ChannelMembersDialog({ async (key: string) => { const signal = lifetime.current?.signal; if (!signal) return; + // Capture this intent's text without changing the callback on each key. + const typed = pickerText( + "channel-members", + input.current?.value.trim().toLowerCase() ?? "", + ); setNotice(""); // A Solo owner's agents join directly; people use ordinary additions. const direct = @@ -1023,6 +1068,7 @@ export function ChannelMembersDialog({ ); else await session.memberAdditions.add(channelId, key); if (!signal.aborted) { + if (!solo) recordChoice(session.scope, typed, personKey(key)); if (direct) { setSoloAdditions((old) => ({ ...old, [key]: { pending: false } })); setQuery(""); @@ -1049,7 +1095,7 @@ export function ChannelMembersDialog({ if (direct) pending.delete(key); } }, - [session, channelId, canAddSoloAgents, place], + [session, channelId, canAddSoloAgents, place, solo], ); const addTeam = useCallback( From 3b152dc8bdc39435ad9c667b9cd2ae8d47d30890 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 09:34:58 -0700 Subject: [PATCH 10/17] fix(channels): rank invitations with shared people matcher Call rankPeople in each invitation group, including profile usernames and managed-agent names. Preserve exact-key and relay-only results after name matches, and cover matching and ordering through the mounted dialog. Co-authored-by: Carl Signed-off-by: Taylor Ho --- .../channels/ChannelMembersDialog.test.tsx | 95 +++++++++++++++++++ src/bundled/channels/ChannelMembersDialog.tsx | 71 +++++++++----- 2 files changed, 142 insertions(+), 24 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index d319b628a8..ec1dca4a46 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -559,6 +559,101 @@ it("keeps public-key lookup results whose names do not match the key text", asyn expect(t.publish).not.toHaveBeenCalled(); }); +it("ranks a matching username with the shared people matcher", async () => { + const t = await setup(); + const handle = profile(keypair(), { display_name: "Alpha", name: "robin" }); + const named = profile(t.person, { name: "Robin Person" }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) ? [named, handle] : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "robin"); + await screen.findByRole("button", { name: /^Add Alpha/ }); + const rows = within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getAllByRole("button", { name: /^Add / }); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Alpha/), + expect.stringMatching(/^Add Robin Person/), + ]); +}); + +it("ranks local usernames and hidden agent names through the shared matcher", async () => { + const t = await setup(); + const exact = keypair(), + word = keypair(), + shown = keypair(), + handle = keypair(); + const profiles = [ + profile(word, { name: "Helper Alpha", is_agent: true }), + profile(exact, { name: "Helper Zed", is_agent: true }), + profile(shown, { name: "Robin Visible", is_agent: true }), + profile(handle, { + display_name: "Handle Agent", + name: "robin", + is_agent: true, + }), + ]; + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? [] + : [ + ...(await query(filters)), + ...profiles.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ], + ); + await act(async () => { + t.control.set({ + agents: [ + { ...t.agent, pubkey: word.pubkey, name: "Red Robin" }, + { ...t.agent, pubkey: exact.pubkey, name: "Robin" }, + { ...t.agent, pubkey: shown.pubkey, name: "Robin Visible" }, + { ...t.agent, pubkey: handle.pubkey, name: "Local Handle" }, + ], + }); + await t.session.profiles.ensure( + [word.pubkey, exact.pubkey, shown.pubkey, handle.pubkey], + "foreground", + ); + }); + await t.user.type(screen.getByRole("searchbox"), "robin"); + const yours = await screen.findByRole("region", { name: "Your agents" }); + const rows = within(yours).getAllByRole("button", { name: /^Add / }); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Handle Agent/), + expect.stringMatching(/^Add Robin Visible/), + expect.stringMatching(/^Add Helper Zed/), + expect.stringMatching(/^Add Helper Alpha/), + ]); +}); + +it("keeps relay-only matches after shared-ranked names without duplicating rows", async () => { + const t = await setup(); + const relayOnly = profile(keypair(), { name: "A different name" }); + const named = profile(t.person, { name: "Robin" }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? [relayOnly, named] + : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "robin"); + await screen.findByRole("button", { name: /^Add Robin/ }); + const rows = within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getAllByRole("button", { name: /^Add / }); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Robin/), + expect.stringMatching(/^Add A different name/), + ]); +}); + it("starts every invitation group at four and expands only the selected group", async () => { const t = await setup(); const client = Array.from({ length: 5 }, (_, index) => ({ diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 3f348d5977..a079dfed87 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -28,13 +28,12 @@ import { useIdentityNames } from "../../features/identity-names/react"; import { usePresenceStatus } from "../../features/presence/react"; import { profileTarget } from "../../features/profiles/target"; import { peopleRelationships } from "../../features/profiles/relationships"; -import { personKey } from "../../features/search/people-rank"; +import { personKey, rankPeople } from "../../features/search/people-rank"; import { noSearchUsage, pickerText, readSearchUsage, recordChoice, - searchOrder, } from "../../features/search/usage"; import { matchPerson } from "../../features/search/person-match"; import { useSearchHighlight } from "../../features/search/use-search-highlight"; @@ -824,7 +823,12 @@ export function ChannelMembersDialog({ : agentChoices.selectable.filter((agent) => !!text || agent.managed)) { if ( !text || - addable(agent.pubkey, label(agent.pubkey, agent.name), agent.name) + addable( + agent.pubkey, + label(agent.pubkey, agent.name), + agent.name, + profiles.get(agent.pubkey)?.username, + ) ) candidates.set(agent.pubkey, { pubkey: agent.pubkey, @@ -877,26 +881,13 @@ export function ChannelMembersDialog({ pickerText("channel-members", needle), new Set(available.map((person) => personKey(person.pubkey))), ); - // Keep the product's outer groups; within each, use New message's shared - // exact/choice/relationship/usage order. Relay ties and key-only matches stay. - const candidateRank = (person: { pubkey: string; name: string }) => { - const rank = - typedMatch(label(person.pubkey, person.name), needle)?.tier ?? 4; - if (solo || !needle) return rank; - const key = personKey(person.pubkey); - const dm = relationships.dm(person.pubkey); - return searchOrder(usage, picked, { - key, - rank, - band: relationships.rank(person.pubkey), - usageKeys: [key, ...(dm ? [`channel:${dm}`] : [])], - }); - }; - available.sort( - (a, b) => - (!solo && candidateGroup(a) - candidateGroup(b)) || - candidateRank(a) - candidateRank(b), - ); + // Solo retains its existing combined, name-ranked page. + if (solo) + available.sort( + (a, b) => + (typedMatch(label(a.pubkey, a.name), needle)?.tier ?? 4) - + (typedMatch(label(b.pubkey, b.name), needle)?.tier ?? 4), + ); // Known agents supplement the server page, not its rendering bound. Keep all // matches reachable through the existing More action without mounting them all. const invitationSize = @@ -919,8 +910,40 @@ export function ChannelMembersDialog({ !solo && roleFilter === "All" ? available.filter((person) => candidateGroup(person) === index) : []; + const ranked = rankPeople( + people.map((person) => { + const dm = relationships.dm(person.pubkey); + const own = known.get(person.pubkey); + return { + ...person, + label: label(person.pubkey, person.name), + username: person.username ?? profiles.get(person.pubkey)?.username, + agentName: own?.managed ? own.name : undefined, + agent: agentCandidate(person), + relationship: relationships.rank(person.pubkey), + usageKeys: [ + personKey(person.pubkey), + ...(dm ? [`channel:${dm}`] : []), + ], + }; + }), + needle, + usage, + picked, + ).map(({ person }) => person); + // Name ranking must not discard exact-key lookups or other relay matches + // (for example a profile's NIP-05). Keep those after the shared name matches. + const rankedKeys = new Set(ranked.map((person) => person.pubkey)); + const ordered = [ + ...ranked, + ...people.filter((person) => !rankedKeys.has(person.pubkey)), + ]; const size = invitationPage.groupSizes[name] ?? INVITATION_GROUP_SIZE; - return { name, people: people.slice(0, size), more: people.length > size }; + return { + name, + people: ordered.slice(0, size), + more: ordered.length > size, + }; }); const visibleCandidates = solo ? roleFilter === "All" From 8338172705d13b150cf2353f88c54d5b1d5f42d4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 09:49:49 -0700 Subject: [PATCH 11/17] fix(channels): use shared invitation section heading Use InlineHeader for the Not in this channel section, retaining caption subgroups and the existing dialog insets and Solo sticky placement. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelMembersDialog.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index a079dfed87..73c575fea4 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -56,6 +56,7 @@ import { publicKeyLabels, } from "../../shared/identity/public-key"; import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Button } from "../../shared/design-system/ui/Button"; import { Select } from "../../shared/design-system/ui/Select"; @@ -1733,11 +1734,11 @@ export function ChannelMembersDialog({ aria-label={`Not in this ${place}`} {...highlight.listProps} > -

    - Not in this {place} -

    + +
{teams.length > 0 && (
    {teams.map(({ team, missing }) => ( From 6c774a5b09a76517ff2c33777d572f70c246059b Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 28 Oct 2026 10:31:55 -0700 Subject: [PATCH 12/17] fix(channels): compact invitation group expansion buttons Use the shared subtle extra-small capsule for each invitation group Show more action, retaining the separate Find more matches control. Co-authored-by: Carl Signed-off-by: Taylor Ho --- src/bundled/channels/ChannelMembersDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index 73c575fea4..4711c4a2cc 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -1819,7 +1819,7 @@ export function ChannelMembersDialog({
)} {(solo - ? [{ name: "", people: visibleCandidates, more: false }] + ? [ + { + name: "", + people: visibleCandidates, + more: false, + next: undefined, + }, + ] : invitationGroups ).map((group) => group.people.length > 0 ? ( @@ -1847,7 +1875,17 @@ export function ChannelMembersDialog({ diff --git a/tests/browser/channel-members-focus.fixture.tsx b/tests/browser/channel-members-focus.fixture.tsx index 8cfa55b823..9065bdec53 100644 --- a/tests/browser/channel-members-focus.fixture.tsx +++ b/tests/browser/channel-members-focus.fixture.tsx @@ -54,14 +54,18 @@ const scrollMembers = new URLSearchParams(location.search).has("scroll") const invitationScale = new URLSearchParams(location.search).has( "invitation-scale", ); -const inventory = invitationScale - ? Array.from({ length: 240 }, (_, index) => ({ - pubkey: keypair().pubkey, - name: `Helper ${String(index + 1).padStart(3, "0")}`, - })) - : solo - ? [{ pubkey: person.pubkey, name: "Morgan" }] - : []; +const invitationFocus = new URLSearchParams(location.search).has( + "invitation-focus", +); +const inventory = + invitationScale || invitationFocus + ? Array.from({ length: invitationScale ? 240 : 10 }, (_, index) => ({ + pubkey: keypair().pubkey, + name: `Helper ${String(index + 1).padStart(3, "0")}`, + })) + : solo + ? [{ pubkey: person.pubkey, name: "Morgan" }] + : []; const searchScale = new URLSearchParams(location.search).has("search-scale"); const managed = searchScale ? Array.from({ length: 60 }, (_, index) => ({ diff --git a/tests/browser/channel-members-focus.spec.mjs b/tests/browser/channel-members-focus.spec.mjs index f94569bf7f..bf8c3b00fe 100644 --- a/tests/browser/channel-members-focus.spec.mjs +++ b/tests/browser/channel-members-focus.spec.mjs @@ -750,6 +750,53 @@ test("large known-agent searches mount only the first invitation page", async ({ expect(errors).toEqual([]); }); +// Browser-only: removing a focused button interacts with the native dialog focus +// trap differently in Chromium and WebKit; jsdom cannot establish the handoff. +test("final invitation expansion continues keyboard focus at the first new row", async ({ + page, +}) => { + const { errors } = watchPageErrors(page); + for (const activation of ["Enter", "Space", "pointer"]) { + await page.goto(`${url}?invitation-focus`); + await page.getByRole("button", { name: "Access" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage members" }); + await dialog.getByRole("searchbox").fill("Helper"); + const group = dialog.getByRole("region", { + name: "Your other agents", + exact: true, + }); + const more = group.getByRole("button", { name: "Show more", exact: true }); + await expect( + group.getByRole("button", { name: /^Add Helper/ }), + ).toHaveCount(4); + await more.focus(); + await page.keyboard.press("Enter"); + await expect( + group.getByRole("button", { name: /^Add Helper/ }), + ).toHaveCount(8); + await expect(more).toBeFocused(); + if (activation === "pointer") await more.click(); + else await page.keyboard.press(activation); + await expect( + group.getByRole("button", { name: /^Add Helper/ }), + ).toHaveCount(10); + await expect(more).toHaveCount(0); + const firstNew = group.getByRole("button", { + name: /^Open profile for Helper 009/, + }); + if (activation === "pointer") await expect(firstNew).not.toBeFocused(); + else { + await expect(firstNew).toBeFocused(); + await page.keyboard.press("Tab"); + await expect( + group.getByRole("button", { name: /^Add Helper 009/ }), + ).toBeFocused(); + } + } + expect(await page.evaluate(() => window.focusFixture.additions)).toEqual([]); + expect(errors).toEqual([]); +}); + // Browser-only: shared Select/modal focus, animated search geometry and native // full-height row action hit testing cannot be established by jsdom. test("member identity dropdown fits beside search and preserves modal focus", async ({