diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5046b29688..5bca86ed4d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -58,6 +58,7 @@ export default defineConfig({ "**/composer-link-shortcut.spec.ts", "**/composer-tooltip-dismiss.spec.ts", "**/mentions.spec.ts", + "**/mention-descriptions-screenshots.spec.ts", "**/team-mentions.spec.ts", "**/persistent-agent-audience.spec.ts", "**/relay-reconnect.spec.ts", diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..12c6874131 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -48,6 +48,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)] @@ -65,6 +69,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 ec4970e0c9..20c401d63c 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -341,6 +341,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, @@ -835,6 +836,28 @@ mod tests { ); } + #[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 43b4288abb..334e9e22b0 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/mentionCandidates.ts b/desktop/src/features/messages/lib/mentionCandidates.ts index 0498bef9ae..cde2653717 100644 --- a/desktop/src/features/messages/lib/mentionCandidates.ts +++ b/desktop/src/features/messages/lib/mentionCandidates.ts @@ -21,6 +21,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; isManagedAgent?: boolean; diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs b/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs new file mode 100644 index 0000000000..d7c6100c13 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping.ts"; + +const AGENT_PUBKEY = "a".repeat(64); + +function agentCandidate(overrides = {}) { + return { + kind: "identity", + pubkey: AGENT_PUBKEY, + isAgent: true, + isMember: true, + ...overrides, + }; +} + +function profileSummary(about) { + return { + displayName: "Bumble", + name: null, + avatarUrl: null, + about, + nip05Handle: null, + ownerPubkey: null, + isAgent: true, + }; +} + +test("agent description comes from the candidate when resolved at build time", () => { + const suggestion = mapMentionCandidateToSuggestion({ + candidate: agentCandidate({ description: "Researcher — deep dives" }), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary("stale profile about") }, + }); + + assert.equal(suggestion.description, "Researcher — deep dives"); +}); + +test("agent description falls back to the profile lookup's about", () => { + const suggestion = mapMentionCandidateToSuggestion({ + candidate: agentCandidate(), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary("Researcher — deep dives") }, + }); + + assert.equal(suggestion.description, "Researcher — deep dives"); +}); + +test("agent description is null when about is missing everywhere", () => { + const suggestion = mapMentionCandidateToSuggestion({ + candidate: agentCandidate(), + label: "Bumble", + profiles: { [AGENT_PUBKEY]: profileSummary(null) }, + }); + + assert.equal(suggestion.description, null); +}); + +test("non-agent suggestions never carry a description", () => { + const suggestion = mapMentionCandidateToSuggestion({ + candidate: agentCandidate({ isAgent: false }), + label: "Alice", + profiles: { [AGENT_PUBKEY]: profileSummary("A human bio") }, + }); + + assert.equal(suggestion.description, null); +}); + +test("multi-line about collapses to a single trimmed line", () => { + const suggestion = mapMentionCandidateToSuggestion({ + candidate: agentCandidate({ + description: " Writer bee.\nDrafts docs\n\tand posts. ", + }), + label: "Honey", + }); + + assert.equal(suggestion.description, "Writer bee. Drafts docs and posts."); +}); + +test("whitespace-only about degrades to null (name-only row)", () => { + const suggestion = mapMentionCandidateToSuggestion({ + candidate: agentCandidate({ description: " \n " }), + label: "Fizz", + }); + + assert.equal(suggestion.description, null); +}); diff --git a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts index c710cf613b..f2871ae061 100644 --- a/desktop/src/features/messages/lib/mentionSuggestionMapping.ts +++ b/desktop/src/features/messages/lib/mentionSuggestionMapping.ts @@ -15,6 +15,7 @@ export type MentionSuggestionCandidate = { isAgent: boolean; isMember: boolean; role?: ChannelRole | null; + description?: string | null; ownerPubkey?: string | null; }; @@ -38,6 +39,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, @@ -58,5 +71,6 @@ 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 0c73b75339..267b251a85 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -282,6 +282,9 @@ export function useMentions( 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 ?? @@ -379,6 +382,7 @@ export function useMentions( 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), @@ -457,6 +461,40 @@ export function useMentions( 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(() => { const names: string[] = []; const seen = new Set(); @@ -551,17 +589,17 @@ export function useMentions( channelType: options?.channelType, currentPubkey, ownerProfiles: ownerProfilesQuery.data?.profiles, - profiles, + profiles: mentionProfiles, }), ); }, [ activePersonaIds, currentPubkey, mentionCandidatesWithTeams, + mentionProfiles, mentionQuery, options?.channelType, ownerProfilesQuery.data?.profiles, - profiles, ]); const fetchMoreSuggestions = React.useCallback(() => { @@ -930,7 +968,7 @@ export function useMentions( channelType: options?.channelType, currentPubkey, ownerProfiles: ownerProfilesQuery.data?.profiles, - profiles, + profiles: mentionProfiles, }); if (flushed?.type === "match") { flushedMentionStartIndexRef.current = flushed.startIndex; @@ -960,10 +998,10 @@ export function useMentions( currentPubkey, isMentionOpen, mentionCandidatesWithTeams, + mentionProfiles, mentionSelectedIndex, options?.channelType, ownerProfilesQuery.data?.profiles, - profiles, suggestions, ], ); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 508e35f402..45b163571f 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -25,6 +25,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 = { @@ -163,13 +165,23 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ team · {suggestion.teamMembers?.length ?? 0} agents ) : suggestion.isAgent ? ( - + ) : suggestion.role ? ( & { +type RawUserProfileSummary = Omit & { name?: string | null; is_agent?: boolean; }; @@ -54,6 +54,7 @@ function fromRawUserProfileSummary( displayName: profile.display_name, name: profile.name ?? null, avatarUrl: profile.avatar_url, + about: profile.about, nip05Handle: profile.nip05_handle, ownerPubkey: profile.owner_pubkey, isAgent: profile.is_agent ?? false, @@ -65,6 +66,7 @@ function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult { pubkey: user.pubkey, displayName: user.display_name, avatarUrl: user.avatar_url, + about: user.about, nip05Handle: user.nip05_handle, ownerPubkey: user.owner_pubkey, isAgent: user.is_agent ?? false, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d28f2d0cf1..d1fd3d19c9 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -144,6 +144,9 @@ export type UserProfileSummary = { * `display_name` *or* `name` at send time). */ name?: string | null; avatarUrl: string | null; + /** Kind-0 `about` — one-line role/description shown in the @-mention + * selector for agents. */ + about?: string | null; nip05Handle: string | null; ownerPubkey: string | null; isAgent?: boolean; @@ -158,6 +161,10 @@ export type UserSearchResult = { pubkey: string; displayName: string | null; avatarUrl: string | null; + /** Kind-0 `about` — one-line role/description shown in the @-mention + * selector for agents. Optional: synthetic results (e.g. locally known + * agents folded into search) omit it. */ + about?: string | null; nip05Handle: string | null; ownerPubkey: string | null; isAgent: boolean; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 02aeeb49d2..9fff01860e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -467,6 +467,7 @@ type RawUserProfileSummary = { display_name: string | null; name?: string | null; avatar_url: string | null; + about?: string | null; nip05_handle: string | null; owner_pubkey: string | null; is_agent?: boolean; @@ -5491,6 +5492,7 @@ async function handleGetUsersBatch( display_name: profile.display_name, name: profile.name ?? null, avatar_url: profile.avatar_url, + about: profile.about, nip05_handle: profile.nip05_handle, owner_pubkey: profile.owner_pubkey, is_agent: profile.is_agent ?? false, @@ -5516,6 +5518,7 @@ async function handleGetUsersBatch( display_name: content.display_name ?? content.name ?? null, name: content.name ?? null, avatar_url: content.picture ?? null, + about: content.about ?? null, nip05_handle: content.nip05 ?? null, owner_pubkey: ((ev.tags ?? []) as string[][]).find( @@ -5545,6 +5548,7 @@ async function handleGetUsersBatch( display_name: profile.display_name, name: profile.name ?? null, avatar_url: profile.avatar_url, + about: profile.about, nip05_handle: profile.nip05_handle, owner_pubkey: profile.owner_pubkey, is_agent: profile.is_agent ?? false, @@ -5602,6 +5606,7 @@ async function handleSearchUsers( pubkey: profile.pubkey, display_name: profile.display_name, avatar_url: profile.avatar_url, + about: profile.about, nip05_handle: profile.nip05_handle, owner_pubkey: profile.owner_pubkey, is_agent: profile.is_agent ?? false, @@ -5628,6 +5633,7 @@ async function handleSearchUsers( pubkey: ev.pubkey ?? "", display_name: content.display_name ?? content.name ?? null, avatar_url: content.picture ?? null, + about: content.about ?? null, nip05_handle: content.nip05 ?? null, owner_pubkey: ((ev.tags ?? []) as string[][]).find( diff --git a/desktop/tests/e2e/mention-descriptions-screenshots.spec.ts b/desktop/tests/e2e/mention-descriptions-screenshots.spec.ts new file mode 100644 index 0000000000..2858da5a62 --- /dev/null +++ b/desktop/tests/e2e/mention-descriptions-screenshots.spec.ts @@ -0,0 +1,178 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/mention-descriptions"; + +// Distinct pubkeys — no overlap with bridge fixtures or other specs. +const FIZZ_PUBKEY = "3a".repeat(32); +const HONEY_PUBKEY = "3b".repeat(32); +const BUMBLE_PUBKEY = "3c".repeat(32); +const BUZZY_PUBKEY = "3d".repeat(32); +const ATLAS_PUBKEY = "3e".repeat(32); + +const FIZZ_ABOUT = "Builder — implements features and fixes bugs"; +const HONEY_ABOUT = "Writer — drafts docs, posts, and summaries"; +const BUMBLE_ABOUT = "Researcher — deep dives, sourcing, and citations"; +const ATLAS_LONG_ABOUT = + "Operations copilot for the whole hive: triages incoming requests, " + + "routes work to the right specialist agent, keeps the runbook current, " + + "and escalates anything ambiguous to a human before acting on it"; + +/** Locator scoped to the mention autocomplete dropdown inside the composer. */ +function autocomplete(page: import("@playwright/test").Page) { + return page + .getByTestId("message-composer") + .getByTestId("mention-autocomplete"); +} + +/** Full-page clip spanning the open dropdown down to the composer bottom. */ +async function shootComposerWithDropdown( + page: import("@playwright/test").Page, + path: string, +) { + await waitForAnimations(page); + const dropdownBox = await autocomplete(page).boundingBox(); + const composerBox = await page + .getByTestId("message-composer") + .boundingBox(); + if (!dropdownBox || !composerBox) { + throw new Error("composer or dropdown not visible for screenshot"); + } + const top = Math.max(0, dropdownBox.y - 8); + await page.screenshot({ + path, + clip: { + x: Math.max(0, composerBox.x - 8), + y: top, + width: composerBox.width + 16, + height: composerBox.y + composerBox.height + 8 - top, + }, + }); +} + +test("mention selector shows each agent's kind-0 about as a role line", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: FIZZ_PUBKEY, + name: "Fizz", + status: "stopped", + channelNames: ["general"], + }, + { + pubkey: HONEY_PUBKEY, + name: "Honey", + status: "stopped", + channelNames: ["general"], + }, + { + pubkey: BUMBLE_PUBKEY, + name: "Bumble", + status: "stopped", + channelNames: ["general"], + }, + { + pubkey: BUZZY_PUBKEY, + name: "Buzzy", + status: "stopped", + channelNames: ["general"], + }, + ], + searchProfiles: [ + { pubkey: FIZZ_PUBKEY, displayName: "Fizz", about: FIZZ_ABOUT }, + { pubkey: HONEY_PUBKEY, displayName: "Honey", about: HONEY_ABOUT }, + { pubkey: BUMBLE_PUBKEY, displayName: "Bumble", about: BUMBLE_ABOUT }, + // Buzzy has no `about` — exercises the name-only fallback row. + // bob is human — an `about` on a person must NOT render a role line. + { + pubkey: TEST_IDENTITIES.bob.pubkey, + displayName: "bob", + about: "A human bio that stays off the mention row", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@"); + + const dropdown = autocomplete(page); + await expect(dropdown).toBeVisible(); + + for (const [name, about] of [ + ["Fizz", FIZZ_ABOUT], + ["Honey", HONEY_ABOUT], + ["Bumble", BUMBLE_ABOUT], + ] as const) { + const row = dropdown.locator("button", { hasText: name }); + await expect(row.getByTestId("mention-agent-icon")).toBeVisible(); + await expect(row.getByTestId("mention-agent-description")).toHaveText( + about, + ); + await expect(row.getByText("managed by you")).toBeVisible(); + } + + // No `about` → today's exact row: bot icon + literal "agent" label. + const buzzyRow = dropdown.locator("button", { hasText: "Buzzy" }); + await expect(buzzyRow.getByTestId("mention-agent-icon")).toBeVisible(); + await expect(buzzyRow.getByText("agent", { exact: true })).toBeVisible(); + await expect(buzzyRow.getByTestId("mention-agent-description")).toHaveCount( + 0, + ); + + // Humans never get a role line, even with an `about` on their profile. + const bobRow = dropdown.locator("button", { hasText: "bob" }); + await expect(bobRow).toBeVisible(); + await expect(bobRow.getByTestId("mention-agent-description")).toHaveCount(0); + + // Scroll the agent rows into frame — the list opens scrolled to the top + // where the viewer/member rows sit. + await dropdown.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + await shootComposerWithDropdown(page, `${SHOTS}/01-agent-role-lines.png`); +}); + +test("long about truncates to a single line beside the managed-by label", async ({ + page, +}) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: ATLAS_PUBKEY, + name: "Atlas", + status: "stopped", + channelNames: ["general"], + }, + ], + searchProfiles: [ + { pubkey: ATLAS_PUBKEY, displayName: "Atlas", about: ATLAS_LONG_ABOUT }, + ], + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-input").fill("@Atlas"); + + const dropdown = autocomplete(page); + const atlasRow = dropdown.locator("button", { hasText: "Atlas" }); + const description = atlasRow.getByTestId("mention-agent-description"); + await expect(description).toBeVisible(); + await expect(description).toHaveAttribute("title", ATLAS_LONG_ABOUT); + await expect(atlasRow.getByText("managed by you")).toBeVisible(); + + // The full text must overflow its one-line box — proof it truncates + // instead of wrapping or pushing the managed-by label out of the row. + const truncates = await description.evaluate( + (el) => el.scrollWidth > el.clientWidth, + ); + expect(truncates).toBe(true); + + await shootComposerWithDropdown(page, `${SHOTS}/02-long-about-truncates.png`); +});