mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
## 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>
161 lines
4.6 KiB
TypeScript
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]);
|
|
}
|