diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 606d2b7883..cb0ae79ae8 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -5,12 +5,14 @@ import { ChevronDown, ChevronRight, RefreshCw, + Share2, } from "lucide-react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import { Badge } from "@/shared/ui/badge"; import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { usesMeshCompute } from "@/features/mesh-compute/meshAgents"; import { useAgentWorking } from "@/features/agents/agentWorkingSignal"; import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; @@ -407,8 +409,23 @@ function RuntimeBlock({

{agent.agentCommand}

- {runtimeSource || agent.model ? ( + {runtimeSource || agent.model || usesMeshCompute(agent) ? (
+ {/* + Where an agent's inference runs is otherwise invisible without + opening its config, and "on shared compute" is the one provider + whose health the user can actually act on from the sidebar card. + */} + {usesMeshCompute(agent) ? ( + + + Shared compute + + ) : null} {runtimeSource ? {runtimeSource} : null} {agent.model ? {agent.model} : null}
diff --git a/desktop/src/features/mesh-compute/meshAgents.test.mjs b/desktop/src/features/mesh-compute/meshAgents.test.mjs new file mode 100644 index 0000000000..245896e476 --- /dev/null +++ b/desktop/src/features/mesh-compute/meshAgents.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * Contract tests for mesh agent detection and the card's call to action. + * + * The CTA's value is that it stays quiet. A nudge that renders every time the + * card does becomes furniture, so most of these tests pin *silence*. + */ + +import { + deriveMeshCallToAction, + summarizeMeshAgents, + usesMeshCompute, +} from "./meshAgents.ts"; + +function persona(overrides = {}) { + return { + id: "p1", + displayName: "Hop", + provider: "relay-mesh", + ...overrides, + }; +} + +test("mesh agents are identified by provider, not by model string", () => { + const summary = summarizeMeshAgents([ + persona({ id: "a", displayName: "Zoe" }), + persona({ id: "b", displayName: "Ada" }), + persona({ id: "c", displayName: "Other", provider: "anthropic" }), + // A model that merely mentions a mesh model is not a mesh agent: intent + // lives in the persisted provider, not in a free-text model field. + persona({ id: "d", displayName: "Lookalike", provider: null }), + ]); + assert.equal(summary.count, 2); + assert.equal(summary.hasAny, true); + assert.deepEqual(summary.names, ["Ada", "Zoe"], "names sort for stable copy"); +}); + +test("provider matching tolerates whitespace and absence", () => { + assert.equal(usesMeshCompute(persona({ provider: " relay-mesh " })), true); + assert.equal(usesMeshCompute(persona({ provider: "anthropic" })), false); + assert.equal(usesMeshCompute(persona({ provider: null })), false); + assert.equal(usesMeshCompute(null), false); + assert.equal(usesMeshCompute(undefined), false); +}); + +test("an empty persona list is not an error", () => { + const summary = summarizeMeshAgents([]); + assert.equal(summary.hasAny, false); + assert.deepEqual(summary.names, []); + assert.equal(summarizeMeshAgents(undefined).hasAny, false); +}); + +test("the CTA appears only when compute has no consumer", () => { + // Sharing, but nothing can use it — the dead end worth naming. + assert.deepEqual( + deriveMeshCallToAction({ + personas: [persona({ provider: "anthropic" })], + isSharing: true, + meshHasCapacity: false, + }), + { kind: "createAgent", label: "Set up an agent to use it" }, + ); + // Not sharing, but the community has capacity this person could use. + assert.equal( + deriveMeshCallToAction({ + personas: [], + isSharing: false, + meshHasCapacity: true, + }).kind, + "createAgent", + ); +}); + +test("the CTA stays silent once a mesh agent exists", () => { + assert.deepEqual( + deriveMeshCallToAction({ + personas: [persona()], + isSharing: true, + meshHasCapacity: true, + }), + { kind: "none" }, + ); +}); + +test("no capacity anywhere means the CTA would just move the dead end", () => { + // Telling someone to build an agent for a mesh with nothing in it does not + // help them; it relocates the problem. + assert.deepEqual( + deriveMeshCallToAction({ + personas: [], + isSharing: false, + meshHasCapacity: false, + }), + { kind: "none" }, + ); +}); + +test("an unloaded persona list never prompts a possible duplicate", () => { + // `undefined` is "not fetched yet". Prompting someone to create an agent they + // may already have is worse than staying quiet for a moment. + assert.deepEqual( + deriveMeshCallToAction({ + personas: undefined, + isSharing: true, + meshHasCapacity: true, + }), + { kind: "none" }, + ); +}); diff --git a/desktop/src/features/mesh-compute/meshAgents.ts b/desktop/src/features/mesh-compute/meshAgents.ts new file mode 100644 index 0000000000..40e133f0e6 --- /dev/null +++ b/desktop/src/features/mesh-compute/meshAgents.ts @@ -0,0 +1,95 @@ +import type { AgentPersona } from "@/shared/api/types"; + +/** + * Which agents are set up to run on Buzz shared compute. + * + * A persona points at the mesh by carrying `provider === "relay-mesh"` — the + * same id the backend gates mesh model discovery on + * (`managed_agents::RELAY_MESH_PROVIDER_ID`), so this asks the persistent + * record rather than inferring intent from a model string. + * + * The point is a call to action: sharing compute that no agent can use is a + * dead end, and the card is where someone would notice. So the card needs to + * know not just *whether* mesh agents exist but whether the whole setup is + * inert — capacity with no consumer, or a consumer with no capacity. + */ + +/** The provider id a persona carries when it runs on shared compute. */ +export const RELAY_MESH_PROVIDER_ID = "relay-mesh"; + +export type MeshAgentSummary = { + /** Agents configured to run on shared compute. */ + count: number; + /** Display names, for naming one specifically. Sorted for stable copy. */ + names: string[]; + /** True when at least one such agent exists. */ + hasAny: boolean; +}; + +export function summarizeMeshAgents( + personas: AgentPersona[] | undefined, +): MeshAgentSummary { + const matching = (personas ?? []).filter( + (persona) => persona.provider?.trim() === RELAY_MESH_PROVIDER_ID, + ); + const names = matching + .map((persona) => persona.displayName) + .sort((a, b) => a.localeCompare(b)); + return { count: matching.length, names, hasAny: matching.length > 0 }; +} + +/** + * Whether a given persona runs on shared compute. + * + * Used to decorate an agent wherever it appears, so "this one is running on the + * mesh" is legible without opening its config. + */ +export function usesMeshCompute( + persona: Pick | null | undefined, +): boolean { + return persona?.provider?.trim() === RELAY_MESH_PROVIDER_ID; +} + +/** + * The card's call to action, or null when none is warranted. + * + * Deliberately silent in the common case. A nudge that appears every time the + * card renders becomes furniture, so this only speaks when the setup is + * genuinely incomplete in a way the person can act on: + * + * - sharing (or capacity exists) but no agent can use it → offer to make one + * - an agent exists but nothing is shared anywhere → offer to share + * + * Returns null while the picture is still unknown (`personas === undefined`), + * because prompting someone to create an agent they may already have is worse + * than staying quiet for a moment. + */ +export type MeshCallToAction = + | { kind: "createAgent"; label: string } + | { kind: "none" }; + +export function deriveMeshCallToAction({ + personas, + isSharing, + meshHasCapacity, +}: { + personas: AgentPersona[] | undefined; + isSharing: boolean; + /** Anyone in the community is sharing, including this machine. */ + meshHasCapacity: boolean; +}): MeshCallToAction { + // Unknown yet — say nothing rather than nudge toward a duplicate. + if (personas === undefined) { + return { kind: "none" }; + } + if (summarizeMeshAgents(personas).hasAny) { + return { kind: "none" }; + } + // Compute with no consumer is the dead end worth naming. Only prompt when + // there is actually capacity to use — telling someone to build an agent for + // a mesh with nothing in it just moves the dead end. + if (isSharing || meshHasCapacity) { + return { kind: "createAgent", label: "Set up an agent to use it" }; + } + return { kind: "none" }; +} diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx index 37e7671c00..4f19aa2c32 100644 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx @@ -8,6 +8,9 @@ import { meshStartNode, meshStopNode } from "@/shared/api/tauriMesh"; import type { MeshModelCatalog } from "@/shared/api/tauriMesh"; import { meshModelCatalog } from "@/shared/api/tauriMesh"; +import { usePersonasQuery } from "@/features/agents/hooks"; +import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; +import { deriveMeshCallToAction } from "../meshAgents"; import { useMeshDownloadProgress } from "../hooks/useMeshDownloadProgress"; import { useMeshLiveView } from "../hooks/useMeshLiveView"; import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus"; @@ -99,6 +102,16 @@ export function SidebarMeshComputeCard({ const { progress: downloadProgress, reset: resetDownloadProgress } = useMeshDownloadProgress(); + // Sharing compute no agent can use is a dead end, and this card is where + // someone would notice. The CTA stays silent unless the setup is actually + // incomplete — see deriveMeshCallToAction. + const { data: personas } = usePersonasQuery(); + const callToAction = deriveMeshCallToAction({ + personas, + isSharing: toggle.isSharing, + meshHasCapacity: (snapshot?.sharingDeviceCount ?? 0) > 0, + }); + // Inbound work has no counter in mesh-llm, so it is inferred by elimination // across two samples: serving, inflight > 0, and our own dispatch count flat // means the work is not ours. Keeping the previous sample in a ref (not @@ -261,6 +274,17 @@ export function SidebarMeshComputeCard({ showSoloHint={model.showSoloHint} /> + {callToAction.kind === "createAgent" ? ( + + ) : null} + {errorText ? (