From ec29b2e80745aae3dd6d707d191f55be4ff0f8ff Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Fri, 3 Jul 2026 12:36:01 -0700 Subject: [PATCH 1/3] feat(desktop): owner-global observer ingestion at app level Rebased from tho/observer-owner-global (#1493) onto post-#1380 main. Make agent observer ingestion (kind 24200 frame decryption + derived active-turn liveness) a single app-level concern instead of per-surface bridge mounts: - useAgentObserverIngestion mounted once in AppShell registers all locally managed agents plus relay agents the identity declared-owns (NIP-OA ownerPubkey via one batched profile query, treated deployed) - Pure combineObserverIngestionAgents() with unit coverage - Remove per-surface bridge mounts: ChannelScreen, UserProfilePanel, useManagedAgentActions, useActiveWorkingChannelsById, usePreventSleep Conflict note vs #1380: UserProfilePanel keeps #1380's activityAgent memo (still feeds the profile activity preview); only its bridge mounts (activityBridgeAgents) are removed since app-level ingestion covers both managed and declared-owned relay agents. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/app/AppShell.tsx | 5 + .../agents/ui/useManagedAgentActions.ts | 6 +- .../agents/useAgentObserverIngestion.test.mjs | 88 +++++++++++++++ .../agents/useAgentObserverIngestion.ts | 105 ++++++++++++++++++ .../src/features/agents/usePreventSleep.ts | 15 +-- .../features/channels/ui/ChannelScreen.tsx | 29 +---- .../features/profile/ui/UserProfilePanel.tsx | 13 +-- .../lib/useActiveWorkingChannelsById.ts | 7 +- 8 files changed, 210 insertions(+), 58 deletions(-) create mode 100644 desktop/src/features/agents/useAgentObserverIngestion.test.mjs create mode 100644 desktop/src/features/agents/useAgentObserverIngestion.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 5861b5081f..d0f850daf2 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -39,6 +39,7 @@ import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; import { usePersonaSync } from "@/features/agents/lib/usePersonaSync"; +import { useAgentObserverIngestion } from "@/features/agents/useAgentObserverIngestion"; import { usePresenceSession, usePresenceSubscription, @@ -147,6 +148,10 @@ export function AppShell() { ); usePersonaSync(identityQuery.data?.pubkey); useAgentsDataRefresh(); + // Owner-global observer ingestion: receives + decrypts agent observer + // frames and keeps derived active-turn liveness in sync app-wide, so no + // individual screen/panel has to mount its own bridge for ingestion. + useAgentObserverIngestion(); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useRelayAutoHeal(); diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index cabea377cf..4432ebbf13 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -14,8 +14,6 @@ import { } from "@/features/agents/hooks"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; -import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; -import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; import type { AcpRuntime, AcpRuntimeCatalogEntry, @@ -85,8 +83,8 @@ export function useManagedAgentActions() { }), [managedAgentsQuery.data], ); - useManagedAgentObserverBridge(managedAgents); - useActiveAgentTurnsBridge(managedAgents); + // Observer ingestion is owner-global (useAgentObserverIngestion in + // AppShell); this hook only reads derived state. const managedPubkeys = React.useMemo( () => new Set(managedAgents.map((agent) => agent.pubkey)), diff --git a/desktop/src/features/agents/useAgentObserverIngestion.test.mjs b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs new file mode 100644 index 0000000000..4a20be9be3 --- /dev/null +++ b/desktop/src/features/agents/useAgentObserverIngestion.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { combineObserverIngestionAgents } from "./useAgentObserverIngestion.ts"; + +const ME = "aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234"; +const OTHER = + "bbbb4321bbbb4321bbbb4321bbbb4321bbbb4321bbbb4321bbbb4321bbbb4321"; +const AGENT_LOCAL = + "cccc1111cccc1111cccc1111cccc1111cccc1111cccc1111cccc1111cccc1111"; +const AGENT_REMOTE = + "dddd2222dddd2222dddd2222dddd2222dddd2222dddd2222dddd2222dddd2222"; +const AGENT_FOREIGN = + "eeee3333eeee3333eeee3333eeee3333eeee3333eeee3333eeee3333eeee3333"; + +describe("combineObserverIngestionAgents", () => { + it("keeps managed agents with their real status", () => { + const result = combineObserverIngestionAgents( + [{ pubkey: AGENT_LOCAL, status: "running" }], + [], + new Map(), + ME, + ); + assert.deepEqual(result, [{ pubkey: AGENT_LOCAL, status: "running" }]); + }); + + it("adds declared-owned relay agents as deployed", () => { + const result = combineObserverIngestionAgents( + [], + [AGENT_REMOTE], + new Map([[AGENT_REMOTE, ME]]), + ME, + ); + assert.deepEqual(result, [{ pubkey: AGENT_REMOTE, status: "deployed" }]); + }); + + it("excludes relay agents owned by someone else", () => { + const result = combineObserverIngestionAgents( + [], + [AGENT_FOREIGN], + new Map([[AGENT_FOREIGN, OTHER]]), + ME, + ); + assert.deepEqual(result, []); + }); + + it("excludes relay agents with no declared owner", () => { + const result = combineObserverIngestionAgents( + [], + [AGENT_REMOTE], + new Map(), + ME, + ); + assert.deepEqual(result, []); + }); + + it("does not duplicate an agent that is both managed and on the relay", () => { + const result = combineObserverIngestionAgents( + [{ pubkey: AGENT_LOCAL, status: "stopped" }], + [AGENT_LOCAL], + new Map([[AGENT_LOCAL, ME]]), + ME, + ); + assert.deepEqual(result, [{ pubkey: AGENT_LOCAL, status: "stopped" }]); + }); + + it("matches ownership case-insensitively", () => { + const result = combineObserverIngestionAgents( + [], + [AGENT_REMOTE.toUpperCase()], + new Map([[AGENT_REMOTE, ME.toUpperCase()]]), + ME, + ); + assert.deepEqual(result, [ + { pubkey: AGENT_REMOTE.toUpperCase(), status: "deployed" }, + ]); + }); + + it("returns only managed agents when identity is not resolved yet", () => { + const result = combineObserverIngestionAgents( + [{ pubkey: AGENT_LOCAL, status: "running" }], + [AGENT_REMOTE], + new Map([[AGENT_REMOTE, ME]]), + undefined, + ); + assert.deepEqual(result, [{ pubkey: AGENT_LOCAL, status: "running" }]); + }); +}); diff --git a/desktop/src/features/agents/useAgentObserverIngestion.ts b/desktop/src/features/agents/useAgentObserverIngestion.ts new file mode 100644 index 0000000000..624043fd90 --- /dev/null +++ b/desktop/src/features/agents/useAgentObserverIngestion.ts @@ -0,0 +1,105 @@ +import * as React from "react"; + +import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +type IngestionAgent = Pick; + +/** + * Combine locally managed agents with relay agents the current identity + * declared-owns (NIP-OA `ownerPubkey == me`) into one ingestion list. + * + * Managed agents keep their real status; owned relay agents that are not + * managed locally are treated as `deployed` so the observer subscription + * starts and their frames decrypt. Registering non-owned agents would be + * pointless — observer frames are `#p`-addressed to the owner, so frames for + * agents we do not own never arrive on our subscription in the first place. + */ +export function combineObserverIngestionAgents( + managedAgents: readonly IngestionAgent[], + relayAgentPubkeys: readonly string[], + ownerByPubkey: ReadonlyMap, + currentPubkey: string | null | undefined, +): IngestionAgent[] { + const managed = managedAgents.map((agent) => ({ + pubkey: agent.pubkey, + status: agent.status, + })); + if (!currentPubkey) { + return managed; + } + + const managedSet = new Set( + managed.map((agent) => normalizePubkey(agent.pubkey)), + ); + const me = normalizePubkey(currentPubkey); + const owned: IngestionAgent[] = []; + for (const pubkey of relayAgentPubkeys) { + const key = normalizePubkey(pubkey); + if (managedSet.has(key)) { + continue; + } + const owner = ownerByPubkey.get(key); + if (owner && normalizePubkey(owner) === me) { + owned.push({ pubkey, status: "deployed" as const }); + } + } + return [...managed, ...owned]; +} + +/** + * App-level owner-global observer ingestion. + * + * Mounted once in AppShell so observer frames (kind 24200) are received, + * decrypted, and folded into the derived active-turns store regardless of + * which screen or panel happens to be open. Individual surfaces read from the + * stores; none of them need to mount their own bridge for ingestion to work. + * + * This is the product invariant: if the current identity owns an agent (local + * managed agent or declared-owned relay agent), its turn activity is ingested + * app-wide — not only while a panel that happens to mount a bridge is open. + */ +export function useAgentObserverIngestion() { + const identityQuery = useIdentityQuery(); + const currentPubkey = identityQuery.data?.pubkey; + + const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgents = managedAgentsQuery.data; + + const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgentPubkeys = React.useMemo( + () => (relayAgentsQuery.data ?? []).map((agent) => agent.pubkey), + [relayAgentsQuery.data], + ); + + const profilesQuery = useUsersBatchQuery(relayAgentPubkeys, { + enabled: Boolean(currentPubkey) && relayAgentPubkeys.length > 0, + }); + const profiles = profilesQuery.data?.profiles; + + const ingestionAgents = React.useMemo(() => { + const ownerByPubkey = new Map(); + for (const [pubkey, summary] of Object.entries(profiles ?? {})) { + if (summary.ownerPubkey) { + ownerByPubkey.set(normalizePubkey(pubkey), summary.ownerPubkey); + } + } + return combineObserverIngestionAgents( + managedAgents ?? [], + relayAgentPubkeys, + ownerByPubkey, + currentPubkey, + ); + }, [currentPubkey, managedAgents, profiles, relayAgentPubkeys]); + + useManagedAgentObserverBridge(ingestionAgents); + useActiveAgentTurnsBridge(ingestionAgents); +} diff --git a/desktop/src/features/agents/usePreventSleep.ts b/desktop/src/features/agents/usePreventSleep.ts index 1ea1a4d5d4..4c2d48b97d 100644 --- a/desktop/src/features/agents/usePreventSleep.ts +++ b/desktop/src/features/agents/usePreventSleep.ts @@ -3,7 +3,6 @@ import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { getAgentObserverSnapshot, subscribeAgentObserverStore, - useManagedAgentObserverBridge, } from "@/features/agents/observerRelayStore"; import { createPreventSleepActivityTracker } from "@/features/agents/preventSleepActivity"; import { setPreventSleepActive } from "@/shared/api/tauri"; @@ -68,18 +67,8 @@ function usePreventSleepInternal() { ); const runningAgentPubkeyKey = runningAgentPubkeys.join(","); - const runningObserverAgents = React.useMemo( - () => - enabled && runningAgentPubkeyKey - ? runningAgentPubkeyKey.split(",").map((pubkey) => ({ - pubkey, - status: "running" as const, - })) - : [], - [enabled, runningAgentPubkeyKey], - ); - - useManagedAgentObserverBridge(runningObserverAgents); + // Observer ingestion is owner-global (useAgentObserverIngestion in + // AppShell); this hook only reads observer snapshots for activity tracking. const hasRunningAgents = runningAgentPubkeys.length > 0; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 7aa670358b..8bc1ee8c97 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -24,8 +24,6 @@ import { usePersonasQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; -import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; -import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { mergeMessages, useChannelMessagesQuery, @@ -358,30 +356,9 @@ export function ChannelScreen({ relayAgents, typingEntries, }); - const observerBridgeAgents = React.useMemo(() => { - if ( - !profilePanelPubkey || - !openAgentSessionPubkey || - normalizePubkey(profilePanelPubkey) !== - normalizePubkey(openAgentSessionPubkey) || - managedAgents.some( - (agent) => - normalizePubkey(agent.pubkey) === normalizePubkey(profilePanelPubkey), - ) - ) { - return managedAgents; - } - - return [ - ...managedAgents, - { - pubkey: profilePanelPubkey, - status: "deployed" as const, - }, - ]; - }, [managedAgents, openAgentSessionPubkey, profilePanelPubkey]); - useManagedAgentObserverBridge(observerBridgeAgents); - useActiveAgentTurnsBridge(observerBridgeAgents); + // 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 messageProfiles = React.useMemo(() => { const base = mergeCurrentProfileIntoLookup( diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 359954c1d6..9bdd0fb2fa 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -27,14 +27,12 @@ import { useUpdatePersonaMutation, } from "@/features/agents/hooks"; import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog"; -import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; import { isManagedAgentActive, startManagedAgentWithRules, stopManagedAgentWithRules, } from "@/features/agents/lib/managedAgentControlActions"; -import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { describeLogFile } from "@/features/agents/ui/agentUi"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; import { @@ -288,14 +286,9 @@ export function UserProfilePanel({ }), [effectivePubkey, isBot, managedAgent, profile, relayAgent, viewerIsOwner], ); - const activityBridgeAgents = React.useMemo( - () => (activityAgent ? [activityAgent] : []), - [activityAgent], - ); - // Populate the active-turns store for this agent so useActiveAgentTurns works - // even if the Agents page hasn't been visited yet. - useActiveAgentTurnsBridge(activityBridgeAgents); - useManagedAgentObserverBridge(activityBridgeAgents); + // Observer ingestion (frame decryption + derived active-turn liveness) is + // owner-global — mounted once in AppShell via useAgentObserverIngestion — + // covering both locally managed agents and declared-owned relay agents. const canEditAgent = isOwner === true && (managedAgent !== undefined || diff --git a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts index 03f245f64a..d1f355ccd4 100644 --- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts +++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts @@ -2,11 +2,9 @@ import * as React from "react"; import { type ActiveChannelTurnSummary, - useActiveAgentTurnsBridge, useActiveAgentTurnsByChannel, } from "@/features/agents/activeAgentTurnsStore"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; -import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import { normalizePubkey } from "@/shared/lib/pubkey"; export function resolveActiveWorkingChannelNames( @@ -36,9 +34,8 @@ export function useActiveWorkingChannelsById(): ReadonlyMap< [managedAgentsQuery.data], ); - useManagedAgentObserverBridge(managedAgents); - useActiveAgentTurnsBridge(managedAgents); - + // Observer ingestion is owner-global (useAgentObserverIngestion in + // AppShell); this hook only reads derived state. const activeWorkingChannels = useActiveAgentTurnsByChannel(); return React.useMemo( () => From b21837da5d436da849514155c5d7d08c7d8f2c33 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> Date: Mon, 6 Jul 2026 18:00:24 -0700 Subject: [PATCH 2/3] docs(desktop): document pre-identity observer mount; normalize owner value Two review follow-ups on owner-global observer ingestion: - Document (AppShell + hook jsdoc) that useAgentObserverIngestion intentionally mounts before identity resolves: managed agents are ingested immediately, relay-owned agents join once currentPubkey arrives. Guards against a future identity/startup gate that would drop managed-agent coverage during startup. - Store ownerByPubkey values normalized (was: normalized key, raw value, normalize-at-compare) so lookups and ownership comparisons never depend on relay casing. Behavior unchanged; removes the asymmetry footgun. Addresses review feedback on #1493. Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- desktop/src/app/AppShell.tsx | 4 ++++ .../features/agents/useAgentObserverIngestion.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index d0f850daf2..f863fe599d 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -151,6 +151,10 @@ export function AppShell() { // Owner-global observer ingestion: receives + decrypts agent observer // frames and keeps derived active-turn liveness in sync app-wide, so no // individual screen/panel has to mount its own bridge for ingestion. + // Intentionally mounted without a `startupReady`/identity guard: before + // `currentPubkey` resolves the hook ingests managed agents only, and + // relay-owned agents join automatically once identity arrives. Adding a + // guard here would drop managed-agent coverage during startup. useAgentObserverIngestion(); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; diff --git a/desktop/src/features/agents/useAgentObserverIngestion.ts b/desktop/src/features/agents/useAgentObserverIngestion.ts index 624043fd90..386b762142 100644 --- a/desktop/src/features/agents/useAgentObserverIngestion.ts +++ b/desktop/src/features/agents/useAgentObserverIngestion.ts @@ -66,6 +66,12 @@ export function combineObserverIngestionAgents( * This is the product invariant: if the current identity owns an agent (local * managed agent or declared-owned relay agent), its turn activity is ingested * app-wide — not only while a panel that happens to mount a bridge is open. + * + * Mounts before identity resolves by design: while `currentPubkey` is still + * `undefined`, `combineObserverIngestionAgents` returns managed agents only, + * and relay-owned agents are folded in on the render after identity arrives. + * Do not gate this hook on identity/startup readiness — that would drop + * managed-agent observer coverage during startup. */ export function useAgentObserverIngestion() { const identityQuery = useIdentityQuery(); @@ -89,7 +95,12 @@ export function useAgentObserverIngestion() { const ownerByPubkey = new Map(); for (const [pubkey, summary] of Object.entries(profiles ?? {})) { if (summary.ownerPubkey) { - ownerByPubkey.set(normalizePubkey(pubkey), summary.ownerPubkey); + // Store both key and value normalized so lookups and ownership + // comparisons never depend on the casing the relay happened to send. + ownerByPubkey.set( + normalizePubkey(pubkey), + normalizePubkey(summary.ownerPubkey), + ); } } return combineObserverIngestionAgents( From 35d43a955773c94359af512704ecbe2257a04f35 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 6 Jul 2026 20:35:53 -0700 Subject: [PATCH 3/3] feat(desktop): unified agentWorkingSignal for all working indicators (#1494) Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co> --- desktop/playwright.config.ts | 1 + .../src/app/navigation/useAppNavigation.ts | 19 +- .../agents/agentWorkingSignal.test.mjs | 215 +++++++++++ .../src/features/agents/agentWorkingSignal.ts | 348 ++++++++++++++++++ .../features/agents/ui/ManagedAgentRow.tsx | 12 +- .../agents/useOpenAgentActivity.test.mjs | 84 +++++ .../features/agents/useOpenAgentActivity.ts | 178 +++++++++ .../channels/ui/AgentSessionThreadPanel.tsx | 43 ++- .../features/channels/ui/BotActivityBar.tsx | 48 +-- .../src/features/channels/ui/ChannelPane.tsx | 36 +- .../ui/useChannelActivityTyping.test.mjs | 75 ++++ .../channels/ui/useChannelActivityTyping.ts | 36 ++ .../features/profile/ui/UserProfilePanel.tsx | 12 +- .../profile/ui/UserProfilePanelSections.tsx | 4 +- .../profile/ui/UserProfilePopover.tsx | 12 +- .../lib/useActiveWorkingChannelsById.ts | 13 +- .../features/workspaces/useWorkspaceInit.ts | 2 + .../activity-scope-label-screenshots.spec.ts | 100 +++++ 18 files changed, 1157 insertions(+), 81 deletions(-) create mode 100644 desktop/src/features/agents/agentWorkingSignal.test.mjs create mode 100644 desktop/src/features/agents/agentWorkingSignal.ts create mode 100644 desktop/src/features/agents/useOpenAgentActivity.test.mjs create mode 100644 desktop/src/features/agents/useOpenAgentActivity.ts create mode 100644 desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs create mode 100644 desktop/tests/e2e/activity-scope-label-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f5ac116bac..c76be810eb 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -36,6 +36,7 @@ export default defineConfig({ "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", "**/observer-feed-screenshots.spec.ts", + "**/activity-scope-label-screenshots.spec.ts", "**/file-attachment.spec.ts", "**/image-attachment-gallery.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 3b40eeab49..820e9f046a 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -145,6 +145,8 @@ export function useAppNavigation() { ( channelId: string, options?: { + /** Open the agent activity pane for this agent pubkey on arrival. */ + agentSession?: string; messageId?: string; replace?: boolean; threadRootId?: string | null; @@ -156,12 +158,17 @@ export function useAppNavigation() { params: { channelId, }, - search: options?.messageId - ? { - messageId: options.messageId, - threadRootId: options.threadRootId ?? undefined, - } - : {}, + search: { + ...(options?.messageId + ? { + messageId: options.messageId, + threadRootId: options.threadRootId ?? undefined, + } + : {}), + ...(options?.agentSession + ? { agentSession: options.agentSession } + : {}), + }, }, { replace: options?.replace, diff --git a/desktop/src/features/agents/agentWorkingSignal.test.mjs b/desktop/src/features/agents/agentWorkingSignal.test.mjs new file mode 100644 index 0000000000..c134c3de60 --- /dev/null +++ b/desktop/src/features/agents/agentWorkingSignal.test.mjs @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + getAgentWorkingState, + getWorkingAgentPubkeysForChannel, + getWorkingChannels, + reportChannelBotTyping, + resetAgentWorkingSignal, + subscribeAgentWorkingSignal, +} from "./agentWorkingSignal.ts"; +import { + resetActiveAgentTurnsStore, + syncAgentTurnsFromEvents, +} from "./activeAgentTurnsStore.ts"; + +const AGENT = + "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; +const AGENT_2 = + "dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321"; + +function makeEvent(overrides) { + return { + seq: 1, + timestamp: new Date().toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId: "chan-1", + sessionId: "sess-1", + turnId: "turn-1", + payload: null, + ...overrides, + }; +} + +function startTurn(agent, channelId, turnId = `turn-${channelId}`) { + syncAgentTurnsFromEvents(agent, [ + makeEvent({ channelId, turnId, seq: Math.floor(Math.random() * 1e9) }), + ]); +} + +beforeEach(() => { + resetActiveAgentTurnsStore(); + resetAgentWorkingSignal(); +}); + +describe("getAgentWorkingState", () => { + it("is idle with no signals", () => { + const state = getAgentWorkingState(AGENT); + assert.equal(state.working, false); + assert.equal(state.source, "none"); + assert.deepEqual(state.channels, []); + }); + + it("reports observer-backed work, unscoped (all-channels rule)", () => { + startTurn(AGENT, "chan-1"); + const state = getAgentWorkingState(AGENT); + assert.equal(state.working, true); + assert.equal(state.source, "observer"); + assert.deepEqual( + state.channels.map((c) => [c.channelId, c.source]), + [["chan-1", "observer"]], + ); + }); + + it("scopes working to the requested channel", () => { + startTurn(AGENT, "chan-1"); + const inChannel = getAgentWorkingState(AGENT, "chan-1"); + assert.equal(inChannel.working, true); + assert.equal(inChannel.source, "observer"); + + const elsewhere = getAgentWorkingState(AGENT, "chan-2"); + assert.equal(elsewhere.working, false); + assert.equal(elsewhere.source, "none"); + // The unscoped channel list is still exposed for badges. + assert.equal(elsewhere.channels.length, 1); + }); + + it("falls back to typing when no observer turns exist", () => { + reportChannelBotTyping("chan-1", [AGENT]); + const state = getAgentWorkingState(AGENT, "chan-1"); + assert.equal(state.working, true); + assert.equal(state.source, "typing"); + assert.equal(state.channels[0].source, "typing"); + assert.ok(state.channels[0].anchorAt <= Date.now()); + }); + + it("prefers observer over typing for the same channel (no duplicate)", () => { + startTurn(AGENT, "chan-1"); + reportChannelBotTyping("chan-1", [AGENT]); + const state = getAgentWorkingState(AGENT, "chan-1"); + assert.equal(state.source, "observer"); + assert.equal(state.channels.length, 1); + assert.equal(state.channels[0].source, "observer"); + }); + + it("typing in one channel does not mark work in another", () => { + reportChannelBotTyping("chan-1", [AGENT]); + const state = getAgentWorkingState(AGENT, "chan-2"); + assert.equal(state.working, false); + }); + + it("typing clears when re-reported empty", () => { + reportChannelBotTyping("chan-1", [AGENT]); + reportChannelBotTyping("chan-1", []); + assert.equal(getAgentWorkingState(AGENT, "chan-1").working, false); + }); + + it("preserves first-seen anchor across typing re-reports", async () => { + reportChannelBotTyping("chan-1", [AGENT]); + const first = getAgentWorkingState(AGENT).channels[0].anchorAt; + await new Promise((resolve) => setTimeout(resolve, 5)); + reportChannelBotTyping("chan-1", [AGENT, AGENT_2]); + const again = getAgentWorkingState(AGENT).channels[0].anchorAt; + assert.equal(again, first); + }); +}); + +describe("getWorkingChannels", () => { + it("merges typing-only agents into an observer channel summary", () => { + startTurn(AGENT, "chan-1"); + reportChannelBotTyping("chan-1", [AGENT_2]); + const channels = getWorkingChannels(); + assert.equal(channels.length, 1); + assert.equal(channels[0].source, "observer"); + assert.equal(channels[0].agentCount, 2); + assert.deepEqual( + new Set(channels[0].agentPubkeys), + new Set([AGENT, AGENT_2]), + ); + }); + + it("adds typing-only channels with a typing source", () => { + startTurn(AGENT, "chan-1"); + reportChannelBotTyping("chan-2", [AGENT_2]); + const channels = getWorkingChannels(); + assert.deepEqual( + channels.map((c) => [c.channelId, c.source]), + [ + ["chan-1", "observer"], + ["chan-2", "typing"], + ], + ); + }); +}); + +describe("getWorkingAgentPubkeysForChannel", () => { + it("unions observer and typing agents for the channel", () => { + startTurn(AGENT, "chan-1"); + reportChannelBotTyping("chan-1", [AGENT_2]); + assert.deepEqual( + new Set(getWorkingAgentPubkeysForChannel("chan-1")), + new Set([AGENT, AGENT_2]), + ); + assert.deepEqual(getWorkingAgentPubkeysForChannel("chan-2"), []); + assert.deepEqual(getWorkingAgentPubkeysForChannel(null), []); + }); +}); + +describe("subscription and caching", () => { + it("returns reference-stable snapshots while subscribed", () => { + startTurn(AGENT, "chan-1"); + const unsubscribe = subscribeAgentWorkingSignal(() => {}); + try { + assert.equal( + getAgentWorkingState(AGENT, "chan-1"), + getAgentWorkingState(AGENT, "chan-1"), + ); + assert.equal(getWorkingChannels(), getWorkingChannels()); + assert.equal( + getWorkingAgentPubkeysForChannel("chan-1"), + getWorkingAgentPubkeysForChannel("chan-1"), + ); + } finally { + unsubscribe(); + } + }); + + it("notifies on typing changes but not on identical re-reports", () => { + let notified = 0; + const unsubscribe = subscribeAgentWorkingSignal(() => { + notified += 1; + }); + try { + reportChannelBotTyping("chan-1", [AGENT]); + assert.equal(notified, 1); + reportChannelBotTyping("chan-1", [AGENT]); + assert.equal(notified, 1); + reportChannelBotTyping("chan-1", []); + assert.equal(notified, 2); + } finally { + unsubscribe(); + } + }); + + it("invalidates snapshots when the turns store changes", () => { + const unsubscribe = subscribeAgentWorkingSignal(() => {}); + try { + const before = getAgentWorkingState(AGENT, "chan-1"); + assert.equal(before.working, false); + startTurn(AGENT, "chan-1"); + const after = getAgentWorkingState(AGENT, "chan-1"); + assert.equal(after.working, true); + assert.equal(after.source, "observer"); + } finally { + unsubscribe(); + } + }); + + it("resetAgentWorkingSignal clears typing state", () => { + reportChannelBotTyping("chan-1", [AGENT]); + resetAgentWorkingSignal(); + assert.equal(getAgentWorkingState(AGENT, "chan-1").working, false); + }); +}); diff --git a/desktop/src/features/agents/agentWorkingSignal.ts b/desktop/src/features/agents/agentWorkingSignal.ts new file mode 100644 index 0000000000..1c3a74fc43 --- /dev/null +++ b/desktop/src/features/agents/agentWorkingSignal.ts @@ -0,0 +1,348 @@ +import * as React from "react"; + +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + type ActiveChannelTurnSummary, + getActiveTurnsByChannel, + getActiveTurnsForAgent, + subscribeActiveAgentTurns, +} from "./activeAgentTurnsStore"; + +/** + * Unified "agent is working" signal. + * + * Every surface that shows a working affordance (sidebar channel badges, + * profile badges, agent rows, composer activity bar, activity panel header, + * future thread ingresses) should read from this module instead of picking + * one of the underlying pipes. The rule is: + * + * 1. Observer-derived active turns (kind 24200 → activeAgentTurnsStore) + * are the primary signal — they carry channel scope and a start anchor. + * 2. Bot typing indicators (kind 20002, mirrored into this module by the + * channel typing hooks) are the fallback for agents whose observer + * stream is absent for that scope (e.g. remote harness without relay + * observer, or frames not yet arrived). + * + * Scope rule: with a channelId, "working" means working in that channel; + * without one, "working" means any active work in any channel (the + * all-channels rule the activity panel uses). + */ + +export type AgentWorkingSource = "observer" | "typing" | "none"; + +export type AgentWorkingChannel = { + channelId: string; + /** Desktop-clock anchor for elapsed displays (turn start / first typing). */ + anchorAt: number; + source: Exclude; +}; + +export type AgentWorkingState = { + working: boolean; + /** Strongest signal backing `working` for the requested scope. */ + source: AgentWorkingSource; + /** Every channel the agent is working in (unscoped), observer-primary. */ + channels: AgentWorkingChannel[]; +}; + +export type WorkingChannelSummary = ActiveChannelTurnSummary & { + source: Exclude; +}; + +const IDLE_STATE: AgentWorkingState = { + working: false, + source: "none", + channels: [], +}; + +// ── Typing registry (fallback input) ──────────────────────────────────────── +// channelId → (normalized agent pubkey → first-seen ms). Fed by +// reportChannelBotTyping from the channel typing hooks; entries follow the +// typing TTL because the hooks re-report whenever their entries change. +const typingByChannel = new Map>(); + +const listeners = new Set<() => void>(); +let unsubscribeTurns: (() => void) | null = null; + +// Reference-stable snapshots for useSyncExternalStore. Valid only while at +// least one listener keeps us subscribed to the underlying turns store. +const stateCache = new Map(); +let channelsCache: WorkingChannelSummary[] | null = null; +const channelPubkeysCache = new Map(); + +function invalidateCaches() { + stateCache.clear(); + channelsCache = null; + channelPubkeysCache.clear(); +} + +function notify() { + invalidateCaches(); + for (const listener of listeners) { + listener(); + } +} + +export function subscribeAgentWorkingSignal(listener: () => void) { + listeners.add(listener); + if (listeners.size === 1) { + invalidateCaches(); + unsubscribeTurns = subscribeActiveAgentTurns(notify); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribeTurns?.(); + unsubscribeTurns = null; + } + }; +} + +/** + * Mirror the current bot typing pubkeys for a channel into the signal. + * Call with the full current set (empty array clears the channel). First-seen + * timestamps are preserved across re-reports so elapsed anchors stay stable. + */ +export function reportChannelBotTyping( + channelId: string, + pubkeys: readonly string[], +) { + const current = typingByChannel.get(channelId); + const next = new Map(); + const now = Date.now(); + for (const pubkey of pubkeys) { + const key = normalizePubkey(pubkey); + next.set(key, current?.get(key) ?? now); + } + + const unchanged = + (current?.size ?? 0) === next.size && + [...next.keys()].every((key) => current?.has(key)); + if (unchanged) { + return; + } + + if (next.size === 0) { + typingByChannel.delete(channelId); + } else { + typingByChannel.set(channelId, next); + } + notify(); +} + +function computeAgentWorkingState( + agentPubkey: string, + channelId: string | null, +): AgentWorkingState { + const key = normalizePubkey(agentPubkey); + const turns = getActiveTurnsForAgent(key); + + const channels: AgentWorkingChannel[] = turns.map((turn) => ({ + channelId: turn.channelId, + anchorAt: turn.anchorAt, + source: "observer" as const, + })); + const observerChannelIds = new Set(turns.map((turn) => turn.channelId)); + + for (const [typingChannelId, entries] of typingByChannel) { + if (observerChannelIds.has(typingChannelId)) { + continue; + } + const since = entries.get(key); + if (since !== undefined) { + channels.push({ + channelId: typingChannelId, + anchorAt: since, + source: "typing", + }); + } + } + + if (channels.length === 0) { + return IDLE_STATE; + } + + channels.sort((a, b) => a.channelId.localeCompare(b.channelId)); + + const scoped = + channelId === null + ? channels + : channels.filter((channel) => channel.channelId === channelId); + const source: AgentWorkingSource = scoped.some( + (channel) => channel.source === "observer", + ) + ? "observer" + : scoped.length > 0 + ? "typing" + : "none"; + + return { working: source !== "none", source, channels }; +} + +/** + * Working state for one agent, optionally scoped to a channel. Returns a + * reference-stable snapshot while subscribed (useSyncExternalStore-safe). + */ +export function getAgentWorkingState( + agentPubkey: string | null | undefined, + channelId: string | null = null, +): AgentWorkingState { + if (!agentPubkey) { + return IDLE_STATE; + } + const cacheKey = `${normalizePubkey(agentPubkey)}|${channelId ?? ""}`; + const useCache = listeners.size > 0; + if (useCache) { + const cached = stateCache.get(cacheKey); + if (cached) { + return cached; + } + } + const state = computeAgentWorkingState(agentPubkey, channelId); + if (useCache) { + stateCache.set(cacheKey, state); + } + return state; +} + +/** + * All channels with agent work in progress, aggregated across agents and + * merged observer-primary: typing-only agents fold into an existing observer + * summary; channels with only typing get a typing-sourced summary anchored to + * first-seen typing. + */ +export function getWorkingChannels(): WorkingChannelSummary[] { + const useCache = listeners.size > 0; + if (useCache && channelsCache) { + return channelsCache; + } + + const byChannel = new Map(); + for (const summary of getActiveTurnsByChannel()) { + byChannel.set(summary.channelId, { ...summary, source: "observer" }); + } + + for (const [channelId, entries] of typingByChannel) { + const existing = byChannel.get(channelId); + if (existing) { + const known = new Set( + existing.agentPubkeys.map((pubkey) => normalizePubkey(pubkey)), + ); + const merged = [...existing.agentPubkeys]; + for (const pubkey of entries.keys()) { + if (!known.has(pubkey)) { + merged.push(pubkey); + } + } + if (merged.length !== existing.agentPubkeys.length) { + byChannel.set(channelId, { + ...existing, + agentPubkeys: merged, + agentCount: merged.length, + }); + } + continue; + } + + let anchorAt = Number.POSITIVE_INFINITY; + for (const since of entries.values()) { + if (since < anchorAt) { + anchorAt = since; + } + } + byChannel.set(channelId, { + channelId, + anchorAt, + agentCount: entries.size, + agentPubkeys: [...entries.keys()], + source: "typing", + }); + } + + const result = [...byChannel.values()].sort((a, b) => + a.channelId.localeCompare(b.channelId), + ); + if (useCache) { + channelsCache = result; + } + return result; +} + +const EMPTY_PUBKEYS: string[] = []; + +/** + * Normalized pubkeys of every agent working in the given channel + * (observer turns ∪ typing fallback). Stable while subscribed. + */ +export function getWorkingAgentPubkeysForChannel( + channelId: string | null | undefined, +): string[] { + if (!channelId) { + return EMPTY_PUBKEYS; + } + const useCache = listeners.size > 0; + if (useCache) { + const cached = channelPubkeysCache.get(channelId); + if (cached) { + return cached; + } + } + const merged = new Set(); + for (const summary of getActiveTurnsByChannel()) { + if (summary.channelId !== channelId) { + continue; + } + for (const pubkey of summary.agentPubkeys) { + merged.add(normalizePubkey(pubkey)); + } + } + const typing = typingByChannel.get(channelId); + if (typing) { + for (const pubkey of typing.keys()) { + merged.add(pubkey); + } + } + const result = merged.size === 0 ? EMPTY_PUBKEYS : [...merged].sort(); + if (useCache) { + channelPubkeysCache.set(channelId, result); + } + return result; +} + +// ── Hooks ──────────────────────────────────────────────────────────────────── + +/** Working state for one agent, optionally scoped to a channel. */ +export function useAgentWorking( + agentPubkey: string | null | undefined, + channelId: string | null = null, +): AgentWorkingState { + return React.useSyncExternalStore(subscribeAgentWorkingSignal, () => + getAgentWorkingState(agentPubkey, channelId), + ); +} + +/** All channels with agent work in progress, across agents. */ +export function useWorkingChannels(): WorkingChannelSummary[] { + return React.useSyncExternalStore( + subscribeAgentWorkingSignal, + getWorkingChannels, + ); +} + +/** Normalized pubkeys of agents working in a channel. */ +export function useChannelWorkingAgentPubkeys( + channelId: string | null | undefined, +): string[] { + return React.useSyncExternalStore(subscribeAgentWorkingSignal, () => + getWorkingAgentPubkeysForChannel(channelId), + ); +} + +/** Workspace-switch reset (see resetWorkspaceState in useWorkspaceInit). */ +export function resetAgentWorkingSignal() { + typingByChannel.clear(); + invalidateCaches(); + for (const listener of listeners) { + listener(); + } +} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 9e8fbcf4f6..aaa07a8317 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -6,7 +6,8 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { Badge } from "@/shared/ui/badge"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; -import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; +import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { useNow } from "@/shared/lib/useNow"; import type { @@ -55,7 +56,7 @@ export function ManagedAgentRow({ ? (personaLabelsById[agent.personaId] ?? null) : null; const presenceStatus = presenceLookup[agent.pubkey.trim().toLowerCase()]; - const activeTurns = useActiveAgentTurns(agent.pubkey); + const activeTurns = useAgentWorking(agent.pubkey).channels; const activeWorkingChannels = React.useMemo( () => activeTurns @@ -202,6 +203,7 @@ function AgentSummary({ presenceStatus: PresenceStatus | undefined; }) { const { goChannel } = useAppNavigation(); + const { openAgentActivity } = useOpenAgentActivity(); return (
@@ -275,7 +277,11 @@ function AgentSummary({ channelId={channel.id} name={channel.name} anchorAt={channel.anchorAt} - onNavigate={goChannel} + // Deep-link straight into the agent's activity pane in the + // working channel, not just the channel timeline. + onNavigate={(channelId) => + openAgentActivity(agent.pubkey, { channelId }) + } /> ))}
diff --git a/desktop/src/features/agents/useOpenAgentActivity.test.mjs b/desktop/src/features/agents/useOpenAgentActivity.test.mjs new file mode 100644 index 0000000000..503566de90 --- /dev/null +++ b/desktop/src/features/agents/useOpenAgentActivity.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + isChannelOpenable, + resolveOpenableActivityChannelId, +} from "./useOpenAgentActivity.ts"; + +describe("isChannelOpenable", () => { + it("allows joined channels regardless of visibility", () => { + assert.equal( + isChannelOpenable({ isMember: true, visibility: "private" }), + true, + ); + assert.equal( + isChannelOpenable({ isMember: true, visibility: "open" }), + true, + ); + }); + + it("allows open channels the viewer hasn't joined (read-only)", () => { + assert.equal( + isChannelOpenable({ isMember: false, visibility: "open" }), + true, + ); + }); + + it("rejects private channels the viewer hasn't joined", () => { + assert.equal( + isChannelOpenable({ isMember: false, visibility: "private" }), + false, + ); + }); + + it("rejects channels missing from the viewer's channel list", () => { + assert.equal(isChannelOpenable(undefined), false); + }); +}); + +describe("resolveOpenableActivityChannelId", () => { + it("prefers the first openable working channel", () => { + assert.equal( + resolveOpenableActivityChannelId({ + agentChannelIds: ["member-1"], + openableChannelIds: new Set(["working-2", "member-1"]), + workingChannelIds: ["working-1", "working-2"], + }), + "working-2", + ); + }); + + it("falls back to the agent's first openable member channel", () => { + assert.equal( + resolveOpenableActivityChannelId({ + agentChannelIds: ["hidden-2", "member-1"], + openableChannelIds: new Set(["member-1"]), + workingChannelIds: ["hidden-1"], + }), + "member-1", + ); + }); + + it("returns null when the agent is only active in inaccessible rooms", () => { + assert.equal( + resolveOpenableActivityChannelId({ + agentChannelIds: ["hidden-2"], + openableChannelIds: new Set(["unrelated"]), + workingChannelIds: ["hidden-1"], + }), + null, + ); + }); + + it("returns null with no candidate channels at all", () => { + assert.equal( + resolveOpenableActivityChannelId({ + agentChannelIds: [], + openableChannelIds: new Set(), + workingChannelIds: [], + }), + null, + ); + }); +}); diff --git a/desktop/src/features/agents/useOpenAgentActivity.ts b/desktop/src/features/agents/useOpenAgentActivity.ts new file mode 100644 index 0000000000..e8cfc0e8ff --- /dev/null +++ b/desktop/src/features/agents/useOpenAgentActivity.ts @@ -0,0 +1,178 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import type { Channel } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { getAgentWorkingState } from "./agentWorkingSignal"; +import { useRelayAgentsQuery } from "./hooks"; + +const INACCESSIBLE_ACTIVITY_MESSAGE = + "This agent is active in a channel you haven't joined, so its activity can't be opened from here."; + +/** + * Can the viewer actually open this channel? Joined channels always; + * open-visibility channels are readable without joining. Channels missing + * from the viewer's channel list (e.g. private rooms they aren't in) are + * not navigable destinations. + */ +export function isChannelOpenable( + channel: Pick | undefined, +): boolean { + return ( + channel !== undefined && (channel.isMember || channel.visibility === "open") + ); +} + +/** + * Pick the channel to land in when opening an agent's activity from a + * non-channel route: the agent's first working channel the viewer can open, + * else the agent's first member channel the viewer can open, else null. + */ +export function resolveOpenableActivityChannelId({ + agentChannelIds, + openableChannelIds, + workingChannelIds, +}: { + agentChannelIds: readonly string[]; + openableChannelIds: ReadonlySet; + workingChannelIds: readonly string[]; +}): string | null { + for (const channelId of workingChannelIds) { + if (openableChannelIds.has(channelId)) { + return channelId; + } + } + for (const channelId of agentChannelIds) { + if (openableChannelIds.has(channelId)) { + return channelId; + } + } + return null; +} + +/** + * Universal ingress for opening an agent's activity pane. + * + * Inside a channel screen the AgentSessionContext handler opens the pane in + * place. Everywhere else (agents page, home profile panel, popovers reached + * from non-channel routes) there is no provider, so we navigate to a channel + * with the `agentSession` search param instead — preferring a channel the + * agent is currently working in (unified working signal), then falling back + * to the first channel the agent is a member of. + * + * Navigation only ever targets channels the viewer can actually open + * (joined, or open visibility). Owner-global ingestion means the working + * signal can report activity in rooms the viewer can't access; deep-linking + * there would land on a screen they can't read. In that case we surface a + * safe warning instead of navigating — no channel content, no trap-door. + * + * This replaces the old behavior where "View activity log" silently + * disappeared on routes without an AgentSessionProvider. + */ +export function useOpenAgentActivity() { + const { onOpenAgentSession } = useAgentSession(); + const { goChannel } = useAppNavigation(); + const relayAgentsQuery = useRelayAgentsQuery(); + const relayAgents = relayAgentsQuery.data; + const channelsQuery = useChannelsQuery(); + const channels = channelsQuery.data; + + const findOpenableChannel = React.useCallback( + (channelId: string): boolean => + isChannelOpenable(channels?.find((entry) => entry.id === channelId)), + [channels], + ); + + const resolveChannelId = React.useCallback( + (pubkey: string): string | null => { + const key = normalizePubkey(pubkey); + const relayAgent = relayAgents?.find( + (agent) => normalizePubkey(agent.pubkey) === key, + ); + const openableChannelIds = new Set( + (channels ?? []) + .filter((channel) => isChannelOpenable(channel)) + .map((channel) => channel.id), + ); + return resolveOpenableActivityChannelId({ + agentChannelIds: relayAgent?.channelIds ?? [], + openableChannelIds, + // Deliberately an unsubscribed snapshot: this callback runs on click + // (and in canOpenAgentActivity), not in render, so we don't need to + // recompute when working state changes — its deps are only + // [channels, relayAgents]. Worst case the preferred working-channel + // target lags a just-changed signal; the member-channel fallback in + // resolveOpenableActivityChannelId keeps the destination valid. + workingChannelIds: getAgentWorkingState(pubkey).channels.map( + (working) => working.channelId, + ), + }); + }, + [channels, relayAgents], + ); + + const canOpenAgentActivity = React.useCallback( + (pubkey: string | null | undefined): boolean => { + if (!pubkey) { + return false; + } + if (onOpenAgentSession) { + return true; + } + // While channels are still loading, resolveChannelId can only see an + // empty openable set and would report a transient false. Stay + // optimistic until channels resolve so "View activity log" doesn't + // flicker in on cold start; openAgentActivity still guards the actual + // navigation. + if (channels === undefined) { + return true; + } + return resolveChannelId(pubkey) !== null; + }, + [channels, onOpenAgentSession, resolveChannelId], + ); + + const openAgentActivity = React.useCallback( + (pubkey: string, options?: { channelId?: string | null }): boolean => { + // An explicit channel target (e.g. clicking a "Working in #channel" + // badge) navigates so the pane opens scoped to that channel — but only + // when the viewer can actually open that channel. Scoping the pane to + // an inaccessible room (in place or via navigation) would expose that + // room's activity content, so we warn and stop instead. + if (options?.channelId) { + if (!findOpenableChannel(options.channelId)) { + toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE); + return false; + } + if (!onOpenAgentSession) { + void goChannel(options.channelId, { agentSession: pubkey }); + return true; + } + onOpenAgentSession(pubkey, options.channelId); + return true; + } + if (onOpenAgentSession) { + onOpenAgentSession(pubkey); + return true; + } + const channelId = resolveChannelId(pubkey); + if (channelId) { + void goChannel(channelId, { agentSession: pubkey }); + return true; + } + // The agent may be working somewhere, just nowhere the viewer can open. + // Say so plainly rather than failing silently — without leaking which + // room, or navigating into it. + if (getAgentWorkingState(pubkey).channels.length > 0) { + toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE); + } + return false; + }, + [findOpenableChannel, goChannel, onOpenAgentSession, resolveChannelId], + ); + + return { canOpenAgentActivity, openAgentActivity }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 92d08ba546..a8ad5cfbf1 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -8,6 +8,7 @@ import { } from "lucide-react"; import { toast } from "sonner"; +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { scopeByChannel } from "@/features/agents/ui/agentSessionPanelLayout"; import type { @@ -52,13 +53,13 @@ import { useTranscriptTimestampsEnabled, } from "@/features/agents/ui/transcriptTimestampPreference"; import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions"; +import { useChannelsQuery } from "@/features/channels/hooks"; type AgentSessionThreadPanelProps = { agent: ChannelAgentSessionAgent; channel: Channel | null; channelId?: string | null; canInterruptTurn: boolean; - isWorking: boolean; layout?: "standalone" | "split"; isSinglePanelView?: boolean; profiles?: UserProfileLookup; @@ -80,7 +81,6 @@ export function AgentSessionThreadPanel({ canInterruptTurn, channel, channelId = null, - isWorking, layout = "standalone", isSinglePanelView = false, profiles, @@ -91,11 +91,17 @@ export function AgentSessionThreadPanel({ }: AgentSessionThreadPanelProps) { const isLive = isManagedAgentActive(agent); const isOverlay = useIsThreadPanelOverlay(); + const sessionChannelId = channelId ?? channel?.id ?? null; + // Unified working signal, scoped to this panel's channel (or all channels + // when the panel is unscoped) — observer turns primary, typing fallback. + const { working: isWorking } = useAgentWorking( + agent.pubkey, + sessionChannelId, + ); const canStopCurrentTurn = isWorking && canInterruptTurn; useEscapeKey(onClose, isOverlay || isSinglePanelView); const { ref: scrollRef, onScroll } = useStickToBottom(); - const sessionChannelId = channelId ?? channel?.id ?? null; const now = useNow(1000); const { events } = useObserverEvents(isLive, agent.pubkey); const transcript = useAgentTranscript(isLive, agent.pubkey); @@ -121,6 +127,29 @@ export function AgentSessionThreadPanel({ ? undefined : `Last updated ${new Date(latestActivityAt).toLocaleString()}`; const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`; + // Scope label input: prefer the passed channel's name; when the pane is + // channel-scoped without a full Channel object (#1380's channelId prop), + // resolve the name from the channels cache. + const channelsQuery = useChannelsQuery({ + enabled: Boolean(sessionChannelId), + }); + const scopeChannelName = React.useMemo(() => { + if (!sessionChannelId) { + return null; + } + if (channel && channel.id === sessionChannelId) { + return channel.name; + } + return ( + channelsQuery.data?.find((entry) => entry.id === sessionChannelId) + ?.name ?? null + ); + }, [channel, channelsQuery.data, sessionChannelId]); + const scopeLabel = sessionChannelId + ? scopeChannelName + ? `#${scopeChannelName}` + : "1 channel" + : "All channels"; const [rawFeedState, setRawFeedState] = React.useState(() => ({ scopeKey: rawFeedScopeKey, show: false, @@ -318,6 +347,14 @@ export function AgentSessionThreadPanel({ subtitleTitle={lastUpdatedTitle} title={showRawFeed ? "Raw ACP Activity" : "Activity"} /> + {/* Scope label: makes channel-targeted vs all-channels state obvious + (an all-channels pane can look "wrong" without it). */} + + {scopeLabel} + {agentHeaderActions} diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index 519969ad3d..d7f7146f4b 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -22,7 +22,7 @@ type BotActivityBarProps = { onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; - typingBotPubkeys: string[]; + workingBotPubkeys: string[]; variant?: "toolbar" | "inline"; }; @@ -36,7 +36,7 @@ export function BotActivityComposerAction({ onOpenAgentSession, openAgentSessionPubkey, profiles, - typingBotPubkeys, + workingBotPubkeys, variant = "toolbar", }: BotActivityBarProps) { const [open, setOpen] = React.useState(false); @@ -44,21 +44,21 @@ export function BotActivityComposerAction({ null, ); - const typingAgents = React.useMemo(() => { - const typingSet = new Set( - typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()), + const workingAgents = React.useMemo(() => { + const workingSet = new Set( + workingBotPubkeys.map((pubkey) => pubkey.toLowerCase()), ); - return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())); - }, [agents, typingBotPubkeys]); - const singleTypingAgent = - typingAgents.length === 1 ? (typingAgents[0] ?? null) : null; + return agents.filter((agent) => workingSet.has(agent.pubkey.toLowerCase())); + }, [agents, workingBotPubkeys]); + const singleWorkingAgent = + workingAgents.length === 1 ? (workingAgents[0] ?? null) : null; const transcript = useAgentTranscript( - Boolean(singleTypingAgent), - singleTypingAgent?.pubkey, + Boolean(singleWorkingAgent), + singleWorkingAgent?.pubkey, ); const activityHeadlines = React.useMemo(() => { - if (!singleTypingAgent) { + if (!singleWorkingAgent) { return []; } @@ -92,7 +92,7 @@ export function BotActivityComposerAction({ } return headlines; - }, [channelId, singleTypingAgent, transcript]); + }, [channelId, singleWorkingAgent, transcript]); const [headlineIndex, setHeadlineIndex] = React.useState(0); const clearHoverTimer = React.useCallback(() => { @@ -136,7 +136,7 @@ export function BotActivityComposerAction({ return () => window.clearInterval(interval); }, [activityHeadlines.length]); - if (typingAgents.length === 0) { + if (workingAgents.length === 0) { return null; } @@ -144,17 +144,17 @@ export function BotActivityComposerAction({ profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null; const selectedPubkey = openAgentSessionPubkey?.toLowerCase() ?? null; const triggerLabel = - typingAgents.length === 1 - ? `${typingAgents[0]?.name ?? "Agent"} is working` - : `${typingAgents.length} agents working`; + workingAgents.length === 1 + ? `${workingAgents[0]?.name ?? "Agent"} is working` + : `${workingAgents.length} agents working`; const isInline = variant === "inline"; const visibleStatusLabel = - typingAgents.length === 1 - ? `${typingAgents[0]?.name ?? "Agent"}: ${ + workingAgents.length === 1 + ? `${workingAgents[0]?.name ?? "Agent"}: ${ activityHeadlines[headlineIndex % activityHeadlines.length] ?? "Working" }` - : `${typingAgents[0]?.name ?? "Agent"} +${typingAgents.length - 1}`; + : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; return ( @@ -179,7 +179,7 @@ export function BotActivityComposerAction({ type="button" > - {typingAgents.slice(0, 2).map((agent) => ( + {workingAgents.slice(0, 2).map((agent) => ( ))} - {typingAgents.length > 2 ? ( + {workingAgents.length > 2 ? ( - +{typingAgents.length - 2} + +{workingAgents.length - 2} ) : null}
- {typingAgents.map((agent) => { + {workingAgents.map((agent) => { const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); return ( diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 37474a2dee..22ae3a507b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -27,6 +27,7 @@ import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; import { containsWelcomePersonaMention, @@ -324,24 +325,14 @@ export const ChannelPane = React.memo(function ChannelPane({ const canDropInMainColumn = hasMainComposerOverlay && !isComposerDisabled && !isSinglePanelView; const hasTypingActivity = typingPubkeys.length > 0; - const composerBotTypingPubkeys = React.useMemo(() => { - const pubkeys: string[] = []; - for (const entry of botTypingEntries) { - if (entry.threadHeadId !== null) { - continue; - } - - if ( - !pubkeys.some( - (pubkey) => pubkey.toLowerCase() === entry.pubkey.toLowerCase(), - ) - ) { - pubkeys.push(entry.pubkey); - } - } - return pubkeys; - }, [botTypingEntries]); - const hasComposerBotActivity = composerBotTypingPubkeys.length > 0; + // Unified working set for the composer bar: observer-derived turns primary, + // bot typing fallback (both folded together by agentWorkingSignal). This is + // what makes the bar show for an agent whose observer stream is live but + // whose typing signal never arrives — and vice versa. + const composerWorkingBotPubkeys = useChannelWorkingAgentPubkeys( + activeChannel?.id ?? null, + ); + const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; const threadComposerBotTypingPubkeys = React.useMemo(() => { if (!openThreadHeadId) { return []; @@ -708,7 +699,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenAgentSession={onOpenAgentSession} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} - typingBotPubkeys={composerBotTypingPubkeys} + workingBotPubkeys={composerWorkingBotPubkeys} variant="inline" />
@@ -803,7 +794,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenAgentSession={onOpenAgentSession} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} - typingBotPubkeys={threadComposerBotTypingPubkeys} + workingBotPubkeys={threadComposerBotTypingPubkeys} variant="inline" /> ) : null @@ -846,11 +837,6 @@ export const ChannelPane = React.memo(function ChannelPane({ : null } channelId={openAgentSessionChannelId} - isWorking={botTypingEntries.some( - (entry) => - entry.pubkey.toLowerCase() === - selectedAgent.pubkey.toLowerCase(), - )} isSinglePanelView={ useSplitAuxiliaryPane ? false : isSinglePanelView } diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs new file mode 100644 index 0000000000..96b756e4ba --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import { + getAgentWorkingState, + getWorkingAgentPubkeysForChannel, + reportChannelBotTyping, + resetAgentWorkingSignal, +} from "../../agents/agentWorkingSignal.ts"; +import { resetActiveAgentTurnsStore } from "../../agents/activeAgentTurnsStore.ts"; +import { channelScopedBotTypingPubkeyKey } from "./useChannelActivityTyping.ts"; + +const AGENT = + "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"; +const AGENT_2 = + "dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321"; + +describe("channelScopedBotTypingPubkeyKey", () => { + it("excludes thread-scoped typing entries", () => { + const key = channelScopedBotTypingPubkeyKey([ + { pubkey: AGENT, threadHeadId: "thread-1" }, + ]); + assert.equal(key, ""); + }); + + it("keeps channel-scoped entries and drops thread-scoped ones", () => { + const key = channelScopedBotTypingPubkeyKey([ + { pubkey: AGENT, threadHeadId: "thread-1" }, + { pubkey: AGENT_2, threadHeadId: null }, + ]); + assert.equal(key, AGENT_2); + }); + + it("sorts and lowercases channel-scoped pubkeys", () => { + const key = channelScopedBotTypingPubkeyKey([ + { pubkey: AGENT_2.toUpperCase(), threadHeadId: null }, + { pubkey: AGENT, threadHeadId: null }, + ]); + assert.equal(key, `${AGENT},${AGENT_2}`); + }); +}); + +describe("thread-only bot typing regression", () => { + beforeEach(() => { + resetActiveAgentTurnsStore(); + resetAgentWorkingSignal(); + }); + + it("does not mark channel-level working", () => { + // The mirror effect reports only the channel-scoped key; thread-only + // typing produces an empty key, so nothing reaches the working signal. + const key = channelScopedBotTypingPubkeyKey([ + { pubkey: AGENT, threadHeadId: "thread-1" }, + ]); + reportChannelBotTyping("chan-1", key ? key.split(",") : []); + + assert.deepEqual(getWorkingAgentPubkeysForChannel("chan-1"), []); + const state = getAgentWorkingState(AGENT, "chan-1"); + assert.equal(state.working, false); + assert.equal(state.source, "none"); + }); + + it("still marks channel-level working for channel-scoped typing", () => { + const key = channelScopedBotTypingPubkeyKey([ + { pubkey: AGENT, threadHeadId: null }, + { pubkey: AGENT_2, threadHeadId: "thread-1" }, + ]); + reportChannelBotTyping("chan-1", key ? key.split(",") : []); + + assert.deepEqual(getWorkingAgentPubkeysForChannel("chan-1"), [AGENT]); + const state = getAgentWorkingState(AGENT, "chan-1"); + assert.equal(state.working, true); + assert.equal(state.source, "typing"); + }); +}); diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index 45079a620c..9bb07acd8c 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -1,5 +1,6 @@ import * as React from "react"; +import { reportChannelBotTyping } from "@/features/agents/agentWorkingSignal"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { @@ -14,6 +15,22 @@ import { getChannelAgentSessionAgents, } from "./useChannelAgentSessions"; +/** + * Key of bot typing pubkeys that may mark the *channel* as working. Only + * 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. + */ +export function channelScopedBotTypingPubkeyKey( + entries: readonly Pick[], +): string { + return entries + .filter((entry) => entry.threadHeadId === null) + .map((entry) => entry.pubkey.toLowerCase()) + .sort() + .join(","); +} + export function useChannelActivityTyping({ activeChannel, activeChannelId, @@ -84,6 +101,25 @@ export function useChannelActivityTyping({ return { botTypingEntries, humanTypingPubkeys }; }, [channelAgentPubkeys, typingEntries]); + // 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); + React.useEffect(() => { + if (!activeChannelId) { + return; + } + reportChannelBotTyping( + activeChannelId, + botTypingPubkeyKey ? botTypingPubkeyKey.split(",") : [], + ); + return () => { + reportChannelBotTyping(activeChannelId, []); + }; + }, [activeChannelId, botTypingPubkeyKey]); + return { agentSessionCandidates: agentCandidates, botTypingEntries, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 9bdd0fb2fa..4c4d8b068e 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -80,7 +80,7 @@ import { } from "@/features/profile/ui/UserProfilePanelUtils"; import { useProfileDmAction } from "@/features/profile/ui/useProfileDmAction"; import { useUserStatusQuery } from "@/features/user-status/hooks"; -import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel"; @@ -229,7 +229,7 @@ export function UserProfilePanel({ const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); - const { onOpenAgentSession } = useAgentSession(); + const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); const { goChannel } = useAppNavigation(); const profile = resolvePanelProfile({ managedAgent, @@ -301,7 +301,9 @@ export function UserProfilePanel({ pubkeyLower.length > 0 && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = - viewerIsOwner && Boolean(onOpenAgentSession) && Boolean(effectivePubkey); + viewerIsOwner && + Boolean(effectivePubkey) && + canOpenAgentActivity(effectivePubkey); const canOpenAgentLogs = isOwner === true && managedAgent?.backend.type === "local"; const canInstantiateAgent = @@ -662,9 +664,9 @@ export function UserProfilePanel({ const handleOpenActivity = React.useCallback( (channelId?: string | null) => { if (!effectivePubkey) return; - onOpenAgentSession?.(effectivePubkey, channelId ?? null); + openAgentActivity(effectivePubkey, { channelId: channelId ?? null }); }, - [effectivePubkey, onOpenAgentSession], + [effectivePubkey, openAgentActivity], ); const handleOpenChannel = React.useCallback( diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 50fe031384..ebde518c7a 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -15,7 +15,7 @@ import { import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; -import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel"; @@ -217,7 +217,7 @@ export function ProfileSummaryView({ unfollowMutation, userStatus, }: ProfileSummaryViewProps) { - const activeTurns = useActiveAgentTurns(isBot ? pubkey : null); + const activeTurns = useAgentWorking(isBot ? pubkey : null).channels; const showMemoriesTab = isOwner === true && Boolean(pubkey); const showInstructionBlock = diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 6b00c26a19..f038d41482 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -22,7 +22,7 @@ import { } from "@/features/agents/hooks"; import { useIsManagedAgent } from "@/features/agent-memory/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; -import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; +import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { ownsAuthorAgent, truncatePubkey, @@ -37,7 +37,7 @@ import { mergeTimelineCacheMessages, } from "@/features/messages/hooks"; import { buildWaveMessageContent } from "@/features/messages/lib/waveMessage"; -import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { sendChannelMessage } from "@/shared/api/tauri"; import type { Channel, RelayEvent } from "@/shared/api/types"; @@ -200,7 +200,7 @@ export function UserProfilePopover({ }); const userStatusQuery = useUserStatusQuery(open ? [pubkey] : []); - const { onOpenAgentSession } = useAgentSession(); + const { canOpenAgentActivity, openAgentActivity } = useOpenAgentActivity(); const { openProfilePanel } = useProfilePanel(); const canOpenProfilePanel = enableProfilePanel && Boolean(openProfilePanel); const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey); @@ -245,14 +245,14 @@ export function UserProfilePopover({ const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey); const viewerIsOwner = isCurrentUserOwner || isOwner === true; const canViewActivity = - isBotProfile && viewerIsOwner && Boolean(onOpenAgentSession); + isBotProfile && viewerIsOwner && canOpenAgentActivity(pubkey); const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; const userStatusText = userStatus?.text.trim() ?? ""; const hasUserStatus = Boolean(userStatusText || userStatus?.emoji); const profileDescription = profile?.about?.trim() ?? ""; const profileSubheader = profileDescription || profile?.nip05Handle?.trim(); - const activeTurns = useActiveAgentTurns(isBotProfile ? pubkey : null); + const activeTurns = useAgentWorking(isBotProfile ? pubkey : null).channels; const channelsQuery = useChannelsQuery(); const channelIdToName = React.useMemo(() => { const map: Record = {}; @@ -609,7 +609,7 @@ export function UserProfilePopover({ data-testid={`user-profile-view-activity-${pubkey}`} onClick={() => { setOpen(false); - onOpenAgentSession?.(pubkey); + openAgentActivity(pubkey); }} type="button" > diff --git a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts index d1f355ccd4..37a3c86656 100644 --- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts +++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts @@ -1,9 +1,7 @@ import * as React from "react"; -import { - type ActiveChannelTurnSummary, - useActiveAgentTurnsByChannel, -} from "@/features/agents/activeAgentTurnsStore"; +import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; +import { useWorkingChannels } from "@/features/agents/agentWorkingSignal"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -34,9 +32,10 @@ export function useActiveWorkingChannelsById(): ReadonlyMap< [managedAgentsQuery.data], ); - // Observer ingestion is owner-global (useAgentObserverIngestion in - // AppShell); this hook only reads derived state. - const activeWorkingChannels = useActiveAgentTurnsByChannel(); + // Unified working signal: observer-derived turns primary, bot typing as + // fallback — so the sidebar badge appears even for agents whose observer + // stream is absent for this build/scope. + const activeWorkingChannels = useWorkingChannels(); return React.useMemo( () => new Map( diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts index c7afd9a188..b853ce0c44 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/workspaces/useWorkspaceInit.ts @@ -11,6 +11,7 @@ import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; import { clearAllDrafts } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetActiveAgentTurnsStore } from "@/features/agents/activeAgentTurnsStore"; +import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; @@ -29,6 +30,7 @@ function resetWorkspaceState(): void { relayClient.disconnect(); resetAgentObserverStore(); resetActiveAgentTurnsStore(); + resetAgentWorkingSignal(); resetSidebarRelayConnectionCardState(); resetMediaCaches(); resetVideoPlayerState(); diff --git a/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts b/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts new file mode 100644 index 0000000000..0fee5ebf9d --- /dev/null +++ b/desktop/tests/e2e/activity-scope-label-screenshots.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/activity-scope-label"; + +const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; +const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; // #agents + +// Open the activity pane via profile → "View activity" (same ingress the +// observer-feed screenshot spec uses). +async function openActivityFromChannel( + page: import("@playwright/test").Page, + channelTestId: string, + channelTitle: string, +) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId(channelTestId).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channelTitle); + + const messageRow = page + .getByTestId("message-row") + .filter({ has: page.getByText("Observer Agent", { exact: false }) }); + await expect(messageRow.first()).toBeVisible({ timeout: 8_000 }); + await messageRow.first().getByRole("button").first().click(); + + const profilePanel = page.getByTestId("user-profile-panel"); + await expect(profilePanel).toBeVisible({ timeout: 10_000 }); + + const activityBtn = page.getByTestId( + `user-profile-view-activity-${AGENT_PUBKEY}`, + ); + await expect(activityBtn).toBeVisible({ timeout: 5_000 }); + await activityBtn.click(); + + const panel = page.getByTestId("agent-session-thread-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + return panel; +} + +test.describe("activity panel scope label", () => { + test("channel-targeted pane shows the channel name", async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT_PUBKEY, + name: "Observer Agent", + status: "running" as const, + channelNames: ["agents"], + }, + ], + }); + + const panel = await openActivityFromChannel( + page, + "channel-agents", + "agents", + ); + await expect(page.getByTestId("agent-session-scope-label")).toHaveText( + "#agents", + ); + + await waitForAnimations(page); + await panel.screenshot({ path: `${SHOTS}/01-channel-scoped.png` }); + }); + + test("unscoped pane shows All channels", async ({ page }) => { + // The agent lives in #random only. Restoring an agentSession URL on + // #agents (where the agent is not in the activity list) puts the pane in + // all-channels scope — the state that looked silently broken before the + // scope label existed. The app uses a hash router, so the deep link goes + // in the hash. + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT_PUBKEY, + name: "Observer Agent", + status: "running" as const, + channelNames: ["random"], + }, + ], + }); + + await page.goto( + `/#/channels/${AGENTS_CHANNEL_ID}?agentSession=${AGENT_PUBKEY}`, + { waitUntil: "domcontentloaded" }, + ); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const panel = page.getByTestId("agent-session-thread-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + await expect(page.getByTestId("agent-session-scope-label")).toHaveText( + "All channels", + ); + + await waitForAnimations(page); + await panel.screenshot({ path: `${SHOTS}/02-all-channels.png` }); + }); +});