mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
215
desktop/src/features/agents/agentWorkingSignal.test.mjs
Normal file
215
desktop/src/features/agents/agentWorkingSignal.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
348
desktop/src/features/agents/agentWorkingSignal.ts
Normal file
348
desktop/src/features/agents/agentWorkingSignal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
});
|
||||
116
desktop/src/features/agents/useAgentObserverIngestion.ts
Normal file
116
desktop/src/features/agents/useAgentObserverIngestion.ts
Normal 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);
|
||||
}
|
||||
84
desktop/src/features/agents/useOpenAgentActivity.test.mjs
Normal file
84
desktop/src/features/agents/useOpenAgentActivity.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
178
desktop/src/features/agents/useOpenAgentActivity.ts
Normal file
178
desktop/src/features/agents/useOpenAgentActivity.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
100
desktop/tests/e2e/activity-scope-label-screenshots.spec.ts
Normal file
100
desktop/tests/e2e/activity-scope-label-screenshots.spec.ts
Normal 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` });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user