From f156835e834496781ab2e5145e712224c60debbb Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Sun, 23 Aug 2026 21:04:27 -0700 Subject: [PATCH] fix(desktop): keep companion effects passive Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho --- desktop/src/app/AppShell.helpers.test.mjs | 23 ++++++++++ desktop/src/app/AppShell.helpers.ts | 21 +++++++++ desktop/src/app/AppShell.tsx | 46 +++++++++---------- .../agents/lib/useAutoRestartPolicy.ts | 8 ++-- .../useManagedAgentRuntimeReconciliation.ts | 4 +- 5 files changed, 73 insertions(+), 29 deletions(-) diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index fc4a14f2cd..dcb0cd0234 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -3,11 +3,34 @@ import test from "node:test"; import { markAllReadSources, + mainOwnedEffects, activateDesktopNotificationTarget, createDesktopNotificationActivationQueue, shouldBounceForChannelNotification, } from "./AppShell.helpers.ts"; +const MAIN_OWNED_EFFECTS_ENABLED = { + agentRuntimeReconciliation: true, + autoRestart: true, + membershipNotifications: true, + presenceSession: true, + reminderNotifications: true, + markAsReadShortcuts: true, +}; + +test("primary window owns singleton and mutating app effects", () => { + assert.deepEqual(mainOwnedEffects(false), MAIN_OWNED_EFFECTS_ENABLED); +}); + +test("companion windows disable singleton and mutating app effects", () => { + assert.deepEqual( + mainOwnedEffects(true), + Object.fromEntries( + Object.keys(MAIN_OWNED_EFFECTS_ENABLED).map((effect) => [effect, false]), + ), + ); +}); + test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); }); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 9fc14736c7..0a0e87044d 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -2,6 +2,27 @@ import { isThreadReply } from "@/features/messages/lib/threading"; import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop"; import type { SearchHit } from "@/shared/api/types"; +export type MainOwnedEffects = { + agentRuntimeReconciliation: boolean; + autoRestart: boolean; + membershipNotifications: boolean; + presenceSession: boolean; + reminderNotifications: boolean; + markAsReadShortcuts: boolean; +}; + +export function mainOwnedEffects(isCompanionWindow: boolean): MainOwnedEffects { + const enabled = !isCompanionWindow; + return { + agentRuntimeReconciliation: enabled, + autoRestart: enabled, + membershipNotifications: enabled, + presenceSession: enabled, + reminderNotifications: enabled, + markAsReadShortcuts: enabled, + }; +} + export type AppView = | "home" | "channel" diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index c93fb7aaca..afe8b883bf 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,7 +1,11 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; -import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; +import { + deriveShellRoute, + mainOwnedEffects, + markAllReadSources, +} from "@/app/AppShell.helpers"; import { useTerminalContext } from "@/app/useTerminalContext"; import { AppShellProvider } from "@/app/AppShellContext"; import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays"; @@ -129,6 +133,7 @@ export function AppShell() { const companionWindowKind = currentCompanionWindowKind(); const isActivityWindow = companionWindowKind === "agent-activity"; const isCompanionWindow = companionWindowKind !== null; + const ownedEffects = mainOwnedEffects(isCompanionWindow); const hasCommunityRail = communitiesHook.communities.length > 1 && !isCompanionWindow; const addCommunityDialog = useAddCommunityDialogState(); @@ -145,7 +150,10 @@ export function AppShell() { const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); - useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot + useManagedAgentRuntimeReconciliation( + communitiesHook.communities, + ownedEffects.agentRuntimeReconciliation, + ); const { goAgents, goChannel, @@ -197,30 +205,13 @@ export function AppShell() { communitiesHook.activeCommunity?.relayUrl, ); useAgentsDataRefresh(); - // Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON). - useAutoRestartPolicy(); - // 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. + useAutoRestartPolicy(ownedEffects.autoRestart); useAgentObserverIngestion(); - // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not - // deferred): seeds kind 24200 for fresh identities, no-ops for explicit - // opt-outs. Frames before the listener opens are permanently lost. const observerReconciled = useObserverArchiveReconciliation( identityQuery.data?.pubkey, ); - // useArchiveSync must wait for reconciliation, or listeners could open - // before kind 24200 is guaranteed present in the subscription. useArchiveSync(observerReconciled); - // The archive batch now persists in Rust, so the agent-metrics invalidation - // signal arrives as a Tauri event rather than an in-process call. useArchiveAgentMetricsBridge(); - // Kind 44200 is relay-persisted (durable) and stays deferred: missed - // startup frames can be replayed, so there's no ordering constraint. const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useAgentMetricArchiveSeed(deferredPubkey); const profileQuery = useProfileQuery(); @@ -228,8 +219,13 @@ export function AppShell() { usePresenceSubscription(); useUserStatusSubscription(); useCommunityEmojiLiveUpdates(); - useMembershipNotifications(identityQuery.data?.pubkey); - const presenceSession = usePresenceSession(deferredPubkey); + const mainOwnedPubkey = ownedEffects.membershipNotifications + ? identityQuery.data?.pubkey + : undefined; + useMembershipNotifications(mainOwnedPubkey); + const presenceSession = usePresenceSession( + startupReady && ownedEffects.presenceSession ? mainOwnedPubkey : undefined, + ); const selfStatusQuery = useUserStatusQuery( deferredPubkey ? [deferredPubkey] : [], ); @@ -240,7 +236,7 @@ export function AppShell() { const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; useReminderNotifications( - identityQuery.data?.pubkey, + ownedEffects.reminderNotifications ? mainOwnedPubkey : undefined, notificationSettings.settings, channels, ); @@ -688,7 +684,9 @@ export function AppShell() { open: isCompanionWindow ? undefined : settingsOpen, }); useMarkAsReadShortcuts({ - activeChannelId: activeChannel?.id ?? null, + activeChannelId: ownedEffects.markAsReadShortcuts + ? (activeChannel?.id ?? null) + : null, activeChannelLastMessageAt: activeChannel?.lastMessageAt, markAllChannelsRead, markChannelRead, diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts index 652617dee3..0090e44dfb 100644 --- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts @@ -35,7 +35,7 @@ const POLICY_TICK_MS = 15_000; * on the backend store lock, so a cross-window double-fire is benign (and * further shrunk by the pre-fire summary re-fetch). */ -export function useAutoRestartPolicy() { +export function useAutoRestartPolicy(enabled = true) { const queryClient = useQueryClient(); const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data; const edgesRef = React.useRef(new Map()); @@ -46,17 +46,17 @@ export function useAutoRestartPolicy() { // Re-evaluate on an interval so the quiescence clock advances even when // summaries and observer stores are quiet. React.useEffect(() => { - if (!documentVisible) return; + if (!enabled || !documentVisible) return; setTick((t) => t + 1); const timer = setInterval(() => setTick((t) => t + 1), POLICY_TICK_MS); return () => clearInterval(timer); - }, [documentVisible]); + }, [documentVisible, enabled]); // No dependency array by design: the tick pattern re-runs this effect // every render so it reads live store state; all mutation is ref-local. React.useEffect(() => { - if (!agents) return; + if (!enabled || !agents) return; const now = Date.now(); const edges = edgesRef.current; diff --git a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts index f2fb2416b9..350d243e9d 100644 --- a/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts +++ b/desktop/src/features/agents/useManagedAgentRuntimeReconciliation.ts @@ -29,6 +29,7 @@ import { reconcileManagedAgentRuntimes } from "@/shared/api/tauriManagedAgents"; */ export function useManagedAgentRuntimeReconciliation( communities: readonly { relayUrl: string }[], + enabled = true, ): void { const queryClient = useQueryClient(); // Canonical relay URLs that have reconciled cleanly — never re-hit. @@ -42,6 +43,7 @@ export function useManagedAgentRuntimeReconciliation( ); React.useEffect(() => { + if (!enabled) return; let cancelled = false; const clearRetryTimer = () => { @@ -131,5 +133,5 @@ export function useManagedAgentRuntimeReconciliation( cancelled = true; clearRetryTimer(); }; - }, [communities, queryClient]); + }, [communities, enabled, queryClient]); }