fix(desktop): sync derived active-turn liveness in channel activity path (#1492)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-06 20:59:23 -07:00
committed by GitHub
parent e3008b3604
commit 1f5ba5bb27
27 changed files with 1475 additions and 141 deletions

View File

@@ -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",
"**/local-archive-screenshots.spec.ts",
"**/agent-readiness-screenshots.spec.ts",
"**/file-attachment.spec.ts",

View File

@@ -172,6 +172,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.
// +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard).

View File

@@ -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,
@@ -150,6 +151,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();
useArchiveSync();
useObserverArchiveSeed(identityQuery.data?.pubkey);
useAgentMetricArchiveSeed(identityQuery.data?.pubkey);

View File

@@ -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,

View File

@@ -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");

View File

@@ -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();

View File

@@ -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);
});
});

View File

@@ -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();
}
}

View File

@@ -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>

View File

@@ -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)),

View File

@@ -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" }]);
});
});

View File

@@ -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);
}

View File

@@ -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,
);
});
});

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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 {
@@ -54,13 +55,13 @@ import {
import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents";
import { useLoadOlderOnScroll } from "@/features/messages/ui/useLoadOlderOnScroll";
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;
@@ -82,7 +83,6 @@ export function AgentSessionThreadPanel({
canInterruptTurn,
channel,
channelId = null,
isWorking,
layout = "standalone",
isSinglePanelView = false,
profiles,
@@ -93,13 +93,18 @@ 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 topSentinelRef = React.useRef<HTMLDivElement>(null);
const sessionChannelId = channelId ?? channel?.id ?? null;
const now = useNow(1000);
const { events } = useObserverEvents(isLive, agent.pubkey);
const transcript = useAgentTranscript(isLive, agent.pubkey);
@@ -136,6 +141,29 @@ export function AgentSessionThreadPanel({
sentinelRef: topSentinelRef,
});
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,
@@ -333,6 +361,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}
</>

View File

@@ -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 (

View File

@@ -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
}

View File

@@ -24,7 +24,6 @@ import {
usePersonasQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
import {
mergeMessages,
useChannelMessagesQuery,
@@ -359,29 +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);
// 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(

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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 {
@@ -86,7 +84,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";
@@ -250,7 +248,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,
@@ -307,14 +305,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 ||
@@ -327,7 +320,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 =
@@ -688,9 +683,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(

View File

@@ -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 =

View File

@@ -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"
>

View File

@@ -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(

View File

@@ -13,6 +13,8 @@ import {
initDraftStore,
} 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";
@@ -30,6 +32,8 @@ import type { Workspace } from "./types";
function resetWorkspaceState(): void {
relayClient.disconnect();
resetAgentObserverStore();
resetActiveAgentTurnsStore();
resetAgentWorkingSignal();
resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetVideoPlayerState();

View File

@@ -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` });
});
});