Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
84 changes: 84 additions & 0 deletions src/bundled/mentions/session-agents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { createMessages } from "../../features/relay/messages";
import type { Outbox } from "../../features/relay/outbox";
import { MessageMarkdown } from "../../features/messages/MessageMarkdown";
import { profileTarget } from "../../features/profiles/target";
import { npubEncode } from "nostr-tools/nip19";
afterEach(cleanup);
function setup(parent: boolean | null = true, archived = false) {
const key = "b".repeat(64),
Expand Down Expand Up @@ -107,6 +108,7 @@ function setup(parent: boolean | null = true, archived = false) {
subscribe: () => () => {},
snapshot: () => 0,
resolve: (_key: string, fallback: string) => fallback,
scope: () => () => undefined,
},
agentLibrary: library.queries,
agentChoices: createAgentChoices({
Expand Down Expand Up @@ -1626,6 +1628,10 @@ it("uses base matches and visible lexical ties for Fizz in both chooser surfaces
names: {
...names,
resolve: (key: string) => rows.find((row) => row.key === key)?.label,
scope: () => (key: string) => {
const name = rows.find((row) => row.key === key)?.label;
return name ? { name, source: "agent-directory" as const } : undefined;
},
},
} satisfies RelaySession;
const expected = [rows[5], rows[4], rows[1], rows[2], rows[3], rows[0]].map(
Expand Down Expand Up @@ -1881,3 +1887,81 @@ it("the persistent toolbar picker reads an empty search again after close and re
);
t.library.dispose();
});

it("names the choice set only while the picker is shown, and qualifies namesakes in both menus", async () => {
const t = setup();
const member = "a".repeat(64),
namesake = "c".repeat(64);
const profiles = new Map([
[member, { name: "Larry" }],
[namesake, { name: "Larry" }],
]);
const list = {
status: "ready" as const,
channels: [
{
id: "parent",
name: "Parent",
channelType: "stream" as const,
members: [member, namesake],
},
],
};
const session = {
...t.session,
profiles: { ...t.session.profiles, snapshot: () => profiles },
channels: { ...t.session.channels, list: () => list },
};
const names = bindNames(
{ profiles: session.profiles, agentLibrary: t.library.queries },
{ snapshot: () => [createAgentDirectory()], subscribe: () => () => {} },
);
const scope = vi.spyOn(names, "scope");
const labels = [member, namesake].map(
(key) => `Larry · ${npubEncode(key).slice(-4)}`,
);
const picker = (disabled: boolean) => (
<MentionPicker
scope="test"
session={{ ...session, names }}
channelId="parent"
disabled={disabled}
select={() => true}
/>
);
const view = render(picker(false));
view.rerender(picker(false));
expect(scope).not.toHaveBeenCalled();
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Mention a member" }));
for (const [index, key] of [member, namesake].entries())
expect(
await screen.findByRole("button", { name: `${labels[index]} ${key}` }),
).toBeInTheDocument();
view.rerender(picker(true));
scope.mockClear();
view.rerender(picker(true));
expect(scope).not.toHaveBeenCalled();
view.unmount();
const publish = vi.fn();
render(
<MentionCompletion
session={{ ...session, names }}
scope="test"
channelId="parent"
observation={{ revision: 1, text: "@Larry", start: 6, end: 6 }}
query={{ start: 0, end: 6, query: "Larry" }}
publish={publish}
/>,
);
await waitFor(() =>
expect(
(publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items
.map((item) => item.label)
.sort(),
).toEqual([...labels].sort()),
);
names.dispose();
t.library.dispose();
});
16 changes: 8 additions & 8 deletions src/bundled/mentions/use-mention-choices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,25 +94,22 @@ export function useMentionChoices(
];
// Directory pages are menu-local, not cached profiles. Name them only when no
// other source knows the key, so namesakes in this choice set are qualified.
const known = session.names?.scope();
const facts = [
...people
.filter((person) => !session.names?.resolve(person.pubkey))
.filter((person) => !known?.(person.pubkey))
.map(({ pubkey, name, isAgent }) => ({
pubkey,
name,
...(isAgent ? { isAgent } : {}),
})),
...selected,
];
// One naming scope per choice set, not one normalization per row.
const name = session.names?.scope(keys, facts);
return candidates.map((choice) => ({
...choice,
label:
session.names?.resolve(
choice.recipient.pubkey,
choice.recipient.name,
keys,
facts,
) ?? choice.recipient.name,
label: name?.(choice.recipient.pubkey)?.name ?? choice.recipient.name,
}));
}, [session, channelId, invite, roster, selected, people]);
const { candidates, local } = useMemo(() => {
Expand All @@ -122,6 +119,8 @@ export function useMentionChoices(
void agents;
void archives;
void resolve;
// A closed or disabled chooser shows nothing, so it names nothing.
if (!open) return { candidates: [], local: new Set<string>() };
return {
candidates: current(),
// Keys known without the directory search: members and agent choices.
Expand All @@ -132,6 +131,7 @@ export function useMentionChoices(
),
};
}, [
open,
current,
session,
channelId,
Expand Down
2 changes: 1 addition & 1 deletion src/bundled/profiles/ProfilePanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ it("updates a mounted profile from the shared name view without replacing its id
activate() {
return undefined;
},
resolve: () => name,
scope: () => () => ({ name }),
},
],
subscribe(listener) {
Expand Down
103 changes: 89 additions & 14 deletions src/features/identity-names/directory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { expect, it, vi } from "vitest";
import type { AgentControlState, AgentView } from "../agents/control";
import { bindNames, type NameSource } from "./service";
import { npubEncode } from "nostr-tools/nip19";
import { agentDirectory, createAgentDirectory } from "./testing";
import {
agentDirectory,
createAgentDirectory,
defaultNamingPolicy,
} from "./testing";
import { createNameProvider } from "./directory";

it("scopes native names to the session community and follows edits and disposal", () => {
const key = "a".repeat(64);
Expand Down Expand Up @@ -67,11 +72,12 @@ it("scopes native names to the session community and follows edits and disposal"
for (const listener of listeners) listener();
expect(changed).toHaveBeenCalledOnce();
expect(names.resolve(key)).toBe("Edited");
expect(provider.resolve({ ...source, relayUrl: undefined }, key)).toBe(
expect(provider.scope({ ...source, relayUrl: undefined })(key)?.name).toBe(
"Legacy",
);
expect(
provider.resolve({ ...source, relayUrl: "https://unrelated.example" }, key),
provider.scope({ ...source, relayUrl: "https://unrelated.example" })(key)
?.name,
).toBe("Legacy");
state = { ...state, status: "error" };
expect(names.resolve(key)).toBe("Legacy");
Expand Down Expand Up @@ -109,14 +115,14 @@ it("suffixes equal names across profiles, without merging keys or suffixing uniq
},
};
for (const key of [a, b, c, d]) {
expect(agentDirectory.resolve(source, key)).toBe(
expect(agentDirectory.scope(source)(key)?.name).toBe(
`Bad Janet · ${npubEncode(key).slice(-4)}`,
);
}
expect(agentDirectory.resolve(source, e)).toBe("Larry");
expect(agentDirectory.scope(source)(e)?.name).toBe("Larry");
library = { ...library, identities: identities.slice(0, 2) };
expect(agentDirectory.resolve(source, a)).toBe("Bad Janet");
expect(agentDirectory.resolve(source, b)).toBeUndefined();
expect(agentDirectory.scope(source)(a)?.name).toBe("Bad Janet");
expect(agentDirectory.scope(source)(b)?.name).toBeUndefined();
});

it("recomputes collisions for native edits, community scope, and profile fallbacks", () => {
Expand Down Expand Up @@ -183,7 +189,7 @@ it("recomputes collisions for native edits, community scope, and profile fallbac
expect(names.resolve(a)).toBe("Native Larry");
expect(names.resolve(b)).toBe("Legacy");
expect(
provider.resolve({ ...source, relayUrl: "https://other.test" }, a),
provider.scope({ ...source, relayUrl: "https://other.test" })(a)?.name,
).toBe(`Legacy · ${npubEncode(a).slice(-4)}`);
expect(agent.name).toBe("Native Larry");

Expand Down Expand Up @@ -244,18 +250,18 @@ it("includes native-only identities but ignores other-community and unready nati
refresh: async () => {},
});
for (const key of [a, b])
expect(provider.resolve(source, key)).toBe(
expect(provider.scope(source)(key)?.name).toBe(
`Larry · ${npubEncode(key).slice(-4)}`,
);
expect(
provider.resolve({ ...source, relayUrl: "https://elsewhere.test" }, a),
provider.scope({ ...source, relayUrl: "https://elsewhere.test" })(a)?.name,
).toBe("Larry");
expect(
provider.resolve({ ...source, relayUrl: "https://elsewhere.test" }, b),
provider.scope({ ...source, relayUrl: "https://elsewhere.test" })(b)?.name,
).toBeUndefined();
native = { ...native, status: "error" };
expect(provider.resolve(source, a)).toBe("Larry");
expect(provider.resolve(source, b)).toBeUndefined();
expect(provider.scope(source)(a)?.name).toBe("Larry");
expect(provider.scope(source)(b)?.name).toBeUndefined();
});

it("applies viewer and owner metadata through the shared view and follows owner edits", () => {
Expand Down Expand Up @@ -296,7 +302,7 @@ it("applies viewer and owner metadata through the shared view and follows owner
});
expect(names.resolve(a)).toBe("Honey");
expect(names.resolve(b)).toBe("Wes’s Honey");
expect(provider.resolve({ ...source, viewer: other }, a)).toBe(
expect(provider.scope({ ...source, viewer: other })(a)?.name).toBe(
"Logan’s Honey",
);
expect(names.resolve(a)).toBe("Honey");
Expand All @@ -308,3 +314,72 @@ it("applies viewer and owner metadata through the shared view and follows owner
expect(names.resolve(b)).toBe("Wesley’s Honey");
names.dispose();
});

it("reuses one policy run per candidate scope across mixed case, outside keys, and updates", () => {
const [a, b, c, d] = ["a", "b", "c", "d"].map((key) => key.repeat(64));
if (!a || !b || !c || !d) throw new Error("Missing fixture keys");
let profiles = new Map([
[a, { name: "Alex" }],
[b, { name: "Alex" }],
[c, { name: "Alex" }],
[d, { name: "Dana" }],
]);
const library = {
status: "ready",
definitions: [],
identities: [],
} as const;
const listeners = new Set<() => void>();
const source: NameSource = {
profiles: {
snapshot: () => profiles,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
ensure: async () => {},
},
agentLibrary: {
snapshot: () => library,
subscribe: () => () => {},
refresh: async () => {},
retain: () => () => {},
},
};
const resolve = vi.fn(defaultNamingPolicy.resolve);
const names = bindNames(source, {
snapshot: () => [createNameProvider({ id: "spy", resolve })],
subscribe: () => () => {},
});
const suffixed = (key: string) => `Alex · ${npubEncode(key).slice(-4)}`;
const candidates = [a.toUpperCase(), b, d];
const scoped = names.scope(candidates);
expect(scoped(a)?.name).toBe(suffixed(a));
expect(scoped(b.toUpperCase())).toEqual({
name: suffixed(b),
qualifier: npubEncode(b).slice(-4),
source: "agent-directory",
});
expect(scoped(d)?.name).toBe("Dana");
expect(names.lookup(b, candidates)).toEqual(scoped(b));
// Interleaved unscoped lookups keep their own cached scope.
expect(names.resolve(d)).toBe("Dana");
expect(scoped(a)?.name).toBe(suffixed(a));
expect(resolve).toHaveBeenCalledTimes(2);
// An outside historical reference joins the scope only for its own lookup.
expect(names.scope([d])(a)?.name).toBe("Alex");
expect(scoped(c)?.name).toBe(suffixed(c));
expect(scoped(a)?.name).toBe(suffixed(a));
expect(resolve).toHaveBeenCalledTimes(4);
// A hot scope survives a stream of distinct historical lookups.
for (const digit of "01234567") {
expect(names.scope([digit.repeat(64)])(a)?.name).toBe("Alex");
expect(scoped(a)?.name).toBe(suffixed(a));
}
expect(resolve).toHaveBeenCalledTimes(12);
profiles = new Map([...profiles, [b, { name: "Blake" }]]);
for (const listener of listeners) listener();
expect(scoped(a)?.name).toBe("Alex");
expect(scoped(b)?.name).toBe("Blake");
expect(resolve).toHaveBeenCalledTimes(13);
});
Loading
Loading