From 36548e0bc3229695c511f72da23d4bcb3dc61223 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Wed, 5 Aug 2026 12:27:35 +1000 Subject: [PATCH] feat(mesh): one-line Compute row with radar-field detail popover Sidebar front-page space is premium, so shared compute is now a single menu line under Agents instead of a ~120px footer card. The row carries one signal -- a coloured dot -- and everything else moved into the popover it opens. The dot distinguishes six states, and the pair that matters most is "nothing here" vs "there is capacity you have not joined": mesh_snapshot reads other members' relay status notes with no local runtime, so idle capacity is knowable without starting a node or running a probe. Grey would have conflated the two, and the second is the reason to look at the row at all. The switch shows only while not sharing; once sharing, the state is legible from the dot and Stop lives in the popover, which frees the trailing slot for live pool capacity. It is never disabled while consuming -- a client runtime can be replaced by a serve runtime at any time, so offering the switch is honest. The popover consolidates what were two overlapping surfaces (card + detail popover) into one, and replaces the small radial SVG with a canvas-2D radar field: self at centre, radius from RTT, node size from shared capacity, per-node breathing seeded by id hash, real adjacency as spokes, and non-participant members as a dashed outer band with no size and no capacity. Visual language follows mesh-llm's own console, drawn in 2D rather than porting its ~2,200 lines of WebGL -- a 288px popover does not justify shader plumbing. Nothing animates along a spoke: mesh-llm's counters are node-local with no per-edge attribution, so a travelling packet would be invented. Motion is confined to node breathing and a centre pulse driven by real inflight work. Row and popover share one useMeshComputeState hook rather than each calling the polling hooks, which would double every poll and let the two disagree mid-flight. Signed-off-by: Michael Neale --- .../mesh-compute/hooks/useMeshComputeState.ts | 199 ++++++++++ .../features/mesh-compute/meshCardModel.ts | 2 +- .../mesh-compute/meshRowModel.test.mjs | 146 ++++++++ .../src/features/mesh-compute/meshRowModel.ts | 173 +++++++++ .../mesh-compute/ui/MeshComputePopover.tsx | 219 +++++++++++ .../mesh-compute/ui/MeshDetailPopover.tsx | 173 --------- .../mesh-compute/ui/MeshRadarField.tsx | 304 +++++++++++++++ .../mesh-compute/ui/MeshTopologyRadial.tsx | 190 ---------- .../mesh-compute/ui/MeshTopologyStrip.tsx | 125 ------- .../ui/SidebarMeshComputeCard.tsx | 352 ------------------ .../mesh-compute/ui/SidebarMeshComputeRow.tsx | 116 ++++++ .../src/features/sidebar/ui/AppSidebar.tsx | 2 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 4 + .../sidebar/ui/SidebarFooterNotices.tsx | 29 +- 14 files changed, 1172 insertions(+), 862 deletions(-) create mode 100644 desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts create mode 100644 desktop/src/features/mesh-compute/meshRowModel.test.mjs create mode 100644 desktop/src/features/mesh-compute/meshRowModel.ts create mode 100644 desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx delete mode 100644 desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx create mode 100644 desktop/src/features/mesh-compute/ui/MeshRadarField.tsx delete mode 100644 desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx delete mode 100644 desktop/src/features/mesh-compute/ui/MeshTopologyStrip.tsx delete mode 100644 desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx create mode 100644 desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx diff --git a/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts b/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts new file mode 100644 index 0000000000..12a8cbdf24 --- /dev/null +++ b/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts @@ -0,0 +1,199 @@ +import * as React from "react"; + +import type { MeshModelCatalog } from "@/shared/api/tauriMesh"; +import { + meshModelCatalog, + meshStartNode, + meshStopNode, +} from "@/shared/api/tauriMesh"; + +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { deriveMeshCallToAction } from "../meshAgents"; +import { activitySample, inferInboundWork } from "../meshActivity"; +import { deriveMeshMemoryModel } from "../meshMemoryModel"; +import { deriveMeshRowModel } from "../meshRowModel"; +import { deriveMeshShareToggle } from "../shareToggleState"; +import { + getShareComputeModel, + resolveShareComputeModel, + setShareComputeModel, + useShareComputeMaxVramGb, + useShareComputeModel, +} from "../shareComputePreferences"; +import { useMeshDownloadProgress } from "./useMeshDownloadProgress"; +import { useMeshLiveView } from "./useMeshLiveView"; +import { useMeshNodeStatus } from "./useMeshNodeStatus"; +import { useMeshServingUsage } from "./useMeshServingUsage"; +import { useMeshSnapshot } from "./useMeshSnapshot"; + +/** + * Everything the sidebar Compute row and its popover need, resolved once. + * + * The row and the popover are two views of one state. Calling the hooks in both + * would double every poll and let the two disagree mid-flight — the row saying + * "sharing" while the popover it opened still said "starting". So the state is + * owned here and passed down. + */ +export function useMeshComputeState() { + const { status, refresh: refreshStatus } = useMeshNodeStatus(); + const selectedModel = useShareComputeModel(); + const maxVramGb = useShareComputeMaxVramGb(); + const [catalog, setCatalog] = React.useState(null); + const [pendingAction, setPendingAction] = React.useState< + "start" | "stop" | null + >(null); + const [actionError, setActionError] = React.useState(null); + // Bumping this re-queries the community snapshot right after a local + // transition, instead of waiting out the 30s poll. + const [snapshotNonce, setSnapshotNonce] = React.useState(0); + + const { snapshot, error: snapshotError } = useMeshSnapshot({ + refreshKey: snapshotNonce, + }); + + const toggle = deriveMeshShareToggle(status); + // Poll whenever a runtime slot is occupied, not just when sharing: a + // consuming node also has inflight work worth showing. + const usage = useMeshServingUsage(toggle.isSharing || toggle.isConsuming); + const { view } = useMeshLiveView(toggle.isSharing || toggle.isConsuming); + const { progress: downloadProgress, reset: resetDownloadProgress } = + useMeshDownloadProgress(); + + // Reads the resolved `provider` off ManagedAgent, not a persona's raw field: + // the mesh provider is usually set globally, so the persona value is null for + // most mesh agents. See meshAgents.ts. + const { data: managedAgents } = useManagedAgentsQuery(); + const callToAction = deriveMeshCallToAction({ + agents: managedAgents, + 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. Keeping the previous sample in a ref (not state) avoids + // a re-render purely to remember history. + const sample = activitySample(usage); + const previousSampleRef = React.useRef(null); + const inboundWork = inferInboundWork({ + isSharing: toggle.isSharing, + current: sample, + previous: previousSampleRef.current, + }); + React.useEffect(() => { + previousSampleRef.current = sample; + }, [sample]); + + // One-shot catalog fetch supplies hardware capacity and initializes the + // shared preference only when Settings has never chosen a model. + React.useEffect(() => { + let cancelled = false; + (async () => { + try { + const value = await meshModelCatalog(); + if (!cancelled) { + setCatalog(value); + if (getShareComputeModel().trim() === "" && value.recommended) { + setShareComputeModel(value.recommended); + } + } + } catch { + // Non-fatal: the switch stays disabled and Settings still works. + } + })(); + return () => { + cancelled = true; + }; + }, []); + + // Re-check the community view whenever the local lifecycle settles. + // biome-ignore lint/correctness/useExhaustiveDependencies: the local lifecycle fields are intentional triggers, not values read in the body — a settled start/stop should refresh the community view immediately + React.useEffect(() => { + setSnapshotNonce((current) => current + 1); + }, [status?.state, status?.mode]); + + const modelToShare = resolveShareComputeModel( + selectedModel, + catalog?.recommended, + ); + const activeModel = status?.modelName ?? status?.modelId ?? modelToShare; + const memory = deriveMeshMemoryModel({ + view, + catalog, + modelRef: activeModel, + }); + const busyNow = (usage?.inflight ?? 0) > 0; + + const row = deriveMeshRowModel({ + snapshot, + status, + toggle, + view, + pendingAction, + busyNow, + }); + + const setSharing = React.useCallback( + async (next: boolean) => { + // Never let this tear down a consume session: `isSharing` is the only + // thing that authorizes a stop, and status can be stale between polls. + if (!next && !toggle.isSharing) return; + setActionError(null); + setPendingAction(next ? "start" : "stop"); + try { + if (next) { + if (!modelToShare) { + throw new Error("Choose a model in Compute settings first"); + } + const parsed = + maxVramGb.trim() === "" ? undefined : Number.parseFloat(maxVramGb); + await meshStartNode({ + mode: "serve", + modelId: modelToShare, + maxVramGb: + typeof parsed === "number" && !Number.isNaN(parsed) + ? parsed + : undefined, + }); + } else { + await meshStopNode(); + } + refreshStatus(); + setSnapshotNonce((current) => current + 1); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } finally { + setPendingAction(null); + // Clear a lingering final download event so the next start does not + // begin by replaying the last run's progress. + resetDownloadProgress(); + } + }, + [ + maxVramGb, + modelToShare, + refreshStatus, + resetDownloadProgress, + toggle.isSharing, + ], + ); + + return { + row, + toggle, + status, + snapshot, + view, + usage, + memory, + callToAction, + inboundWork, + busyNow, + downloadProgress, + pendingAction, + modelToShare, + setSharing, + // A transport failure is worth showing; an empty mesh is not an error and + // is already expressed by the row tone. + error: actionError ?? snapshotError, + }; +} diff --git a/desktop/src/features/mesh-compute/meshCardModel.ts b/desktop/src/features/mesh-compute/meshCardModel.ts index 2cb159d50c..28473597ab 100644 --- a/desktop/src/features/mesh-compute/meshCardModel.ts +++ b/desktop/src/features/mesh-compute/meshCardModel.ts @@ -64,7 +64,7 @@ export function formatCapacityGb(gb: number): string { return `${rounded} GB`; } -function plural(n: number, one: string, many = `${one}s`): string { +export function plural(n: number, one: string, many = `${one}s`): string { return n === 1 ? one : many; } diff --git a/desktop/src/features/mesh-compute/meshRowModel.test.mjs b/desktop/src/features/mesh-compute/meshRowModel.test.mjs new file mode 100644 index 0000000000..4c0d0c65ce --- /dev/null +++ b/desktop/src/features/mesh-compute/meshRowModel.test.mjs @@ -0,0 +1,146 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * Contract tests for the sidebar Compute row. + * + * The row is one line with one signal, so the tone carries almost all of the + * meaning. These tests pin the distinctions that are easy to collapse by + * accident — especially "nothing here" vs "something here you have not joined", + * which is the whole reason the row shows capacity while switched off. + */ + +import { deriveMeshRowModel } from "./meshRowModel.ts"; + +const OFF = { isSharing: false, isConsuming: false, slotOccupied: false }; +const SHARING = { isSharing: true, isConsuming: false, slotOccupied: true }; +const CONSUMING = { isSharing: false, isConsuming: true, slotOccupied: true }; + +function snapshot(overrides = {}) { + return { + sharingDeviceCount: 0, + sharedCapacityGb: null, + models: [], + devices: [], + includesSelf: false, + memberCount: 0, + reason: null, + ...overrides, + }; +} + +function view(overrides = {}) { + return { connected: true, selfCapacityGb: 115, peers: [], ...overrides }; +} + +function peer(overrides = {}) { + return { + id: "p1", + label: "studio54", + state: "serving", + capacityGb: 36, + models: [], + rttMs: 30, + ...overrides, + }; +} + +function derive(overrides = {}) { + return deriveMeshRowModel({ + snapshot: null, + status: null, + toggle: OFF, + view: null, + pendingAction: null, + busyNow: false, + ...overrides, + }); +} + +test("an unfetched snapshot is unknown, not empty", () => { + // `null` is "not asked yet". Claiming the community has nothing before the + // first query lands is a verdict on other people's machines from no data. + const model = derive({ snapshot: null }); + assert.equal(model.tone, "unknown"); + assert.match(model.tooltip, /Checking/); +}); + +test("a fetched empty snapshot says so plainly", () => { + const model = derive({ snapshot: snapshot() }); + assert.equal(model.tone, "unknown"); + assert.match(model.tooltip, /No shared compute/); +}); + +test("capacity you have not joined is its own tone", () => { + // The point of the row: someone else is sharing 92 GB and you are outside it. + const model = derive({ + snapshot: snapshot({ sharingDeviceCount: 3, sharedCapacityGb: 92 }), + }); + assert.equal(model.tone, "available"); + assert.match(model.tooltip, /92 GB/); + assert.equal(model.showSwitch, true); +}); + +test("unknown capacity degrades to a device count, never 0 GB", () => { + const model = derive({ + snapshot: snapshot({ sharingDeviceCount: 2, sharedCapacityGb: null }), + }); + assert.equal(model.tone, "available"); + assert.match(model.tooltip, /2 devices/); + assert.doesNotMatch(model.tooltip, /0 GB/); +}); + +test("sharing hides the switch and shows live capacity instead", () => { + const model = derive({ + toggle: SHARING, + view: view({ peers: [peer()] }), + }); + assert.equal(model.tone, "sharing"); + // Stop lives in the popover, so the row reclaims the space for the payoff. + assert.equal(model.showSwitch, false); + assert.equal(model.badge, "151 GB"); + assert.match(model.tooltip, /1 peer\b/); +}); + +test("a solo sharer is named as waiting, not as a peer count of zero", () => { + const model = derive({ toggle: SHARING, view: view() }); + assert.match(model.tooltip, /waiting for another device/); +}); + +test("consuming keeps the switch: sharing is always available", () => { + const model = derive({ toggle: CONSUMING }); + assert.equal(model.tone, "consuming"); + // A client runtime can be replaced by a serve runtime. Consuming is not a + // lock, so the row must never present it as one. + assert.equal(model.showSwitch, true); +}); + +test("pulse tracks inflight work only", () => { + assert.equal(derive({ toggle: SHARING, view: view() }).pulse, false); + assert.equal( + derive({ toggle: SHARING, view: view(), busyNow: true }).pulse, + true, + ); + assert.equal(derive({ toggle: CONSUMING, busyNow: true }).pulse, true); +}); + +test("a transition outranks any steady tone", () => { + assert.equal(derive({ pendingAction: "start" }).tone, "starting"); + assert.equal( + derive({ toggle: SHARING, pendingAction: "stop" }).tone, + "starting", + ); + assert.equal( + derive({ toggle: SHARING, status: { state: "starting" } }).tone, + "starting", + ); +}); + +test("an unhealthy serve runtime reads failed, and can still be stopped", () => { + const model = derive({ + toggle: SHARING, + status: { state: "running", health: { status: "unreachable" } }, + }); + assert.equal(model.tone, "failed"); + assert.equal(model.switchLabel, "Stop sharing"); +}); diff --git a/desktop/src/features/mesh-compute/meshRowModel.ts b/desktop/src/features/mesh-compute/meshRowModel.ts new file mode 100644 index 0000000000..487965eaab --- /dev/null +++ b/desktop/src/features/mesh-compute/meshRowModel.ts @@ -0,0 +1,173 @@ +import type { + MeshLiveView, + MeshNodeStatus, + MeshSnapshot, +} from "@/shared/api/tauriMesh"; +import type { MeshShareToggleModel } from "./shareToggleState"; +import { formatCapacityGb, plural } from "./meshCardModel"; + +/** + * Pure projection for the sidebar Compute row. + * + * The row replaced a ~120px card with a single menu line, so it carries exactly + * one signal: a coloured dot. Everything else moved into the popover. + * + * ## Why "not participating" is two states, not one + * + * Grey would conflate "nobody is sharing anything" with "there is 115 GB here + * and you simply have not joined" — and the second is the whole reason to look + * at this row. They are distinguishable without any probe: `mesh_snapshot` + * reads other members' relay status notes and needs no local runtime, so we + * know the pool exists while our own node is off. + * + * `unknown` therefore means *no capacity known* (nothing published, or not + * fetched yet), and `available` means *capacity exists and we are outside it*. + */ +export type MeshRowTone = + /** Nothing published by anyone, or the snapshot has not landed yet. */ + | "unknown" + /** The community has capacity; this machine is not in the mesh. */ + | "available" + /** Serving: this machine contributes compute. */ + | "sharing" + /** Client only: this machine uses someone else's compute. */ + | "consuming" + /** A start/stop is in flight, or the node is loading a model. */ + | "starting" + /** The runtime occupies the slot but reports unhealthy. */ + | "failed"; + +export type MeshRowModel = { + tone: MeshRowTone; + /** Accessible state sentence, used as the row tooltip. */ + tooltip: string; + /** + * Compact trailing figure, or null. Only shown when the switch is absent + * (i.e. while sharing) so the two never compete for the same 256px. + */ + badge: string | null; + /** Show the Share switch. Hidden once sharing — stop lives in the popover. */ + showSwitch: boolean; + /** Label for the switch, read by screen readers. */ + switchLabel: string; + /** + * Pulse the dot. Inflight work only — never a decorative animation, so a + * still dot is a real statement that nothing is happening. + */ + pulse: boolean; +}; + +/** Live pool capacity: this machine plus every peer that reports a figure. */ +function liveCapacityGb(view: MeshLiveView | null): number { + if (!view?.connected) return 0; + return [ + view.selfCapacityGb ?? 0, + ...view.peers.map((peer) => peer.capacityGb ?? 0), + ].reduce((total, gb) => total + gb, 0); +} + +export function deriveMeshRowModel({ + snapshot, + status, + toggle, + view, + pendingAction, + busyNow, +}: { + snapshot: MeshSnapshot | null; + status: MeshNodeStatus | null; + toggle: MeshShareToggleModel; + view: MeshLiveView | null; + pendingAction: "start" | "stop" | null; + /** Work in flight on this node, inbound or outbound. */ + busyNow: boolean; +}): MeshRowModel { + const shareLabel = "Share this computer's compute"; + + // A start/stop in flight outranks everything: the tone must not claim a + // steady state while the runtime is mid-transition. + if ( + pendingAction !== null || + (toggle.isSharing && status?.state === "starting") + ) { + return { + tone: "starting", + tooltip: + pendingAction === "stop" ? "Stopping sharing…" : "Starting to share…", + badge: null, + showSwitch: false, + switchLabel: shareLabel, + pulse: false, + }; + } + + if (toggle.isSharing) { + const health = status?.health; + if (health && health.status !== "ok") { + return { + tone: "failed", + tooltip: "Sharing failed — open for details", + badge: null, + showSwitch: false, + switchLabel: "Stop sharing", + pulse: false, + }; + } + const gb = liveCapacityGb(view); + const peerCount = view?.peers.length ?? 0; + return { + tone: "sharing", + tooltip: + peerCount > 0 + ? `Sharing compute · ${peerCount} ${plural(peerCount, "peer")}` + : "Sharing compute · waiting for another device", + // Capacity is the payoff for sharing, so it earns the badge slot the + // switch vacates. + badge: gb > 0 ? formatCapacityGb(gb) : null, + showSwitch: false, + switchLabel: "Stop sharing", + pulse: busyNow, + }; + } + + if (toggle.isConsuming) { + return { + tone: "consuming", + tooltip: "Using shared compute from the mesh", + badge: null, + // Deliberately still offered: a member may replace a client runtime with + // a serve runtime at any time. Consuming is not a lock. + showSwitch: true, + switchLabel: shareLabel, + pulse: busyNow, + }; + } + + // Not participating. The relay snapshot is the only view, and the only thing + // that distinguishes "there is a mesh to join" from "there is nothing here". + const count = snapshot?.sharingDeviceCount ?? 0; + if (!snapshot || count === 0) { + return { + tone: "unknown", + tooltip: snapshot + ? "No shared compute in this community yet" + : "Checking for shared compute…", + badge: null, + showSwitch: true, + switchLabel: shareLabel, + pulse: false, + }; + } + const gb = snapshot.sharedCapacityGb; + return { + tone: "available", + tooltip: + gb === null + ? `Shared compute available · ${count} ${plural(count, "device")}` + : `${formatCapacityGb(gb)} of shared compute available`, + badge: null, + showSwitch: true, + switchLabel: shareLabel, + pulse: false, + }; +} diff --git a/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx b/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx new file mode 100644 index 0000000000..c117a15a5d --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx @@ -0,0 +1,219 @@ +import type * as React from "react"; + +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { POPOVER_SHADOW_STYLE } from "@/shared/ui/popoverSurface"; +import { Switch } from "@/shared/ui/switch"; +import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; + +import type { useMeshComputeState } from "../hooks/useMeshComputeState"; +import { + describeStartupHeadline, + describeStartupStage, +} from "../meshCardModel"; +import { deriveMeshDetailModel } from "../meshDetailModel"; +import { MeshMemoryBulbs } from "./MeshMemoryBulbs"; +import { MeshRadarField } from "./MeshRadarField"; + +/** + * The single mesh detail surface, anchored to the sidebar Compute row. + * + * Consolidates what used to be a footer card plus a separate popover. Sidebar + * real estate is premium, so the row carries one dot and everything else lives + * here — including Stop sharing, which is why the row hides its switch once + * sharing is on. + * + * Every figure is one this machine can vouch for. Peers come from live gossip, + * so a node shown is a node we are connected to. Inbound work has no counter in + * mesh-llm, so it is inferred by elimination and only named when that inference + * holds. See `meshDetailModel.ts` and `meshActivity.ts`. + */ +export function MeshComputePopover({ + children, + mesh, + onOpenComputeSettings, +}: { + children: React.ReactNode; + mesh: ReturnType; + onOpenComputeSettings?: () => void; +}) { + const { + view, + snapshot, + status, + usage, + memory, + toggle, + row, + callToAction, + inboundWork, + downloadProgress, + pendingAction, + modelToShare, + setSharing, + error, + } = mesh; + + const model = deriveMeshDetailModel({ + view, + snapshot, + usage, + isSharing: toggle.isSharing, + inboundWork, + }); + 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), + ); + + return ( + + {children} + +
+
+
+

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

+

+ {model.participationLabel} +

+
+ {/* + Stop lives here, not on the row: the row hides its switch once + sharing so the state reads at a glance without a control competing + for the same 256px. + */} + +
+ + {model.connected ? ( + + ) : null} + + {toggle.isSharing && !isStarting ? ( + + ) : null} + + {isStarting ? ( +
+

+ {describeStartupHeadline(downloadProgress)} +

+

+ {describeStartupStage(downloadProgress, selectedModel)} +

+
+ ) : null} + +
+ {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.connected ? null : ( +

+ Turn on sharing to see who's on the mesh. +

+ )} + + {callToAction.kind === "createAgent" ? ( + // A tip, not a warning: there is compute here and nothing set up to + // use it. Disappears the moment a mesh agent exists. + + ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + + {onOpenComputeSettings ? ( + + ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx b/desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx deleted file mode 100644 index 4687f26cc9..0000000000 --- a/desktop/src/features/mesh-compute/ui/MeshDetailPopover.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import type { - MeshLiveView, - MeshNodeStatus, - MeshServingUsage, - MeshSnapshot, -} from "@/shared/api/tauriMesh"; -import type { MeshDownloadProgress } from "../hooks/useMeshDownloadProgress"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; -import { POPOVER_SHADOW_STYLE } from "@/shared/ui/popoverSurface"; -import { - describeStartupHeadline, - describeStartupStage, -} from "../meshCardModel"; -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. Peers come from - * live gossip, so a node shown is a node we are connected to — not a relay note - * that may have outlived its writer. Inbound work has no counter, so it is - * inferred by elimination and only named when that inference holds. See - * `meshDetailModel.ts` and `meshActivity.ts`. - */ -export function MeshDetailPopover({ - children, - view, - snapshot, - status, - pendingAction, - downloadProgress, - startingModel, - usage, - isSharing, - inboundWork, - onOpenComputeSettings, -}: { - children: React.ReactNode; - /** Live gossip view — the source for the topology. */ - view: MeshLiveView | null; - /** Relay snapshot, used only when no local runtime exists. */ - snapshot: MeshSnapshot | null; - status: MeshNodeStatus | null; - pendingAction: "start" | "stop" | null; - downloadProgress: MeshDownloadProgress | null; - startingModel: string | null; - usage: MeshServingUsage | null; - isSharing: boolean; - inboundWork: boolean; - onOpenComputeSettings?: () => void; -}) { - const model = deriveMeshDetailModel({ - view, - snapshot, - usage, - isSharing, - inboundWork, - }); - const isStarting = - pendingAction === "start" || - (isSharing && - (status?.state === "starting" || - snapshot?.devices.some( - (device) => device.isSelf && device.state === "loading", - ))); - const selectedModel = status?.modelName ?? status?.modelId ?? startingModel; - - return ( - - {children} - -
-
-

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

-

- {model.participationLabel} -

-
- - {model.connected ? ( - - ) : null} - - {isStarting ? ( -
-

- {describeStartupHeadline(downloadProgress)} -

-

- {describeStartupStage(downloadProgress, selectedModel)} -

-
- ) : null} - -
- {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.connected ? null : ( -

- Turn on sharing to see who's on the mesh. -

- )} - - {onOpenComputeSettings ? ( - - ) : null} -
-
-
- ); -} diff --git a/desktop/src/features/mesh-compute/ui/MeshRadarField.tsx b/desktop/src/features/mesh-compute/ui/MeshRadarField.tsx new file mode 100644 index 0000000000..e585aa189b --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/MeshRadarField.tsx @@ -0,0 +1,304 @@ +import * as React from "react"; +import { useReducedMotion } from "motion/react"; + +import type { MeshPeer } from "@/shared/api/tauriMesh"; +import { cn } from "@/shared/lib/cn"; + +/** + * Canvas-2D radar field for the mesh. + * + * Visual language borrowed from mesh-llm's own console (self at centre, radius + * from latency, size from VRAM, per-node breathing seeded by id) but drawn in + * 2D rather than porting its ~2,200 lines of WebGL: a 288px popover does not + * justify shader plumbing, and this has to stay cheap enough to run beside a + * chat client. + * + * ## What is real here + * + * Every solid node is a peer this machine's runtime is **connected to right + * now** — live gossip, not a relay note that outlives its writer. Edge geometry + * is real adjacency and edge opacity is real RTT. + * + * Nothing travels along an edge. mesh-llm's counters are node-local and carry + * no per-edge attribution, so a packet gliding down a spoke would be invented. + * Motion here is confined to what we can defend: a node breathes, and the + * centre pulses when this machine has work in flight. + * + * Ghosts are community members with no published status note. They are drawn as + * a dashed outer band with **no size and no capacity**, because a member who + * never started a node has disclosed no hardware. They are a count, never GB. + */ + +/** Stable 0..1 from a node id, so each node breathes on its own phase. */ +function hashUnit(value: string): number { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return ((hash >>> 0) % 10000) / 10000; +} + +type Palette = { + self: string; + serving: string; + consuming: string; + standby: string; + edge: string; + ghost: string; +}; + +const LIGHT: Palette = { + self: "16, 185, 129", + serving: "16, 185, 129", + consuming: "14, 165, 233", + standby: "148, 163, 184", + edge: "100, 116, 139", + ghost: "148, 163, 184", +}; + +const DARK: Palette = { + self: "52, 211, 153", + serving: "52, 211, 153", + consuming: "56, 189, 248", + standby: "148, 163, 184", + edge: "148, 163, 184", + ghost: "148, 163, 184", +}; + +function peerColor(state: MeshPeer["state"], palette: Palette): string { + if (state === "serving") return palette.serving; + if (state === "consuming") return palette.consuming; + return palette.standby; +} + +/** Radius band by RTT: closer nodes sit nearer the centre. */ +function radiusFor(rttMs: number | null, min: number, max: number): number { + if (rttMs === null) return (min + max) / 2; + // 0..120ms maps across the band; beyond that everything pins to the rim. + const t = Math.min(1, Math.max(0, rttMs / 120)); + return min + (max - min) * t; +} + +/** Node radius from shared capacity, with a floor so a client is still visible. */ +function sizeFor(capacityGb: number | null): number { + if (capacityGb === null || capacityGb <= 0) return 3.2; + return Math.min(7, 3.4 + Math.sqrt(capacityGb) * 0.45); +} + +export function MeshRadarField({ + peers, + selfCapacityGb, + ghostCount, + busyNow, + className, + height = 150, +}: { + peers: MeshPeer[]; + selfCapacityGb: number | null; + /** Community members with no node. Drawn as a dashed band, count only. */ + ghostCount: number; + busyNow: boolean; + className?: string; + height?: number; +}) { + const shouldReduceMotion = useReducedMotion(); + const canvasRef = React.useRef(null); + const [isDark, setIsDark] = React.useState(false); + + // Palette follows the app theme; the canvas cannot inherit CSS variables. + React.useEffect(() => { + const read = () => + setIsDark(document.documentElement.classList.contains("dark")); + read(); + const observer = new MutationObserver(read); + observer.observe(document.documentElement, { + attributeFilter: ["class"], + attributes: true, + }); + return () => observer.disconnect(); + }, []); + + // Snapshot the frame inputs so the draw loop is not re-created per render. + const frame = React.useRef({ + peers, + selfCapacityGb, + ghostCount, + busyNow, + isDark, + }); + frame.current = { peers, selfCapacityGb, ghostCount, busyNow, isDark }; + + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const context = canvas.getContext("2d"); + if (!context) return; + + let raf = 0; + let disposed = false; + const started = performance.now(); + + const draw = (now: number) => { + if (disposed) return; + const { + peers: livePeers, + selfCapacityGb: selfGb, + ghostCount: ghosts, + busyNow: busy, + isDark: dark, + } = frame.current; + const palette = dark ? DARK : LIGHT; + + const ratio = Math.min(2, globalThis.devicePixelRatio || 1); + const width = canvas.clientWidth; + const drawHeight = canvas.clientHeight; + if (canvas.width !== Math.round(width * ratio)) { + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(drawHeight * ratio); + } + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.clearRect(0, 0, width, drawHeight); + + const cx = width / 2; + const cy = drawHeight / 2; + // Reduced motion freezes the clock rather than removing the geometry, so + // the field still reads the same — it simply stops breathing. + const elapsed = shouldReduceMotion ? 0 : (now - started) / 1000; + const rim = Math.min(cx, cy) - 12; + const innerMin = rim * 0.42; + const innerMax = rim * 0.82; + + // Faint range rings for depth. + context.strokeStyle = `rgba(${palette.edge}, ${dark ? 0.1 : 0.09})`; + context.lineWidth = 1; + for (const scale of [0.42, 0.82]) { + context.beginPath(); + context.arc(cx, cy, rim * scale, 0, Math.PI * 2); + context.stroke(); + } + + // Ghost band: members with no node. Dashed, sizeless, no capacity implied. + if (ghosts > 0) { + context.save(); + context.setLineDash([2, 3]); + context.strokeStyle = `rgba(${palette.ghost}, ${dark ? 0.22 : 0.2})`; + context.beginPath(); + context.arc(cx, cy, rim, 0, Math.PI * 2); + context.stroke(); + context.setLineDash([]); + const shown = Math.min(ghosts, 14); + for (let index = 0; index < shown; index += 1) { + const angle = (index / shown) * Math.PI * 2 - Math.PI / 2 + 0.31; + const x = cx + Math.cos(angle) * rim; + const y = cy + Math.sin(angle) * rim; + context.beginPath(); + context.arc(x, y, 1.8, 0, Math.PI * 2); + context.fillStyle = `rgba(${palette.ghost}, ${dark ? 0.34 : 0.3})`; + context.fill(); + } + context.restore(); + } + + // Peers: angle spread evenly, radius from RTT, size from capacity. + const count = livePeers.length; + livePeers.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); + const x = cx + Math.cos(angle) * radius; + 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(); + + // Breathing, phase-seeded by id so the field shimmers rather than blinks. + const phase = hashUnit(`${peer.id}:phase`) * Math.PI * 2; + const breath = + peer.state === "serving" + ? 0.82 + Math.sin(elapsed * 1.7 + phase) * 0.18 + : 0.9; + const size = sizeFor(peer.capacityGb) * breath; + + const glow = context.createRadialGradient(x, y, 0, x, y, size * 3.4); + glow.addColorStop(0, `rgba(${rgb}, ${dark ? 0.36 : 0.3})`); + glow.addColorStop(1, `rgba(${rgb}, 0)`); + context.fillStyle = glow; + context.beginPath(); + context.arc(x, y, size * 3.4, 0, Math.PI * 2); + context.fill(); + + context.fillStyle = `rgba(${rgb}, ${peer.state === "serving" ? 0.95 : 0.72})`; + context.beginPath(); + context.arc(x, y, size, 0, Math.PI * 2); + context.fill(); + }); + + // Self, last so it sits above the spokes. + const selfSize = sizeFor(selfGb) + 1.4; + if (busy && !shouldReduceMotion) { + // Expanding ring: this machine has work in flight. Not attributed to any + // edge, because nothing tells us which peer it belongs to. + const wave = (elapsed % 1.6) / 1.6; + context.strokeStyle = `rgba(${palette.self}, ${0.34 * (1 - wave)})`; + context.lineWidth = 1.5; + context.beginPath(); + context.arc(cx, cy, selfSize + wave * 22, 0, Math.PI * 2); + context.stroke(); + } + const selfGlow = context.createRadialGradient( + cx, + cy, + 0, + cx, + cy, + selfSize * 3.6, + ); + selfGlow.addColorStop(0, `rgba(${palette.self}, ${dark ? 0.42 : 0.34})`); + selfGlow.addColorStop(1, `rgba(${palette.self}, 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(); + + // A frozen field needs no frames; reduced motion draws once and stops. + if (!shouldReduceMotion) { + raf = requestAnimationFrame(draw); + } + }; + + raf = requestAnimationFrame(draw); + return () => { + disposed = true; + cancelAnimationFrame(raf); + }; + }, [shouldReduceMotion]); + + return ( + + ); +} diff --git a/desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx b/desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx deleted file mode 100644 index 3a4fdbf79a..0000000000 --- a/desktop/src/features/mesh-compute/ui/MeshTopologyRadial.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { motion, useReducedMotion } from "motion/react"; - -import type { MeshPeer } from "@/shared/api/tauriMesh"; -import { cn } from "@/shared/lib/cn"; -import { formatCapacityGb, shortModelLabel } from "../meshCardModel"; - -/** - * Radial mesh field: this computer at the centre, live peers around it. - * - * ## One source of truth - * - * Every node drawn here comes from the runtime's own gossip view, so a node is - * on screen because we are *connected to it right now*. This deliberately does - * not overlay relay status notes (valid for 120s, so they outlive the node that - * wrote them) or community roster members who never started a node. Mixing - * those in meant refereeing disagreements between sources and produced a graph - * showing devices gossip already knew were gone. - * - * ## What the geometry claims - * - * A spoke means **"connected to you"** — and that much is true, because this is - * our own adjacency. Nothing is animated *along* a spoke: the activity counters - * are node-local rather than edge-attributed, so a packet gliding down a chosen - * link would be invented. - * - * The centre pulses on this node's own inflight work, which we hold directly. - * Peers pulse only on the state they publish about themselves, never on - * inferred traffic. - */ - -const CENTER = 60; -const RADIUS = 42; - -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): { 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 peerTitle(peer: MeshPeer): string { - const parts = [peer.label]; - if (peer.capacityGb !== null) { - parts.push(formatCapacityGb(peer.capacityGb)); - } else if (peer.state === "consuming") { - parts.push("using shared compute"); - } - if (peer.models.length > 0) { - parts.push(shortModelLabel(peer.models[0])); - } - if (peer.rttMs !== null) { - parts.push(`${peer.rttMs}ms`); - } - return parts.join(" · "); -} - -export function MeshTopologyRadial({ - peers, - selfCapacityGb, - isSharing, - busyNow, -}: { - peers: MeshPeer[]; - selfCapacityGb: number | null; - /** Whether this machine is contributing, which sets the centre's colour. */ - isSharing: boolean; - /** This node has inference in flight — the only honest live signal. */ - busyNow: boolean; -}) { - const shouldReduceMotion = useReducedMotion(); - - // Resolve geometry and keys up front so render maps over identities rather - // than array positions. - const nodes = peers.map((peer, index) => ({ - peer, - pulseDelay: index * 0.35, - ...pointOnRing(index, peers.length), - })); - - const selfTitle = [ - "This computer", - isSharing && selfCapacityGb !== null - ? formatCapacityGb(selfCapacityGb) - : null, - isSharing ? null : "not sharing", - ] - .filter(Boolean) - .join(" · "); - - return ( -
- - {/* Adjacency, not traffic — see the file comment. */} - - {nodes.map((node) => ( - - ))} - - - {nodes.map(({ peer, pulseDelay, x, y }) => { - const isPulsing = peer.state === "serving" && !shouldReduceMotion; - return ( - - {peerTitle(peer)} - {isPulsing ? ( - - ) : null} - - - ); - })} - - {/* This computer. The only node whose activity we can honestly animate, - because inflight is our own counter. */} - - {selfTitle} - {busyNow && !shouldReduceMotion ? ( - - ) : null} - - - -
- ); -} diff --git a/desktop/src/features/mesh-compute/ui/MeshTopologyStrip.tsx b/desktop/src/features/mesh-compute/ui/MeshTopologyStrip.tsx deleted file mode 100644 index b250a17643..0000000000 --- a/desktop/src/features/mesh-compute/ui/MeshTopologyStrip.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import { motion, useReducedMotion } from "motion/react"; - -import { cn } from "@/shared/lib/cn"; -import type { MeshSnapshotDevice } from "@/shared/api/tauriMesh"; -import { formatCapacityGb, shortModelLabel } from "../meshCardModel"; - -/** - * Compact contributor strip for the sidebar card. - * - * Deliberately NOT a network topology graph. The relay tells us which devices - * are participating and what each advertises, but the published activity - * counters are **node-local, not edge-attributed** — so drawing traffic moving - * along a particular link would be fiction. Each device gets a dot; a dot - * pulses only when that device is actually serving. - * - * Honest at every scale, including the common one: - * n=1 → a single dot plus "Waiting for another device" - * n=2+ → one dot per device, yours marked - * many → the first few dots plus a "+N" remainder - */ - -const MAX_VISIBLE_DOTS = 8; - -const STATE_DOT_CLASS: Record = { - serving: "bg-emerald-500 dark:bg-emerald-400", - loading: "bg-amber-500 dark:bg-amber-400", - standby: "bg-muted-foreground/40", - consuming: "bg-sky-500 dark:bg-sky-400", -}; - -function deviceTitle(device: MeshSnapshotDevice): string { - const parts = [ - device.isSelf ? `${device.label} (this computer)` : device.label, - ]; - if (device.capacityGb !== null) { - parts.push(formatCapacityGb(device.capacityGb)); - } - if (device.models.length > 0) { - parts.push(shortModelLabel(device.models[0])); - } else if (device.state === "standby") { - parts.push("standing by"); - } else if (device.state === "consuming") { - parts.push("using shared compute"); - } - return parts.join(" · "); -} - -export function MeshTopologyStrip({ - devices, - showSoloHint, -}: { - devices: MeshSnapshotDevice[]; - showSoloHint: boolean; -}) { - const shouldReduceMotion = useReducedMotion(); - - if (devices.length === 0) { - return null; - } - - const visible = devices.slice(0, MAX_VISIBLE_DOTS); - const remainder = devices.length - visible.length; - - return ( -
-
- {visible.map((device, index) => { - // Pulse only for devices genuinely serving. Reduced motion gets a - // static dot rather than a slower animation. - const isPulsing = device.state === "serving" && !shouldReduceMotion; - return ( - - {isPulsing ? ( - - ) : null} - - - ); - })} - {remainder > 0 ? ( - - +{remainder} - - ) : null} -
- - {showSoloHint ? ( - - Waiting for another device - - ) : null} -
- ); -} diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx deleted file mode 100644 index 0f9c8af40e..0000000000 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx +++ /dev/null @@ -1,352 +0,0 @@ -import * as React from "react"; -import { Cpu, Loader2, TriangleAlert, Download, Share2 } from "lucide-react"; -import { motion, useReducedMotion } from "motion/react"; - -import { Switch } from "@/shared/ui/switch"; -import { cn } from "@/shared/lib/cn"; -import { meshStartNode, meshStopNode } from "@/shared/api/tauriMesh"; -import type { MeshModelCatalog } from "@/shared/api/tauriMesh"; -import { meshModelCatalog } from "@/shared/api/tauriMesh"; - -import { useManagedAgentsQuery } from "@/features/agents/hooks"; -import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; -import { deriveMeshCallToAction } from "../meshAgents"; -import { deriveMeshMemoryModel } from "../meshMemoryModel"; -import { useMeshDownloadProgress } from "../hooks/useMeshDownloadProgress"; -import { useMeshLiveView } from "../hooks/useMeshLiveView"; -import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus"; -import { useMeshServingUsage } from "../hooks/useMeshServingUsage"; -import { useMeshSnapshot } from "../hooks/useMeshSnapshot"; -import { deriveMeshShareToggle } from "../shareToggleState"; -import { - getShareComputeModel, - resolveShareComputeModel, - setShareComputeModel, - useShareComputeMaxVramGb, - useShareComputeModel, -} from "../shareComputePreferences"; -import { activitySample, inferInboundWork } from "../meshActivity"; -import { deriveMeshCardModel, type MeshCardTone } from "../meshCardModel"; -import { MeshDetailPopover } from "./MeshDetailPopover"; -import { MeshMemoryBulbs } from "./MeshMemoryBulbs"; -import { MeshTopologyStrip } from "./MeshTopologyStrip"; - -/** - * Sidebar shared-compute card. - * - * Answers three questions at a glance, which is the whole reason it exists: - * - Am I sharing this computer? → switch on, "Sharing this computer" - * - Am I using someone else's? → switch off, "Using shared compute" - * - What does the community have? → "MeshLLM · 115 GB, 2 peers" - * - * Deliberately NOT a model picker. Turning it on uses the model and memory cap - * chosen in Settings → Compute; choosing them stays in Settings rather than - * duplicating configuration controls in the sidebar. - * - * Own file rather than living in `AppSidebar.tsx`, which sits at 999/1000 lines - * against `desktop/scripts/check-file-sizes.mjs`. - */ - -const TONE_ICON: Record = { - idle: , - sharing: , - consuming: , - pending: , - failed: , -}; - -// Accent per role. Sharing (giving) and consuming (taking) must be -// distinguishable at a glance, not just by wording. -const TONE_ICON_CLASS: Record = { - idle: "text-muted-foreground", - sharing: "text-emerald-600 dark:text-emerald-400", - consuming: "text-sky-600 dark:text-sky-400", - pending: "text-muted-foreground", - failed: "text-destructive", -}; - -const TONE_RING_CLASS: Record = { - idle: "border-border/70 bg-background/70 dark:bg-background/50", - sharing: - "border-emerald-500/30 bg-emerald-500/[0.06] dark:border-emerald-400/25 dark:bg-emerald-400/[0.07]", - consuming: - "border-sky-500/30 bg-sky-500/[0.06] dark:border-sky-400/25 dark:bg-sky-400/[0.07]", - pending: "border-border/70 bg-background/70 dark:bg-background/50", - failed: "border-destructive/40 bg-destructive/[0.06]", -}; - -export function SidebarMeshComputeCard({ - className, - onOpenComputeSettings, -}: { - className?: string; - onOpenComputeSettings?: () => void; -}) { - const shouldReduceMotion = useReducedMotion(); - const { status, refresh: refreshStatus } = useMeshNodeStatus(); - const selectedModel = useShareComputeModel(); - const maxVramGb = useShareComputeMaxVramGb(); - const [catalog, setCatalog] = React.useState(null); - const [pendingAction, setPendingAction] = React.useState< - "start" | "stop" | null - >(null); - const [actionError, setActionError] = React.useState(null); - // Bumping this re-queries the community snapshot right after a local - // transition, instead of waiting out the 30s poll. - const [snapshotNonce, setSnapshotNonce] = React.useState(0); - - const { snapshot, error: snapshotError } = useMeshSnapshot({ - refreshKey: snapshotNonce, - }); - - const toggle = deriveMeshShareToggle(status); - // Poll whenever a runtime slot is occupied, not just when sharing: a - // consuming node also has inflight work worth showing. - const usage = useMeshServingUsage(toggle.isSharing || toggle.isConsuming); - // `inflight` is the only honest "working" signal: mesh-llm's routing metrics - // are all OUTBOUND (incremented by this node's own ingress), so a remote/ - // endpoint attempt means this machine is CONSUMING, not being consumed. - // Reading them as "someone is using my compute" is exactly backwards. - // Live gossip view: peers we are actually connected to. Only meaningful while - // a runtime exists, so it is gated on slot occupancy. - const { view } = useMeshLiveView(toggle.isSharing || toggle.isConsuming); - const { progress: downloadProgress, reset: resetDownloadProgress } = - useMeshDownloadProgress(); - - // Sharing compute no agent can use is a dead end, and this card is where - // someone would notice. Reads the resolved `provider` off ManagedAgent, not - // a persona's raw field: the mesh provider is usually set globally, so the - // persona value is null for most mesh agents. See meshAgents.ts. - const { data: managedAgents } = useManagedAgentsQuery(); - const callToAction = deriveMeshCallToAction({ - agents: managedAgents, - 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 - // state) avoids a re-render purely to remember history. - const sample = activitySample(usage); - const previousSampleRef = React.useRef(null); - const inboundWork = inferInboundWork({ - isSharing: toggle.isSharing, - current: sample, - previous: previousSampleRef.current, - }); - React.useEffect(() => { - previousSampleRef.current = sample; - }, [sample]); - - // One-shot catalog fetch supplies hardware capacity and initializes the - // shared preference only when Settings has never chosen a model. - React.useEffect(() => { - let cancelled = false; - (async () => { - try { - const value = await meshModelCatalog(); - if (!cancelled) { - setCatalog(value); - if (getShareComputeModel().trim() === "" && value.recommended) { - setShareComputeModel(value.recommended); - } - } - } catch { - // Non-fatal: the switch stays disabled and Settings still works. - } - })(); - return () => { - cancelled = true; - }; - }, []); - - // Re-check the community view whenever the local lifecycle settles. - // biome-ignore lint/correctness/useExhaustiveDependencies: the local lifecycle fields are intentional triggers, not values read in the body — a settled start/stop should refresh the community view immediately - React.useEffect(() => { - setSnapshotNonce((current) => current + 1); - }, [status?.state, status?.mode]); - - const modelToShare = resolveShareComputeModel( - selectedModel, - catalog?.recommended, - ); - const activeModel = status?.modelName ?? status?.modelId ?? modelToShare; - const memory = deriveMeshMemoryModel({ - view, - catalog, - modelRef: activeModel, - }); - const model = deriveMeshCardModel({ - snapshot, - status, - toggle, - pendingAction, - canShare: Boolean(modelToShare), - view, - usage, - inboundWork, - downloadProgress, - startingModel: modelToShare, - }); - - async function handleToggle(next: boolean) { - // Never let this switch tear down a consume session. The switch is already - // disabled while consuming, but status can be stale between polls. - if (!next && !toggle.isSharing) { - return; - } - setActionError(null); - setPendingAction(next ? "start" : "stop"); - try { - if (next) { - if (!modelToShare) { - throw new Error("Choose a model in Compute settings first"); - } - const maxVram = - maxVramGb.trim() === "" ? undefined : Number.parseFloat(maxVramGb); - await meshStartNode({ - mode: "serve", - modelId: modelToShare, - maxVramGb: - typeof maxVram === "number" && !Number.isNaN(maxVram) - ? maxVram - : undefined, - }); - } else { - await meshStopNode(); - } - refreshStatus(); - setSnapshotNonce((current) => current + 1); - } catch (err) { - setActionError(err instanceof Error ? err.message : String(err)); - } finally { - setPendingAction(null); - // Clear a lingering final download event so the next start does not begin - // by replaying the last run's progress. - resetDownloadProgress(); - } - } - - // A transport failure is worth showing; an empty mesh is not an error and is - // already expressed by the headline ("No mesh capacity yet"). - const errorText = actionError ?? snapshotError; - - return ( - -
-
- - {TONE_ICON[model.tone]} - - - {/* - Clickable region is the text block, not the whole card: wrapping the - Switch in a button would nest interactive elements. - */} - - - - - -
- - {toggle.isSharing && model.tone === "sharing" ? ( - - ) : ( - - )} - - {callToAction.kind === "createAgent" ? ( - // A tip, not a warning: there is compute here and nothing set up to - // use it. The dot pulses to catch the eye once, then the tip simply - // disappears the moment a mesh agent exists. - - ) : null} - - {errorText ? ( -

- {errorText} -

- ) : null} -
-
- ); -} diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx new file mode 100644 index 0000000000..6318cbf51e --- /dev/null +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx @@ -0,0 +1,116 @@ +import { SidebarMenuButton, SidebarMenuItem } from "@/shared/ui/sidebar"; +import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label"; +import { Switch } from "@/shared/ui/switch"; +import { cn } from "@/shared/lib/cn"; + +import { useMeshComputeState } from "../hooks/useMeshComputeState"; +import type { MeshRowTone } from "../meshRowModel"; +import { MeshComputePopover } from "./MeshComputePopover"; + +/** + * The sidebar Compute 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 + * — a coloured dot — and everything else lives in the popover it opens. + * + * The dot distinguishes six states, and the pair that matters most is `unknown` + * vs `available`: "nobody is sharing" and "there is 115 GB here you have not + * joined" must not look alike, since the second is the reason to look at all. + * See `meshRowModel.ts`. + */ + +const TONE_DOT: Record = { + // Hollow: nothing known. Deliberately not a filled grey, which would read as + // a state rather than an absence of information. + unknown: "border border-muted-foreground/45 bg-transparent", + available: "bg-slate-400 dark:bg-slate-500", + sharing: "bg-emerald-500 dark:bg-emerald-400", + consuming: "bg-sky-500 dark:bg-sky-400", + starting: "bg-amber-500 dark:bg-amber-400", + failed: "bg-transparent border-2 border-destructive", +}; + +const TONE_PING: Record = { + unknown: "", + available: "", + sharing: "bg-emerald-500 dark:bg-emerald-400", + consuming: "bg-sky-500 dark:bg-sky-400", + starting: "bg-amber-500 dark:bg-amber-400", + failed: "", +}; + +export function SidebarMeshComputeRow({ + onOpenComputeSettings, +}: { + onOpenComputeSettings?: () => void; +}) { + const mesh = useMeshComputeState(); + const { row, setSharing } = mesh; + + return ( + + + + + + {row.pulse ? ( + + ) : null} + + + + Compute + + + + {/* + The switch sits outside the button rather than inside it: nesting a + control in a control is invalid, and the row itself must stay clickable + as the way into the popover. + + Hidden once sharing — the state is legible from the dot, and Stop lives + in the popover. Collapsed sidebars hide it too: no room, and the dot + plus tooltip still carry the state. + */} + {row.showSwitch ? ( +
+ +
+ ) : row.badge ? ( + + {row.badge} + + ) : null} +
+ ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index b3b3e8130d..18da3c4054 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -606,6 +606,7 @@ export function AppSidebar({ > onSelectSettings("compute")} onSelectAgents={onSelectAgents} onSelectHome={onSelectHome} onSelectProjects={onSelectProjects} @@ -873,7 +874,6 @@ export function AppSidebar({ relayConnectionCard.onDismissRelayConnectionCard } onDismissUpdateCard={() => setIsSidebarUpdateCardDismissed(true)} - onOpenComputeSettings={() => onSelectSettings("compute")} onReconnectRelay={relayConnectionCard.onReconnectRelay} relayConnectionCard={relayConnectionCard} showUpdateCard={showSidebarUpdateCard} diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 14463060d4..e753153e0f 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -11,6 +11,7 @@ import { SidebarMenuItem, } from "@/shared/ui/sidebar"; import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label"; +import { SidebarMeshComputeRow } from "@/features/mesh-compute/ui/SidebarMeshComputeRow"; type SidebarSelectedView = | "home" @@ -37,6 +38,7 @@ type AppSidebarPinnedHeaderProps = { type AppSidebarPrimaryMenuProps = { homeBadgeCount: number; + onOpenComputeSettings?: () => void; onSelectAgents: () => void; onSelectHome: () => void; onSelectProjects: () => void; @@ -82,6 +84,7 @@ export function AppSidebarPinnedHeader({ export function AppSidebarPrimaryMenu({ homeBadgeCount, + onOpenComputeSettings, onSelectAgents, onSelectHome, onSelectProjects, @@ -173,6 +176,7 @@ export function AppSidebarPrimaryMenu({ + void; onDismissUpdateCard: () => void; - onOpenComputeSettings: () => void; onReconnectRelay: () => void; relayConnectionCard: ReturnType; showUpdateCard: boolean; @@ -52,17 +52,6 @@ export function SidebarFooterNotices({ ) : null} - {/* - Yield to the cards above: a relay problem or a pending update is more - urgent than an invitation to share compute. - */} - {relayConnectionCard.showSidebarRelayConnectionCard || - showUpdateCard ? null : ( - - )} ); }