diff --git a/desktop/src/features/mesh-compute/meshDetailModel.test.mjs b/desktop/src/features/mesh-compute/meshDetailModel.test.mjs new file mode 100644 index 000000000..2ea546d72 --- /dev/null +++ b/desktop/src/features/mesh-compute/meshDetailModel.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * Contract tests for the mesh detail popover projection. + * + * The load-bearing rule: mesh-llm exposes **no inbound counter**. Every figure + * available describes work this machine dispatched, so nothing here may claim + * another member consumed our compute. Several tests exist purely to pin that. + */ + +import { + describeActivity, + describeRequestOrigin, + deriveMeshDetailModel, +} from "./meshDetailModel.ts"; + +function usage(overrides = {}) { + return { + inflight: 0, + peakInflight: 0, + requestsServed: 0, + tokensServed: 0, + tokensPerSecond: 0, + localAttempts: 0, + remoteAttempts: 0, + endpointAttempts: 0, + peers: 0, + locallyServed: 0, + remotelyServed: 0, + endpointServed: 0, + ...overrides, + }; +} + +function device(overrides = {}) { + return { + deviceId: "d1", + label: "Device", + capacityGb: 16, + models: ["m"], + state: "serving", + isSelf: false, + ...overrides, + }; +} + +function snapshot(overrides = {}) { + return { + sharingDeviceCount: 1, + sharedCapacityGb: 16, + models: ["m"], + devices: [device({ isSelf: true })], + includesSelf: true, + memberCount: 1, + reason: null, + ...overrides, + }; +} + +test("request origin distinguishes borrowed compute from local work", () => { + assert.equal( + describeRequestOrigin(usage({ locallyServed: 5 })), + "5 requests · all on this computer", + ); + assert.equal( + describeRequestOrigin(usage({ remotelyServed: 4 })), + "4 requests · all on shared compute", + ); + assert.equal( + describeRequestOrigin(usage({ locallyServed: 9, remotelyServed: 3 })), + "3 of 12 requests on shared compute", + ); +}); + +test("endpoint-served requests count as shared, not local", () => { + // An endpoint is not this machine's GPU, so it must not read as local work. + assert.equal( + describeRequestOrigin(usage({ locallyServed: 1, endpointServed: 1 })), + "1 of 2 requests on shared compute", + ); +}); + +test("no completed requests yields no origin claim, never '0 remote'", () => { + assert.equal(describeRequestOrigin(usage()), null); + assert.equal(describeRequestOrigin(null), null); +}); + +test("singular grammar for a single request", () => { + assert.equal( + describeRequestOrigin(usage({ locallyServed: 1 })), + "1 request · all on this computer", + ); +}); + +test("activity reports inflight work without attributing it to anyone", () => { + const live = describeActivity(usage({ inflight: 2 }), true); + assert.equal(live, "2 requests in flight"); + // The counter cannot distinguish a local agent from a remote member, so the + // phrasing must never imply someone else is using this machine. + assert.ok( + !/(served|for (others|members|someone)|using your|consumed)/i.test(live), + `must not attribute inbound work: ${live}`, + ); +}); + +test("a warm sharing runtime with no traffic is its own state", () => { + assert.equal(describeActivity(usage(), true), "Ready · no requests yet"); + // Not sharing and idle is not noteworthy — stay silent. + assert.equal(describeActivity(usage(), false), null); + assert.equal(describeActivity(null, true), null); +}); + +test("participation names the roster denominator", () => { + const model = deriveMeshDetailModel({ + snapshot: snapshot({ + sharingDeviceCount: 2, + memberCount: 12, + sharedCapacityGb: 115, + devices: [device({ isSelf: true }), device({ deviceId: "d2" })], + }), + usage: usage(), + isSharing: true, + }); + assert.equal(model.participationLabel, "2 of 12 members sharing"); + assert.equal(model.capacityLabel, "115 GB"); + // 12 members, 2 with published status notes -> 10 ghosts. + assert.equal(model.ghostCount, 10); +}); + +test("ghost count never goes negative when the roster lags devices", () => { + // A device can report while a stale roster page omits it. A negative ghost + // count is nonsense, so it clamps at zero. + const model = deriveMeshDetailModel({ + snapshot: snapshot({ + memberCount: 1, + devices: [device({ isSelf: true }), device({ deviceId: "d2" })], + }), + usage: usage(), + isSharing: true, + }); + assert.equal(model.ghostCount, 0); +}); + +test("an empty roster falls back to a device count", () => { + const model = deriveMeshDetailModel({ + snapshot: snapshot({ sharingDeviceCount: 1, memberCount: 0 }), + usage: usage(), + isSharing: true, + }); + assert.equal(model.participationLabel, "1 device sharing"); +}); + +test("unknown capacity drops the figure rather than printing 0 GB", () => { + const model = deriveMeshDetailModel({ + snapshot: snapshot({ sharedCapacityGb: null }), + usage: usage(), + isSharing: true, + }); + assert.equal(model.capacityLabel, null); +}); + +test("a null snapshot is renderable and claims nothing", () => { + const model = deriveMeshDetailModel({ + snapshot: null, + usage: null, + isSharing: false, + }); + assert.equal(model.capacityLabel, null); + assert.equal(model.ghostCount, 0); + assert.equal(model.busyNow, false); + assert.equal(model.activityLabel, null); + assert.equal(model.originLabel, null); +}); + +test("capacity formatting keeps small figures meaningful", () => { + assert.equal( + deriveMeshDetailModel({ + snapshot: snapshot({ sharedCapacityGb: 4.62 }), + usage: usage(), + isSharing: true, + }).capacityLabel, + "4.6 GB", + ); +}); diff --git a/desktop/src/features/mesh-compute/meshDetailModel.ts b/desktop/src/features/mesh-compute/meshDetailModel.ts new file mode 100644 index 000000000..e84ec82f6 --- /dev/null +++ b/desktop/src/features/mesh-compute/meshDetailModel.ts @@ -0,0 +1,136 @@ +import type { MeshServingUsage, MeshSnapshot } from "@/shared/api/tauriMesh"; + +/** + * Pure projection for the mesh detail popover. + * + * The popover answers three questions the 256px card cannot: + * 1. How big is the pool, and how much of the community is in it? + * 2. Is the spice flowing — is work actually happening right now? + * 3. Am I running on someone else's machine, or my own? + * + * The hard constraint this file exists to encode: **mesh-llm exposes no inbound + * counter.** `routing_metrics` is incremented only by this node's own OpenAI + * ingress, so every number available describes work *this machine dispatched*. + * Nothing can prove another member consumed our compute. So: + * + * - "I used someone else's machine" → provable (`remotelyServed`) + * - "my own GPU did the work" → provable (`locallyServed`) + * - "someone used MY machine" → NOT provable, only ever hinted + * + * `inflight` is the one live signal, and it does not say who the work is for. + * That is why the busy state is worded "working" and never "someone is using + * your compute". + */ + +/** Ghost = a community member with no published status note. */ +export type MeshDetailModel = { + /** e.g. "115 GB" — pool capacity, or null when nobody reported a figure. */ + capacityLabel: string | null; + /** e.g. "2 of 12 members sharing". */ + participationLabel: string; + /** Members with no status note at all. Count only — never GB. */ + ghostCount: number; + /** True when inference is in flight on this node right now. */ + busyNow: boolean; + /** + * Live activity phrase, or null when idle. Deliberately vague about *who*: + * the counters cannot attribute inbound work. + */ + activityLabel: string | null; + /** Where this machine's completed requests actually ran. */ + originLabel: string | null; + /** Distinct models ready across the pool. */ + modelCount: number; +}; + +function plural(n: number, one: string, many = `${one}s`): string { + return n === 1 ? one : many; +} + +/** + * Where this machine's work ran — the honest "am I borrowing someone's GPU" + * line. + * + * `remotelyServed` counts *completed* requests a peer answered for us, so it is + * a fact about our own consumption, not a guess. Returns null before any + * request completes rather than claiming "0 remote". + */ +export function describeRequestOrigin( + usage: MeshServingUsage | null, +): string | null { + if (!usage) { + return null; + } + const remote = usage.remotelyServed + usage.endpointServed; + const total = usage.locallyServed + remote; + if (total === 0) { + return null; + } + if (remote === 0) { + return `${total} ${plural(total, "request")} · all on this computer`; + } + if (usage.locallyServed === 0) { + return `${remote} ${plural(remote, "request")} · all on shared compute`; + } + return `${remote} of ${total} requests on shared compute`; +} + +/** + * The live "spice is flowing" phrase. + * + * Wording is intentionally agent-agnostic: `inflight` counts work in flight on + * this node without distinguishing a local agent from a remote member, so this + * may never assert that someone else is using this machine. + */ +export function describeActivity( + usage: MeshServingUsage | null, + isSharing: boolean, +): string | null { + const inflight = usage?.inflight ?? 0; + if (inflight > 0) { + return `${inflight} ${plural(inflight, "request")} in flight`; + } + if (!usage) { + return null; + } + // Sharing with a warm runtime and no traffic is a real, distinct state: + // ready, but nothing has asked yet. + if (isSharing && usage.requestsServed === 0) { + return "Ready · no requests yet"; + } + return null; +} + +export function deriveMeshDetailModel({ + snapshot, + usage, + isSharing, +}: { + snapshot: MeshSnapshot | null; + usage: MeshServingUsage | null; + isSharing: boolean; +}): MeshDetailModel { + const devices = snapshot?.devices ?? []; + const sharing = snapshot?.sharingDeviceCount ?? 0; + const members = snapshot?.memberCount ?? 0; + // Members who published nothing. Clamped at 0: a device may report without + // appearing in a stale roster page, and a negative ghost count is nonsense. + const ghostCount = Math.max(0, members - devices.length); + const capacityGb = snapshot?.sharedCapacityGb ?? null; + + return { + capacityLabel: + capacityGb === null + ? null + : `${capacityGb >= 10 ? Math.round(capacityGb) : Math.round(capacityGb * 10) / 10} GB`, + participationLabel: + members === 0 + ? `${sharing} ${plural(sharing, "device")} sharing` + : `${sharing} of ${members} ${plural(members, "member")} sharing`, + ghostCount, + busyNow: (usage?.inflight ?? 0) > 0, + activityLabel: describeActivity(usage, isSharing), + originLabel: describeRequestOrigin(usage), + modelCount: snapshot?.models.length ?? 0, + }; +} diff --git a/desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx b/desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx new file mode 100644 index 000000000..16f681892 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx @@ -0,0 +1,119 @@ +import type { MeshServingUsage, MeshSnapshot } from "@/shared/api/tauriMesh"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { POPOVER_SHADOW_STYLE } from "@/shared/ui/popoverSurface"; +import { deriveMeshDetailModel } from "../meshDetailModel"; +import { MeshTopologyRadial } from "./MeshTopologyRadial"; + +/** + * The mesh detail popover, anchored to the sidebar card. + * + * Anchored rather than a centred dialog: this is a glance, not a task. It sits + * beside the thing it explains, and dismisses on outside click. + * + * Every figure here is one this machine can actually vouch for. In particular + * there is no "N requests served for others" line, because mesh-llm exposes no + * inbound counter — only a soft hint when the pool is live. See + * `meshDetailModel.ts` for the full constraint. + */ +export function MeshDetailPopover({ + children, + snapshot, + usage, + isSharing, + onOpenComputeSettings, +}: { + children: React.ReactNode; + snapshot: MeshSnapshot | null; + usage: MeshServingUsage | null; + isSharing: boolean; + onOpenComputeSettings?: () => void; +}) { + const model = deriveMeshDetailModel({ snapshot, usage, isSharing }); + + return ( + + {children} + +
+
+

+ {model.capacityLabel ?? "Mesh capacity"} +

+

+ {model.participationLabel} +

+
+ + + +
+ {model.activityLabel ? ( +
+ {model.busyNow ? ( + + + + + ) : null} +

+ {model.activityLabel} +

+
+ ) : null} + + {model.originLabel ? ( +

+ {model.originLabel} +

+ ) : null} + + {model.modelCount > 0 ? ( +

+ {model.modelCount === 1 + ? "1 model ready" + : `${model.modelCount} models ready`} +

+ ) : null} +
+ + {model.ghostCount > 0 ? ( +

+ {model.ghostCount === 1 + ? "1 member isn't sharing yet." + : `${model.ghostCount} members aren't sharing yet.`} +

+ ) : null} + + {onOpenComputeSettings ? ( + + ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx b/desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx new file mode 100644 index 000000000..12f355fc0 --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx @@ -0,0 +1,234 @@ +import { motion, useReducedMotion } from "motion/react"; + +import type { MeshSnapshotDevice } from "@/shared/api/tauriMesh"; +import { cn } from "@/shared/lib/cn"; +import { formatCapacityGb, shortModelLabel } from "../meshCardModel"; + +/** + * Radial mesh field for the detail popover: this computer at the centre, + * participating devices around it, unshared members as ghosts. + * + * ## What the geometry does and does not claim + * + * The spokes mean **"in the same mesh as you"** — nothing more. The relay + * snapshot carries no adjacency: it tells us who is participating and what each + * advertises, but not who is connected to whom, and the activity counters are + * node-local rather than edge-attributed. So a spoke is a membership line, and + * **nothing is ever animated along one** — a packet gliding down a chosen edge + * would be invented data. + * + * What *is* animated is the centre: this node's own inflight work. That is a + * fact we hold directly. Peers pulse only on their own published `serving` + * state, never on inferred traffic. + * + * Ghosts are members with no published status note. They are drawn dashed, with + * no capacity and no label, because a member who never starts a node discloses + * no hardware — by design. Their contribution to the picture is a count, and + * they exist here to make joining feel like joining something, not to imply + * capacity nobody offered. + */ + +const CENTER = 60; +const RADIUS = 42; +const GHOST_RADIUS = 54; +/** Beyond this, ghosts collapse into a count so the ring stays legible. */ +const MAX_GHOST_DOTS = 10; + +const STATE_FILL: Record = { + serving: "fill-emerald-500 dark:fill-emerald-400", + loading: "fill-amber-500 dark:fill-amber-400", + standby: "fill-muted-foreground/40", + consuming: "fill-sky-500 dark:fill-sky-400", +}; + +function pointOnRing( + index: number, + count: number, + radius: number, +): { x: number; y: number } { + // Start at the top and go clockwise; -90° puts the first node at 12 o'clock. + const angle = (index / Math.max(1, count)) * Math.PI * 2 - Math.PI / 2; + return { + x: CENTER + Math.cos(angle) * radius, + y: CENTER + Math.sin(angle) * radius, + }; +} + +function deviceTitle(device: MeshSnapshotDevice): string { + const parts = [device.isSelf ? `${device.label} (you)` : device.label]; + if (device.capacityGb !== null) { + parts.push(formatCapacityGb(device.capacityGb)); + } + if (device.models.length > 0) { + parts.push(shortModelLabel(device.models[0])); + } + return parts.join(" · "); +} + +export function MeshTopologyRadial({ + devices, + ghostCount, + busyNow, +}: { + devices: MeshSnapshotDevice[]; + ghostCount: number; + /** This node has inference in flight — the only honest live signal. */ + busyNow: boolean; +}) { + const shouldReduceMotion = useReducedMotion(); + + const self = devices.find((device) => device.isSelf); + const others = devices.filter((device) => !device.isSelf); + const ghostDots = Math.min(ghostCount, MAX_GHOST_DOTS); + const ghostOverflow = ghostCount - ghostDots; + + // Ghosts sit on an outer ring, offset half a step so they interleave with + // real devices rather than hiding behind them. + const ghostOffset = others.length > 0 ? 0.5 : 0; + + // Resolve geometry and keys up front. Peers key on device identity; ghosts + // have none to key on (a member with no status note publishes nothing), so + // their ring slot is the key, named explicitly rather than taken from a + // render-time array index. + const peerNodes = others.map((device, index) => ({ + device, + key: device.deviceId ?? `${device.label}-${index}`, + pulseDelay: index * 0.35, + ...pointOnRing(index, others.length, RADIUS), + })); + + const ghostNodes = Array.from({ length: ghostDots }, (_, slot) => ({ + key: `ghost-slot-${slot}`, + ...pointOnRing( + slot + ghostOffset, + Math.max(ghostDots, others.length), + GHOST_RADIUS, + ), + })); + + return ( +
+ + {/* Membership spokes. Not traffic — see the file comment. */} + + {peerNodes.map((node) => ( + + ))} + {ghostNodes.map((node) => ( + + ))} + + + {/* Ghost members: dashed, unlabelled, no capacity claimed. */} + {ghostNodes.map((node) => ( + + ))} + + {/* Participating peers. Pulse reflects their own published state. */} + {peerNodes.map(({ device, key, pulseDelay, x, y }) => { + const isPulsing = device.state === "serving" && !shouldReduceMotion; + return ( + + {deviceTitle(device)} + {isPulsing ? ( + + ) : null} + + + ); + })} + + {/* This computer, at the centre. The only node whose activity we can + honestly animate, because inflight is our own counter. */} + + + {self ? deviceTitle(self) : "This computer (not participating)"} + + {busyNow && !shouldReduceMotion ? ( + + ) : null} + + + + + {ghostOverflow > 0 ? ( + + +{ghostOverflow} more not sharing + + ) : null} +
+ ); +} diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx index 13fcddbc8..a52149ced 100644 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx @@ -13,6 +13,7 @@ import { useMeshServingUsage } from "../hooks/useMeshServingUsage"; import { useMeshSnapshot } from "../hooks/useMeshSnapshot"; import { deriveMeshShareToggle } from "../shareToggleState"; import { deriveMeshCardModel, type MeshCardTone } from "../meshCardModel"; +import { MeshDetailPopover } from "./MeshDetailPopover"; import { MeshTopologyStrip } from "./MeshTopologyStrip"; /** @@ -62,12 +63,9 @@ const TONE_RING_CLASS: Record = { export function SidebarMeshComputeCard({ className, onOpenComputeSettings, - onOpenDetail, }: { className?: string; onOpenComputeSettings?: () => void; - /** Open the full mesh view (topology, capacity, per-device detail). */ - onOpenDetail?: () => void; }) { const shouldReduceMotion = useReducedMotion(); const { status, refresh: refreshStatus } = useMeshNodeStatus(); @@ -192,28 +190,33 @@ export function SidebarMeshComputeCard({ Clickable region is the text block, not the whole card: wrapping the Switch in a button would nest interactive elements. */} - + {model.detail ? ( +

+ {model.detail} +

+ ) : null} + + ) : null} - - {onOpenComputeSettings ? ( - - ) : null} );