+ {group.name && (
+
+ {group.name}
+ {group.name === "Your agents" && (
+
+ )}
+ {group.name === "Your other agents" && (
+
+
+ }
+ />
+ }
+ />
+
+
+ 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,
)}
{search.loading && (
)}
{(moreCandidates || search.more) && (
-
+
)}
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:
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);
diff --git a/src/features/search/people-rank.test.ts b/src/features/search/people-rank.test.ts
index 2695c05de6..db08e92072 100644
--- a/src/features/search/people-rank.test.ts
+++ b/src/features/search/people-rank.test.ts
@@ -61,6 +61,36 @@ it("puts the earlier choice for this text and usage ahead, within the order", ()
expect(order(people, "dana", usage)).toEqual(["Dana Two", "Dana One"]);
});
+it.each(["", " \t "])(
+ "orders idle suggestions by relationship and usage for %j",
+ (needle) => {
+ const people = [
+ person("stranger", "Stranger"),
+ person("managed", "Managed", { agent: true, relationship: 2 }),
+ person("visited", "Visited", { agent: true, relationship: 2 }),
+ person("other-dm", "Other DM", { relationship: 0 }),
+ person("dm", "Direct", {
+ relationship: 0,
+ usageKeys: [personKey("dm"), "channel:dm"],
+ }),
+ ];
+ const usage: SearchUsage = {
+ boost: (...keys) =>
+ keys.includes(personKey("visited")) || keys.includes("channel:dm")
+ ? 1
+ : 0,
+ pick: () => undefined,
+ };
+ expect(order(people, needle, usage)).toEqual([
+ "Direct",
+ "Other DM",
+ "Visited",
+ "Managed",
+ "Stranger",
+ ]);
+ },
+);
+
it("ranks a hidden agent name after shown names, as mentions do", () => {
const people = [
person("agent", "Helper Bot", {
diff --git a/src/features/search/people-rank.ts b/src/features/search/people-rank.ts
index 0609e4ed67..5ae6abf671 100644
--- a/src/features/search/people-rank.ts
+++ b/src/features/search/people-rank.ts
@@ -24,6 +24,8 @@ export const personKey = (pubkey: string) => `person:${pubkey}`;
* after every shown name, as `mentionMatch` ranks hidden aliases.
*/
export function personMatch(person: PersonCandidate, needle: string) {
+ // Blank suggestions have no exact name; relationship and usage order them.
+ if (!needle.trim()) return 1;
const tier = (name: string | undefined) =>
name === undefined ? undefined : matchPerson(name, needle)?.tier;
const shown = [tier(person.label), tier(person.username)].filter(
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 c2dab9301f..bf8c3b00fe 100644
--- a/tests/browser/channel-members-focus.spec.mjs
+++ b/tests/browser/channel-members-focus.spec.mjs
@@ -277,10 +277,7 @@ 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.getByRole("region", { name: "People", exact: true });
const others = dialog.getByRole("region", {
name: "Not in this channel",
exact: true,
@@ -290,7 +287,14 @@ 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(
+ dialog
+ .getByRole("region", {
+ name: "People not in this channel",
+ exact: true,
+ })
+ .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 +730,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/ }),
@@ -744,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 ({
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 }),