From 96f8acbd44c9e2e92d7760f6391dec1e2693ef56 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Mon, 20 Jul 2026 10:33:32 -0700 Subject: [PATCH] fix(desktop): scope active-agents working timer to owned agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel "active agents" working timer/badge is fed by two signals: observer-derived turns (correctly scoped to agents the current identity owns) and a bot-typing fallback. The typing fallback mirrored *every* agent typing in the channel into the working signal, so another member's agent typing in a shared channel lit up your own timer. Scope the typing mirror to owned agents only — locally managed agents plus relay agents whose declared owner (NIP-OA) is the current identity — reusing combineObserverIngestionAgents so both feeds agree on "my agents". The in-channel "typing…" row is unchanged and still shows all agents; only what feeds the working timer is narrowed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/channels/ui/ChannelScreen.tsx | 1 + .../ui/useChannelActivityTyping.test.mjs | 47 ++++++++++++++ .../channels/ui/useChannelActivityTyping.ts | 62 ++++++++++++++++++- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 840d0b5d2b..dbd3bde476 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -338,6 +338,7 @@ export function ChannelScreen({ activeChannel, activeChannelId, channelMembers, + currentPubkey, managedAgents, openThreadHeadId: effectiveOpenThreadHeadId, relayAgents, diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs index 3e3943f43e..4621eea74b 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs @@ -41,6 +41,53 @@ describe("channelScopedBotTypingPubkeyKey", () => { ]); assert.equal(key, `${AGENT},${AGENT_2}`); }); + + it("keeps only owned agents when an ownedPubkeys set is given", () => { + const key = channelScopedBotTypingPubkeyKey( + [ + { pubkey: AGENT, threadHeadId: null }, + { pubkey: AGENT_2, threadHeadId: null }, + ], + new Set([AGENT]), + ); + // AGENT_2 belongs to another member — it must not light the working timer. + assert.equal(key, AGENT); + }); + + it("matches ownership case-insensitively against the normalized set", () => { + const key = channelScopedBotTypingPubkeyKey( + [{ pubkey: AGENT.toUpperCase(), threadHeadId: null }], + new Set([AGENT]), + ); + assert.equal(key, AGENT); + }); + + it("returns empty when no channel-scoped typer is owned", () => { + const key = channelScopedBotTypingPubkeyKey( + [{ pubkey: AGENT_2, threadHeadId: null }], + new Set([AGENT]), + ); + assert.equal(key, ""); + }); + + it("still applies the thread-scope filter alongside ownership", () => { + const key = channelScopedBotTypingPubkeyKey( + [ + { pubkey: AGENT, threadHeadId: "thread-1" }, + { pubkey: AGENT, threadHeadId: null }, + ], + new Set([AGENT]), + ); + assert.equal(key, AGENT); + }); + + it("an empty ownedPubkeys set narrows to nothing", () => { + const key = channelScopedBotTypingPubkeyKey( + [{ pubkey: AGENT, threadHeadId: null }], + new Set(), + ); + assert.equal(key, ""); + }); }); describe("thread-only bot typing regression", () => { diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index 0f6807c059..719fcb8ff8 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -1,7 +1,9 @@ import * as React from "react"; import { reportChannelBotTyping } from "@/features/agents/agentWorkingSignal"; +import { combineObserverIngestionAgents } from "@/features/agents/useAgentObserverIngestion"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel, @@ -20,12 +22,25 @@ import { * channel-scoped entries (`threadHeadId === null`) count — thread-only typing * must not light channel-level surfaces (composer bar, sidebar, profile); * thread surfaces apply their own `threadHeadId` filter. + * + * When `ownedPubkeys` is supplied, entries are additionally narrowed to agents + * the current identity owns (normalized pubkeys). The working timer/badge is a + * "my agents are busy" affordance, so another member's agent typing in a shared + * channel must not light it — only the owner sees their own agents' activity. + * Omitting `ownedPubkeys` keeps every channel-scoped entry (used by surfaces + * that legitimately show all agents, e.g. the in-channel "typing…" row). */ export function channelScopedBotTypingPubkeyKey( entries: readonly Pick[], + ownedPubkeys?: ReadonlySet, ): string { return entries - .filter((entry) => entry.threadHeadId === null) + .filter( + (entry) => + entry.threadHeadId === null && + (ownedPubkeys === undefined || + ownedPubkeys.has(normalizePubkey(entry.pubkey))), + ) .map((entry) => entry.pubkey.toLowerCase()) .sort() .join(","); @@ -35,6 +50,7 @@ export function useChannelActivityTyping({ activeChannel, activeChannelId, channelMembers, + currentPubkey, managedAgents, openThreadHeadId, relayAgents, @@ -43,6 +59,7 @@ export function useChannelActivityTyping({ activeChannel: Channel | null; activeChannelId: string | null; channelMembers?: ChannelMember[]; + currentPubkey: string | null | undefined; managedAgents: ManagedAgent[]; openThreadHeadId: string | null; relayAgents: RelayAgent[]; @@ -101,12 +118,51 @@ export function useChannelActivityTyping({ return { botTypingEntries, humanTypingPubkeys }; }, [channelAgentPubkeys, typingEntries]); + // Agents the current identity owns: locally managed agents plus relay agents + // whose declared owner (NIP-OA) is me. Reuses the exact owned-set rule the + // observer-turn ingestion applies (combineObserverIngestionAgents), so both + // feeds of the working signal agree on "my agents". The relay-agent owner + // lookup shares React Query's cache with the observer ingestion's identical + // query, so this adds no extra fetch. + const relayAgentPubkeys = React.useMemo( + () => relayAgents.map((agent) => agent.pubkey), + [relayAgents], + ); + const ownerProfilesQuery = useUsersBatchQuery(relayAgentPubkeys, { + enabled: Boolean(currentPubkey) && relayAgentPubkeys.length > 0, + }); + const ownerProfiles = ownerProfilesQuery.data?.profiles; + const ownedAgentPubkeys = React.useMemo(() => { + const ownerByPubkey = new Map(); + for (const [pubkey, summary] of Object.entries(ownerProfiles ?? {})) { + if (summary.ownerPubkey) { + ownerByPubkey.set( + normalizePubkey(pubkey), + normalizePubkey(summary.ownerPubkey), + ); + } + } + return new Set( + combineObserverIngestionAgents( + managedAgents, + relayAgentPubkeys, + ownerByPubkey, + currentPubkey, + ).map((agent) => normalizePubkey(agent.pubkey)), + ); + }, [managedAgents, relayAgentPubkeys, ownerProfiles, currentPubkey]); + // Mirror bot typing into the unified working signal so surfaces that read // agentWorkingSignal (sidebar badges, activity panel, composer bar) get the // typing fallback. Entries follow the typing TTL because this effect // re-reports whenever botTypingEntries changes. Thread-only typing is - // excluded — see channelScopedBotTypingPubkeyKey. - const botTypingPubkeyKey = channelScopedBotTypingPubkeyKey(botTypingEntries); + // excluded, and — unlike the in-channel typing row — only agents *I own* are + // mirrored, so another member's agent never lights my working timer (see + // channelScopedBotTypingPubkeyKey). + const botTypingPubkeyKey = channelScopedBotTypingPubkeyKey( + botTypingEntries, + ownedAgentPubkeys, + ); React.useEffect(() => { if (!activeChannelId) { return;