Skip to content
Draft
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
5 changes: 4 additions & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
7 changes: 6 additions & 1 deletion desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -316,7 +317,11 @@ function AppReady({
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
}

return <RouterProvider router={router} />;
return (
<KnownAgentPubkeysProvider>
<RouterProvider router={router} />
</KnownAgentPubkeysProvider>
);
}

export function App() {
Expand Down
39 changes: 39 additions & 0 deletions desktop/src/features/agents/knownAgentPubkeys.test.mjs
Original file line number Diff line number Diff line change
@@ -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]);
});
27 changes: 27 additions & 0 deletions desktop/src/features/agents/knownAgentPubkeys.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const pubkeys = new Set<string>();
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;
}
85 changes: 85 additions & 0 deletions desktop/src/features/agents/useKnownAgentPubkeys.tsx
Original file line number Diff line number Diff line change
@@ -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<string> = new Set();

const KnownAgentPubkeysContext = React.createContext<ReadonlySet<string>>(
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 (
<KnownAgentPubkeysContext.Provider value={stable}>
{children}
</KnownAgentPubkeysContext.Provider>
);
}

/**
* 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<string> {
return React.useContext(KnownAgentPubkeysContext);
}
2 changes: 0 additions & 2 deletions desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -606,7 +606,6 @@ export const ChannelPane = React.memo(function ChannelPane({
) : null}
<MessageTimeline
ref={messageTimelineRef}
agentPubkeys={agentPubkeys}
channelId={activeChannel?.id}
channelIntro={channelIntro}
directMessageIntro={directMessageIntro}
Expand Down Expand Up @@ -788,7 +787,6 @@ export const ChannelPane = React.memo(function ChannelPane({
(() => {
const panel = (
<MessageThreadPanel
agentPubkeys={agentPubkeys}
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
Expand Down
53 changes: 15 additions & 38 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys";
import {
mergeMessages,
useChannelMessagesQuery,
Expand Down Expand Up @@ -56,10 +57,6 @@ import { useThreadReplies } from "@/features/messages/useThreadReplies";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import type { TimelineMessage } from "@/features/messages/types";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
mergeCurrentProfileIntoLookup,
profileLookupsEqual,
} from "@/features/profile/lib/identity";
import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types";
import { useChannelFind } from "@/features/search/useChannelFind";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
Expand All @@ -72,11 +69,9 @@ import { useElementWidth } from "@/shared/hooks/use-mobile";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
mergeAgentNamesIntoProfiles,
useChannelActivityTyping,
} from "./useChannelActivityTyping";
import { useChannelActivityTyping } from "./useChannelActivityTyping";
import { useChannelAgentSessions } from "./useChannelAgentSessions";
import { useMessageProfiles } from "./useMessageProfiles";
import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState";
import { useChannelProfilePanel } from "./useChannelProfilePanel";
import { useChannelRouteTarget } from "./useChannelRouteTarget";
Expand Down Expand Up @@ -352,47 +347,29 @@ export function ChannelScreen({
// Observer ingestion (frame decryption + derived active-turn liveness) is
// owner-global — mounted once in AppShell via useAgentObserverIngestion —
// so this screen no longer mounts its own observer/turns bridges.
const messageProfilesRaw = React.useMemo(() => {
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 ?? [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
Loading
Loading