mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user