diff --git a/desktop/src/features/mesh-compute/meshFieldNodes.test.mjs b/desktop/src/features/mesh-compute/meshFieldNodes.test.mjs new file mode 100644 index 000000000..35a6f2936 --- /dev/null +++ b/desktop/src/features/mesh-compute/meshFieldNodes.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * Contract tests for the radar field's node source. + * + * The load-bearing claim: the community's shared compute is visible **without** + * running a node, because `mesh_snapshot` reads other members' relay status + * notes. An earlier version gated the graph on a local runtime and told the user + * to "turn on sharing to see who's on the mesh", which was false. + * + * The equally load-bearing counter-claim: relay notes are *last known*, not + * live. These tests pin that the two sources stay distinguishable — no RTT and + * no claimed membership from notes. + */ + +import { deriveMeshFieldModel } from "./meshFieldNodes.ts"; + +function device(overrides = {}) { + return { + deviceId: "d1", + label: "studio54", + capacityGb: 36, + models: ["gemma"], + state: "serving", + isSelf: false, + ...overrides, + }; +} + +function snapshot(overrides = {}) { + return { + sharingDeviceCount: 1, + sharedCapacityGb: 36, + models: [], + devices: [device()], + includesSelf: false, + memberCount: 1, + reason: null, + ...overrides, + }; +} + +function peer(overrides = {}) { + return { + id: "p1", + label: "studio54", + state: "serving", + capacityGb: 36, + models: [], + rttMs: 30, + ...overrides, + }; +} + +test("a live runtime wins: only it can vouch for reachability", () => { + const field = deriveMeshFieldModel({ + view: { connected: true, selfCapacityGb: 115, peers: [peer()] }, + snapshot: snapshot(), + }); + assert.equal(field.source, "live"); + assert.equal(field.selfParticipating, true); + assert.equal(field.selfCapacityGb, 115); + assert.equal(field.nodes[0].rttMs, 30); +}); + +test("with no runtime, relay notes still show the pool", () => { + // The whole point: you can see there is a mesh before joining it. + const field = deriveMeshFieldModel({ + view: null, + snapshot: snapshot({ + sharingDeviceCount: 2, + devices: [device(), device({ deviceId: "d2" })], + }), + }); + assert.equal(field.source, "relay"); + assert.equal(field.nodes.length, 2); +}); + +test("relay nodes carry no RTT and claim no membership", () => { + const field = deriveMeshFieldModel({ view: null, snapshot: snapshot() }); + // Inventing an RTT would place a node at a distance that means nothing. + assert.equal(field.nodes[0].rttMs, null); + // A hollow centre and no spokes depend on this staying false. + assert.equal(field.selfParticipating, false); + assert.equal(field.selfCapacityGb, null); + assert.match(field.caption, /Last reported/); +}); + +test("our own note is excluded so we are never drawn twice", () => { + const field = deriveMeshFieldModel({ + view: null, + snapshot: snapshot({ + devices: [device(), device({ deviceId: "self", isSelf: true })], + }), + }); + assert.equal(field.nodes.length, 1); +}); + +test("a device id is optional; ring slot keeps keys stable", () => { + const field = deriveMeshFieldModel({ + view: null, + snapshot: snapshot({ + devices: [device({ deviceId: null }), device({ deviceId: null })], + }), + }); + assert.notEqual(field.nodes[0].id, field.nodes[1].id); +}); + +test("ghosts are a count, never capacity", () => { + const field = deriveMeshFieldModel({ + view: null, + snapshot: snapshot({ sharingDeviceCount: 1, memberCount: 12 }), + }); + assert.equal(field.ghostCount, 11); + // No ghost contributes to a capacity figure anywhere in this model. + assert.equal(field.selfCapacityGb, null); +}); + +test("an empty community is 'none', not an empty live view", () => { + const field = deriveMeshFieldModel({ + view: null, + snapshot: snapshot({ sharingDeviceCount: 0, devices: [], memberCount: 0 }), + }); + assert.equal(field.source, "none"); + assert.equal(field.caption, null); +}); + +test("a solo sharer is told it is waiting, not that it is alone in error", () => { + const field = deriveMeshFieldModel({ + view: { connected: true, selfCapacityGb: 115, peers: [] }, + snapshot: null, + }); + assert.equal(field.source, "live"); + assert.match(field.caption, /waiting for another device/); +}); diff --git a/desktop/src/features/mesh-compute/meshFieldNodes.ts b/desktop/src/features/mesh-compute/meshFieldNodes.ts new file mode 100644 index 000000000..bb910992a --- /dev/null +++ b/desktop/src/features/mesh-compute/meshFieldNodes.ts @@ -0,0 +1,108 @@ +import type { MeshLiveView, MeshSnapshot } from "@/shared/api/tauriMesh"; +import type { MeshFieldNode } from "./ui/MeshRadarField"; + +/** + * Nodes for the radar field, from whichever source this machine actually has. + * + * ## Why two sources + * + * A running node knows its peers through live gossip: present means reachable + * *now*, and each carries an RTT. With no node there is no gossip — but the + * community's shared compute is still knowable, because `mesh_snapshot` reads + * other members' relay status notes and needs no local runtime. So the field + * renders either way, and the earlier "turn on sharing to see who's on the + * mesh" was simply wrong. + * + * The two are not equivalent, and the difference is not cosmetic: + * + * - **live**: reachable now, RTT known, adjacency to us is real + * - **relay**: last known within 120s, no RTT, adjacency unknown + * + * So relay nodes get no RTT (they land mid-band rather than faking proximity), + * the caller draws no spokes when we have not joined, and the caption names + * which view is on screen. Never dress last-known state as live. + */ + +export type MeshFieldSource = "live" | "relay" | "none"; + +export type MeshFieldModel = { + nodes: MeshFieldNode[]; + source: MeshFieldSource; + /** True when this machine is part of the mesh being drawn. */ + selfParticipating: boolean; + selfCapacityGb: number | null; + /** + * Community members with no published status note. + * + * A count only. A member who never started a node has disclosed no hardware, + * so ghosts never contribute capacity — they are the invitation, not a total. + */ + ghostCount: number; + /** Caption naming the source, or null when there is nothing to qualify. */ + caption: string | null; +}; + +export function deriveMeshFieldModel({ + view, + snapshot, +}: { + view: MeshLiveView | null; + snapshot: MeshSnapshot | null; +}): MeshFieldModel { + const ghostCount = Math.max( + 0, + (snapshot?.memberCount ?? 0) - (snapshot?.sharingDeviceCount ?? 0), + ); + + // A local runtime is the better source whenever it exists: it is the only one + // that can vouch for reachability. + if (view?.connected) { + return { + nodes: view.peers.map((peer) => ({ + id: peer.id, + state: peer.state, + capacityGb: peer.capacityGb, + rttMs: peer.rttMs, + })), + source: "live", + selfParticipating: true, + selfCapacityGb: view.selfCapacityGb, + ghostCount, + caption: + view.peers.length === 0 + ? "Connected · waiting for another device" + : null, + }; + } + + // No runtime. Other members' status notes are all we have — and they are + // enough to show that a pool exists and roughly how big it is. + const devices = (snapshot?.devices ?? []).filter((device) => !device.isSelf); + if (devices.length === 0) { + return { + nodes: [], + source: "none", + selfParticipating: false, + selfCapacityGb: null, + ghostCount, + caption: null, + }; + } + return { + nodes: devices.map((device, index) => ({ + // Status notes may omit a device id; the ring slot keeps keys stable + // enough for the per-node animation phase. + id: device.deviceId ?? `relay:${index}`, + state: device.state, + capacityGb: device.capacityGb, + // Deliberately null: notes carry no RTT, and inventing one would place a + // node at a distance that means nothing. + rttMs: null, + })), + source: "relay", + selfParticipating: false, + selfCapacityGb: null, + ghostCount, + caption: "Last reported by the community · join to see live detail", + }; +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx b/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx index c117a15a5..5aa3729fb 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx @@ -11,6 +11,7 @@ import { describeStartupStage, } from "../meshCardModel"; import { deriveMeshDetailModel } from "../meshDetailModel"; +import { deriveMeshFieldModel } from "../meshFieldNodes"; import { MeshMemoryBulbs } from "./MeshMemoryBulbs"; import { MeshRadarField } from "./MeshRadarField"; @@ -62,12 +63,10 @@ export function MeshComputePopover({ }); const isStarting = row.tone === "starting"; const selectedModel = status?.modelName ?? status?.modelId ?? modelToShare; - // Members with no published status note. A count only: a member who never - // started a node has disclosed no hardware, so they never imply capacity. - const ghostCount = Math.max( - 0, - (snapshot?.memberCount ?? 0) - (snapshot?.sharingDeviceCount ?? 0), - ); + // The field renders from live gossip when a node is up, and from other + // members' relay status notes when it is not — the community's shared compute + // is knowable either way. See meshFieldNodes.ts. + const field = deriveMeshFieldModel({ view, snapshot }); return ( @@ -108,14 +107,25 @@ export function MeshComputePopover({ /> - {model.connected ? ( - - ) : null} + {field.source === "none" && field.ghostCount === 0 ? null : ( +
+ + {field.caption ? ( +

+ {field.caption} +

+ ) : null} +
+ )} {toggle.isSharing && !isStarting ? ( @@ -171,11 +181,12 @@ export function MeshComputePopover({ ) : null} - {model.connected ? null : ( + {field.source === "none" ? (

- Turn on sharing to see who's on the mesh. + No one is sharing compute here yet. Turn on sharing to start the + community mesh.

- )} + ) : null} {callToAction.kind === "createAgent" ? ( // A tip, not a warning: there is compute here and nothing set up to diff --git a/desktop/src/features/mesh-compute/ui/MeshRadarField.tsx b/desktop/src/features/mesh-compute/ui/MeshRadarField.tsx index e585aa189..1a131cfb8 100644 --- a/desktop/src/features/mesh-compute/ui/MeshRadarField.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshRadarField.tsx @@ -1,9 +1,24 @@ import * as React from "react"; import { useReducedMotion } from "motion/react"; -import type { MeshPeer } from "@/shared/api/tauriMesh"; +import type { MeshDeviceState } from "@/shared/api/tauriMesh"; import { cn } from "@/shared/lib/cn"; +/** + * One node on the ring, from either source. + * + * Live gossip peers carry an RTT and are reachable *now*. Relay status notes + * carry no RTT and are only *last known* (valid 120s), so they land mid-band. + * The caller says which it passed via `live`, and the caption names it — the + * field never implies reachability it cannot show. + */ +export type MeshFieldNode = { + id: string; + state: MeshDeviceState; + capacityGb: number | null; + rttMs: number | null; +}; + /** * Canvas-2D radar field for the mesh. * @@ -66,7 +81,7 @@ const DARK: Palette = { ghost: "148, 163, 184", }; -function peerColor(state: MeshPeer["state"], palette: Palette): string { +function peerColor(state: MeshDeviceState, palette: Palette): string { if (state === "serving") return palette.serving; if (state === "consuming") return palette.consuming; return palette.standby; @@ -87,15 +102,22 @@ function sizeFor(capacityGb: number | null): number { } export function MeshRadarField({ - peers, + nodes, selfCapacityGb, + selfParticipating, ghostCount, busyNow, className, height = 150, }: { - peers: MeshPeer[]; + nodes: MeshFieldNode[]; selfCapacityGb: number | null; + /** + * Whether this machine is in the mesh. When false the centre is drawn hollow: + * we are looking at a pool we have not joined, and a solid centre would claim + * membership we do not have. + */ + selfParticipating: boolean; /** Community members with no node. Drawn as a dashed band, count only. */ ghostCount: number; busyNow: boolean; @@ -121,13 +143,21 @@ export function MeshRadarField({ // Snapshot the frame inputs so the draw loop is not re-created per render. const frame = React.useRef({ - peers, + nodes, selfCapacityGb, + selfParticipating, ghostCount, busyNow, isDark, }); - frame.current = { peers, selfCapacityGb, ghostCount, busyNow, isDark }; + frame.current = { + nodes, + selfCapacityGb, + selfParticipating, + ghostCount, + busyNow, + isDark, + }; React.useEffect(() => { const canvas = canvasRef.current; @@ -142,8 +172,9 @@ export function MeshRadarField({ const draw = (now: number) => { if (disposed) return; const { - peers: livePeers, + nodes: fieldNodes, selfCapacityGb: selfGb, + selfParticipating: joined, ghostCount: ghosts, busyNow: busy, isDark: dark, @@ -201,8 +232,8 @@ export function MeshRadarField({ } // Peers: angle spread evenly, radius from RTT, size from capacity. - const count = livePeers.length; - livePeers.forEach((peer, index) => { + const count = fieldNodes.length; + fieldNodes.forEach((peer, index) => { const spread = count === 0 ? 0 : (index / count) * Math.PI * 2; const angle = spread - Math.PI / 2 + hashUnit(peer.id) * 0.4; const radius = radiusFor(peer.rttMs, innerMin, innerMax); @@ -210,15 +241,20 @@ export function MeshRadarField({ const y = cy + Math.sin(angle) * radius; const rgb = peerColor(peer.state, palette); - // Edge: real adjacency. Opacity carries RTT — closer reads stronger. - const proximity = - peer.rttMs === null ? 0.5 : 1 - Math.min(1, peer.rttMs / 120); - context.strokeStyle = `rgba(${palette.edge}, ${0.12 + proximity * 0.22})`; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(cx, cy); - context.lineTo(x, y); - context.stroke(); + // A spoke asserts "this machine is connected to that node". True only + // when we have joined; from the outside the nodes are real but our + // adjacency to them is not, so the spokes are simply omitted. + if (joined) { + // Opacity carries RTT — closer reads stronger. + const proximity = + peer.rttMs === null ? 0.5 : 1 - Math.min(1, peer.rttMs / 120); + context.strokeStyle = `rgba(${palette.edge}, ${0.12 + proximity * 0.22})`; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(cx, cy); + context.lineTo(x, y); + context.stroke(); + } // Breathing, phase-seeded by id so the field shimmers rather than blinks. const phase = hashUnit(`${peer.id}:phase`) * Math.PI * 2; @@ -262,22 +298,39 @@ export function MeshRadarField({ cy, selfSize * 3.6, ); - selfGlow.addColorStop(0, `rgba(${palette.self}, ${dark ? 0.42 : 0.34})`); - selfGlow.addColorStop(1, `rgba(${palette.self}, 0)`); + selfGlow.addColorStop( + 0, + joined + ? `rgba(${palette.self}, ${dark ? 0.42 : 0.34})` + : `rgba(${palette.standby}, ${dark ? 0.16 : 0.12})`, + ); + selfGlow.addColorStop( + 1, + `rgba(${joined ? palette.self : palette.standby}, 0)`, + ); context.fillStyle = selfGlow; context.beginPath(); context.arc(cx, cy, selfSize * 3.6, 0, Math.PI * 2); context.fill(); - context.fillStyle = `rgba(${palette.self}, 0.98)`; - context.beginPath(); - context.arc(cx, cy, selfSize, 0, Math.PI * 2); - context.fill(); - context.strokeStyle = dark - ? "rgba(255, 255, 255, 0.5)" - : "rgba(255, 255, 255, 0.85)"; - context.lineWidth = 1.4; - context.stroke(); + if (joined) { + context.fillStyle = `rgba(${palette.self}, 0.98)`; + context.beginPath(); + context.arc(cx, cy, selfSize, 0, Math.PI * 2); + context.fill(); + context.strokeStyle = dark + ? "rgba(255, 255, 255, 0.5)" + : "rgba(255, 255, 255, 0.85)"; + context.lineWidth = 1.4; + context.stroke(); + } else { + // Hollow centre: this is a pool we are looking at, not one we are in. + context.strokeStyle = `rgba(${palette.standby}, ${dark ? 0.7 : 0.6})`; + context.lineWidth = 1.4; + context.beginPath(); + context.arc(cx, cy, selfSize, 0, Math.PI * 2); + context.stroke(); + } // A frozen field needs no frames; reduced motion draws once and stops. if (!shouldReduceMotion) { diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx index 6318cbf51..9d7970cbb 100644 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx @@ -8,7 +8,7 @@ import type { MeshRowTone } from "../meshRowModel"; import { MeshComputePopover } from "./MeshComputePopover"; /** - * The sidebar Compute row. + * The sidebar Community mesh row. * * Replaces the ~120px footer card with one menu line beside Inbox and Agents, * because front-page sidebar space is premium. The row carries a single signal @@ -80,7 +80,9 @@ export function SidebarMeshComputeRow({ /> - Compute + + Community mesh +