From ee40613898bcf902caf2933198fe0ba04872d52a Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Thu, 16 Jul 2026 13:44:37 +1000 Subject: [PATCH] feat(desktop): scope the agents UI per community MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter every managed-agent surface to the active community's relay so concurrently running workspaces stop bleeding into each other's UI (Phase 3 of lazy multi-workspace agents, building on the Phase 1 relay pinning and Phase 2 lazy activation): - Add agentRelayScope helpers: a frontend mirror of the backend relay normalizer plus agentBelongsToRelay / partitionAgentsByRelay / hasRunningAgentInCommunity. A blank pin follows the active community (same defense-in-depth fallback as effective_agent_relay_url), and a missing provider/community degrades to unscoped rather than blanking every surface. - Scope the useManagedAgentActions list — and the bulk stop, start/ stop/delete lookups, and presence derived from it — to the active relay via a new lenient useActiveRelayUrl hook. Persona delete keeps counting instances against the unscoped record set since deleting a persona removes instances in every community. - Gate the 5s managed-agents liveness poll on a running agent in this community; agents running in other communities render no process state here, so they no longer keep the poll alive. - Hold auto-restart for agents pinned to other communities — their working/observer signals are read from the active relay only, so a pure workspace switch must never fire a restart — and re-check the pin in the pre-fire re-fetch so a rebind cannot restart a foreign agent. - Scope mergeKnownAgentPubkeys' managed-agent source to the active relay; the relay-agent (kind:10100) source stays unfiltered. - Surface an "N agents running in other communities" line in the agents header so concurrent background agents stay discoverable. Tested: desktop pnpm test (2939 passed, incl. new agentRelayScope, knownAgentPubkeys, and autoRestartPolicy unit tests), tsc --noEmit, biome check, Playwright smoke project (537 passed, 5 failed; 2 of the failures passed on rerun and the other 3 — channels intro-scroll, video review mode, shared-compute empty state — reproduce on baseline HEAD without this change, so all 5 are pre-existing flakes). Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- .../features/agents/agentRelayScope.test.mjs | 129 ++++++++++++++++++ .../src/features/agents/agentRelayScope.ts | 98 +++++++++++++ desktop/src/features/agents/hooks.ts | 12 +- .../agents/knownAgentPubkeys.test.mjs | 57 ++++++++ .../src/features/agents/knownAgentPubkeys.ts | 19 ++- .../agents/lib/autoRestartPolicy.test.mjs | 26 ++++ .../features/agents/lib/autoRestartPolicy.ts | 9 ++ .../agents/lib/useAutoRestartPolicy.ts | 9 +- desktop/src/features/agents/ui/AgentsView.tsx | 21 ++- .../agents/ui/useManagedAgentActions.ts | 27 +++- .../features/agents/useKnownAgentPubkeys.tsx | 8 +- .../features/communities/useCommunities.tsx | 13 ++ 12 files changed, 414 insertions(+), 14 deletions(-) create mode 100644 desktop/src/features/agents/agentRelayScope.test.mjs create mode 100644 desktop/src/features/agents/agentRelayScope.ts diff --git a/desktop/src/features/agents/agentRelayScope.test.mjs b/desktop/src/features/agents/agentRelayScope.test.mjs new file mode 100644 index 000000000..ecb5d69f7 --- /dev/null +++ b/desktop/src/features/agents/agentRelayScope.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + agentBelongsToRelay, + hasRunningAgentInCommunity, + normalizeRelayUrlForCompare, + partitionAgentsByRelay, +} from "./agentRelayScope.ts"; + +const RELAY_A = "ws://relay-a.example.com:3000"; +const RELAY_B = "wss://relay-b.example.com"; + +// ── normalizeRelayUrlForCompare ────────────────────────────────────────────── +// Must agree with the Rust `normalize_relay_url` (desktop/src-tauri/src/relay.rs) +// because record pins are stamped by the backend and compared here. These +// vectors mirror the Rust unit tests. + +test("normalize_stripsWhitespaceAndTrailingSlashes", () => { + assert.equal( + normalizeRelayUrlForCompare(" wss://relay.example.com// "), + "wss://relay.example.com", + ); +}); + +test("normalize_lowercasesSchemeAndAuthority_preservesPathCase", () => { + assert.equal( + normalizeRelayUrlForCompare("WSS://Relay.Example.COM:3000/Path"), + "wss://relay.example.com:3000/Path", + ); +}); + +test("normalize_schemelessInput_passesThroughTrimmed", () => { + assert.equal(normalizeRelayUrlForCompare("not-a-url/"), "not-a-url"); +}); + +// ── agentBelongsToRelay ────────────────────────────────────────────────────── + +test("belongs_exactMatch", () => { + assert.equal(agentBelongsToRelay(RELAY_A, RELAY_A), true); +}); + +test("belongs_cosmeticDifferences_stillMatch", () => { + // Trailing slash and scheme/host case must not split an agent from its + // community — this is exactly what the shared normalizer exists for. + assert.equal( + agentBelongsToRelay("WS://RELAY-A.example.com:3000/", RELAY_A), + true, + ); +}); + +test("belongs_differentRelay_doesNotMatch", () => { + assert.equal(agentBelongsToRelay(RELAY_A, RELAY_B), false); +}); + +test("belongs_blankPin_followsActiveCommunity", () => { + // Defense-in-depth mirror of the backend's `effective_agent_relay_url` + // blank fallback: a record that escaped stamping follows the visited + // community instead of vanishing from every community. + assert.equal(agentBelongsToRelay("", RELAY_A), true); + assert.equal(agentBelongsToRelay(" ", RELAY_B), true); + assert.equal(agentBelongsToRelay(undefined, RELAY_A), true); +}); + +test("belongs_noActiveCommunityRelay_degradesToUnscoped", () => { + assert.equal(agentBelongsToRelay(RELAY_A, null), true); + assert.equal(agentBelongsToRelay(RELAY_A, ""), true); + assert.equal(agentBelongsToRelay(RELAY_A, undefined), true); +}); + +// ── partitionAgentsByRelay: the agent-list filter ──────────────────────────── + +test("partition_scopesListToActiveCommunity", () => { + // Two communities' agents coexist after lazy activation; the list the + // user sees in community A must contain only A's agents (plus blank-pin + // strays), with B's surfaced only through the "other communities" count. + const agents = [ + { pubkey: "a1", relayUrl: RELAY_A }, + { pubkey: "b1", relayUrl: RELAY_B }, + { pubkey: "a2", relayUrl: `${RELAY_A}/` }, + { pubkey: "stray", relayUrl: "" }, + ]; + + const { inCommunity, other } = partitionAgentsByRelay(agents, RELAY_A); + + assert.deepEqual( + inCommunity.map((agent) => agent.pubkey), + ["a1", "a2", "stray"], + ); + assert.deepEqual( + other.map((agent) => agent.pubkey), + ["b1"], + ); +}); + +test("partition_undefinedAgents_yieldsEmpty", () => { + const { inCommunity, other } = partitionAgentsByRelay(undefined, RELAY_A); + assert.deepEqual(inCommunity, []); + assert.deepEqual(other, []); +}); + +// ── hasRunningAgentInCommunity: the polling gate ───────────────────────────── + +test("pollingGate_runningAgentInOtherCommunity_doesNotPoll", () => { + // A workspace switch leaves the previous community's agents running; they + // must not keep this community's 5s liveness poll alive. + const agents = [ + { relayUrl: RELAY_B, status: "running" }, + { relayUrl: RELAY_A, status: "stopped" }, + ]; + + assert.equal(hasRunningAgentInCommunity(agents, RELAY_A), false); +}); + +test("pollingGate_runningAgentInThisCommunity_polls", () => { + const agents = [ + { relayUrl: RELAY_B, status: "running" }, + { relayUrl: RELAY_A, status: "running" }, + ]; + + assert.equal(hasRunningAgentInCommunity(agents, RELAY_A), true); +}); + +test("pollingGate_blankPinRunningAgent_polls", () => { + assert.equal( + hasRunningAgentInCommunity([{ relayUrl: "", status: "running" }], RELAY_A), + true, + ); +}); diff --git a/desktop/src/features/agents/agentRelayScope.ts b/desktop/src/features/agents/agentRelayScope.ts new file mode 100644 index 000000000..5decfa823 --- /dev/null +++ b/desktop/src/features/agents/agentRelayScope.ts @@ -0,0 +1,98 @@ +/** + * Relay-scoping for managed agents. Every managed-agent record is pinned to + * its home relay (`ManagedAgent.relayUrl`, stamped by the backend at create), + * and the desktop UI presents agents per community — so every surface that + * lists, counts, or acts on managed agents must scope to the active + * community's relay through these helpers. Agents pinned to other relays + * keep running in the background; they are just not "in" this community. + */ + +/** + * Canonical form of a relay URL for identity comparisons — NOT for + * connecting. Frontend mirror of `normalize_relay_url` in + * `desktop/src-tauri/src/relay.rs`; the two must agree because record pins + * are stamped by the backend and compared here: trim, strip trailing + * slashes, lowercase scheme + authority (case-insensitive per RFC 3986), + * preserve any path or query case-sensitively. + * + * Distinct from `normalizeRelayUrl` in `communityStorage.ts` (input + * canonicalisation: prepends `wss://`) and in `selfProfileStorage.ts` + * (storage keys: lowercases the whole URL, path included). + */ +export function normalizeRelayUrlForCompare(url: string): string { + const trimmed = url.trim().replace(/\/+$/, ""); + const schemeEnd = trimmed.indexOf("://"); + if (schemeEnd === -1) { + return trimmed; + } + const scheme = trimmed.slice(0, schemeEnd); + const rest = trimmed.slice(schemeEnd + "://".length); + const pathStart = rest.indexOf("/"); + const authority = pathStart === -1 ? rest : rest.slice(0, pathStart); + const path = pathStart === -1 ? "" : rest.slice(pathStart); + return `${scheme.toLowerCase()}://${authority.toLowerCase()}${path}`; +} + +/** + * Whether an agent record belongs to the given community relay. + * + * A blank pin follows the active community — the same defense-in-depth + * fallback as the backend's `effective_agent_relay_url` for records that + * escaped stamping. A blank/absent community relay (no provider, no active + * community yet) degrades to unscoped rather than blanking every surface. + */ +export function agentBelongsToRelay( + agentRelayUrl: string | null | undefined, + communityRelayUrl: string | null | undefined, +): boolean { + const community = communityRelayUrl?.trim() ?? ""; + if (community === "") { + return true; + } + const pinned = agentRelayUrl?.trim() ?? ""; + if (pinned === "") { + return true; + } + return ( + normalizeRelayUrlForCompare(pinned) === + normalizeRelayUrlForCompare(community) + ); +} + +/** + * Split agents into those pinned to the active community's relay and the + * rest. `inCommunity` drives the agents screen; `other` exists only for + * cross-community affordances (the "running in other communities" count). + */ +export function partitionAgentsByRelay( + agents: readonly T[] | undefined, + communityRelayUrl: string | null | undefined, +): { inCommunity: T[]; other: T[] } { + const inCommunity: T[] = []; + const other: T[] = []; + for (const agent of agents ?? []) { + if (agentBelongsToRelay(agent.relayUrl, communityRelayUrl)) { + inCommunity.push(agent); + } else { + other.push(agent); + } + } + return { inCommunity, other }; +} + +/** + * The managed-agents polling gate: poll only while an agent *in this + * community* is running. Agents running in other communities don't render + * process state on this community's surfaces, so they must not keep its + * 5s liveness poll alive. + */ +export function hasRunningAgentInCommunity( + agents: readonly { relayUrl?: string | null; status: string }[] | undefined, + communityRelayUrl: string | null | undefined, +): boolean { + return (agents ?? []).some( + (agent) => + agent.status === "running" && + agentBelongsToRelay(agent.relayUrl, communityRelayUrl), + ); +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 43146e993..9ceeb0220 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { hasRunningAgentInCommunity } from "@/features/agents/agentRelayScope"; import { connectAcpRuntime, discoverAcpAuthMethods, @@ -11,6 +12,7 @@ import { ensureChannelAgentPresetInChannel, provisionChannelManagedAgent, } from "@/features/agents/channelAgents"; +import { useActiveRelayUrl } from "@/features/communities/useCommunities"; import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng"; import { channelsQueryKey, @@ -310,6 +312,7 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) { } export function useManagedAgentsQuery(options?: { enabled?: boolean }) { + const activeRelayUrl = useActiveRelayUrl(); return useQuery({ enabled: options?.enabled ?? true, queryKey: managedAgentsQueryKey, @@ -321,10 +324,11 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { // with no relay event to signal it, so this poll is the only liveness // path for them. When nothing is running there IS an event path — // `agents-data-changed` (control-plane changes) — so the idle branch - // drops its poll entirely rather than falling back to 30s. - return agents?.some((agent) => agent.status === "running") - ? 5_000 - : false; + // drops its poll entirely rather than falling back to 30s. Scoped to + // the active community's relay: agents left running in other + // communities render no process state on this community's surfaces, + // so they must not keep its poll alive. + return hasRunningAgentInCommunity(agents, activeRelayUrl) ? 5_000 : false; }, }); } diff --git a/desktop/src/features/agents/knownAgentPubkeys.test.mjs b/desktop/src/features/agents/knownAgentPubkeys.test.mjs index 2b7b3e18a..57ae4a088 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.test.mjs +++ b/desktop/src/features/agents/knownAgentPubkeys.test.mjs @@ -34,3 +34,60 @@ test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => { assert.deepEqual([...merged], [MANAGED]); }); + +// ── relay scoping ──────────────────────────────────────────────────────────── + +const RELAY_A = "ws://relay-a.example.com:3000"; +const RELAY_B = "wss://relay-b.example.com"; + +test("managedAgentPinnedToOtherRelay_excludedWhenScoped", () => { + // With multiple communities' agents running concurrently, a managed agent + // pinned to relay B is not an agent "in" community A — it must not enter + // A's known-agent baseline just because it's locally managed. + const merged = mergeKnownAgentPubkeys( + [ + { pubkey: MANAGED, relayUrl: RELAY_B }, + // Cosmetic URL differences must not split an agent from its community. + { pubkey: RELAY, relayUrl: `${RELAY_A}/` }, + ], + undefined, + RELAY_A, + ); + + assert.deepEqual([...merged], [RELAY]); +}); + +test("blankPinnedRelay_followsActiveCommunity", () => { + // Defense-in-depth mirror of the backend's blank-relay fallback: a record + // that escaped stamping belongs to whichever community is visited. + const merged = mergeKnownAgentPubkeys( + [{ pubkey: MANAGED, relayUrl: "" }], + undefined, + RELAY_A, + ); + + assert.deepEqual([...merged], [MANAGED]); +}); + +test("relayAgents_neverRelayFiltered", () => { + // Relay agents come from the active relay's own kind:10100 profiles — + // already community-scoped at the source, so the merge must keep them + // even when a managed-agent scope is in effect. + const merged = mergeKnownAgentPubkeys( + [{ pubkey: MANAGED, relayUrl: RELAY_B }], + [{ pubkey: RELAY }], + RELAY_A, + ); + + assert.deepEqual([...merged], [RELAY]); +}); + +test("noActiveRelay_degradesToUnscopedMerge", () => { + const merged = mergeKnownAgentPubkeys( + [{ pubkey: MANAGED, relayUrl: RELAY_B }], + undefined, + null, + ); + + assert.deepEqual([...merged], [MANAGED]); +}); diff --git a/desktop/src/features/agents/knownAgentPubkeys.ts b/desktop/src/features/agents/knownAgentPubkeys.ts index 978509af0..51a98f523 100644 --- a/desktop/src/features/agents/knownAgentPubkeys.ts +++ b/desktop/src/features/agents/knownAgentPubkeys.ts @@ -1,3 +1,4 @@ +import { agentBelongsToRelay } from "@/features/agents/agentRelayScope"; import { normalizePubkey } from "@/shared/lib/pubkey"; /** @@ -5,15 +6,27 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; * normalised via `normalizePubkey` so membership checks work against * normalised pubkeys. * - * Structurally typed on `{ pubkey }` so node unit tests don't need to build - * full `ManagedAgent`/`RelayAgent` values. + * Managed agents are scoped to `activeRelayUrl` (the active community's + * relay): the baseline answers "is this pubkey an agent *in this + * community*", and a locally managed agent pinned to another community's + * relay is not — it neither posts here nor appears in this community's + * directory. An agent genuinely present on both relays is still covered by + * the relay-agent source (kind:10100 from the active relay), which is never + * relay-filtered. Omitting `activeRelayUrl` degrades to the unscoped merge. + * + * Structurally typed on `{ pubkey, relayUrl? }` so node unit tests don't + * need to build full `ManagedAgent`/`RelayAgent` values. */ export function mergeKnownAgentPubkeys( - managedAgents: readonly { pubkey: string }[] | undefined, + managedAgents: + | readonly { pubkey: string; relayUrl?: string | null }[] + | undefined, relayAgents: readonly { pubkey: string }[] | undefined, + activeRelayUrl?: string | null, ): ReadonlySet { const pubkeys = new Set(); for (const agent of managedAgents ?? []) { + if (!agentBelongsToRelay(agent.relayUrl, activeRelayUrl)) continue; pubkeys.add(normalizePubkey(agent.pubkey)); } for (const agent of relayAgents ?? []) { diff --git a/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs b/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs index d36d4c7a1..165100eb9 100644 --- a/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs +++ b/desktop/src/features/agents/lib/autoRestartPolicy.test.mjs @@ -23,6 +23,7 @@ function greenInputs(overrides = {}) { workingSource: "none", connected: true, isLocalBackend: true, + inActiveCommunity: true, isRunning: true, edgeConsumed: false, quiescentForMs: AUTO_RESTART_QUIESCENCE_MS, @@ -55,6 +56,7 @@ const NEVER_FIRE_ROWS = [ ["typing source alone defers", { workingSource: "typing" }], ["observer relay not connected", { connected: false }], ["remote backend", { isLocalBackend: false }], + ["agent pinned to another community's relay", { inActiveCommunity: false }], ["agent not running", { isRunning: false }], [ "edge already consumed (one attempt per rising edge)", @@ -72,6 +74,30 @@ for (const [label, overrides] of NEVER_FIRE_ROWS) { }); } +// ── workspace switch ───────────────────────────────────────────────────────── + +test("a pure workspace switch never fires a restart", () => { + // Switching communities leaves the previous community's agents running, + // and their working/observer signals are read from the ACTIVE relay only — + // so from the new community they read as idle even mid-turn. Even with a + // fully elapsed quiescence window and every other gate green, an agent + // pinned to another community's relay must hold, whatever the (blind) + // connected/working signals claim. + for (const overrides of [ + {}, + { connected: true, working: false, workingSource: "none" }, + { quiescentForMs: AUTO_RESTART_QUIESCENCE_MS * 10 }, + ]) { + assert.equal( + decideAutoRestart( + greenInputs({ ...overrides, inActiveCommunity: false }), + ), + "hold", + "an out-of-community fire is a kill decided on blind data", + ); + } +}); + // ── the quiescence window ─────────────────────────────────────────────────── test("arms (does not fire) before the window elapses", () => { diff --git a/desktop/src/features/agents/lib/autoRestartPolicy.ts b/desktop/src/features/agents/lib/autoRestartPolicy.ts index ad7a18a25..033f7e18d 100644 --- a/desktop/src/features/agents/lib/autoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/autoRestartPolicy.ts @@ -33,6 +33,13 @@ export type AutoRestartInputs = { connected: boolean; /** Only local agents can be restarted by this loop. */ isLocalBackend: boolean; + /** Whether the agent is pinned to the active community's relay. Agents + * from other communities keep running after a workspace switch, but their + * working/observer signals are read from the ACTIVE relay only — they + * look idle and disconnected here regardless of what they're actually + * doing, so any decision about them would be made on blind data. A pure + * workspace switch must never fire a restart. */ + inActiveCommunity: boolean; /** Agent process status from the summary ("running" required). */ isRunning: boolean; /** Edge-trigger state: true when this needsRestart rising edge has @@ -58,6 +65,7 @@ export function decideAutoRestart( workingSource, connected, isLocalBackend, + inActiveCommunity, isRunning, edgeConsumed, quiescentForMs, @@ -67,6 +75,7 @@ export function decideAutoRestart( if (!autoRestartEnabled) return "hold"; if (!needsRestart) return "hold"; if (!isLocalBackend) return "hold"; + if (!inActiveCommunity) return "hold"; if (!isRunning) return "hold"; if (!connected) return "hold"; // Any working signal — observer OR typing — defers. `working` and diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts index 7e3093090..bafcdea2c 100644 --- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts @@ -5,12 +5,14 @@ import { managedAgentsQueryKey, useManagedAgentsQuery, } from "@/features/agents/hooks"; +import { useActiveRelayUrl } from "@/features/communities/useCommunities"; import { startManagedAgent, stopManagedAgent, } from "@/shared/api/tauriManagedAgents"; import { listManagedAgents } from "@/shared/api/tauri"; import type { ManagedAgent } from "@/shared/api/types"; +import { agentBelongsToRelay } from "../agentRelayScope"; import { getAgentObserverSnapshot } from "../observerRelayStore"; import { getAgentWorkingState } from "../agentWorkingSignal"; import { @@ -36,6 +38,7 @@ const POLICY_TICK_MS = 15_000; export function useAutoRestartPolicy() { const queryClient = useQueryClient(); const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data; + const activeRelayUrl = useActiveRelayUrl(); const edgesRef = React.useRef(new Map()); const inFlightRef = React.useRef(new Set()); const [, setTick] = React.useState(0); @@ -71,6 +74,7 @@ export function useAutoRestartPolicy() { workingSource: working.source, connected: observer.connectionState === "open", isLocalBackend: agent.backend.type === "local", + inActiveCommunity: agentBelongsToRelay(agent.relayUrl, activeRelayUrl), isRunning, edgeConsumed: edge.consumed, quiescentForMs: edge.armedAt === null ? 0 : now - edge.armedAt, @@ -97,13 +101,16 @@ export function useAutoRestartPolicy() { void (async () => { try { - // Pre-fire re-fetch: shrink the stale-decision window to ~0. + // Pre-fire re-fetch: shrink the stale-decision window to ~0. The + // relay pin is re-checked too — a rebind (community relay edit) + // between decision and fire must not restart a foreign agent. const fresh = await listManagedAgents(); const current = fresh.find((a) => a.pubkey === agent.pubkey); if ( !current?.needsRestart || !current.autoRestartOnConfigChange || current.status !== "running" || + !agentBelongsToRelay(current.relayUrl, activeRelayUrl) || getAgentWorkingState(agent.pubkey).source !== "none" ) { return; diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a00ad6291..cbe6df262 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -136,7 +136,21 @@ export function AgentsView() { ) : null} } - description="Set up and manage your agents." + description={ + <> + Set up and manage your agents. + {agents.otherCommunityRunningCount > 0 ? ( + + {agents.otherCommunityRunningCount === 1 + ? "1 agent running in other communities." + : `${agents.otherCommunityRunningCount} agents running in other communities.`} + + ) : null} + + } title="Agents" />
@@ -323,7 +337,10 @@ export function AgentsView() { {personas.personaToDelete ? ( a.personaId === personas.personaToDelete?.id, ).length } diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index 4eb9b6ed0..b38128b0e 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -12,8 +12,10 @@ import { useStopManagedAgentMutation, useDeleteManagedAgentMutation, } from "@/features/agents/hooks"; +import { partitionAgentsByRelay } from "@/features/agents/agentRelayScope"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { useActiveRelayUrl } from "@/features/communities/useCommunities"; import { usePresenceQuery } from "@/features/presence/hooks"; import type { AgentPersona, @@ -75,16 +77,33 @@ export function useManagedAgentActions() { return () => window.clearTimeout(timeoutId); }, []); + // The agents screen is presented per community ("Agents in this + // community"), so scope the list — and every action derived from it (bulk + // stop, start/stop/delete lookups, presence) — to records pinned to the + // active community's relay. Agents pinned elsewhere keep running; they + // surface only through `otherCommunityRunningCount`. + const activeRelayUrl = useActiveRelayUrl(); + const allManagedAgents = managedAgentsQuery.data; + const relayPartition = React.useMemo( + () => partitionAgentsByRelay(allManagedAgents, activeRelayUrl), + [allManagedAgents, activeRelayUrl], + ); + const managedAgents = React.useMemo( () => - [...(managedAgentsQuery.data ?? [])].sort((left, right) => { + [...relayPartition.inCommunity].sort((left, right) => { const activeScore = (s: string) => s === "running" || s === "deployed" ? 1 : 0; const diff = activeScore(right.status) - activeScore(left.status); if (diff !== 0) return diff; return left.name.localeCompare(right.name); }), - [managedAgentsQuery.data], + [relayPartition], + ); + + const otherCommunityRunningCount = React.useMemo( + () => relayPartition.other.filter(isManagedAgentActive).length, + [relayPartition], ); // Observer ingestion is owner-global (useAgentObserverIngestion in // AppShell); this hook only reads derived state. @@ -395,6 +414,10 @@ export function useManagedAgentActions() { managedAgentLogQuery, managedPresenceQuery, managedAgents, + /** Unscoped record set — for cross-community concerns only (e.g. how + * many instances a persona delete would remove across ALL communities). */ + allManagedAgents, + otherCommunityRunningCount, managedPubkeys, channelIdToName, channelsByPubkey, diff --git a/desktop/src/features/agents/useKnownAgentPubkeys.tsx b/desktop/src/features/agents/useKnownAgentPubkeys.tsx index e9fe7b9a9..1ae9a7efa 100644 --- a/desktop/src/features/agents/useKnownAgentPubkeys.tsx +++ b/desktop/src/features/agents/useKnownAgentPubkeys.tsx @@ -5,6 +5,7 @@ import { useRelayAgentsQuery, } from "@/features/agents/hooks"; import { mergeKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; +import { useActiveRelayUrl } from "@/features/communities/useCommunities"; import { useStableSet } from "@/shared/hooks/useStableReference"; const EMPTY_KNOWN_AGENT_PUBKEYS: ReadonlySet = new Set(); @@ -41,10 +42,13 @@ export function KnownAgentPubkeysProvider({ }) { const managedAgents = useManagedAgentsQuery().data; const relayAgents = useRelayAgentsQuery().data; + // Scope managed agents to the active community's relay: another + // community's still-running agents are not agents "in" this community. + const activeRelayUrl = useActiveRelayUrl(); const merged = React.useMemo( - () => mergeKnownAgentPubkeys(managedAgents, relayAgents), - [managedAgents, relayAgents], + () => mergeKnownAgentPubkeys(managedAgents, relayAgents, activeRelayUrl), + [managedAgents, relayAgents, activeRelayUrl], ); const stable = useStableSet(merged); diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index 590e28f84..9a7759393 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -112,6 +112,19 @@ export function useCommunities(): UseCommunitiesReturn { return ctx; } +/** + * Lenient read of the active community's relay URL — the key that scopes + * managed agents to a community (records are pinned to their home relay). + * Unlike `useCommunities`, this returns `null` instead of throwing outside + * `CommunitiesProvider`, so relay-scoping consumers (agent lists, polling + * gates, the auto-restart policy) degrade to unscoped in provider-less + * mounts rather than crashing. + */ +export function useActiveRelayUrl(): string | null { + const ctx = useContext(CommunitiesContext); + return ctx?.activeCommunity?.relayUrl ?? null; +} + function useCommunitiesInternal(): UseCommunitiesReturn { const [communities, setCommunitiesState] = useState(loadCommunities);