feat(mesh): honest empty state and named startup stages

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 <michael.neale@gmail.com>
This commit is contained in:
Michael Neale
2026-08-04 19:38:07 +10:00
parent f3c66994df
commit ebf34346ca
3 changed files with 187 additions and 6 deletions
@@ -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}`,
);
});
@@ -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",
@@ -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();
}
}