Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
17c4f67
fix(channels): prioritize this client's agents in member invitations
tellaho Oct 28, 2026
b1a8a34
fix(channels): start invitation groups with four options
tellaho Oct 28, 2026
fbbaed0
Merge remote-tracking branch 'origin/main' into tho/channel-members-o…
tellaho Oct 28, 2026
861c308
feat(channels): explain agent imports with community scope hints
tellaho Oct 28, 2026
79dc1e9
fix(channels): label unconfigured agents as your other agents
tellaho Oct 28, 2026
9bcf25e
test(channels): provide community reader in panel lifecycle fixture
tellaho Oct 28, 2026
4cad744
Merge remote-tracking branch 'origin/main' into tho/channel-members-o…
tellaho Oct 28, 2026
afd4d96
test(channels): supply community reader to browser fixtures
tellaho Oct 28, 2026
8c46704
test(channels): avoid repeated accessibility scans when paging
tellaho Oct 28, 2026
febca63
test(channels): align browser journeys with grouped invitations
tellaho Oct 28, 2026
a7e388e
Merge remote-tracking branch 'origin/main' into tho/channel-members-o…
tellaho Oct 28, 2026
ed48963
fix(channels): reuse shared ranking within invitation groups
tellaho Oct 28, 2026
3b152dc
fix(channels): rank invitations with shared people matcher
tellaho Oct 28, 2026
8338172
fix(channels): use shared invitation section heading
tellaho Oct 28, 2026
6c774a5
fix(channels): compact invitation group expansion buttons
tellaho Oct 28, 2026
0543047
merge(channels): integrate current main for member picker
tellaho Oct 28, 2026
f7a879a
fix(search): apply shared ranking to idle suggestions
tellaho Oct 28, 2026
0098cc3
fix(channels): reveal fetched invitation matches
tellaho Oct 28, 2026
2b1d01b
fix(channels): distinguish invitation landmarks
tellaho Oct 28, 2026
5cdd321
merge(channels): integrate current relay read behavior
tellaho Oct 28, 2026
961a8de
fix(channels): keep paging within supported APIs
tellaho Oct 28, 2026
6d56557
fix(channels): preserve focus after final invitation expansion
tellaho Oct 28, 2026
8e88fe5
merge(channels): sync current agent and community behavior
tellaho Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
837 changes: 791 additions & 46 deletions src/bundled/channels/ChannelMembersDialog.test.tsx

Large diffs are not rendered by default.

382 changes: 335 additions & 47 deletions src/bundled/channels/ChannelMembersDialog.tsx

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -158,6 +161,7 @@ export function ChannelsPage({
archive={archive}
companion={companion}
agents={agents}
communities={communities}
/>
)}
</section>
Expand All @@ -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;
Expand Down Expand Up @@ -1544,6 +1550,7 @@ function ChannelWorkspace({
key={current.id}
session={queries}
channelId={current.id}
communities={communities}
close={() => setMembersOpen(false)}
trigger={
membersTrigger.current?.isConnected
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/channels/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const inject = [
"navigation",
"settingsCards",
"agents",
"communityReader",
"shortcuts",
];
export const apply: PluginModule["apply"] = (ctx) => {
Expand Down Expand Up @@ -111,6 +112,7 @@ export const apply: PluginModule["apply"] = (ctx) => {
archive={archive}
companion={companion}
agents={agents}
communities={ctx.communityReader}
/>
),
});
Expand Down
1 change: 1 addition & 0 deletions src/features/agents/AgentCommunityScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type AgentScopeReader = Pick<Agents, "subscribe" | "memberships">;

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:
Expand Down
12 changes: 12 additions & 0 deletions src/features/panels/service.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 30 additions & 0 deletions src/features/search/people-rank.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
2 changes: 2 additions & 0 deletions src/features/search/people-rank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
20 changes: 12 additions & 8 deletions tests/browser/channel-members-focus.fixture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
71 changes: 62 additions & 9 deletions tests/browser/channel-members-focus.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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/ }),
Expand All @@ -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 ({
Expand Down
12 changes: 12 additions & 0 deletions tests/fixtures/conversation.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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", {
Expand Down
12 changes: 12 additions & 0 deletions tests/fixtures/mentions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }),
Expand Down
Loading