fix(desktop): scope active-agents working timer to owned agents

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) <noreply@anthropic.com>
This commit is contained in:
Bradley Axen
2026-07-20 10:33:32 -07:00
parent 8a65d81fab
commit 96f8acbd44
3 changed files with 107 additions and 3 deletions

View File

@@ -338,6 +338,7 @@ export function ChannelScreen({
activeChannel,
activeChannelId,
channelMembers,
currentPubkey,
managedAgents,
openThreadHeadId: effectiveOpenThreadHeadId,
relayAgents,

View File

@@ -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", () => {

View File

@@ -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<TypingIndicatorEntry, "pubkey" | "threadHeadId">[],
ownedPubkeys?: ReadonlySet<string>,
): 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<string, string>();
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;