From 696a36aefc5b8fee46f805b6daed9d72d12e7ced Mon Sep 17 00:00:00 2001 From: webdevtodayjason Date: Fri, 24 Jul 2026 06:24:39 -0500 Subject: [PATCH 1/5] feat(desktop): show each agent's role in the @-mention selector Surface the kind-0 about field as a one-line role/description under agent rows in the mention autocomplete (#2699). Carried through the Tauri profile summary path, resolved via the existing users-batch delta cache for agents missing from the caller's profile lookup, and rendered as a truncated single line that degrades to today's plain "agent" label when about is empty. Ports upstream/block#2706 onto the fork. Fork deviations from the upstream diff, all mechanical conflict resolution, no behavior change: - useMentions.ts hunks land in buildMentionCandidates.ts, where the fork already extracted candidate building out of useMentions.ts. - The nostr_convert about-carrying test lands in nostr_convert/tests.rs, where the fork already moved `mod tests` into its own file. - Every ported mapMentionCandidateToSuggestion call gains agentProvenanceReady: true, a fork-only required opt predating this port; the six ported assertions are otherwise unchanged. - MentionAutocomplete.tsx keeps the fork's literal "agent" fallback (upstream carries an agentLabel var) and keeps showAgentProvenanceMarker inside the agent span -- both fork baseline shape, not upstream's. - mentionSuggestionMapping.test.mjs is appended to, not created; the file already exists on zs/main. Signed-off-by: webdevtodayjason Signed-off-by: wiggdevin <202901685+wiggdevin@users.noreply.github.com> --- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/models.rs | 8 + desktop/src-tauri/src/nostr_convert.rs | 1 + desktop/src-tauri/src/nostr_convert/tests.rs | 22 +++ .../src/nostr_convert/user_search.rs | 13 ++ .../messages/lib/buildMentionCandidates.ts | 4 + .../messages/lib/mentionCandidates.ts | 4 + .../lib/mentionSuggestionMapping.test.mjs | 85 ++++++++- .../messages/lib/mentionSuggestionMapping.ts | 14 ++ .../src/features/messages/lib/useMentions.ts | 41 +++- .../messages/ui/MentionAutocomplete.tsx | 18 +- desktop/src/features/profile/lib/identity.ts | 2 + desktop/src/shared/api/tauriProfiles.ts | 4 +- desktop/src/shared/api/types.ts | 7 + desktop/src/testing/e2eBridge.ts | 6 + .../mention-descriptions-screenshots.spec.ts | 176 ++++++++++++++++++ 16 files changed, 397 insertions(+), 9 deletions(-) create mode 100644 desktop/tests/e2e/mention-descriptions-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 4a27ee671a6..0eed100b312 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -79,6 +79,7 @@ export default defineConfig({ "**/mentions.spec.ts", "**/mention-spacing.spec.ts", "**/mention-clipboard.spec.ts", + "**/mention-descriptions-screenshots.spec.ts", "**/cloud-provenance.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 9693f1563ac..90e0b70e49b 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -50,6 +50,10 @@ pub struct UserProfileSummaryInfo { #[serde(default)] pub name: Option, pub avatar_url: Option, + /// Kind-0 `about` field — one-line role/description surfaced in the + /// @-mention selector for agents. + #[serde(default)] + pub about: Option, pub nip05_handle: Option, pub owner_pubkey: Option, #[serde(default)] @@ -67,6 +71,10 @@ pub struct UserSearchResultInfo { pub pubkey: String, pub display_name: Option, pub avatar_url: Option, + /// Kind-0 `about` field — one-line role/description surfaced in the + /// @-mention selector for agents. + #[serde(default)] + pub about: Option, pub nip05_handle: Option, pub owner_pubkey: Option, #[serde(default)] diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index 51f648769d3..b7515422b6b 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -358,6 +358,7 @@ pub fn users_batch_from_events( .map(str::to_string), name: v.get("name").and_then(Value::as_str).map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + about: v.get("about").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), owner_pubkey, diff --git a/desktop/src-tauri/src/nostr_convert/tests.rs b/desktop/src-tauri/src/nostr_convert/tests.rs index 68d8cb7dcbb..9baee493774 100644 --- a/desktop/src-tauri/src/nostr_convert/tests.rs +++ b/desktop/src-tauri/src/nostr_convert/tests.rs @@ -278,6 +278,28 @@ fn users_batch_marks_valid_nip_oa_profiles_as_agents() { ); } +#[test] +fn users_batch_carries_about_when_present() { + let with_about = ev( + 0, + r#"{"display_name":"Bumble","about":"Researcher — deep dives & sourcing"}"#, + vec![], + ); + let without_about = ev(0, r#"{"display_name":"Fizz"}"#, vec![]); + let pk_with = with_about.pubkey.to_hex(); + let pk_without = without_about.pubkey.to_hex(); + + let resp = users_batch_from_events( + &[with_about, without_about], + &[pk_with.clone(), pk_without.clone()], + ); + assert_eq!( + resp.profiles[&pk_with].about.as_deref(), + Some("Researcher — deep dives & sourcing") + ); + assert_eq!(resp.profiles[&pk_without].about, None); +} + #[test] fn user_notes_builds_cursor_from_last() { let e1 = ev(1, "first", vec![]); diff --git a/desktop/src-tauri/src/nostr_convert/user_search.rs b/desktop/src-tauri/src/nostr_convert/user_search.rs index 43b4288abbb..334e9e22b0d 100644 --- a/desktop/src-tauri/src/nostr_convert/user_search.rs +++ b/desktop/src-tauri/src/nostr_convert/user_search.rs @@ -19,6 +19,7 @@ pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { .or_else(|| v.get("name").and_then(Value::as_str)) .map(str::to_string), avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + about: v.get("about").and_then(Value::as_str).map(str::to_string), nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), is_agent: owner_pubkey.is_some(), owner_pubkey, @@ -238,6 +239,18 @@ mod tests { assert_eq!(r.users[1].display_name.as_deref(), Some("B")); } + #[test] + fn user_search_result_carries_about_when_present() { + let with_about = ev(0, r#"{"name":"honey","about":"Writer bee"}"#, vec![]); + let without_about = ev(0, r#"{"name":"fizz"}"#, vec![]); + + assert_eq!( + user_search_result_from_event(&with_about).about.as_deref(), + Some("Writer bee") + ); + assert_eq!(user_search_result_from_event(&without_about).about, None); + } + #[test] fn user_search_result_marks_valid_nip_oa_profile_as_agent() { let event = oa_profile_event(r#"{"display_name":"Mira"}"#); diff --git a/desktop/src/features/messages/lib/buildMentionCandidates.ts b/desktop/src/features/messages/lib/buildMentionCandidates.ts index 61373886352..f8467647728 100644 --- a/desktop/src/features/messages/lib/buildMentionCandidates.ts +++ b/desktop/src/features/messages/lib/buildMentionCandidates.ts @@ -117,6 +117,9 @@ export function buildMentionCandidates({ role: current.role ?? candidate.role ?? null, secondaryLabel: current.secondaryLabel ?? candidate.secondaryLabel ?? null, + // `??` (not `?? null`) so both-undefined stays undefined — that is + // the "still unknown, resolve from the profile lookup" signal. + description: current.description ?? candidate.description, ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? @@ -219,6 +222,7 @@ export function buildMentionCandidates({ relayAgentNamesByPubkey.has(pubkey), personaName: personaNameByPubkey.get(pubkey) ?? null, secondaryLabel: formatSearchUserSecondaryLabel(user), + description: user.about ?? null, ownerPubkey: user.ownerPubkey ?? null, isGlobalSearchResult: true, isManagedAgent: managedAgentNamesByPubkey.has(pubkey), diff --git a/desktop/src/features/messages/lib/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 659832deadf..f8259731968 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -44,6 +44,10 @@ export type MentionCandidate = { role?: ChannelRole | null; personaName?: string | null; secondaryLabel?: string | null; + /** Kind-0 `about` when already resolved at candidate-build time (e.g. from + * a user-search result). `undefined` means "unknown — resolve from the + * profile lookup at suggestion-mapping time"; `null` means "known absent". */ + description?: string | null; ownerPubkey?: string | null; isAgent: boolean; isActiveAgent?: boolean; diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs b/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs index 599b59a8117..1b6d408cf1b 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs @@ -4,11 +4,12 @@ import test from "node:test"; import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping.ts"; const OWNER = "a".repeat(64); +const AGENT_PUBKEY = "b".repeat(64); function candidate(overrides = {}) { return { kind: "identity", - pubkey: "b".repeat(64), + pubkey: AGENT_PUBKEY, isAgent: true, isMember: true, ownerPubkey: OWNER, @@ -16,6 +17,10 @@ function candidate(overrides = {}) { }; } +function agentCandidate(overrides = {}) { + return candidate(overrides); +} + function suggestion(overrides = {}, agentProvenanceReady = true) { return mapMentionCandidateToSuggestion({ agentProvenanceReady, @@ -25,6 +30,18 @@ function suggestion(overrides = {}, agentProvenanceReady = true) { }); } +function profileSummary(about) { + return { + displayName: "Bumble", + name: null, + avatarUrl: null, + about, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }; +} + test("labels Desktop-managed agent identities as managed here", () => { assert.equal( suggestion({ isManagedAgent: true }).agentProvenance, @@ -58,3 +75,69 @@ test("does not attribute people or personas to a device", () => { undefined, ); }); + +test("agent description comes from the candidate when resolved at build time", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ description: "Researcher — deep dives" }), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary("stale profile about") }, + }); + + assert.equal(result.description, "Researcher — deep dives"); +}); + +test("agent description falls back to the profile lookup's about", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate(), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary("Researcher — deep dives") }, + }); + + assert.equal(result.description, "Researcher — deep dives"); +}); + +test("agent description is null when about is missing everywhere", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate(), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary(null) }, + }); + + assert.equal(result.description, null); +}); + +test("non-agent suggestions never carry a description", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ isAgent: false }), + label: "Alice", + profiles: { [AGENT_PUBKEY]: profileSummary("A human bio") }, + }); + + assert.equal(result.description, null); +}); + +test("multi-line about collapses to a single trimmed line", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ + description: " Writer bee.\nDrafts docs\n\tand posts. ", + }), + label: "Honey", + }); + + assert.equal(result.description, "Writer bee. Drafts docs and posts."); +}); + +test("whitespace-only about degrades to null (name-only row)", () => { + const result = mapMentionCandidateToSuggestion({ + agentProvenanceReady: true, + candidate: agentCandidate({ description: " \n " }), + label: "Fizz", + }); + + assert.equal(result.description, null); +}); diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index b0983cee52d..18a98abf3db 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -19,6 +19,7 @@ export type MentionSuggestionCandidate = { isManagedAgent?: boolean; isMember: boolean; role?: ChannelRole | null; + description?: string | null; ownerPubkey?: string | null; }; @@ -44,6 +45,18 @@ export function mapMentionCandidateToSuggestion(opts: { ? formatOwnerLabel(candidate.ownerPubkey, currentPubkey, ownerProfiles) : null; + // Agent role line: prefer an `about` resolved at candidate-build time + // (search results), else fall back to the profile lookup like avatarUrl. + // Collapsed to a single line — the selector is a quick picker, not a + // profile card. + const rawDescription = candidate.isAgent + ? (candidate.description ?? + (candidate.pubkey + ? profiles?.[normalizePubkey(candidate.pubkey)]?.about + : null)) + : null; + const description = rawDescription?.replace(/\s+/g, " ").trim() || null; + return { pubkey: candidate.pubkey, personaId: candidate.personaId ?? undefined, @@ -77,6 +90,7 @@ export function mapMentionCandidateToSuggestion(opts: { candidate.isMember === false, ownerLabel, role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null, + description, }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 38e6ed2212f..085f8843bdc 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -320,6 +320,39 @@ export function useMentions( const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, { enabled: ownerPubkeys.length > 0, }); + // Agent candidates whose kind-0 `about` is not already known — neither + // resolved at candidate-build time (search results) nor present in the + // caller's profile lookup. Batch-resolve them so the selector can show a + // role line even for agents who haven't authored anything in the loaded + // timeline. The per-pubkey entry cache behind useUsersBatchQuery keeps + // repeat lookups off the network. + const agentProfilePubkeys = React.useMemo( + () => [ + ...new Set( + mentionCandidates + .filter( + (candidate) => + candidate.isAgent && + candidate.pubkey && + candidate.description === undefined && + !profiles?.[candidate.pubkey], + ) + .map((candidate) => candidate.pubkey as string), + ), + ], + [mentionCandidates, profiles], + ); + const agentProfilesQuery = useUsersBatchQuery(agentProfilePubkeys, { + enabled: agentProfilePubkeys.length > 0, + }); + const mentionProfiles = React.useMemo(() => { + const agentProfiles = agentProfilesQuery.data?.profiles; + if (!agentProfiles || Object.keys(agentProfiles).length === 0) { + return profiles; + } + // Caller-provided profiles win on conflict. + return { ...agentProfiles, ...profiles }; + }, [agentProfilesQuery.data?.profiles, profiles]); const searchableNames = React.useMemo( () => uniqueAutocompleteLabels(mentionCandidatesWithTeams), [mentionCandidatesWithTeams], @@ -386,7 +419,7 @@ export function useMentions( channelType: options?.channelType, currentPubkey, ownerProfiles: ownerProfilesQuery.data?.profiles, - profiles, + profiles: mentionProfiles, }), ); }, [ @@ -394,10 +427,10 @@ export function useMentions( agentDirectoriesReady, currentPubkey, mentionCandidatesWithTeams, + mentionProfiles, mentionQuery, options?.channelType, ownerProfilesQuery.data?.profiles, - profiles, ]); const getDefaultAgentSuggestion = useDefaultAgentSuggestion({ activePersonaIds, @@ -846,7 +879,7 @@ export function useMentions( channelType: options?.channelType, currentPubkey, ownerProfiles: ownerProfilesQuery.data?.profiles, - profiles, + profiles: mentionProfiles, requireExact: exactMentionSpace, }); if (exactMentionSpace && flushed?.type !== "match") @@ -880,10 +913,10 @@ export function useMentions( currentPubkey, isMentionOpen, mentionCandidatesWithTeams, + mentionProfiles, mentionSelectedIndex, options?.channelType, ownerProfilesQuery.data?.profiles, - profiles, setSelected, suggestions, ], diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index a380ac83a09..32e29ce87a4 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -31,6 +31,8 @@ export type MentionSuggestion = { notInChannel?: boolean; ownerLabel?: string | null; role?: string | null; + /** One-line agent role/description from the kind-0 `about` field. */ + description?: string | null; }; type MentionAutocompleteProps = { @@ -344,13 +346,23 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ team · {suggestion.teamMembers?.length ?? 0} agents ) : suggestion.isAgent ? ( - +