fix(desktop): keep companion effects passive

Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-23 21:04:27 -07:00
committed by Rizz
parent aa781c6d12
commit f156835e83
5 changed files with 73 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

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