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 : ( - - )} ); }