Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions desktop/src/app/useTrayMenu.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test from "node:test";

import { resolveTrayActivities, resolveTrayAgentName } from "./useTrayMenu.ts";

const REMOTE_AGENT_PUBKEY = "1".repeat(64);

test("resolveTrayAgentName uses a hydrated remote-agent profile", () => {
assert.equal(
resolveTrayAgentName({
knownAgentName: undefined,
profile: {
avatarUrl: null,
displayName: "Hermes",
isAgent: true,
nip05Handle: null,
ownerPubkey: "2".repeat(64),
},
pubkey: REMOTE_AGENT_PUBKEY,
}),
"Hermes",
);
});

test("resolveTrayActivities replaces a completed activity fallback after profile hydration", () => {
const activities = resolveTrayActivities({
activities: [
{
activityId: `recent:channel:${REMOTE_AGENT_PUBKEY}:1`,
agentName: "Agent 111111…111111",
agentPubkey: REMOTE_AGENT_PUBKEY,
channelId: "channel",
channelName: "hermes-acceptance",
elapsed: "1s",
},
],
knownAgentNames: new Map(),
profiles: {
[REMOTE_AGENT_PUBKEY]: {
avatarUrl: null,
displayName: "Hermes",
isAgent: true,
nip05Handle: null,
ownerPubkey: "2".repeat(64),
},
},
});

assert.equal(activities[0].agentName, "Hermes");
assert.equal("agentPubkey" in activities[0], false);
});
102 changes: 88 additions & 14 deletions desktop/src/app/useTrayMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
useManagedAgentsQuery,
useRelayAgentsQuery,
} from "@/features/agents/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { UserProfileSummary } from "@/shared/api/types";
import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey";
import { useNow } from "@/shared/lib/useNow";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
Expand All @@ -23,12 +26,52 @@ type TrayAgentActivity = {
elapsed: string;
};

type TrayAgentActivityState = TrayAgentActivity & {
agentPubkey: string;
};

type TrayAction =
| { kind: "newChannel" }
| { kind: "openChannel"; channelId: string };

const MAX_RECENT_TRAY_ACTIVITIES = 5;

export function resolveTrayAgentName({
knownAgentName,
profile,
pubkey,
}: {
knownAgentName?: string;
profile?: Pick<UserProfileSummary, "displayName" | "name">;
pubkey: string;
}): string {
return (
profile?.displayName?.trim() ||
profile?.name?.trim() ||
knownAgentName?.trim() ||
`Agent ${truncatePubkey(pubkey)}`
);
}

export function resolveTrayActivities({
activities,
knownAgentNames,
profiles,
}: {
activities: TrayAgentActivityState[];
knownAgentNames: Map<string, string>;
profiles?: UserProfileLookup;
}): TrayAgentActivity[] {
return activities.map(({ agentPubkey, ...activity }) => ({
...activity,
agentName: resolveTrayAgentName({
knownAgentName: knownAgentNames.get(normalizePubkey(agentPubkey)),
profile: profiles?.[normalizePubkey(agentPubkey)],
pubkey: agentPubkey,
}),
}));
}

/**
* Keeps Buzz's native tray menu synchronized with active agent turns and
* forwards its navigation actions into the React app.
Expand All @@ -47,20 +90,40 @@ export function useTrayMenu({
const managedAgents = useManagedAgentsQuery().data;
const relayAgents = useRelayAgentsQuery().data;
const previousActivitiesRef = React.useRef(
new Map<string, TrayAgentActivity>(),
new Map<string, TrayAgentActivityState>(),
);
const [recentActivities, setRecentActivities] = React.useState<
TrayAgentActivity[]
TrayAgentActivityState[]
>([]);
const activityAgentPubkeys = React.useMemo(
() => [
...new Set(
[
...activeTurns.flatMap((turn) => turn.agentPubkeys),
...recentActivities.map((activity) => activity.agentPubkey),
].map((pubkey) => normalizePubkey(pubkey)),
),
],
[activeTurns, recentActivities],
);
const profiles = useUsersBatchQuery(activityAgentPubkeys, {
enabled: activityAgentPubkeys.length > 0,
}).data?.profiles;
const knownAgentNames = React.useMemo(
() =>
new Map(
[...(managedAgents ?? []), ...(relayAgents ?? [])].map((agent) => [
normalizePubkey(agent.pubkey),
agent.name,
]),
),
[managedAgents, relayAgents],
);

const activities = React.useMemo<TrayAgentActivity[]>(() => {
const activities = React.useMemo<TrayAgentActivityState[]>(() => {
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) => {
Expand All @@ -70,9 +133,12 @@ export function useTrayMenu({

return {
activityId: `${channelTurn.channelId}:${normalizePubkey(pubkey)}`,
agentName:
agentNames.get(normalizePubkey(pubkey)) ??
`Agent ${truncatePubkey(pubkey)}`,
agentPubkey: pubkey,
agentName: resolveTrayAgentName({
knownAgentName: knownAgentNames.get(normalizePubkey(pubkey)),
profile: profiles?.[normalizePubkey(pubkey)],
pubkey,
}),
channelId: channelTurn.channelId,
channelName:
channelNames.get(channelTurn.channelId) ?? "Unknown channel",
Expand All @@ -82,7 +148,7 @@ export function useTrayMenu({
};
}),
);
}, [activeTurns, channels, managedAgents, now, relayAgents]);
}, [activeTurns, channels, knownAgentNames, now, profiles]);

React.useEffect(() => {
const currentActivities = new Map(
Expand All @@ -109,12 +175,20 @@ export function useTrayMenu({
React.useEffect(() => {
if (!isTauri()) return;
void invoke("update_tray_agent_activity", {
activities,
recentActivities,
activities: resolveTrayActivities({
activities,
knownAgentNames,
profiles,
}),
recentActivities: resolveTrayActivities({
activities: recentActivities,
knownAgentNames,
profiles,
}),
}).catch((error) => {
console.error("Failed to update the macOS tray menu", error);
});
}, [activities, recentActivities]);
}, [activities, knownAgentNames, profiles, recentActivities]);

React.useEffect(() => {
if (!isTauri()) return;
Expand Down
140 changes: 139 additions & 1 deletion desktop/src/features/agents/useAgentObserverIngestion.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { combineObserverIngestionAgents } from "./useAgentObserverIngestion.ts";
import {
combineObserverIngestionAgents,
projectObserverIngestionAgents,
} from "./useAgentObserverIngestion.ts";
import { useUsersBatchQuery } from "@/features/profile/hooks.ts";
import { CommunitiesProvider } from "@/features/communities/useCommunities.tsx";

const ME = "aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234aaaa1234";
const OTHER =
Expand Down Expand Up @@ -86,3 +95,132 @@ describe("combineObserverIngestionAgents", () => {
assert.deepEqual(result, [{ pubkey: AGENT_LOCAL, status: "running" }]);
});
});

describe("projectObserverIngestionAgents", () => {
it("includes an owned agent profile from channel membership when listRelayAgents omits it", () => {
const result = projectObserverIngestionAgents(
[],
[],
[AGENT_REMOTE],
{
[AGENT_REMOTE]: {
isAgent: true,
ownerPubkey: ME,
},
},
ME,
);

assert.deepEqual(result, [{ pubkey: AGENT_REMOTE, status: "deployed" }]);
});

it("keeps an owned agent when combined profile candidates exceed the relay query cap", async () => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
const previousWindow = globalThis.window;
const previousDocument = globalThis.document;
const previousLocalStorage = globalThis.localStorage;
const previousNavigatorDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"navigator",
);
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.localStorage = dom.window.localStorage;
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
globalThis.IS_REACT_ACT_ENVIRONMENT = true;

const ownedAgent = "f".repeat(64);
const channelMembers = Array.from({ length: 1_001 }, (_, index) =>
index.toString(16).padStart(64, "0"),
);
channelMembers.push(ownedAgent);
const relayAgents = [ownedAgent.toUpperCase()];
const requestedBatches = [];

dom.window.__TAURI_INTERNALS__ = {
invoke(command, args) {
assert.equal(command, "get_users_batch");
requestedBatches.push(args.pubkeys);
const visiblePubkeys = args.pubkeys.slice(0, 1_000);
const profiles = {};
if (visiblePubkeys.includes(ownedAgent)) {
profiles[ownedAgent] = {
display_name: "Owned agent",
avatar_url: null,
nip05_handle: null,
owner_pubkey: ME,
is_agent: true,
};
}
return Promise.resolve({
profiles,
missing: args.pubkeys.filter((pubkey) => !(pubkey in profiles)),
});
},
transformCallback: () => 1,
};

const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
let latestQuery;
function Probe() {
latestQuery = useUsersBatchQuery([...relayAgents, ...channelMembers]);
return null;
}

const root = createRoot(dom.window.document.createElement("div"));
try {
await act(async () => {
root.render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(
CommunitiesProvider,
null,
React.createElement(Probe),
),
),
);
});
for (let index = 0; index < 10 && latestQuery?.isFetching; index += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}

const result = projectObserverIngestionAgents(
[],
relayAgents,
channelMembers,
latestQuery?.data?.profiles,
ME,
);
assert.deepEqual(result, [{ pubkey: ownedAgent, status: "deployed" }]);
assert.ok(requestedBatches.length > 1);
assert.ok(requestedBatches.every((batch) => batch.length <= 1_000));
} finally {
await act(async () => root.unmount());
queryClient.clear();
dom.window.close();
globalThis.window = previousWindow;
globalThis.document = previousDocument;
globalThis.localStorage = previousLocalStorage;
if (previousNavigatorDescriptor) {
Object.defineProperty(
globalThis,
"navigator",
previousNavigatorDescriptor,
);
} else {
delete globalThis.navigator;
}
}
});
});
Loading