diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 149b1e08cc..d9d25c323d 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -246,7 +246,10 @@ const overrides = new Map([ // (reply-inclusive; would clear unread state early). The file was already // at the 1000 ceiling; comment-only overage, not code growth. Queued to // split with the rest of this list. - ["src/features/channels/ui/ChannelScreen.tsx", 1002], + // member-agent-flags: messageProfiles merge + ref stabilisation split out to + // useMessageProfiles.ts, ratcheting 1002 -> 972 (under the 1000 default; + // entry kept as a ratchet). + ["src/features/channels/ui/ChannelScreen.tsx", 972], // Shared UI was added to this guard after splitting globals/markdown so // large shared renderers cannot grow further while follow-up splits land. // +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard). diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index e978edf920..5e8b070116 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -14,6 +14,7 @@ import { import { router } from "@/app/router"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; +import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSlideTransition"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; @@ -316,7 +317,11 @@ function AppReady({ return isWorkspaceSwitch ? : ; } - return ; + return ( + + + + ); } export function App() { diff --git a/desktop/src/features/agents/knownAgentPubkeys.test.mjs b/desktop/src/features/agents/knownAgentPubkeys.test.mjs new file mode 100644 index 0000000000..8da09a2650 --- /dev/null +++ b/desktop/src/features/agents/knownAgentPubkeys.test.mjs @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { mergeKnownAgentPubkeys } from "./knownAgentPubkeys.ts"; + +const MANAGED = + "1111111111111111111111111111111111111111111111111111111111111111"; +const RELAY = + "2222222222222222222222222222222222222222222222222222222222222222"; +const FEED = "3333333333333333333333333333333333333333333333333333333333333333"; + +test("mergesAllThreeSources", () => { + const merged = mergeKnownAgentPubkeys( + [{ pubkey: MANAGED }], + [{ pubkey: RELAY }], + [{ pubkey: FEED }], + ); + + assert.deepEqual([...merged].sort(), [MANAGED, RELAY, FEED].sort()); +}); + +test("undefinedSources_yieldEmptySet", () => { + const merged = mergeKnownAgentPubkeys(undefined, undefined, undefined); + + assert.equal(merged.size, 0); +}); + +test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => { + // The same agent appearing in multiple sources with different casing / + // stray whitespace must collapse to one normalised entry, so membership + // checks against normalizePubkey output always hit. + const merged = mergeKnownAgentPubkeys( + [{ pubkey: MANAGED.toUpperCase() }], + [{ pubkey: ` ${MANAGED}` }], + [{ pubkey: MANAGED }], + ); + + assert.deepEqual([...merged], [MANAGED]); +}); diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts new file mode 100644 index 0000000000..eb7e65db3c --- /dev/null +++ b/desktop/src/features/agents/knownAgentPubkeys.ts @@ -0,0 +1,27 @@ +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Pure merge behind `useKnownAgentPubkeys`: managed agents ∪ relay agents ∪ + * home-feed agent activity, normalised via `normalizePubkey` so membership + * checks work against normalised pubkeys. + * + * Structurally typed on `{ pubkey }` so node unit tests don't need to build + * full `ManagedAgent`/`RelayAgent`/`FeedItem` values. + */ +export function mergeKnownAgentPubkeys( + managedAgents: readonly { pubkey: string }[] | undefined, + relayAgents: readonly { pubkey: string }[] | undefined, + feedAgentActivity: readonly { pubkey: string }[] | undefined, +): ReadonlySet { + const pubkeys = new Set(); + for (const agent of managedAgents ?? []) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + for (const agent of relayAgents ?? []) { + pubkeys.add(normalizePubkey(agent.pubkey)); + } + for (const item of feedAgentActivity ?? []) { + pubkeys.add(normalizePubkey(item.pubkey)); + } + return pubkeys; +} diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.tsx b/desktop/src/features/agents/useKnownAgentPubkeys.tsx new file mode 100644 index 0000000000..7be964b561 --- /dev/null +++ b/desktop/src/features/agents/useKnownAgentPubkeys.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; + +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; +import { useHomeFeedQuery } from "@/features/home/hooks"; +import { useStableSet } from "@/shared/hooks/useStableReference"; + +const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet = new Set(); + +const KnownAgentPubkeysContext = React.createContext>( + EMPTY_KNOWN_AGENT_PUBKEYS, +); + +/** + * Owns the app's only React Query subscription to the known-agent source + * queries and publishes the merged set over context. + * + * The subscription lives here — not in `useKnownAgentPubkeys` — on purpose. + * Consumers include every mounted `MessageRow`; if each consumer held its own + * query observers, every batch of row mounts (channel switch, thread panel + * open, load-older) would find short-staleTime data stale and re-trigger + * fetches via `refetchOnMount`, including the deliberately relaxed + * whole-profile-set `listRelayAgents` relay query. And each source-query data + * churn (managed agents poll at 5s while an agent runs) would re-render every + * row before `useStableSet` could bail. With the single subscription here, + * query churn re-renders only this provider — `children` is referentially + * stable, so nothing cascades — and context consumers re-render only when the + * published set's identity changes, which `useStableSet` restricts to actual + * membership change. + * + * Mounted once per workspace inside `AppReady` (under the workspace-keyed + * `WorkspaceQueryProvider` remount boundary), so the observers tear down and + * re-create on workspace switch without a `resetWorkspaceState()` entry. + */ +export function KnownAgentPubkeysProvider({ + children, +}: { + children: React.ReactNode; +}) { + const managedAgents = useManagedAgentsQuery().data; + const relayAgents = useRelayAgentsQuery().data; + const feedAgentActivity = useHomeFeedQuery().data?.feed.agentActivity; + + const merged = React.useMemo( + () => mergeKnownAgentPubkeys(managedAgents, relayAgents, feedAgentActivity), + [feedAgentActivity, managedAgents, relayAgents], + ); + const stable = useStableSet(merged); + + return ( + + {children} + + ); +} + +/** + * The workspace-scoped "known agent pubkeys" baseline: locally managed agents + * ∪ relay-registered agents ∪ home-feed agent activity, normalised via + * `normalizePubkey`. + * + * Every surface that decides whether a pubkey belongs to an agent — the + * config-nudge trust gate, bot avatars/popovers, agent mention pills — must + * share this baseline. Surfaces previously derived their own sets from + * different source subsets, so the same event could pass the trust gate on + * one screen and fail it on another. + * + * Surface-local signals stay additive on top: merge channel-member roles or + * a profile lookup's `isAgent` flags at the call site (or check + * `profiles[normalizePubkey(pk)]?.isAgent` per pubkey). They can only widen + * the baseline, never diverge from it. + * + * Reads content-stable context published by `KnownAgentPubkeysProvider` — + * consumers add no query observers and re-render only when membership + * actually changes, so the set is safe as a memo/comparator dependency in + * render-hot consumers. Outside the provider (unit tests, stray surfaces) + * this degrades gracefully to the empty set; surfaces still fold in their + * local `isAgent` profile flags. + */ +export function useKnownAgentPubkeys(): ReadonlySet { + return React.useContext(KnownAgentPubkeysContext); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index f7cd01b781..39f0aa1d6b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -606,7 +606,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} { const panel = ( { - const base = - mergeCurrentProfileIntoLookup( - messageProfilesQuery.data?.profiles, - currentProfile, - ) ?? {}; - return mergeAgentNamesIntoProfiles( - base, - managedAgents, - relayAgents, - currentPubkey, - ); - }, [ + const messageProfiles = useMessageProfiles({ + channelMembers, currentProfile, currentPubkey, managedAgents, - messageProfilesQuery.data?.profiles, + profiles: messageProfilesQuery.data?.profiles, relayAgents, - ]); - // Stabilise the merged lookup's reference across renders when no profile - // value changed. `messageProfilesRaw` gets a fresh identity whenever the - // `users-batch` query re-keys — which typing churn triggers constantly — and - // that identity flows to MessageRow's `prev.profiles === next.profiles` memo - // check, so an unstable reference re-renders the whole timeline per keystroke. - const messageProfilesRef = React.useRef(messageProfilesRaw); - if (!profileLookupsEqual(messageProfilesRef.current, messageProfilesRaw)) { - messageProfilesRef.current = messageProfilesRaw; - } - const messageProfiles = messageProfilesRef.current; - // Derived from the stabilised lookup so this Set only churns when a profile - // value actually changed — MessageRow compares `agentPubkeys` by reference, - // and each row previously re-derived this same scan locally (removed). + }); + // Agent set for ChannelPane's own consumers (DM huddle member resolution, + // the agents list): the workspace-scoped baseline shared by every surface, + // widened with channel-member roles and this screen's profile lookup. + // Message rows no longer take this — MessageRow derives agent-ness itself + // from useKnownAgentPubkeys + per-pubkey profile checks. + const workspaceAgentPubkeys = useKnownAgentPubkeys(); const agentPubkeys = React.useMemo(() => { - const pubkeys = new Set(knownAgentPubkeys); + const pubkeys = new Set([...workspaceAgentPubkeys, ...knownAgentPubkeys]); for (const [pubkey, profile] of Object.entries(messageProfiles)) { if (profile.isAgent) { pubkeys.add(normalizePubkey(pubkey)); } } return pubkeys; - }, [knownAgentPubkeys, messageProfiles]); + }, [knownAgentPubkeys, messageProfiles, workspaceAgentPubkeys]); const personasQuery = usePersonasQuery(); const { personaLookup, respondToLookup } = React.useMemo(() => { const agents = managedAgentsQuery.data ?? []; diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs index 96b756e4ba..b77ded48be 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs @@ -8,7 +8,10 @@ import { resetAgentWorkingSignal, } from "../../agents/agentWorkingSignal.ts"; import { resetActiveAgentTurnsStore } from "../../agents/activeAgentTurnsStore.ts"; -import { channelScopedBotTypingPubkeyKey } from "./useChannelActivityTyping.ts"; +import { + channelScopedBotTypingPubkeyKey, + mergeMemberAgentFlagsIntoProfiles, +} from "./useChannelActivityTyping.ts"; const AGENT = "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; @@ -73,3 +76,81 @@ describe("thread-only bot typing regression", () => { assert.equal(state.source, "typing"); }); }); + +describe("mergeMemberAgentFlagsIntoProfiles", () => { + it("flags a role-only bot member (no isAgent) as an agent", () => { + const merged = mergeMemberAgentFlagsIntoProfiles({}, [ + { pubkey: AGENT, role: "bot", isAgent: false }, + ]); + + assert.equal(merged[AGENT]?.isAgent, true); + assert.equal(merged[AGENT]?.displayName, null); + assert.equal(merged[AGENT]?.avatarUrl, null); + assert.equal(merged[AGENT]?.nip05Handle, null); + assert.equal(merged[AGENT]?.ownerPubkey, null); + }); + + it("flags an isAgent-only member (non-bot role) as an agent", () => { + const merged = mergeMemberAgentFlagsIntoProfiles({}, [ + { pubkey: AGENT, role: "member", isAgent: true }, + ]); + + assert.equal(merged[AGENT]?.isAgent, true); + }); + + it("normalises member pubkeys so lookups by normalised key hit", () => { + const merged = mergeMemberAgentFlagsIntoProfiles({}, [ + { pubkey: ` ${AGENT.toUpperCase()}`, role: "bot", isAgent: false }, + ]); + + assert.equal(merged[AGENT]?.isAgent, true); + }); + + it("returns the same reference when no member carries an agent flag", () => { + const profiles = { + [AGENT]: { + displayName: "Human", + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + }, + }; + + assert.equal( + mergeMemberAgentFlagsIntoProfiles(profiles, undefined), + profiles, + ); + assert.equal(mergeMemberAgentFlagsIntoProfiles(profiles, []), profiles); + assert.equal( + mergeMemberAgentFlagsIntoProfiles(profiles, [ + { pubkey: AGENT_2, role: "member", isAgent: false }, + ]), + profiles, + ); + }); + + it("preserves existing profile fields and does not mutate the input", () => { + const profiles = { + [AGENT]: { + displayName: "Deploy Bot", + avatarUrl: "https://example.com/a.png", + nip05Handle: "deploy@example.com", + ownerPubkey: AGENT_2, + }, + }; + + const merged = mergeMemberAgentFlagsIntoProfiles(profiles, [ + { pubkey: AGENT, role: "bot", isAgent: false }, + ]); + + assert.notEqual(merged, profiles); + assert.deepEqual(merged[AGENT], { + displayName: "Deploy Bot", + avatarUrl: "https://example.com/a.png", + nip05Handle: "deploy@example.com", + ownerPubkey: AGENT_2, + isAgent: true, + }); + assert.equal(profiles[AGENT].isAgent, undefined); + }); +}); diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index 9bb07acd8c..2dece2aac7 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -159,3 +159,41 @@ export function mergeAgentNamesIntoProfiles( } return merged; } + +/** + * Fold channel-member agent flags (`role === "bot"` or `isAgent`) into a + * profile lookup as `isAgent: true` entries — the same pattern + * `mergeAgentNamesIntoProfiles` applies to managed/relay agents, extended to + * the membership signal. Per-pubkey `profiles[pk]?.isAgent` checks + * (MessageRow's agent predicate) then see member-only bots — agents known + * through channel membership alone, with no profile flag and no + * managed/relay/feed presence — without a separate agent-set prop. + * + * Returns the input lookup unchanged (same reference) when no member carries + * an agent flag. + */ +export function mergeMemberAgentFlagsIntoProfiles( + profiles: UserProfileLookup, + channelMembers: + | readonly Pick[] + | undefined, +): UserProfileLookup { + const agentMembers = (channelMembers ?? []).filter( + (member) => member.role === "bot" || member.isAgent, + ); + if (agentMembers.length === 0) { + return profiles; + } + const merged = { ...profiles }; + for (const member of agentMembers) { + const key = normalizePubkey(member.pubkey); + merged[key] = { + displayName: merged[key]?.displayName ?? null, + avatarUrl: merged[key]?.avatarUrl ?? null, + nip05Handle: merged[key]?.nip05Handle ?? null, + ownerPubkey: merged[key]?.ownerPubkey ?? null, + isAgent: true, + }; + } + return merged; +} diff --git a/desktop/src/features/channels/ui/useMessageProfiles.ts b/desktop/src/features/channels/ui/useMessageProfiles.ts new file mode 100644 index 0000000000..61ee80b3ff --- /dev/null +++ b/desktop/src/features/channels/ui/useMessageProfiles.ts @@ -0,0 +1,78 @@ +import * as React from "react"; + +import { + mergeCurrentProfileIntoLookup, + profileLookupsEqual, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import type { + ChannelMember, + ManagedAgent, + Profile, + RelayAgent, +} from "@/shared/api/types"; +import { + mergeAgentNamesIntoProfiles, + mergeMemberAgentFlagsIntoProfiles, +} from "./useChannelActivityTyping"; + +/** + * The channel screen's message-row profile lookup: the `users-batch` query + * profiles overlaid with the current profile, managed/relay agent names, and + * channel-member agent flags. + * + * Member agent flags (`role === "bot"` or `isAgent`) ride along in the lookup + * so a member-only bot — known through channel membership alone, with no + * profile `isAgent` flag and no managed/relay/feed presence — still passes + * MessageRow's per-pubkey `profiles[pk]?.isAgent` check; rows no longer see + * a member-derived agent set. + * + * The returned reference is stabilised across renders when no profile value + * changed: the raw merge gets a fresh identity whenever the `users-batch` + * query re-keys — which typing churn triggers constantly — and that identity + * flows to MessageRow's `prev.profiles === next.profiles` memo check, so an + * unstable reference re-renders the whole timeline per keystroke. Consumers + * read profiles by pubkey value only, never treating identity as a change + * signal, so returning the stale-but-value-identical reference is safe. + */ +export function useMessageProfiles({ + channelMembers, + currentProfile, + currentPubkey, + managedAgents, + profiles, + relayAgents, +}: { + channelMembers: ChannelMember[] | undefined; + currentProfile: Profile | undefined; + currentPubkey: string | undefined; + managedAgents: ManagedAgent[]; + profiles: UserProfileLookup | undefined; + relayAgents: RelayAgent[]; +}): UserProfileLookup { + const raw = React.useMemo(() => { + const base = mergeCurrentProfileIntoLookup(profiles, currentProfile) ?? {}; + return mergeMemberAgentFlagsIntoProfiles( + mergeAgentNamesIntoProfiles( + base, + managedAgents, + relayAgents, + currentPubkey, + ), + channelMembers, + ); + }, [ + channelMembers, + currentProfile, + currentPubkey, + managedAgents, + profiles, + relayAgents, + ]); + + const ref = React.useRef(raw); + if (!profileLookupsEqual(ref.current, raw)) { + ref.current = raw; + } + return ref.current; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 2822c6d557..d8a25e59d8 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -3,6 +3,7 @@ import { RefreshCcw } from "lucide-react"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet"; @@ -286,12 +287,12 @@ export function HomeView({ enabled: feedProfilePubkeys.length > 0, }); const feedProfiles = feedProfilesQuery.data?.profiles; + // Agent set for the inbox list/detail bot badges: the workspace-scoped + // baseline (which already includes feed agent activity) widened with this + // surface's profile lookup. + const workspaceAgentPubkeys = useKnownAgentPubkeys(); const inboxAgentPubkeys = React.useMemo(() => { - const pubkeys = new Set(); - - for (const item of feed?.feed.agentActivity ?? []) { - pubkeys.add(normalizePubkey(item.pubkey)); - } + const pubkeys = new Set(workspaceAgentPubkeys); for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) { if (profile.isAgent) { @@ -300,7 +301,7 @@ export function HomeView({ } return pubkeys; - }, [feed?.feed.agentActivity, feedProfiles]); + }, [feedProfiles, workspaceAgentPubkeys]); const inboxItems = React.useMemo( () => buildInboxItems({ diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index bbd79fc925..758e889e7e 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import type { TimelineMessage } from "@/features/messages/types"; +import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { HuddleAttachment } from "@/features/huddle/components/HuddleAttachment"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; @@ -43,10 +44,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); -/** Stable empty fallback so rows without an agent-pubkey set keep a constant - * reference (a fresh `new Set()` per render would defeat downstream memos). */ -const EMPTY_AGENT_PUBKEYS: ReadonlySet = new Set(); - export type ThreadDepthGuideAction = { active?: boolean; depth: number; @@ -89,10 +86,8 @@ export const MessageRow = React.memo( profiles, searchQuery, showDepthGuides = true, - agentPubkeys, videoReviewContext, }: { - agentPubkeys?: ReadonlySet; channelId?: string | null; collapseDepthGuideActions?: ReadonlyArray; connectDescendants?: boolean; @@ -172,15 +167,25 @@ export const MessageRow = React.memo( () => resolveMentionPubkeysByName(message.tags, profiles), [profiles, message.tags], ); - // The agent-pubkey set is computed once by the parent (ChannelScreen) - // from the same profile lookup and passed down already normalised — no - // per-row rescan of `profiles` (that duplicated the parent's work in every - // mounted row and re-ran on each profile-lookup change). - const resolvedAgentPubkeys = agentPubkeys ?? EMPTY_AGENT_PUBKEYS; + // "Is this pubkey an agent" = the workspace-scoped baseline every surface + // shares (managed ∪ relay ∪ feed agent activity) plus the pubkey's own + // profile `isAgent` flag from this surface's lookup. Both are per-pubkey + // O(1) checks — no per-row rescan of `profiles` (that duplicated parent + // work in every mounted row and re-ran on each profile-lookup change). + const knownAgentPubkeys = useKnownAgentPubkeys(); + const isKnownAgentPubkey = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + return ( + knownAgentPubkeys.has(normalized) || + profiles?.[normalized]?.isAgent === true + ); + }, + [knownAgentPubkeys, profiles], + ); const profilePopoverRole = message.role === "bot" || - (message.pubkey && - resolvedAgentPubkeys.has(normalizePubkey(message.pubkey))) + (message.pubkey && isKnownAgentPubkey(message.pubkey)) ? "bot" : message.role; const agentMentionPubkeysByName = React.useMemo(() => { @@ -190,13 +195,13 @@ export const MessageRow = React.memo( const values: Record = {}; for (const [name, pubkey] of Object.entries(mentionPubkeysByName)) { - if (resolvedAgentPubkeys.has(normalizePubkey(pubkey))) { + if (isKnownAgentPubkey(pubkey)) { values[name] = pubkey; } } return Object.keys(values).length > 0 ? values : undefined; - }, [resolvedAgentPubkeys, mentionPubkeysByName]); + }, [isKnownAgentPubkey, mentionPubkeysByName]); const imetaByUrl = React.useMemo( () => (message.tags ? parseImetaTags(message.tags) : undefined), @@ -335,7 +340,7 @@ export const MessageRow = React.memo( // display author — to prevent actor/p-tag spoofing. configNudgeAuthorPubkey={getConfigNudgeAuthorPubkey( message, - resolvedAgentPubkeys, + isKnownAgentPubkey, )} content={message.body} customEmoji={customEmoji} @@ -792,7 +797,6 @@ export const MessageRow = React.memo( prev.message.tags === next.message.tags && prev.message.role === next.message.role && prev.message.personaDisplayName === next.message.personaDisplayName && - prev.agentPubkeys === next.agentPubkeys && prev.collapseDepthGuideActions === next.collapseDepthGuideActions && prev.collapseDescendantsLabel === next.collapseDescendantsLabel && prev.connectDescendants === next.connectDescendants && diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 391e3a0919..a743ecd323 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -38,7 +38,6 @@ import { useAnchoredScroll } from "./useAnchoredScroll"; import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot"; type MessageThreadPanelProps = { - agentPubkeys?: ReadonlySet; channel: Channel | null; channelId: string | null; channelName: string; @@ -295,7 +294,6 @@ export function MessageThreadPanelSkeleton({ } export function MessageThreadPanel({ - agentPubkeys, channel, channelId, channelName, @@ -616,7 +614,6 @@ export function MessageThreadPanel({
{showUnreadDivider ? : null} ; channelId?: string | null; channelIntro?: ChannelIntro | null; channelName?: string; @@ -141,7 +140,6 @@ const MessageTimelineBase = React.forwardRef< MessageTimelineProps >(function MessageTimeline( { - agentPubkeys, channelId, channelIntro = null, directMessageIntro = null, @@ -581,7 +579,6 @@ const MessageTimelineBase = React.forwardRef< > ; channelId?: string | null; channelName?: string; channelType?: ChannelType | null; @@ -84,7 +83,6 @@ type TimelineMessageListProps = { }; export const TimelineMessageList = React.memo(function TimelineMessageList({ - agentPubkeys, channelId, channelName, channelType, @@ -196,7 +194,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ case "message": return ( AGENT_PUBKEYS.has(pubkey); function makeEvent(overrides = {}) { return { @@ -80,7 +84,7 @@ test("signerIsHuman_actorTagAttributedToAgent_returnsUndefined", () => { // The guard must reject: signer is human, not in AGENT_PUBKEYS. assert.equal( - getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + getConfigNudgeAuthorPubkey(msg, isKnownAgentPubkey), undefined, "human signer with actor-tag attribution to agent must NOT enable the card", ); @@ -103,7 +107,7 @@ test("signerIsAgent_genuine_returnsAgentPubkey", () => { ); assert.equal( - getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + getConfigNudgeAuthorPubkey(msg, isKnownAgentPubkey), AGENT_PUBKEY, "genuine agent-signed kind:9 must enable the card", ); @@ -123,7 +127,7 @@ test("nonKind9_agentSigner_returnsUndefined", () => { const [msg] = format(event); assert.equal( - getConfigNudgeAuthorPubkey(msg, AGENT_PUBKEYS), + getConfigNudgeAuthorPubkey(msg, isKnownAgentPubkey), undefined, "non-kind:9 events must never enable the card even if signer is known agent", ); diff --git a/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts index 1866e59040..433ff0ffa7 100644 --- a/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts +++ b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts @@ -8,9 +8,12 @@ import type { TimelineMessage } from "@/features/messages/types"; * The card is enabled ONLY when: * 1. `message.kind === KIND_STREAM_MESSAGE` — restricts to the setup-listener * wire format. - * 2. `message.signerPubkey` is set and is a known agent — authenticates - * against the raw event signer (NOT `message.pubkey`, which is the - * tag-attributed display author and can be spoofed via `actor`/`p` tags). + * 2. `message.signerPubkey` is set and passes `isKnownAgentPubkey` — + * authenticates against the raw event signer (NOT `message.pubkey`, + * which is the tag-attributed display author and can be spoofed via + * `actor`/`p` tags). The caller's predicate combines the workspace-wide + * known-agent baseline (`useKnownAgentPubkeys`) with any surface-local + * signals such as the signer profile's `isAgent` flag. * * Extracting this predicate as a pure helper lets tests exercise the exact * signer-vs-attributed-author distinction with a real `TimelineMessage` from @@ -18,12 +21,12 @@ import type { TimelineMessage } from "@/features/messages/types"; */ export function getConfigNudgeAuthorPubkey( message: Pick, - resolvedAgentPubkeys: ReadonlySet, + isKnownAgentPubkey: (pubkey: string) => boolean, ): string | undefined { if ( message.kind === KIND_STREAM_MESSAGE && message.signerPubkey && - resolvedAgentPubkeys.has(message.signerPubkey) + isKnownAgentPubkey(message.signerPubkey) ) { return message.signerPubkey; } diff --git a/desktop/src/shared/hooks/useStableReference.ts b/desktop/src/shared/hooks/useStableReference.ts index e02bca4032..fe99ac610f 100644 --- a/desktop/src/shared/hooks/useStableReference.ts +++ b/desktop/src/shared/hooks/useStableReference.ts @@ -52,3 +52,27 @@ function arraysShallowEqual( } return true; } + +/** + * Returns `next` but preserves the previous reference when the two Sets have + * identical membership. Same purpose as `useStableMap` for Set-valued derived + * state (e.g. pubkey sets rebuilt whenever a polling query re-materialises + * its data without changing which pubkeys are in it). + */ +export function useStableSet(next: ReadonlySet): ReadonlySet { + const ref = React.useRef(next); + const prev = ref.current; + if (prev !== next && setsEqual(prev, next)) { + return prev; + } + ref.current = next; + return next; +} + +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const value of a) { + if (!b.has(value)) return false; + } + return true; +}