From 8d0becfdb5b7081919556eac133c0a11667d23f1 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
 <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Date: Fri, 3 Jul 2026 12:34:04 -0700
Subject: [PATCH 1/3] fix(desktop): sync derived active-turn liveness in
 channel activity path
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Rebased from tho/active-turn-bridge-fix (#1492) onto post-#1380 main,
squashing the temporary debug-harness add/remove churn that netted to
zero. Behavior preserved:

- ChannelScreen mounts useActiveAgentTurnsBridge next to the existing
  observer bridge so raw observer frames (e.g. turn_completed) and the
  derived liveness store stay in sync in the channel activity path
- Extract syncActiveAgentTurnsFromObserver() from the bridge hook so the
  regression test drives the exact observer→derived-liveness path
- resetActiveAgentTurnsStore() wired into workspace-switch reset per the
  workspace-switching singleton contract
- Regression coverage: observer→active-turns bridge sync keeps the raw
  feed's completion event while derived liveness clears; non-running
  agents don't populate the store

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
---
 .../agents/activeAgentTurnsStore.test.mjs     | 71 +++++++++++++++++++
 .../features/agents/activeAgentTurnsStore.ts  | 21 ++++--
 .../features/channels/ui/ChannelScreen.tsx    |  4 +-
 .../features/workspaces/useWorkspaceInit.ts   |  2 +
 4 files changed, 91 insertions(+), 7 deletions(-)

diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
index c1359f6c3f..75d216e2eb 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
+++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
@@ -3,11 +3,17 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test";
 
 import {
   syncAgentTurnsFromEvents,
+  syncActiveAgentTurnsFromObserver,
   getActiveTurnsForAgent,
   getActiveTurnsByChannel,
   resetActiveAgentTurnsStore,
   subscribeActiveAgentTurns,
 } from "./activeAgentTurnsStore.ts";
+import {
+  injectObserverEventsForE2E,
+  getAgentObserverSnapshot,
+  resetAgentObserverStore,
+} from "./observerRelayStore.ts";
 import { formatElapsed } from "./ui/agentSessionUtils.ts";
 
 const AGENT =
@@ -1230,6 +1236,71 @@ describe("activeAgentTurnsStore", () => {
   });
 });
 
+/**
+ * Regression: raw observer ingestion and derived active-turn liveness must
+ * stay in sync. The channel activity path previously mounted only the observer
+ * bridge, so a raw `turn_completed` could be visible in the activity feed
+ * while the derived liveness indicator kept spinning. This drives events
+ * through the real observer store (appendAgentEvent path) and the same sync
+ * the bridge hook runs, asserting derived liveness clears with the raw feed.
+ */
+describe("observer → active-turns bridge sync", () => {
+  const bridgeAgents = [{ pubkey: AGENT, status: "deployed" }];
+
+  beforeEach(() => {
+    resetActiveAgentTurnsStore();
+    resetAgentObserverStore();
+  });
+
+  afterEach(() => {
+    resetAgentObserverStore();
+  });
+
+  it("clears derived liveness when raw turn_completed arrives", () => {
+    injectObserverEventsForE2E(AGENT, [
+      makeEvent({ seq: 1, kind: "turn_started" }),
+    ]);
+    syncActiveAgentTurnsFromObserver(bridgeAgents);
+    assert.ok(
+      channelIdsOf(getActiveTurnsForAgent(AGENT)).has("chan-1"),
+      "turn_started must surface an active turn",
+    );
+
+    injectObserverEventsForE2E(AGENT, [
+      makeEvent({
+        seq: 2,
+        kind: "turn_completed",
+        timestamp: "2024-01-01T00:00:05Z",
+      }),
+    ]);
+    syncActiveAgentTurnsFromObserver(bridgeAgents);
+
+    const rawEvents = getAgentObserverSnapshot(AGENT, true).events;
+    assert.equal(
+      rawEvents.at(-1)?.kind,
+      "turn_completed",
+      "raw feed must contain the completion event",
+    );
+    assert.equal(
+      getActiveTurnsForAgent(AGENT).length,
+      0,
+      "derived liveness must clear when the raw feed shows turn_completed",
+    );
+  });
+
+  it("skips agents that are neither running nor deployed", () => {
+    injectObserverEventsForE2E(AGENT, [
+      makeEvent({ seq: 1, kind: "turn_started" }),
+    ]);
+    syncActiveAgentTurnsFromObserver([{ pubkey: AGENT, status: "stopped" }]);
+    assert.equal(
+      getActiveTurnsForAgent(AGENT).length,
+      0,
+      "inactive agents must not populate the active-turns store",
+    );
+  });
+});
+
 describe("formatElapsed", () => {
   it("renders sub-10s as whole seconds", () => {
     assert.equal(formatElapsed(0), "0s");
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts
index 9344c3e8bd..9ffd34db63 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.ts
+++ b/desktop/src/features/agents/activeAgentTurnsStore.ts
@@ -527,6 +527,21 @@ export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] {
   );
 }
 
+/**
+ * Sync every running/deployed agent's observer events into the active-turns
+ * store. Extracted from the bridge hook so a regression can drive the exact
+ * observer→derived-liveness path without a React renderer.
+ */
+export function syncActiveAgentTurnsFromObserver(
+  agents: readonly { pubkey: string; status: string }[],
+) {
+  for (const agent of agents) {
+    if (agent.status !== "running" && agent.status !== "deployed") continue;
+    const snapshot = getAgentObserverSnapshot(agent.pubkey, true);
+    syncAgentTurnsFromEvents(agent.pubkey, snapshot.events);
+  }
+}
+
 /**
  * Bridge hook: processes observer events into the active-turns store.
  * Should be called by a parent component that has access to the observer events.
@@ -536,11 +551,7 @@ export function useActiveAgentTurnsBridge(
 ) {
   React.useEffect(() => {
     function syncAll() {
-      for (const agent of agents) {
-        if (agent.status !== "running" && agent.status !== "deployed") continue;
-        const snapshot = getAgentObserverSnapshot(agent.pubkey, true);
-        syncAgentTurnsFromEvents(agent.pubkey, snapshot.events);
-      }
+      syncActiveAgentTurnsFromObserver(agents);
     }
 
     syncAll();
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 1e5cef9323..7aa670358b 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -24,6 +24,7 @@ import {
   usePersonasQuery,
   useRelayAgentsQuery,
 } from "@/features/agents/hooks";
+import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore";
 import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
 import {
   mergeMessages,
@@ -196,8 +197,6 @@ export function ChannelScreen({
     }
     return null;
   }, [messagesQuery.data]);
-  // No `lastMessageAt` fallback: it is reply-inclusive and would clear unread
-  // thread/sidebar state before a real top-level position is known.
   const activeReadAt = latestActiveMessage
     ? new Date(latestActiveMessage.created_at * 1_000).toISOString()
     : null;
@@ -382,6 +381,7 @@ export function ChannelScreen({
     ];
   }, [managedAgents, openAgentSessionPubkey, profilePanelPubkey]);
   useManagedAgentObserverBridge(observerBridgeAgents);
+  useActiveAgentTurnsBridge(observerBridgeAgents);
   const messageProfiles = React.useMemo(() => {
     const base =
       mergeCurrentProfileIntoLookup(
diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/workspaces/useWorkspaceInit.ts
index fe01c30ce4..c7afd9a188 100644
--- a/desktop/src/features/workspaces/useWorkspaceInit.ts
+++ b/desktop/src/features/workspaces/useWorkspaceInit.ts
@@ -10,6 +10,7 @@ import { resetMediaCaches } from "@/shared/lib/mediaUrl";
 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 { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
 import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
 import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
@@ -27,6 +28,7 @@ import type { Workspace } from "./types";
 function resetWorkspaceState(): void {
   relayClient.disconnect();
   resetAgentObserverStore();
+  resetActiveAgentTurnsStore();
   resetSidebarRelayConnectionCardState();
   resetMediaCaches();
   resetVideoPlayerState();

From bb069eb06439d2f0568a77ca2689a79cca9946d9 Mon Sep 17 00:00:00 2001
From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
 <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Date: Mon, 6 Jul 2026 18:00:02 -0700
Subject: [PATCH 2/3] docs(desktop): restore load-bearing activeReadAt fallback
 comment

Restores the comment explaining why lastMessageAt is not used as an
activeReadAt fallback: it is reply-inclusive and would clear unread
thread/sidebar state before a real top-level position is known.
Behavior unchanged.

ChannelScreen.tsx sat at 999/1000 lines, so the two restored comment
lines trip the file-size guard; adds a narrowly scoped 1002-line
override entry noting the comment-only overage.

Addresses review feedback on #1492.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
---
 desktop/scripts/check-file-sizes.mjs               | 6 ++++++
 desktop/src/features/channels/ui/ChannelScreen.tsx | 2 ++
 2 files changed, 8 insertions(+)

diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs
index 68b79d0e4e..f030444551 100644
--- a/desktop/scripts/check-file-sizes.mjs
+++ b/desktop/scripts/check-file-sizes.mjs
@@ -142,6 +142,12 @@ const overrides = new Map([
   // test. Load-bearing feature growth, queued to split publishSplitSlots path
   // into readStateManagerSplit.ts.
   ["src/features/channels/readState/readStateManager.ts", 1030],
+  // review feedback on #1492 restored the two-line load-bearing comment
+  // documenting why `lastMessageAt` must not be an `activeReadAt` fallback
+  // (reply-inclusive; would clear unread state early). The file was already
+  // at the 1000 ceiling; comment-only overage, not code growth. Queued to
+  // split with the rest of this list.
+  ["src/features/channels/ui/ChannelScreen.tsx", 1002],
   // Shared UI was added to this guard after splitting globals/markdown so
   // large shared renderers cannot grow further while follow-up splits land.
   ["src/shared/ui/markdown.tsx", 2119],
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 7aa670358b..2e98fcfc71 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -197,6 +197,8 @@ export function ChannelScreen({
     }
     return null;
   }, [messagesQuery.data]);
+  // No `lastMessageAt` fallback: it is reply-inclusive and would clear unread
+  // thread/sidebar state before a real top-level position is known.
   const activeReadAt = latestActiveMessage
     ? new Date(latestActiveMessage.created_at * 1_000).toISOString()
     : null;

From a9908ac52614c02caa96199b8bd7e7513259de3e Mon Sep 17 00:00:00 2001
From: Taylor Ho <taylorkmho@gmail.com>
Date: Mon, 6 Jul 2026 20:36:36 -0700
Subject: [PATCH 3/3] feat(desktop): owner-global observer ingestion at app
 level (#1493)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
---
 desktop/playwright.config.ts                  |   1 +
 desktop/src/app/AppShell.tsx                  |   9 +
 .../src/app/navigation/useAppNavigation.ts    |  19 +-
 .../agents/agentWorkingSignal.test.mjs        | 215 +++++++++++
 .../src/features/agents/agentWorkingSignal.ts | 348 ++++++++++++++++++
 .../features/agents/ui/ManagedAgentRow.tsx    |  12 +-
 .../agents/ui/useManagedAgentActions.ts       |   6 +-
 .../agents/useAgentObserverIngestion.test.mjs |  88 +++++
 .../agents/useAgentObserverIngestion.ts       | 116 ++++++
 .../agents/useOpenAgentActivity.test.mjs      |  84 +++++
 .../features/agents/useOpenAgentActivity.ts   | 178 +++++++++
 .../src/features/agents/usePreventSleep.ts    |  15 +-
 .../channels/ui/AgentSessionThreadPanel.tsx   |  43 ++-
 .../features/channels/ui/BotActivityBar.tsx   |  48 +--
 .../src/features/channels/ui/ChannelPane.tsx  |  36 +-
 .../features/channels/ui/ChannelScreen.tsx    |  29 +-
 .../ui/useChannelActivityTyping.test.mjs      |  75 ++++
 .../channels/ui/useChannelActivityTyping.ts   |  36 ++
 .../features/profile/ui/UserProfilePanel.tsx  |  25 +-
 .../profile/ui/UserProfilePanelSections.tsx   |   4 +-
 .../profile/ui/UserProfilePopover.tsx         |  12 +-
 .../lib/useActiveWorkingChannelsById.ts       |  16 +-
 .../features/workspaces/useWorkspaceInit.ts   |   2 +
 .../activity-scope-label-screenshots.spec.ts  | 100 +++++
 24 files changed, 1380 insertions(+), 137 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/useAgentObserverIngestion.test.mjs
 create mode 100644 desktop/src/features/agents/useAgentObserverIngestion.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/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 5861b5081f..f863fe599d 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,14 @@ 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.
+  // 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;
   useRelayAutoHeal();
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<AgentWorkingSource, "none">;
+};
+
+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<AgentWorkingSource, "none">;
+};
+
+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<string, Map<string, number>>();
+
+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<string, AgentWorkingState>();
+let channelsCache: WorkingChannelSummary[] | null = null;
+const channelPubkeysCache = new Map<string, string[]>();
+
+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<string, number>();
+  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<string, WorkingChannelSummary>();
+  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<string>();
+  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 (
     <div className="min-w-0">
@@ -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 })
+                  }
                 />
               ))}
             </div>
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..386b762142
--- /dev/null
+++ b/desktop/src/features/agents/useAgentObserverIngestion.ts
@@ -0,0 +1,116 @@
+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<ManagedAgent, "pubkey" | "status">;
+
+/**
+ * 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<string, string>,
+  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.
+ *
+ * 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();
+  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<string, string>();
+    for (const [pubkey, summary] of Object.entries(profiles ?? {})) {
+      if (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(
+      managedAgents ?? [],
+      relayAgentPubkeys,
+      ownerByPubkey,
+      currentPubkey,
+    );
+  }, [currentPubkey, managedAgents, profiles, relayAgentPubkeys]);
+
+  useManagedAgentObserverBridge(ingestionAgents);
+  useActiveAgentTurnsBridge(ingestionAgents);
+}
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<Channel, "isMember" | "visibility"> | 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<string>;
+  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/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/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<HTMLDivElement>();
-  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). */}
+        <span
+          className="min-w-0 shrink truncate text-xs text-muted-foreground"
+          data-testid="agent-session-scope-label"
+        >
+          {scopeLabel}
+        </span>
       </AuxiliaryPanelHeaderGroup>
       {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 (
     <Popover onOpenChange={setOpen} open={open}>
@@ -179,7 +179,7 @@ export function BotActivityComposerAction({
           type="button"
         >
           <span className="flex items-center overflow-visible py-px -space-x-1">
-            {typingAgents.slice(0, 2).map((agent) => (
+            {workingAgents.slice(0, 2).map((agent) => (
               <UserAvatar
                 avatarUrl={agentAvatarUrl(agent)}
                 className={cn(
@@ -194,9 +194,9 @@ export function BotActivityComposerAction({
               />
             ))}
           </span>
-          {typingAgents.length > 2 ? (
+          {workingAgents.length > 2 ? (
             <span className="text-2xs leading-none">
-              +{typingAgents.length - 2}
+              +{workingAgents.length - 2}
             </span>
           ) : null}
           <span
@@ -228,7 +228,7 @@ export function BotActivityComposerAction({
           Agents working
         </div>
         <div className="mt-1 flex flex-col gap-1">
-          {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"
                         />
                       </div>
@@ -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/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 2e98fcfc71..505495495b 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,
@@ -360,30 +358,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/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<TypingIndicatorEntry, "pubkey" | "threadHeadId">[],
+): 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 359954c1d6..4c4d8b068e 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 {
@@ -82,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";
@@ -231,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,
@@ -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 ||
@@ -308,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 =
@@ -669,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<string, string> = {};
@@ -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 03f245f64a..37a3c86656 100644
--- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts
+++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts
@@ -1,12 +1,8 @@
 import * as React from "react";
 
-import {
-  type ActiveChannelTurnSummary,
-  useActiveAgentTurnsBridge,
-  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 { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
 import { normalizePubkey } from "@/shared/lib/pubkey";
 
 export function resolveActiveWorkingChannelNames(
@@ -36,10 +32,10 @@ export function useActiveWorkingChannelsById(): ReadonlyMap<
     [managedAgentsQuery.data],
   );
 
-  useManagedAgentObserverBridge(managedAgents);
-  useActiveAgentTurnsBridge(managedAgents);
-
-  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` });
+  });
+});
