From 50edc8fe32a749efe1047c1a611e78ac5d33e10a Mon Sep 17 00:00:00 2001 From: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 11:09:12 -0700 Subject: [PATCH 1/4] fix(desktop): restore tray session navigation Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau --- desktop/src-tauri/src/tray_menu.rs | 6 +- desktop/src/app/trayActionConsumer.test.mjs | 129 ++++++++++++++++++++ desktop/src/app/trayActionConsumer.ts | 74 +++++++++++ desktop/src/app/useTrayMenu.ts | 54 +++----- 4 files changed, 225 insertions(+), 38 deletions(-) create mode 100644 desktop/src/app/trayActionConsumer.test.mjs create mode 100644 desktop/src/app/trayActionConsumer.ts diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 6dcef0ecf7..44f67d9cdf 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -235,6 +235,8 @@ pub(crate) fn show_main_window(app: &AppHandle) { } } +/// Queues an action before the main window is restored. The frontend drains on +/// focus as well as the emitted event, so hidden WebKit cannot strand it. fn queue_tray_action(app: &AppHandle, mut action: TrayAction) { let state = app.state::>(); let Ok(mut queue) = state.action_queue.lock() else { @@ -446,15 +448,14 @@ fn handle_menu_event(app: &AppHandle, id: &str) { match id { OPEN_BUZZ_ID => show_main_window(app), NEW_CHANNEL_ID => { - show_main_window(app); queue_tray_action(app, TrayAction::NewChannel); + show_main_window(app); } QUIT_ID => app.exit(0), _ => { let Some(channel_id) = id.strip_prefix(OPEN_CHANNEL_PREFIX) else { return; }; - show_main_window(app); let channel_id = channel_id .split_once(OPEN_CHANNEL_ACTIVITY_SEPARATOR) .map(|(channel_id, _)| channel_id) @@ -466,6 +467,7 @@ fn handle_menu_event(app: &AppHandle, id: &str) { community_generation: 0, }, ); + show_main_window(app); } } } diff --git a/desktop/src/app/trayActionConsumer.test.mjs b/desktop/src/app/trayActionConsumer.test.mjs new file mode 100644 index 0000000000..4d1f35a01e --- /dev/null +++ b/desktop/src/app/trayActionConsumer.test.mjs @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { subscribeToTrayActions } from "./trayActionConsumer.ts"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function createHarness(initialActions = []) { + const queue = [...initialActions]; + const handled = []; + const requeued = []; + const availableListeners = new Set(); + const focusListeners = new Set(); + let takePendingActions = async () => queue.splice(0); + + const dispose = subscribeToTrayActions({ + addFocusListener(listener) { + focusListeners.add(listener); + return () => focusListeners.delete(listener); + }, + async listenForAvailable(listener) { + availableListeners.add(listener); + return () => availableListeners.delete(listener); + }, + takePendingActions: () => takePendingActions(), + async requeueActions(actions) { + requeued.push(...actions); + queue.unshift(...actions); + }, + handleAction(action) { + handled.push(action); + }, + }); + + return { + dispose, + available: () => { + for (const listener of availableListeners) listener(); + }, + focus: () => { + for (const listener of focusListeners) listener(); + }, + handled, + queue, + requeued, + setTakePendingActions: (take) => { + takePendingActions = take; + }, + }; +} + +test("drains an action queued before the consumer mounts", async () => { + const action = { kind: "openChannel", channelId: "channel-before-mount" }; + const harness = createHarness([action]); + + await tick(); + + assert.deepEqual(harness.handled, [action]); + harness.dispose(); +}); + +test("drains an action whose native event was lost while the webview was hidden", async () => { + const harness = createHarness(); + await tick(); + + const action = { kind: "openChannel", channelId: "channel-while-hidden" }; + harness.queue.push(action); + harness.focus(); + await tick(); + + assert.deepEqual(harness.handled, [action]); + harness.dispose(); +}); + +test("serializes overlapping wake-ups without stranding queued actions", async () => { + const firstAction = { kind: "openChannel", channelId: "first-channel" }; + const secondAction = { kind: "openChannel", channelId: "second-channel" }; + const harness = createHarness([firstAction]); + let releaseFirstTake; + let markFirstTakeStarted; + let takeCount = 0; + const firstTakeStarted = new Promise((resolve) => { + markFirstTakeStarted = resolve; + }); + const firstTakeBlocked = new Promise((resolve) => { + releaseFirstTake = resolve; + }); + harness.setTakePendingActions(async () => { + takeCount += 1; + const actions = harness.queue.splice(0); + if (takeCount === 1) { + markFirstTakeStarted(); + await firstTakeBlocked; + } + return actions; + }); + + await firstTakeStarted; + harness.queue.push(secondAction); + harness.available(); + harness.focus(); + releaseFirstTake(); + await tick(); + + assert.deepEqual(harness.handled, [firstAction, secondAction]); + assert.equal(takeCount, 2); + harness.dispose(); +}); + +test("requeues actions drained while the consumer unmounts", async () => { + const action = { kind: "openChannel", channelId: "channel-during-unmount" }; + const harness = createHarness(); + let releaseTake; + const pendingTake = new Promise((resolve) => { + releaseTake = resolve; + }); + harness.setTakePendingActions(async () => { + await pendingTake; + return [action]; + }); + + await tick(); + harness.dispose(); + releaseTake(); + await tick(); + + assert.deepEqual(harness.handled, []); + assert.deepEqual(harness.requeued, [action]); +}); diff --git a/desktop/src/app/trayActionConsumer.ts b/desktop/src/app/trayActionConsumer.ts new file mode 100644 index 0000000000..5245de8870 --- /dev/null +++ b/desktop/src/app/trayActionConsumer.ts @@ -0,0 +1,74 @@ +export type TrayAction = + | { kind: "newChannel" } + | { kind: "openChannel"; channelId: string }; + +type TrayActionConsumerOptions = { + addFocusListener: (listener: () => void) => () => void; + listenForAvailable: (listener: () => void) => Promise<() => void>; + takePendingActions: () => Promise; + requeueActions: (actions: TrayAction[]) => Promise; + handleAction: (action: TrayAction) => void; +}; + +/** Subscribes to native tray actions and drains anything queued before mount. */ +export function subscribeToTrayActions({ + addFocusListener, + listenForAvailable, + takePendingActions, + requeueActions, + handleAction, +}: TrayActionConsumerOptions): () => void { + let disposed = false; + let drainAgain = false; + let drainInFlight = false; + let unlisten: (() => void) | undefined; + + const handlePendingActions = async () => { + if (disposed) return; + if (drainInFlight) { + drainAgain = true; + return; + } + + drainInFlight = true; + try { + do { + drainAgain = false; + const actions = await takePendingActions(); + if (disposed) { + if (actions.length > 0) { + await requeueActions(actions); + } + return; + } + for (const action of actions) { + handleAction(action); + } + } while (drainAgain); + } finally { + drainInFlight = false; + } + }; + + const removeFocusListener = addFocusListener(() => { + void handlePendingActions(); + }); + + void (async () => { + const nextUnlisten = await listenForAvailable(() => { + void handlePendingActions(); + }); + if (disposed) { + nextUnlisten(); + return; + } + unlisten = nextUnlisten; + await handlePendingActions(); + })(); + + return () => { + disposed = true; + removeFocusListener(); + unlisten?.(); + }; +} diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 355c8e5d4f..8d15aa1649 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -14,6 +14,10 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; +import { + subscribeToTrayActions, + type TrayAction, +} from "@/app/trayActionConsumer"; type TrayAgentActivity = { activityId: string; @@ -23,10 +27,6 @@ type TrayAgentActivity = { elapsed: string; }; -type TrayAction = - | { kind: "newChannel" } - | { kind: "openChannel"; channelId: string }; - const MAX_RECENT_TRAY_ACTIVITIES = 5; /** @@ -119,42 +119,24 @@ export function useTrayMenu({ React.useEffect(() => { if (!isTauri()) return; - let disposed = false; - let unlisten: (() => void) | undefined; - - const handlePendingActions = async () => { - if (disposed) return; - const actions = await invoke("take_tray_actions"); - if (disposed) { - if (actions.length > 0) { - await invoke("requeue_tray_actions", { actions }); - } - return; - } - for (const action of actions) { + return subscribeToTrayActions({ + addFocusListener: (listener) => { + // Native queues before restoring the window, so focus is the durable + // wake-up if WebKit dropped the earlier event while Buzz was hidden. + window.addEventListener("focus", listener); + return () => window.removeEventListener("focus", listener); + }, + listenForAvailable: (listener) => + listen("tray-action-available", listener), + takePendingActions: () => invoke("take_tray_actions"), + requeueActions: (actions) => invoke("requeue_tray_actions", { actions }), + handleAction: (action) => { if (action.kind === "newChannel") { openCreateChannel(); } else { void goChannel(action.channelId); } - } - }; - - void (async () => { - const nextUnlisten = await listen("tray-action-available", () => { - void handlePendingActions(); - }); - if (disposed) { - nextUnlisten(); - return; - } - unlisten = nextUnlisten; - await handlePendingActions(); - })(); - - return () => { - disposed = true; - unlisten?.(); - }; + }, + }); }, [goChannel, openCreateChannel]); } From 7fce11257b1933d7f949fe54f3b482ac4b7c196d Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 13:50:22 -0700 Subject: [PATCH 2/4] fix(desktop): hide stale tray session destinations Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src/app/trayActivities.test.mjs | 29 ++++++++++++++++++++++++ desktop/src/app/trayActivities.ts | 15 +++++++++++++ desktop/src/app/useTrayMenu.ts | 30 +++++++++++++++---------- 3 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 desktop/src/app/trayActivities.test.mjs create mode 100644 desktop/src/app/trayActivities.ts diff --git a/desktop/src/app/trayActivities.test.mjs b/desktop/src/app/trayActivities.test.mjs new file mode 100644 index 0000000000..c78e2966ae --- /dev/null +++ b/desktop/src/app/trayActivities.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { keepOpenableTrayActivities } from "./trayActivities.ts"; + +const activity = (activityId, channelId) => ({ + activityId, + agentName: "Agent", + channelId, + channelName: "channel", + elapsed: "1m", +}); + +test("removes tray activities whose channel is absent", () => { + const openable = activity("openable", "channel-present"); + const stale = activity("stale", "channel-missing"); + + assert.deepEqual( + keepOpenableTrayActivities([openable, stale], new Set(["channel-present"])), + [openable], + ); +}); + +test("does not publish activities before channels are available", () => { + assert.deepEqual( + keepOpenableTrayActivities([activity("pending", "channel")], new Set()), + [], + ); +}); diff --git a/desktop/src/app/trayActivities.ts b/desktop/src/app/trayActivities.ts new file mode 100644 index 0000000000..b5a2544023 --- /dev/null +++ b/desktop/src/app/trayActivities.ts @@ -0,0 +1,15 @@ +export type TrayAgentActivity = { + activityId: string; + agentName: string; + channelId: string; + channelName: string; + elapsed: string; +}; + +/** Removes tray rows whose destination is absent from the active community. */ +export function keepOpenableTrayActivities( + activities: readonly TrayAgentActivity[], + channelIds: ReadonlySet, +): TrayAgentActivity[] { + return activities.filter((activity) => channelIds.has(activity.channelId)); +} diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 8d15aa1649..083253312a 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -14,19 +14,15 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; +import { + keepOpenableTrayActivities, + type TrayAgentActivity, +} from "@/app/trayActivities"; import { subscribeToTrayActions, type TrayAction, } from "@/app/trayActionConsumer"; -type TrayAgentActivity = { - activityId: string; - agentName: string; - channelId: string; - channelName: string; - elapsed: string; -}; - const MAX_RECENT_TRAY_ACTIVITIES = 5; /** @@ -53,6 +49,10 @@ export function useTrayMenu({ TrayAgentActivity[] >([]); + const channelIds = React.useMemo( + () => new Set(channels.map((channel) => channel.id)), + [channels], + ); const activities = React.useMemo(() => { const channelNames = new Map( channels.map((channel) => [channel.id, channel.name]), @@ -62,7 +62,7 @@ export function useTrayMenu({ agentNames.set(normalizePubkey(agent.pubkey), agent.name); } - return activeTurns.flatMap((channelTurn) => + const currentActivities = activeTurns.flatMap((channelTurn) => channelTurn.agentPubkeys.map((pubkey) => { const agentTurn = getActiveTurnsForAgent(pubkey).find( (turn) => turn.channelId === channelTurn.channelId, @@ -82,7 +82,8 @@ export function useTrayMenu({ }; }), ); - }, [activeTurns, channels, managedAgents, now, relayAgents]); + return keepOpenableTrayActivities(currentActivities, channelIds); + }, [activeTurns, channelIds, channels, managedAgents, now, relayAgents]); React.useEffect(() => { const currentActivities = new Map( @@ -106,15 +107,20 @@ export function useTrayMenu({ previousActivitiesRef.current = currentActivities; }, [activities]); + const openableRecentActivities = React.useMemo( + () => keepOpenableTrayActivities(recentActivities, channelIds), + [channelIds, recentActivities], + ); + React.useEffect(() => { if (!isTauri()) return; void invoke("update_tray_agent_activity", { activities, - recentActivities, + recentActivities: openableRecentActivities, }).catch((error) => { console.error("Failed to update the macOS tray menu", error); }); - }, [activities, recentActivities]); + }, [activities, openableRecentActivities]); React.useEffect(() => { if (!isTauri()) return; From b27428e4702b714941729a13ea8da0e306ca3fde Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:22:38 -0700 Subject: [PATCH 3/4] revert: remove unsupported tray navigation fixes Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/tray_menu.rs | 6 +- desktop/src/app/trayActionConsumer.test.mjs | 129 -------------------- desktop/src/app/trayActionConsumer.ts | 74 ----------- desktop/src/app/trayActivities.test.mjs | 29 ----- desktop/src/app/trayActivities.ts | 15 --- desktop/src/app/useTrayMenu.ts | 84 +++++++------ 6 files changed, 50 insertions(+), 287 deletions(-) delete mode 100644 desktop/src/app/trayActionConsumer.test.mjs delete mode 100644 desktop/src/app/trayActionConsumer.ts delete mode 100644 desktop/src/app/trayActivities.test.mjs delete mode 100644 desktop/src/app/trayActivities.ts diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 44f67d9cdf..6dcef0ecf7 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -235,8 +235,6 @@ pub(crate) fn show_main_window(app: &AppHandle) { } } -/// Queues an action before the main window is restored. The frontend drains on -/// focus as well as the emitted event, so hidden WebKit cannot strand it. fn queue_tray_action(app: &AppHandle, mut action: TrayAction) { let state = app.state::>(); let Ok(mut queue) = state.action_queue.lock() else { @@ -448,14 +446,15 @@ fn handle_menu_event(app: &AppHandle, id: &str) { match id { OPEN_BUZZ_ID => show_main_window(app), NEW_CHANNEL_ID => { - queue_tray_action(app, TrayAction::NewChannel); show_main_window(app); + queue_tray_action(app, TrayAction::NewChannel); } QUIT_ID => app.exit(0), _ => { let Some(channel_id) = id.strip_prefix(OPEN_CHANNEL_PREFIX) else { return; }; + show_main_window(app); let channel_id = channel_id .split_once(OPEN_CHANNEL_ACTIVITY_SEPARATOR) .map(|(channel_id, _)| channel_id) @@ -467,7 +466,6 @@ fn handle_menu_event(app: &AppHandle, id: &str) { community_generation: 0, }, ); - show_main_window(app); } } } diff --git a/desktop/src/app/trayActionConsumer.test.mjs b/desktop/src/app/trayActionConsumer.test.mjs deleted file mode 100644 index 4d1f35a01e..0000000000 --- a/desktop/src/app/trayActionConsumer.test.mjs +++ /dev/null @@ -1,129 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { subscribeToTrayActions } from "./trayActionConsumer.ts"; - -const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); - -function createHarness(initialActions = []) { - const queue = [...initialActions]; - const handled = []; - const requeued = []; - const availableListeners = new Set(); - const focusListeners = new Set(); - let takePendingActions = async () => queue.splice(0); - - const dispose = subscribeToTrayActions({ - addFocusListener(listener) { - focusListeners.add(listener); - return () => focusListeners.delete(listener); - }, - async listenForAvailable(listener) { - availableListeners.add(listener); - return () => availableListeners.delete(listener); - }, - takePendingActions: () => takePendingActions(), - async requeueActions(actions) { - requeued.push(...actions); - queue.unshift(...actions); - }, - handleAction(action) { - handled.push(action); - }, - }); - - return { - dispose, - available: () => { - for (const listener of availableListeners) listener(); - }, - focus: () => { - for (const listener of focusListeners) listener(); - }, - handled, - queue, - requeued, - setTakePendingActions: (take) => { - takePendingActions = take; - }, - }; -} - -test("drains an action queued before the consumer mounts", async () => { - const action = { kind: "openChannel", channelId: "channel-before-mount" }; - const harness = createHarness([action]); - - await tick(); - - assert.deepEqual(harness.handled, [action]); - harness.dispose(); -}); - -test("drains an action whose native event was lost while the webview was hidden", async () => { - const harness = createHarness(); - await tick(); - - const action = { kind: "openChannel", channelId: "channel-while-hidden" }; - harness.queue.push(action); - harness.focus(); - await tick(); - - assert.deepEqual(harness.handled, [action]); - harness.dispose(); -}); - -test("serializes overlapping wake-ups without stranding queued actions", async () => { - const firstAction = { kind: "openChannel", channelId: "first-channel" }; - const secondAction = { kind: "openChannel", channelId: "second-channel" }; - const harness = createHarness([firstAction]); - let releaseFirstTake; - let markFirstTakeStarted; - let takeCount = 0; - const firstTakeStarted = new Promise((resolve) => { - markFirstTakeStarted = resolve; - }); - const firstTakeBlocked = new Promise((resolve) => { - releaseFirstTake = resolve; - }); - harness.setTakePendingActions(async () => { - takeCount += 1; - const actions = harness.queue.splice(0); - if (takeCount === 1) { - markFirstTakeStarted(); - await firstTakeBlocked; - } - return actions; - }); - - await firstTakeStarted; - harness.queue.push(secondAction); - harness.available(); - harness.focus(); - releaseFirstTake(); - await tick(); - - assert.deepEqual(harness.handled, [firstAction, secondAction]); - assert.equal(takeCount, 2); - harness.dispose(); -}); - -test("requeues actions drained while the consumer unmounts", async () => { - const action = { kind: "openChannel", channelId: "channel-during-unmount" }; - const harness = createHarness(); - let releaseTake; - const pendingTake = new Promise((resolve) => { - releaseTake = resolve; - }); - harness.setTakePendingActions(async () => { - await pendingTake; - return [action]; - }); - - await tick(); - harness.dispose(); - releaseTake(); - await tick(); - - assert.deepEqual(harness.handled, []); - assert.deepEqual(harness.requeued, [action]); -}); diff --git a/desktop/src/app/trayActionConsumer.ts b/desktop/src/app/trayActionConsumer.ts deleted file mode 100644 index 5245de8870..0000000000 --- a/desktop/src/app/trayActionConsumer.ts +++ /dev/null @@ -1,74 +0,0 @@ -export type TrayAction = - | { kind: "newChannel" } - | { kind: "openChannel"; channelId: string }; - -type TrayActionConsumerOptions = { - addFocusListener: (listener: () => void) => () => void; - listenForAvailable: (listener: () => void) => Promise<() => void>; - takePendingActions: () => Promise; - requeueActions: (actions: TrayAction[]) => Promise; - handleAction: (action: TrayAction) => void; -}; - -/** Subscribes to native tray actions and drains anything queued before mount. */ -export function subscribeToTrayActions({ - addFocusListener, - listenForAvailable, - takePendingActions, - requeueActions, - handleAction, -}: TrayActionConsumerOptions): () => void { - let disposed = false; - let drainAgain = false; - let drainInFlight = false; - let unlisten: (() => void) | undefined; - - const handlePendingActions = async () => { - if (disposed) return; - if (drainInFlight) { - drainAgain = true; - return; - } - - drainInFlight = true; - try { - do { - drainAgain = false; - const actions = await takePendingActions(); - if (disposed) { - if (actions.length > 0) { - await requeueActions(actions); - } - return; - } - for (const action of actions) { - handleAction(action); - } - } while (drainAgain); - } finally { - drainInFlight = false; - } - }; - - const removeFocusListener = addFocusListener(() => { - void handlePendingActions(); - }); - - void (async () => { - const nextUnlisten = await listenForAvailable(() => { - void handlePendingActions(); - }); - if (disposed) { - nextUnlisten(); - return; - } - unlisten = nextUnlisten; - await handlePendingActions(); - })(); - - return () => { - disposed = true; - removeFocusListener(); - unlisten?.(); - }; -} diff --git a/desktop/src/app/trayActivities.test.mjs b/desktop/src/app/trayActivities.test.mjs deleted file mode 100644 index c78e2966ae..0000000000 --- a/desktop/src/app/trayActivities.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { keepOpenableTrayActivities } from "./trayActivities.ts"; - -const activity = (activityId, channelId) => ({ - activityId, - agentName: "Agent", - channelId, - channelName: "channel", - elapsed: "1m", -}); - -test("removes tray activities whose channel is absent", () => { - const openable = activity("openable", "channel-present"); - const stale = activity("stale", "channel-missing"); - - assert.deepEqual( - keepOpenableTrayActivities([openable, stale], new Set(["channel-present"])), - [openable], - ); -}); - -test("does not publish activities before channels are available", () => { - assert.deepEqual( - keepOpenableTrayActivities([activity("pending", "channel")], new Set()), - [], - ); -}); diff --git a/desktop/src/app/trayActivities.ts b/desktop/src/app/trayActivities.ts deleted file mode 100644 index b5a2544023..0000000000 --- a/desktop/src/app/trayActivities.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type TrayAgentActivity = { - activityId: string; - agentName: string; - channelId: string; - channelName: string; - elapsed: string; -}; - -/** Removes tray rows whose destination is absent from the active community. */ -export function keepOpenableTrayActivities( - activities: readonly TrayAgentActivity[], - channelIds: ReadonlySet, -): TrayAgentActivity[] { - return activities.filter((activity) => channelIds.has(activity.channelId)); -} diff --git a/desktop/src/app/useTrayMenu.ts b/desktop/src/app/useTrayMenu.ts index 083253312a..355c8e5d4f 100644 --- a/desktop/src/app/useTrayMenu.ts +++ b/desktop/src/app/useTrayMenu.ts @@ -14,14 +14,18 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import type { Channel } from "@/shared/api/types"; -import { - keepOpenableTrayActivities, - type TrayAgentActivity, -} from "@/app/trayActivities"; -import { - subscribeToTrayActions, - type TrayAction, -} from "@/app/trayActionConsumer"; + +type TrayAgentActivity = { + activityId: string; + agentName: string; + channelId: string; + channelName: string; + elapsed: string; +}; + +type TrayAction = + | { kind: "newChannel" } + | { kind: "openChannel"; channelId: string }; const MAX_RECENT_TRAY_ACTIVITIES = 5; @@ -49,10 +53,6 @@ export function useTrayMenu({ TrayAgentActivity[] >([]); - const channelIds = React.useMemo( - () => new Set(channels.map((channel) => channel.id)), - [channels], - ); const activities = React.useMemo(() => { const channelNames = new Map( channels.map((channel) => [channel.id, channel.name]), @@ -62,7 +62,7 @@ export function useTrayMenu({ agentNames.set(normalizePubkey(agent.pubkey), agent.name); } - const currentActivities = activeTurns.flatMap((channelTurn) => + return activeTurns.flatMap((channelTurn) => channelTurn.agentPubkeys.map((pubkey) => { const agentTurn = getActiveTurnsForAgent(pubkey).find( (turn) => turn.channelId === channelTurn.channelId, @@ -82,8 +82,7 @@ export function useTrayMenu({ }; }), ); - return keepOpenableTrayActivities(currentActivities, channelIds); - }, [activeTurns, channelIds, channels, managedAgents, now, relayAgents]); + }, [activeTurns, channels, managedAgents, now, relayAgents]); React.useEffect(() => { const currentActivities = new Map( @@ -107,42 +106,55 @@ export function useTrayMenu({ previousActivitiesRef.current = currentActivities; }, [activities]); - const openableRecentActivities = React.useMemo( - () => keepOpenableTrayActivities(recentActivities, channelIds), - [channelIds, recentActivities], - ); - React.useEffect(() => { if (!isTauri()) return; void invoke("update_tray_agent_activity", { activities, - recentActivities: openableRecentActivities, + recentActivities, }).catch((error) => { console.error("Failed to update the macOS tray menu", error); }); - }, [activities, openableRecentActivities]); + }, [activities, recentActivities]); React.useEffect(() => { if (!isTauri()) return; - return subscribeToTrayActions({ - addFocusListener: (listener) => { - // Native queues before restoring the window, so focus is the durable - // wake-up if WebKit dropped the earlier event while Buzz was hidden. - window.addEventListener("focus", listener); - return () => window.removeEventListener("focus", listener); - }, - listenForAvailable: (listener) => - listen("tray-action-available", listener), - takePendingActions: () => invoke("take_tray_actions"), - requeueActions: (actions) => invoke("requeue_tray_actions", { actions }), - handleAction: (action) => { + let disposed = false; + let unlisten: (() => void) | undefined; + + const handlePendingActions = async () => { + if (disposed) return; + const actions = await invoke("take_tray_actions"); + if (disposed) { + if (actions.length > 0) { + await invoke("requeue_tray_actions", { actions }); + } + return; + } + for (const action of actions) { if (action.kind === "newChannel") { openCreateChannel(); } else { void goChannel(action.channelId); } - }, - }); + } + }; + + void (async () => { + const nextUnlisten = await listen("tray-action-available", () => { + void handlePendingActions(); + }); + if (disposed) { + nextUnlisten(); + return; + } + unlisten = nextUnlisten; + await handlePendingActions(); + })(); + + return () => { + disposed = true; + unlisten?.(); + }; }, [goChannel, openCreateChannel]); } From 32c516a85cf40fafef773c2ad4b1a2d13dfb997d Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 14:22:41 -0700 Subject: [PATCH 4/4] fix(desktop): serialize tray channel actions for frontend Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/tray_menu.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index 6dcef0ecf7..3f9fe49fe0 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -213,7 +213,9 @@ struct TrayMenuState { pub enum TrayAction { NewChannel, OpenChannel { + #[serde(rename = "channelId")] channel_id: String, + #[serde(rename = "communityGeneration")] community_generation: u64, }, } @@ -614,6 +616,23 @@ pub fn update_tray_agent_activity( mod tests { use super::{requeue_actions, TrayAction, TrayActionQueue}; + #[test] + fn open_channel_action_serializes_with_frontend_field_names() { + let action = TrayAction::OpenChannel { + channel_id: "channel-123".into(), + community_generation: 7, + }; + + assert_eq!( + serde_json::to_value(action).expect("tray action should serialize"), + serde_json::json!({ + "kind": "openChannel", + "channelId": "channel-123", + "communityGeneration": 7, + }) + ); + } + #[test] fn stale_channel_actions_are_not_requeued_after_community_change() { let mut queue = TrayActionQueue {