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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ pub struct UserProfileSummaryInfo {
#[serde(default)]
pub name: Option<String>,
pub avatar_url: Option<String>,
/// Kind-0 `about` field — one-line role/description surfaced in the
/// @-mention selector for agents.
#[serde(default)]
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
Expand All @@ -65,6 +69,10 @@ pub struct UserSearchResultInfo {
pub pubkey: String,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
/// Kind-0 `about` field — one-line role/description surfaced in the
/// @-mention selector for agents.
#[serde(default)]
pub about: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
#[serde(default)]
Expand Down
23 changes: 23 additions & 0 deletions desktop/src-tauri/src/nostr_convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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![]);
Expand Down
13 changes: 13 additions & 0 deletions desktop/src-tauri/src/nostr_convert/user_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"}"#);
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/messages/lib/mentionCandidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
});
14 changes: 14 additions & 0 deletions desktop/src/features/messages/lib/mentionSuggestionMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type MentionSuggestionCandidate = {
isAgent: boolean;
isMember: boolean;
role?: ChannelRole | null;
description?: string | null;
ownerPubkey?: string | null;
};

Expand All @@ -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,
Expand All @@ -58,5 +71,6 @@ export function mapMentionCandidateToSuggestion(opts: {
candidate.isMember === false,
ownerLabel,
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
description,
};
}
46 changes: 42 additions & 4 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<UserProfileLookup | undefined>(() => {
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<string[]>(() => {
const names: string[] = [];
const seen = new Set<string>();
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -960,10 +998,10 @@ export function useMentions(
currentPubkey,
isMentionOpen,
mentionCandidatesWithTeams,
mentionProfiles,
mentionSelectedIndex,
options?.channelType,
ownerProfilesQuery.data?.profiles,
profiles,
suggestions,
],
);
Expand Down
18 changes: 15 additions & 3 deletions desktop/src/features/messages/ui/MentionAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -163,13 +165,23 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
team · {suggestion.teamMembers?.length ?? 0} agents
</span>
) : suggestion.isAgent ? (
<span className="inline-flex shrink-0 items-center gap-1">
<span className="inline-flex min-w-0 items-center gap-1">
<Bot
aria-hidden="true"
className="h-3.5 w-3.5"
className="h-3.5 w-3.5 shrink-0"
data-testid="mention-agent-icon"
/>
{agentLabel}
{suggestion.description ? (
<span
className="min-w-0 truncate"
data-testid="mention-agent-description"
title={suggestion.description}
>
{suggestion.description}
</span>
) : (
agentLabel
)}
</span>
) : suggestion.role ? (
<Badge
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/profile/lib/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function profileLookupsEqual(
prev.displayName !== next.displayName ||
prev.name !== next.name ||
prev.avatarUrl !== next.avatarUrl ||
prev.about !== next.about ||
prev.nip05Handle !== next.nip05Handle ||
prev.ownerPubkey !== next.ownerPubkey ||
prev.isAgent !== next.isAgent
Expand Down Expand Up @@ -80,6 +81,7 @@ export function mergeCurrentProfileIntoLookup(
// lookup already resolved so mention aliases survive the merge.
name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null,
avatarUrl: currentProfile.avatarUrl,
about: profiles?.[normalizePubkey(currentProfile.pubkey)]?.about ?? null,
nip05Handle: currentProfile.nip05Handle,
isAgent: profiles?.[normalizePubkey(currentProfile.pubkey)]?.isAgent,
ownerPubkey:
Expand Down
Loading