From 76debee19d3ccf38dfa5c5e413573f55837eaccd Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 4 Aug 2026 17:19:41 +1000 Subject: [PATCH] feat(mesh): anchored detail popover with radial topology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the card headline opens a popover anchored to it — a glance beside the thing it explains, not a centred dialog task. Shows pool capacity, "N of M members sharing" from the NIP-43 roster, and a radial field with this computer at the centre, participating peers around it, and members who publish no status note as dashed ghosts. Two honesty constraints are encoded in the code and pinned by tests: Spokes mean "in the same mesh as you", nothing more. The relay snapshot carries no adjacency and the activity counters are node-local rather than edge-attributed, so nothing is animated along a spoke. Only the centre pulses, driven by this node's own inflight count. Ghosts are a count, never capacity. A member who never starts a node discloses no hardware by design, so the overlay never estimates GB on their behalf. Consuming is now visible: describeRequestOrigin uses the pressure split to say "3 of 12 requests on shared compute" — provable, because those are completed requests this machine dispatched. There is deliberately no "served for others" line: fronted_request_count turns out to alias request_count, confirming mesh-llm exposes no inbound counter, so activity copy hints that work is flowing without claiming who asked for it. UX only — no routing, admission, or lifecycle change. Signed-off-by: Michael Neale --- .../mesh-compute/meshDetailModel.test.mjs | 185 ++++++++++++++ .../features/mesh-compute/meshDetailModel.ts | 136 ++++++++++ .../mesh-compute/ui/MeshDetailPopover.tsx | 119 +++++++++ .../mesh-compute/ui/MeshTopologyRadial.tsx | 234 ++++++++++++++++++ .../ui/SidebarMeshComputeCard.tsx | 54 ++-- 5 files changed, 697 insertions(+), 31 deletions(-) create mode 100644 desktop/src/features/mesh-compute/meshDetailModel.test.mjs create mode 100644 desktop/src/features/mesh-compute/meshDetailModel.ts create mode 100644 desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx create mode 100644 desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx 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} );