Files
buzz/desktop/src/app/useTrayMenu.ts
klopez4212 d0a24bcb52 Add macOS agent menu-bar menu (#3565)
## Summary

- Add a **macOS-only** monochrome Buzz menu-bar icon with **Running**
and **Recent** agent sections.
- Show each agent as one selectable macOS row with `Name · elapsed` and
its channel underneath.
- Keep completed work available for quick channel re-entry, alongside
New Channel, Open Buzz, and Quit Buzz.
- Keep the main window alive on close and restore it from both the menu
bar and the macOS Dock.
- Fence queued channel actions by community generation so an in-flight
action from the previous community cannot navigate the newly selected
community.
- Leave Windows and Linux unchanged; cross-platform tray lifecycle
support can follow with platform-specific validation.

<img width="812" height="760" alt="Buzz macOS agent menu"
src="https://github.com/user-attachments/assets/19bfd874-8f06-4496-bdda-7ed2e7b5733f"
/>

## Validation

- `cargo fmt --check`
- Desktop Tauri suite: 1,860 passed, 14 ignored after merging current
`main`
- Desktop tests: 3,769 passed
- Desktop lint, file-size, text, and pubkey checks pass (two
pre-existing informational template-literal notices)
- Regression coverage verifies stale `OpenChannel` actions are discarded
across community changes while `NewChannel` survives

## Manual validation remaining

A native macOS smoke test is still requested before merge: menu
appearance, elapsed updates, Running → Recent, channel navigation, New
Channel, minimized/closed/Dock restore, Open Buzz, and Quit. E2E stubs
the tray IPC and does not exercise the native menu.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-07-30 07:51:25 -07:00

161 lines
4.6 KiB
TypeScript

import * as React from "react";
import { isTauri, invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import {
getActiveTurnsForAgent,
useActiveAgentTurnsByChannel,
} from "@/features/agents/activeAgentTurnsStore";
import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
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";
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;
/**
* Keeps Buzz's native tray menu synchronized with active agent turns and
* forwards its navigation actions into the React app.
*/
export function useTrayMenu({
channels,
goChannel,
openCreateChannel,
}: {
channels: Channel[];
goChannel: (channelId: string) => Promise<unknown>;
openCreateChannel: () => void;
}): void {
const activeTurns = useActiveAgentTurnsByChannel();
const now = useNow(1000);
const managedAgents = useManagedAgentsQuery().data;
const relayAgents = useRelayAgentsQuery().data;
const previousActivitiesRef = React.useRef(
new Map<string, TrayAgentActivity>(),
);
const [recentActivities, setRecentActivities] = React.useState<
TrayAgentActivity[]
>([]);
const activities = React.useMemo<TrayAgentActivity[]>(() => {
const channelNames = new Map(
channels.map((channel) => [channel.id, channel.name]),
);
const agentNames = new Map<string, string>();
for (const agent of [...(managedAgents ?? []), ...(relayAgents ?? [])]) {
agentNames.set(normalizePubkey(agent.pubkey), agent.name);
}
return activeTurns.flatMap((channelTurn) =>
channelTurn.agentPubkeys.map((pubkey) => {
const agentTurn = getActiveTurnsForAgent(pubkey).find(
(turn) => turn.channelId === channelTurn.channelId,
);
return {
activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`,
agentName:
agentNames.get(normalizePubkey(pubkey)) ??
`Agent ${truncatePubkey(pubkey)}`,
channelId: channelTurn.channelId,
channelName:
channelNames.get(channelTurn.channelId) ?? "Unknown channel",
elapsed: formatElapsed(
now - (agentTurn?.anchorAt ?? channelTurn.anchorAt),
),
};
}),
);
}, [activeTurns, channels, managedAgents, now, relayAgents]);
React.useEffect(() => {
const currentActivities = new Map(
activities.map((activity) => [activity.activityId, activity]),
);
const completedActivities = [...previousActivitiesRef.current.entries()]
.filter(([activityId]) => !currentActivities.has(activityId))
.map(([, activity]) => ({
...activity,
activityId: `recent:${activity.activityId}:${Date.now()}`,
}));
if (completedActivities.length > 0) {
setRecentActivities((current) =>
[...completedActivities, ...current].slice(
0,
MAX_RECENT_TRAY_ACTIVITIES,
),
);
}
previousActivitiesRef.current = currentActivities;
}, [activities]);
React.useEffect(() => {
if (!isTauri()) return;
void invoke("update_tray_agent_activity", {
activities,
recentActivities,
}).catch((error) => {
console.error("Failed to update the macOS tray menu", error);
});
}, [activities, recentActivities]);
React.useEffect(() => {
if (!isTauri()) return;
let disposed = false;
let unlisten: (() => void) | undefined;
const handlePendingActions = async () => {
if (disposed) return;
const actions = await invoke<TrayAction[]>("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]);
}