From ebf34346ca304b9b80286007d6cf04a011fc29fc Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 4 Aug 2026 18:19:19 +1000 Subject: [PATCH] feat(mesh): honest empty state and named startup stages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two idle situations shared one branch and read identically, which was the confusion: a community with compute to offer is an invitation, while an empty one is a call to be first. They now say different things, and neither is an error — an empty mesh is a normal state. A machine that cannot host anything gets its own line rather than an instruction it cannot follow: it can still consume shared compute once somebody else shares. This pairs with the fit-aware recommendation, which now returns None for such machines instead of a model that would not load. An unfetched snapshot is still distinguished from a genuinely empty one, so "be the first" never renders as a verdict on other people's machines during startup. Startup was one opaque "Starting to share…" covering resolve, download, and load. The download dominates — minutes for a multi-gigabyte model — so it is now named and measured ("Downloading model · 29%", "5 GB of 17 GB · first run only"). A download with no known total says "Downloading model…" rather than fabricating 0%. Signed-off-by: Michael Neale --- .../mesh-compute/meshCardModel.test.mjs | 85 +++++++++++++++ .../features/mesh-compute/meshCardModel.ts | 101 ++++++++++++++++-- .../ui/SidebarMeshComputeCard.tsx | 7 ++ 3 files changed, 187 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/mesh-compute/meshCardModel.test.mjs b/desktop/src/features/mesh-compute/meshCardModel.test.mjs index 22ccef7aa..66aa9af79 100644 --- a/desktop/src/features/mesh-compute/meshCardModel.test.mjs +++ b/desktop/src/features/mesh-compute/meshCardModel.test.mjs @@ -17,6 +17,8 @@ import { describeMeshCapacity, describeMeshHeadline, describeReadyModels, + describeStartupHeadline, + describeStartupStage, deriveMeshCardModel, formatCapacityGb, shortModelLabel, @@ -103,6 +105,7 @@ function derive(overrides = {}) { view: null, usage: usage(), inboundWork: false, + downloadProgress: null, ...overrides, }); } @@ -350,3 +353,85 @@ test("an unknown runtime occupant is not silently replaceable", () => { }); assert.equal(model.switchDisabled, true); }); + +function progress(overrides = {}) { + return { + label: "gemma", + file: "model.gguf", + downloadedBytes: null, + totalBytes: null, + status: "downloading", + done: false, + ...overrides, + }; +} + +test("a download is named and measured, not hidden behind 'Starting…'", () => { + // Minutes of unlabelled spinner reads as a hang; the download is the longest + // and most opaque startup step, so it says so. + assert.equal( + describeStartupHeadline( + progress({ downloadedBytes: 5e9, totalBytes: 17e9 }), + ), + "Downloading model · 29%", + ); + assert.match( + describeStartupStage(progress({ downloadedBytes: 5e9, totalBytes: 17e9 })), + /5 GB of 17 GB/, + ); +}); + +test("a download with no known total says so rather than faking 0%", () => { + assert.equal(describeStartupHeadline(progress()), "Downloading model…"); + assert.equal( + describeStartupHeadline(progress({ downloadedBytes: 1e9, totalBytes: 0 })), + "Downloading model…", + ); +}); + +test("each startup stage is distinguishable", () => { + assert.equal( + describeStartupHeadline(progress({ status: "preparing" })), + "Preparing model…", + ); + assert.equal(describeStartupHeadline(null), "Starting to share…"); + assert.match(describeStartupStage(null), /Loading the model into memory/); +}); + +test("starting shows the download stage on the card", () => { + const model = derive({ + pendingAction: "start", + downloadProgress: progress({ downloadedBytes: 2e9, totalBytes: 4e9 }), + }); + assert.equal(model.tone, "pending"); + assert.equal(model.headline, "Downloading model · 50%"); +}); + +test("an empty community is a call to be first, not an error", () => { + const model = derive({ + snapshot: snapshot({ sharingDeviceCount: 0, sharedCapacityGb: null }), + }); + assert.equal(model.tone, "idle"); + assert.match(model.detail, /Be the first to share/); +}); + +test("a machine that cannot host is told it can still consume", () => { + // Saying "turn it on" to someone who cannot is a dead end; consuming still + // works once somebody else shares. + const model = derive({ + snapshot: snapshot({ sharingDeviceCount: 0, sharedCapacityGb: null }), + canShare: false, + }); + assert.match(model.detail, /too small to share, but can use shared compute/); + assert.equal(model.switchDisabled, true); +}); + +test("an unfetched snapshot is not treated as an empty community", () => { + // null is "not checked yet" — claiming "be the first" before the relay + // answers would be a verdict on everyone else's machines. + const model = derive({ snapshot: null }); + assert.ok( + !/Be the first/.test(model.detail ?? ""), + `must not claim empty before fetching: ${model.detail}`, + ); +}); diff --git a/desktop/src/features/mesh-compute/meshCardModel.ts b/desktop/src/features/mesh-compute/meshCardModel.ts index a2fd4312a..a8a94645f 100644 --- a/desktop/src/features/mesh-compute/meshCardModel.ts +++ b/desktop/src/features/mesh-compute/meshCardModel.ts @@ -5,6 +5,7 @@ import type { MeshSnapshot, MeshSnapshotDevice, } from "@/shared/api/tauriMesh"; +import type { MeshDownloadProgress } from "./hooks/useMeshDownloadProgress"; import { describeParticipationHint } from "./meshActivity"; import type { MeshShareToggleModel } from "./shareToggleState"; @@ -145,6 +146,62 @@ export function describeReadyModels( return `${models.length} models ready`; } +/** + * Name the startup stage instead of showing one opaque "Starting…". + * + * A first-time start does three very different things behind one spinner: + * resolve the model, download several gigabytes, then load it into memory. The + * download dominates — minutes, not seconds — and an unlabelled spinner during + * it reads as a hang. So the download is named and measured; everything else + * stays honest about being indeterminate. + */ +export function describeStartupHeadline( + progress: MeshDownloadProgress | null, +): string { + if (progress?.status === "downloading") { + const pct = downloadPercent(progress); + return pct === null ? "Downloading model…" : `Downloading model · ${pct}%`; + } + if (progress?.status === "preparing") { + return "Preparing model…"; + } + return "Starting to share…"; +} + +/** Secondary line for the startup stages. */ +export function describeStartupStage( + progress: MeshDownloadProgress | null, +): string { + if (progress?.status === "downloading") { + const total = progress.totalBytes; + return total === null + ? "First run downloads the model once." + : `${formatBytes(progress.downloadedBytes ?? 0)} of ${formatBytes(total)} · first run only`; + } + if (progress?.status === "preparing") { + return "Checking what's already downloaded."; + } + return "Loading the model into memory."; +} + +function downloadPercent(progress: MeshDownloadProgress): number | null { + const { downloadedBytes, totalBytes } = progress; + // A percentage needs both figures and a non-zero denominator; without them a + // bare "Downloading…" beats a fabricated 0%. + if (downloadedBytes === null || totalBytes === null || totalBytes <= 0) { + return null; + } + return Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100)); +} + +function formatBytes(bytes: number): string { + const gb = bytes / 1e9; + if (gb >= 1) { + return `${Math.round(gb * 10) / 10} GB`; + } + return `${Math.round(bytes / 1e6)} MB`; +} + /** * Trim a model reference down to something that fits a 256px sidebar. * `unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M` → `Gemma 4 26B A4B`. @@ -178,6 +235,7 @@ export function deriveMeshCardModel({ view, usage, inboundWork, + downloadProgress, }: { snapshot: MeshSnapshot | null; status: MeshNodeStatus | null; @@ -194,6 +252,14 @@ export function deriveMeshCardModel({ * count flat). Sampled, so it can undercount; it never over-claims. */ inboundWork: boolean; + /** + * Live model-download progress, when a download is running. + * + * Without this, a first-time start shows "Starting to share…" for however + * long a multi-gigabyte download takes, which reads as a hang. A download is + * the single longest and most opaque step, so it gets named and measured. + */ + downloadProgress: MeshDownloadProgress | null; }): MeshCardModel { const devices = snapshot?.devices ?? []; const headline = describeMeshHeadline({ view, snapshot }); @@ -201,6 +267,7 @@ export function deriveMeshCardModel({ // Solo means "connected but nobody else is here" — a live-view fact. The // relay snapshot cannot tell us this: a lone note may just be a stale one. const isSolo = view?.connected === true && view.peers.length === 0; + const startingDetail = describeStartupStage(downloadProgress); const hint = describeParticipationHint({ isSharing: toggle.isSharing, isConsuming: toggle.isConsuming, @@ -230,8 +297,8 @@ export function deriveMeshCardModel({ return { ...base, tone: "pending", - headline: "Starting to share…", - detail: "Loading the model. This can take a moment.", + headline: describeStartupHeadline(downloadProgress), + detail: startingDetail, showSoloHint: false, }; } @@ -275,8 +342,8 @@ export function deriveMeshCardModel({ return { ...base, tone: "pending", - headline: "Starting to share…", - detail: "Loading the model. This can take a moment.", + headline: describeStartupHeadline(downloadProgress), + detail: startingDetail, showSoloHint: false, }; } @@ -291,8 +358,30 @@ export function deriveMeshCardModel({ }; } - // Idle: the invitation. Lead with what the community already has, because - // that is the reason to join — not with a description of the mechanism. + // Idle. Two very different situations share this branch, and conflating them + // was the old bug: a community with compute to offer is an invitation, while + // an empty one is a call to be first. Neither is an error. + const communityIsEmpty = + snapshot !== null && snapshot.sharingDeviceCount === 0; + if (communityIsEmpty) { + return { + ...base, + tone: "idle", + headline, + // No hedging about what *might* be available: nobody is sharing, so the + // only true statement is that turning this on creates the capacity. + detail: canShare + ? "Be the first to share compute here." + : // A machine that cannot host anything is not broken, and saying + // "turn it on" to someone who cannot is a dead end. It can still + // consume once somebody else shares. + "This computer is too small to share, but can use shared compute.", + showSoloHint: false, + }; + } + + // The community has compute. Lead with what it already has, because that is + // the reason to join — not with a description of the mechanism. return { ...base, tone: "idle", diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx index d39bf4ee6..37e7671c0 100644 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeCard.tsx @@ -8,6 +8,7 @@ import { meshStartNode, meshStopNode } from "@/shared/api/tauriMesh"; import type { MeshModelCatalog } from "@/shared/api/tauriMesh"; import { meshModelCatalog } from "@/shared/api/tauriMesh"; +import { useMeshDownloadProgress } from "../hooks/useMeshDownloadProgress"; import { useMeshLiveView } from "../hooks/useMeshLiveView"; import { useMeshNodeStatus } from "../hooks/useMeshNodeStatus"; import { useMeshServingUsage } from "../hooks/useMeshServingUsage"; @@ -95,6 +96,8 @@ export function SidebarMeshComputeCard({ // 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(); // 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 @@ -144,6 +147,7 @@ export function SidebarMeshComputeCard({ view, usage, inboundWork, + downloadProgress, }); async function handleToggle(next: boolean) { @@ -169,6 +173,9 @@ export function SidebarMeshComputeCard({ 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(); } }