feat(mesh): sidebar shared-compute card with community snapshot

Adds a sidebar card showing mesh participation at a glance: whether this
machine is sharing, consuming, or neither, plus the community's shared
capacity and a per-device topology strip.

New mesh_snapshot command projects the member status notes already on the
relay (capacity, models, node state) through discovery's existing
membership/binding/freshness rules. Read-only; never selects a serve target.

Footer cards extracted to SidebarFooterNotices because AppSidebar.tsx sat at
exactly the 1000-line ceiling with no room to add a card inline.

Checkpoint commit: card renders and toggles correctly against a live mesh.

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 feccf4eabc
commit 9df2a5a69a
15 changed files with 1421 additions and 26 deletions
@@ -0,0 +1,47 @@
//! `mesh_snapshot` — community-wide shared-compute snapshot for the UI.
//!
//! Separate file from `commands/mesh_llm.rs` deliberately: that module is at
//! the 1000-line file ceiling enforced by `desktop/scripts/check-file-sizes.mjs`.
//!
//! Read-only. Queries the same member-status + membership filters routing uses,
//! then projects them through [`mesh_llm::snapshot_from_events`], which applies
//! the membership/binding/freshness rules. This never selects a serve target —
//! routing stays with `availability_from_events`.
use tauri::State;
use crate::app_state::AppState;
use crate::mesh_llm;
type CmdResult<T> = Result<T, String>;
/// Snapshot of who is sharing compute in this community right now.
///
/// An empty mesh is a normal state, not an error: the snapshot carries a
/// `reason` string instead of failing, so the card can render an honest empty
/// state. A genuine relay/transport failure still returns `Err`.
#[tauri::command]
pub async fn mesh_snapshot(state: State<'_, AppState>) -> CmdResult<mesh_llm::MeshSnapshot> {
let events = crate::relay::query_relay(
&state,
&[
mesh_llm::mesh_status_filter(),
mesh_llm::relay_membership_filter(),
],
)
.await
.map_err(|error| format!("Shared compute status query failed: {error}"))?;
// Identify this member's own device so the card can say "including yours".
// A missing/locked identity is not fatal here — the snapshot is still
// useful, just without self-attribution.
let self_pubkey = state
.signing_keys()
.ok()
.map(|keys| keys.public_key().to_hex());
Ok(mesh_llm::snapshot_from_events(
events,
self_pubkey.as_deref(),
))
}
+4
View File
@@ -32,6 +32,8 @@ mod media_snapshot_png;
mod media_transcode;
#[cfg(feature = "mesh-llm")]
pub(crate) mod mesh_llm;
#[cfg(feature = "mesh-llm")]
mod mesh_snapshot;
mod messages;
mod notifications;
mod observer_archive;
@@ -87,6 +89,8 @@ pub use media::*;
pub use media_download::*;
#[cfg(feature = "mesh-llm")]
pub use mesh_llm::*;
#[cfg(feature = "mesh-llm")]
pub use mesh_snapshot::*;
pub use messages::*;
pub use notifications::*;
pub use observer_archive::*;
+1
View File
@@ -773,6 +773,7 @@ pub fn run() {
mesh_serving_usage,
mesh_installed_models,
mesh_model_catalog,
mesh_snapshot,
update_managed_agent,
discover_backend_providers,
probe_backend_provider,
+4 -4
View File
@@ -12,7 +12,7 @@ use super::{dedupe_models, MeshAvailability, MeshModelOption, MeshServeTarget, M
pub(super) const STATUS_FRESHNESS_SECS: u64 = 120;
pub(crate) const MESH_STATUS_PAGE_SIZE: usize = 100;
fn status_is_fresh(event: &nostr::Event, now: u64) -> bool {
pub(super) fn status_is_fresh(event: &nostr::Event, now: u64) -> bool {
event
.created_at
.as_secs()
@@ -63,7 +63,7 @@ pub fn owner_ids_from_events(events: &[nostr::Event]) -> Vec<String> {
ids
}
fn latest_membership_list(events: &[nostr::Event]) -> Option<BTreeSet<String>> {
pub(super) fn latest_membership_list(events: &[nostr::Event]) -> Option<BTreeSet<String>> {
events
.iter()
.filter(|event| event.kind.as_u16() == 13_534)
@@ -108,7 +108,7 @@ pub(crate) fn has_membership_snapshot(events: &[nostr::Event]) -> bool {
events.iter().any(|event| event.kind.as_u16() == 13_534)
}
fn owner_id_from_status_event(event: &nostr::Event) -> Option<String> {
pub(super) fn owner_id_from_status_event(event: &nostr::Event) -> Option<String> {
let content = serde_json::from_str::<serde_json::Value>(&event.content).ok()?;
let owner_id = content
.get("ownerId")
@@ -136,7 +136,7 @@ fn owner_id_from_status_event(event: &nostr::Event) -> Option<String> {
Some(owner_id.to_string())
}
fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value) -> bool {
pub(super) fn endpoint_binding_is_valid(event: &nostr::Event, content: &serde_json::Value) -> bool {
let Some(endpoint_tokens) = super::identity::advertised_endpoint_tokens(content) else {
return false;
};
+3
View File
@@ -13,6 +13,9 @@ pub(crate) use discovery::{
};
use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity};
mod snapshot;
pub use snapshot::{snapshot_from_events, MeshSnapshot};
mod catalog;
pub(crate) use catalog::canonical_curated_model_id;
pub use catalog::{model_catalog, MeshModelCatalog};
+304
View File
@@ -0,0 +1,304 @@
//! Community-wide mesh snapshot — an **allowlisted** projection of the member
//! status notes already on the relay.
//!
//! Every sharing member already publishes its whole SDK status payload as
//! kind:30003 content (see `mod.rs::status_report_payload`), but
//! `availability_from_events` reads only `serveTargets`/`models` and discards
//! the rest. This module projects the few extra fields the Share-compute UI
//! needs — per-device shared capacity, a coarse state, and a stable label —
//! and **nothing else**.
//!
//! Deliberately narrow, for two reasons:
//! 1. Routing must keep using [`super::availability_from_events`]. This
//! snapshot is presentation-only and must never select a serve target.
//! 2. The published payload also carries a hardware fingerprint, peer
//! topology, and operational counters. Naming each field we surface keeps
//! the UI from quietly growing into the rest of it.
//!
//! Capacity uses the payload's `my_vram_gb`, which mesh-llm derives from a
//! `vram_bytes` already clamped to the member's own `--max-vram` cap. Raw
//! `gpus[].vram_bytes` is *not* capped, so summing GPUs would advertise more
//! than the member agreed to share.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::discovery::{
device_name_from_status, endpoint_binding_is_valid, endpoint_id_from_status,
latest_membership_list, owner_id_from_status_event, status_is_fresh,
};
use super::{MeshServeTarget, MESH_STATUS_KIND};
/// Coarse per-device state. Intentionally not the SDK's `NodeState`: the UI
/// only needs "is this device giving compute, warming up, or just consuming?"
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MeshDeviceState {
/// Advertising at least one routable model.
Serving,
/// Serve-mode, model not ready yet.
Loading,
/// Serve-mode participant with no model work (contributes capacity only).
Standby,
/// Client-mode: consuming someone else's compute, not sharing.
Consuming,
}
/// One device visible on the mesh.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct MeshSnapshotDevice {
/// Stable endpoint/device id, when the note carries one.
pub device_id: Option<String>,
/// Human label: hostname when published, else a short endpoint id.
pub label: String,
/// Shared AI memory in GB, honoring the member's own cap. `None` when the
/// member is not currently running a node (a stopped member publishes only
/// its owner binding).
pub capacity_gb: Option<f64>,
/// Models this device is advertising right now.
pub models: Vec<String>,
pub state: MeshDeviceState,
/// Whether this is the local member's own device.
pub is_self: bool,
}
/// What the community's shared compute looks like right now.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
pub struct MeshSnapshot {
/// Devices advertising at least one routable model.
pub sharing_device_count: usize,
/// Summed shared capacity across sharing devices. `None` when no sharing
/// device published a figure — the UI then shows a count with no GB, never
/// a misleading `0 GB`.
pub shared_capacity_gb: Option<f64>,
/// Distinct models ready to use, deduped across devices.
pub models: Vec<String>,
/// Per-device rows, for the topology view. Sharing devices first.
pub devices: Vec<MeshSnapshotDevice>,
/// True when the local member's own device is among the sharing devices.
pub includes_self: bool,
/// Why the snapshot is empty, when it is. Never a hard error: an empty
/// mesh is a normal, expected state.
pub reason: Option<String>,
}
impl MeshSnapshot {
fn empty(reason: impl Into<String>) -> Self {
Self {
reason: Some(reason.into()),
..Default::default()
}
}
}
fn f64_field(content: &serde_json::Value, key: &str) -> Option<f64> {
content
.get(key)
.and_then(serde_json::Value::as_f64)
.filter(|value| value.is_finite() && *value > 0.0)
}
/// Coarse state from the published `node_state`, falling back to whether the
/// note actually advertises routable targets.
fn device_state(content: &serde_json::Value, has_targets: bool) -> MeshDeviceState {
let raw = content
.get("node_state")
.and_then(serde_json::Value::as_str)
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase();
match raw.as_str() {
"serving" => MeshDeviceState::Serving,
"loading" => MeshDeviceState::Loading,
"client" => MeshDeviceState::Consuming,
"standby" => MeshDeviceState::Standby,
// Unknown/absent: trust the routable evidence rather than guessing.
_ if has_targets => MeshDeviceState::Serving,
_ => MeshDeviceState::Standby,
}
}
/// Project validated member status notes into a presentation snapshot.
///
/// Applies the same trust rules as routing — current NIP-43 membership, owner
/// binding, endpoint binding, and [`status_is_fresh`] — so a stale or
/// non-member note can never inflate the headline. Pure and total so it can be
/// unit-tested without a relay.
pub fn snapshot_from_events(
events: Vec<nostr::Event>,
self_member_pubkey: Option<&str>,
) -> MeshSnapshot {
if events.is_empty() {
return MeshSnapshot::empty("No shared compute has been published yet");
}
let Some(members) = latest_membership_list(&events) else {
return MeshSnapshot::empty("Waiting for the current member roster");
};
let self_pubkey = self_member_pubkey.map(str::to_ascii_lowercase);
let now = nostr::Timestamp::now().as_secs();
// Status notes are replaceable per owner id, so key on that: two devices
// signed in as the same member each publish their own note.
let mut by_owner = BTreeMap::<String, MeshSnapshotDevice>::new();
for event in events {
if event.kind.as_u16() as u64 != MESH_STATUS_KIND
|| !status_is_fresh(&event, now)
|| !members.contains(&event.pubkey.to_hex().to_ascii_lowercase())
{
continue;
}
let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) else {
continue;
};
let Some(owner_id) = owner_id_from_status_event(&event) else {
continue;
};
if !endpoint_binding_is_valid(&event, &content) {
continue;
}
// Reuse the routing-side target parse so "sharing" here means exactly
// what it means to the router: a validated, advertisable endpoint.
let targets = content
.get("serveTargets")
.or_else(|| content.get("serve_targets"))
.cloned()
.and_then(|value| serde_json::from_value::<Vec<MeshServeTarget>>(value).ok())
.unwrap_or_default()
.into_iter()
.filter(|target| {
super::transport_policy::validate_advertised_endpoint(&target.endpoint_addr).is_ok()
})
.collect::<Vec<_>>();
let mut models = targets
.iter()
.map(|target| target.model_id.clone())
.collect::<Vec<_>>();
models.sort();
models.dedup();
let endpoint_id = endpoint_id_from_status(&content, None);
let is_self = self_pubkey
.as_deref()
.is_some_and(|pubkey| event.pubkey.to_hex().to_ascii_lowercase() == pubkey);
by_owner.insert(
owner_id,
MeshSnapshotDevice {
label: device_name_from_status(&content, endpoint_id.as_deref())
.unwrap_or_else(|| "Unknown device".to_string()),
device_id: endpoint_id,
capacity_gb: f64_field(&content, "my_vram_gb"),
state: device_state(&content, !models.is_empty()),
models,
is_self,
},
);
}
let mut devices = by_owner.into_values().collect::<Vec<_>>();
// Sharing devices first, then the local device, then by label so the list
// is stable across polls (no jitter in the topology view).
devices.sort_by(|a, b| {
let sharing = |d: &MeshSnapshotDevice| d.state == MeshDeviceState::Serving;
sharing(b)
.cmp(&sharing(a))
.then_with(|| b.is_self.cmp(&a.is_self))
.then_with(|| a.label.cmp(&b.label))
});
let sharing = devices
.iter()
.filter(|device| device.state == MeshDeviceState::Serving)
.collect::<Vec<_>>();
let sharing_device_count = sharing.len();
let includes_self = sharing.iter().any(|device| device.is_self);
// Sum only reported figures; keep `None` when nobody reported one so the
// UI can drop the number instead of printing `0 GB`.
let shared_capacity_gb = sharing
.iter()
.filter_map(|device| device.capacity_gb)
.fold(None::<f64>, |acc, gb| Some(acc.unwrap_or(0.0) + gb));
let mut models = sharing
.iter()
.flat_map(|device| device.models.iter().cloned())
.collect::<Vec<_>>();
models.sort();
models.dedup();
let reason = if sharing_device_count == 0 {
Some("No one is sharing compute yet".to_string())
} else {
None
};
MeshSnapshot {
sharing_device_count,
shared_capacity_gb,
models,
devices,
includes_self,
reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_events_report_a_reason_not_an_error() {
let snapshot = snapshot_from_events(Vec::new(), None);
assert_eq!(snapshot.sharing_device_count, 0);
assert_eq!(snapshot.shared_capacity_gb, None);
assert!(snapshot.reason.is_some());
}
#[test]
fn missing_membership_snapshot_is_not_treated_as_an_empty_mesh() {
// A relay gap must not read as "nobody is sharing".
let event = nostr::EventBuilder::new(nostr::Kind::Custom(30003), "{}")
.sign_with_keys(&nostr::Keys::generate())
.expect("test event signs");
let snapshot = snapshot_from_events(vec![event], None);
assert_eq!(
snapshot.reason.as_deref(),
Some("Waiting for the current member roster")
);
}
#[test]
fn unknown_node_state_falls_back_to_routable_evidence() {
assert_eq!(
device_state(&serde_json::json!({}), true),
MeshDeviceState::Serving
);
assert_eq!(
device_state(&serde_json::json!({}), false),
MeshDeviceState::Standby
);
assert_eq!(
device_state(&serde_json::json!({"node_state": "client"}), false),
MeshDeviceState::Consuming
);
}
#[test]
fn capacity_ignores_zero_and_nonfinite_reports() {
// A node that has not surveyed yet publishes 0.0; that must not count
// as a real reported capacity.
assert_eq!(
f64_field(&serde_json::json!({"my_vram_gb": 0.0}), "my_vram_gb"),
None
);
assert_eq!(
f64_field(&serde_json::json!({"my_vram_gb": 36.5}), "my_vram_gb"),
Some(36.5)
);
}
}
+5
View File
@@ -42,3 +42,8 @@ pub async fn mesh_installed_models(
pub async fn mesh_model_catalog() -> CmdResult<serde_json::Value> {
Err("mesh-llm feature not enabled".to_string())
}
#[tauri::command]
pub async fn mesh_snapshot(_state: State<'_, AppState>) -> CmdResult<serde_json::Value> {
Err("mesh-llm feature not enabled".to_string())
}
@@ -0,0 +1,62 @@
import * as React from "react";
import { meshSnapshot } from "@/shared/api/tauriMesh";
import type { MeshSnapshot } from "@/shared/api/tauriMesh";
/**
* Poll the community shared-compute snapshot.
*
* Deliberately slow compared to `useMeshNodeStatus` (750ms/4000ms): that hook
* watches a LOCAL lifecycle that must feel responsive, while this one reads
* RELAY state that only changes as fast as the 45s member heartbeat, with a
* 120s freshness window. Polling faster would just add relay queries without
* adding information.
*
* An empty mesh is not an error — the snapshot carries its own `reason`. `error`
* here means the query itself failed (offline relay, stub build).
*/
const SNAPSHOT_POLL_INTERVAL_MS = 30_000;
export function useMeshSnapshot(options?: { refreshKey?: number }): {
snapshot: MeshSnapshot | null;
error: string | null;
refresh: () => void;
} {
const [snapshot, setSnapshot] = React.useState<MeshSnapshot | null>(null);
const [error, setError] = React.useState<string | null>(null);
const refreshKey = options?.refreshKey ?? 0;
// Guards against a slow in-flight response overwriting a newer one.
const requestSeq = React.useRef(0);
const fetchOnce = React.useCallback(() => {
const seq = ++requestSeq.current;
(async () => {
try {
const value = await meshSnapshot();
if (seq === requestSeq.current) {
setSnapshot(value);
setError(null);
}
} catch (err) {
if (seq === requestSeq.current) {
setError(err instanceof Error ? err.message : String(err));
}
}
})();
}, []);
// Re-fetch immediately when the local node transitions (refreshKey), so
// turning sharing on updates the community view without waiting for a poll.
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional trigger, not a value read in the body — bumping it re-queries the community snapshot right after a local node transition instead of waiting out the 30s poll
React.useEffect(() => {
fetchOnce();
}, [fetchOnce, refreshKey]);
React.useEffect(() => {
const handle = window.setInterval(fetchOnce, SNAPSHOT_POLL_INTERVAL_MS);
return () => window.clearInterval(handle);
}, [fetchOnce]);
return { snapshot, error, refresh: fetchOnce };
}
@@ -0,0 +1,274 @@
/**
* Contract tests for the sidebar shared-compute card model.
*
* The card's whole job is to make "am I giving compute / taking compute /
* neither" unmistakable, so these tests pin the distinctions that are easy to
* regress:
*
* - consuming must never render as sharing (both report state:"running")
* - unknown capacity must never print "0 GB"
* - a serve node with no advertised model is warming up, not serving
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import {
describeMeshCapacity,
describeReadyModels,
deriveMeshCardModel,
formatCapacityGb,
shortModelLabel,
} from "./meshCardModel.ts";
const OFF_TOGGLE = {
isSharing: false,
isConsuming: false,
slotOccupied: false,
};
const SHARING_TOGGLE = {
isSharing: true,
isConsuming: false,
slotOccupied: true,
};
const CONSUMING_TOGGLE = {
isSharing: false,
isConsuming: true,
slotOccupied: true,
};
function device(overrides = {}) {
return {
deviceId: "endpoint-1",
label: "Studio",
capacityGb: 36,
models: ["unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"],
state: "serving",
isSelf: false,
...overrides,
};
}
function snapshot(overrides = {}) {
return {
sharingDeviceCount: 1,
sharedCapacityGb: 36,
models: ["unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"],
devices: [device()],
includesSelf: false,
reason: null,
...overrides,
};
}
function derive(overrides = {}) {
return deriveMeshCardModel({
snapshot: snapshot(),
status: null,
toggle: OFF_TOGGLE,
pendingAction: null,
canShare: true,
activeConsumers: false,
...overrides,
});
}
test("capacity headline counts devices and sums reported memory", () => {
assert.equal(
describeMeshCapacity(
snapshot({ sharingDeviceCount: 3, sharedCapacityGb: 42.3 }),
),
"3 devices sharing · 42 GB",
);
assert.equal(
describeMeshCapacity(
snapshot({ sharingDeviceCount: 1, sharedCapacityGb: 18 }),
),
"1 device sharing · 18 GB",
);
});
test("unknown capacity degrades to a device count, never 0 GB", () => {
const text = describeMeshCapacity(
snapshot({ sharingDeviceCount: 3, sharedCapacityGb: null }),
);
assert.equal(text, "3 devices sharing");
assert.ok(!text.includes("0 GB"), "must not claim zero capacity");
});
test("an empty mesh reads as an honest empty state", () => {
assert.equal(
describeMeshCapacity(
snapshot({ sharingDeviceCount: 0, sharedCapacityGb: null }),
),
"No compute shared yet",
);
assert.equal(describeMeshCapacity(null), "No compute shared yet");
});
test("capacity formatting keeps small figures meaningful", () => {
assert.equal(formatCapacityGb(42.3), "42 GB");
assert.equal(formatCapacityGb(4.62), "4.6 GB");
});
test("model labels shorten to fit a narrow sidebar", () => {
assert.equal(
shortModelLabel("unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"),
"Gemma 4 26B A4B",
);
assert.equal(
shortModelLabel("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"),
"Gemma 4 E4B",
);
});
test("ready models collapse to a count past one", () => {
assert.equal(describeReadyModels(snapshot()), "Gemma 4 26B A4B ready");
assert.equal(
describeReadyModels(snapshot({ models: ["a", "b"] })),
"2 models ready",
);
assert.equal(describeReadyModels(snapshot({ models: [] })), null);
});
test("consuming never renders as sharing", () => {
const model = derive({
toggle: CONSUMING_TOGGLE,
snapshot: snapshot({ devices: [device({ label: "Mac Mini" })] }),
});
assert.equal(model.tone, "consuming");
assert.equal(model.switchOn, false, "the Share switch must stay off");
assert.match(model.headline, /Using shared compute/);
assert.match(model.detail, /Mac Mini/);
// A consuming client may be replaced by a serve runtime, so the switch stays
// usable rather than locked.
assert.equal(model.switchDisabled, false);
});
test("sharing states name the model and the community capacity", () => {
const model = derive({
toggle: SHARING_TOGGLE,
status: {
state: "running",
mode: "serve",
health: { status: "ok", reason: null },
modelId: "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M",
modelName: null,
apiBaseUrl: null,
consoleUrl: null,
},
snapshot: snapshot({
devices: [device({ isSelf: true })],
includesSelf: true,
}),
});
assert.equal(model.tone, "sharing");
assert.equal(model.switchOn, true);
assert.match(model.detail, /Gemma 4 26B A4B/);
});
test("active consumers are called out in the headline", () => {
const model = derive({
toggle: SHARING_TOGGLE,
status: {
state: "running",
mode: "serve",
health: { status: "ok", reason: null },
modelId: "m",
modelName: null,
apiBaseUrl: null,
consoleUrl: null,
},
activeConsumers: true,
});
assert.match(model.headline, /in use now/);
});
test("a serve node with no advertised model is warming up, not serving", () => {
const model = derive({
toggle: SHARING_TOGGLE,
status: {
state: "running",
mode: "serve",
health: { status: "ok", reason: null },
modelId: "m",
modelName: null,
apiBaseUrl: null,
consoleUrl: null,
},
snapshot: snapshot({
sharingDeviceCount: 0,
devices: [device({ isSelf: true, state: "loading", models: [] })],
}),
});
assert.equal(model.tone, "pending");
assert.match(model.headline, /Starting to share/);
});
test("a failed runtime surfaces its reason instead of claiming to share", () => {
const model = derive({
toggle: SHARING_TOGGLE,
status: {
state: "running",
mode: "serve",
health: { status: "degraded", reason: "llama runtime exited" },
modelId: "m",
modelName: null,
apiBaseUrl: null,
consoleUrl: null,
},
});
assert.equal(model.tone, "failed");
assert.match(model.detail, /llama runtime exited/);
});
test("the idle invitation leads with what the community already has", () => {
const model = derive({
snapshot: snapshot({ sharingDeviceCount: 2, sharedCapacityGb: 54 }),
});
assert.equal(model.tone, "idle");
assert.equal(model.headline, "2 devices sharing · 54 GB");
});
test("the switch is disabled until a model can be resolved", () => {
assert.equal(derive({ canShare: false }).switchDisabled, true);
assert.equal(derive({ canShare: true }).switchDisabled, false);
});
test("an unknown runtime occupant is not silently replaceable", () => {
const model = derive({
toggle: { isSharing: false, isConsuming: false, slotOccupied: true },
});
assert.equal(model.switchDisabled, true);
});
test("a solo sharer gets the waiting hint; a populated mesh does not", () => {
const sharingStatus = {
state: "running",
mode: "serve",
health: { status: "ok", reason: null },
modelId: "m",
modelName: null,
apiBaseUrl: null,
consoleUrl: null,
};
const solo = derive({
toggle: SHARING_TOGGLE,
status: sharingStatus,
snapshot: snapshot({ devices: [device({ isSelf: true })] }),
});
assert.equal(solo.showSoloHint, true);
const populated = derive({
toggle: SHARING_TOGGLE,
status: sharingStatus,
snapshot: snapshot({
sharingDeviceCount: 2,
devices: [
device({ isSelf: true }),
device({ deviceId: "e2", label: "Studio 2" }),
],
}),
});
assert.equal(populated.showSoloHint, false);
});
@@ -0,0 +1,243 @@
import type {
MeshNodeStatus,
MeshSnapshot,
MeshSnapshotDevice,
} from "@/shared/api/tauriMesh";
import type { MeshShareToggleModel } from "./shareToggleState";
/**
* Pure projection for the sidebar shared-compute card.
*
* The card has one job: make it obvious, at a glance, whether **this machine**
* is giving compute, taking compute, or neither — and what the community has
* to offer. Copy lives here (not in the component) so the wording is unit
* tested and cannot drift between states.
*
* Two distinctions this model refuses to blur:
*
* 1. **Sharing vs consuming.** One mesh runtime slot serves both roles and both
* report `state:"running"`, so the role comes from `deriveMeshShareToggle`,
* never from `state` alone. Consuming must never read as sharing.
* 2. **Unknown capacity vs zero capacity.** `sharedCapacityGb: null` means
* nobody reported a figure, so the headline drops the number rather than
* claiming "0 GB".
*/
/** Which role this machine is playing. Drives the card's accent + icon. */
export type MeshCardTone =
| "idle"
| "sharing"
| "consuming"
| "pending"
| "failed";
export type MeshCardModel = {
tone: MeshCardTone;
/** Primary line — what is happening, from this machine's point of view. */
headline: string;
/** Secondary line — the community context, or the reason for a problem. */
detail: string | null;
/** Switch position. Reflects serve-mode occupancy only. */
switchOn: boolean;
switchDisabled: boolean;
/** Accessible label for the switch, naming the consequence of flipping it. */
switchLabel: string;
/** Devices for the topology strip, sharing first. */
devices: MeshSnapshotDevice[];
/** True when the mesh has exactly one participant (the solo case). */
isSolo: boolean;
/** Whether to show the "waiting for another device" hint. */
showSoloHint: boolean;
};
/**
* Format shared memory for display. Whole numbers at 10GB+ (a "42 GB" mesh
* reads better than "42.3 GB"); one decimal below that so a single small
* machine still shows a meaningful figure.
*/
export function formatCapacityGb(gb: number): string {
const rounded = gb >= 10 ? Math.round(gb) : Math.round(gb * 10) / 10;
return `${rounded} GB`;
}
function plural(n: number, one: string, many = `${one}s`): string {
return n === 1 ? one : many;
}
/**
* The community headline: how much compute is actually available right now.
*
* Counts only devices advertising a routable model — the same standard routing
* uses — so the number never promises capacity that cannot be reached. Never
* says "total": the underlying query is capped at 100 members.
*/
export function describeMeshCapacity(snapshot: MeshSnapshot | null): string {
if (!snapshot || snapshot.sharingDeviceCount === 0) {
return "No compute shared yet";
}
const { sharingDeviceCount: count, sharedCapacityGb: gb } = snapshot;
const devices = `${count} ${plural(count, "device")} sharing`;
// Unknown capacity degrades to the device count rather than printing 0 GB.
return gb === null ? devices : `${devices} · ${formatCapacityGb(gb)}`;
}
/** Short label for what is ready to run, or null when nothing is. */
export function describeReadyModels(
snapshot: MeshSnapshot | null,
): string | null {
const models = snapshot?.models ?? [];
if (models.length === 0) {
return null;
}
if (models.length === 1) {
return `${shortModelLabel(models[0])} ready`;
}
return `${models.length} models ready`;
}
/**
* 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`.
*/
export function shortModelLabel(modelRef: string): string {
const basename = modelRef.split("/").at(-1) ?? modelRef;
const withoutQuant = basename.replace(/[:-](?:GGUF|UD)?[:-]?Q\d.*$/i, "");
const cleaned = withoutQuant
.replace(/-GGUF.*$/i, "")
.replace(/-it$/i, "")
.replaceAll("-", " ")
.trim();
if (cleaned === "") {
return basename;
}
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
/**
* Project everything the card needs into one view model.
*
* Total: every input may be null (nothing fetched yet) and the result is still
* renderable.
*/
export function deriveMeshCardModel({
snapshot,
status,
toggle,
pendingAction,
canShare,
activeConsumers,
}: {
snapshot: MeshSnapshot | null;
status: MeshNodeStatus | null;
toggle: MeshShareToggleModel;
pendingAction: "start" | "stop" | null;
/** False when no model can be resolved yet (catalog still loading). */
canShare: boolean;
/** Another member is actively using this machine's compute right now. */
activeConsumers: boolean;
}): MeshCardModel {
const devices = snapshot?.devices ?? [];
const participantCount = devices.length;
const capacity = describeMeshCapacity(snapshot);
const ready = describeReadyModels(snapshot);
const isSolo = participantCount === 1;
const base = {
devices,
isSolo,
switchOn: toggle.isSharing,
// A serve node can always be stopped. Anything else waits for a resolvable
// model, and never lets an unknown occupant be replaced silently.
switchDisabled:
pendingAction !== null ||
(toggle.isSharing
? false
: toggle.slotOccupied && !toggle.isConsuming
? true
: !canShare),
switchLabel: toggle.isSharing
? "Stop sharing this computer's compute"
: "Share this computer's compute",
};
if (pendingAction === "start") {
return {
...base,
tone: "pending",
headline: "Starting to share…",
detail: "Loading the model. This can take a moment.",
showSoloHint: false,
};
}
if (pendingAction === "stop") {
return {
...base,
tone: "pending",
headline: "Stopping…",
detail: null,
showSoloHint: false,
};
}
// Consuming: this machine is TAKING compute, not giving it. Say so plainly —
// the switch is off here and that must not read as "nothing is happening".
if (toggle.isConsuming) {
const peer = devices.find(
(device) => !device.isSelf && device.state === "serving",
);
return {
...base,
tone: "consuming",
headline: "Using shared compute",
detail: peer
? `Running on ${peer.label}. Turn on to share this computer too.`
: "Running on another member's computer.",
showSoloHint: false,
};
}
if (toggle.isSharing) {
const health = status?.health;
if (health && health.status !== "ok") {
return {
...base,
tone: "failed",
headline: "Sharing needs attention",
detail:
health.reason ?? "The shared compute runtime reported a problem.",
showSoloHint: false,
};
}
// A serve node with no advertised model yet is warming up, not serving.
const selfDevice = devices.find((device) => device.isSelf);
if (selfDevice?.state === "loading" || status?.state === "starting") {
return {
...base,
tone: "pending",
headline: "Starting to share…",
detail: "Loading the model. This can take a moment.",
showSoloHint: false,
};
}
const model = status?.modelName ?? status?.modelId;
return {
...base,
tone: "sharing",
headline: activeConsumers
? "Sharing · in use now"
: "Sharing this computer",
detail: model ? `${shortModelLabel(model)} · ${capacity}` : capacity,
showSoloHint: isSolo,
};
}
// Idle: the invitation. Lead with what the community already has, because
// that is the reason to join — not with a description of the mechanism.
return {
...base,
tone: "idle",
headline: capacity,
detail: ready ?? "Turn on to let members run agents on this computer.",
showSoloHint: false,
};
}
@@ -0,0 +1,125 @@
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<MeshSnapshotDevice["state"], string> = {
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 (
<div
className="mt-2 flex items-center gap-1.5"
data-testid="mesh-topology-strip"
>
<div className="flex items-center gap-1">
{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 (
<span
className="relative flex h-2 w-2 items-center justify-center"
key={device.deviceId ?? `${device.label}-${index}`}
title={deviceTitle(device)}
>
{isPulsing ? (
<motion.span
animate={{ opacity: [0.5, 0, 0.5], scale: [1, 2.1, 1] }}
className={cn(
"absolute h-2 w-2 rounded-full",
STATE_DOT_CLASS[device.state],
)}
transition={{
duration: 2.4,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
// Stagger so a busy mesh breathes instead of strobing.
delay: index * 0.35,
}}
/>
) : null}
<span
className={cn(
"relative h-2 w-2 rounded-full",
STATE_DOT_CLASS[device.state],
// Mark this computer with a ring so "which one is me" needs
// no legend.
device.isSelf &&
"ring-2 ring-background ring-offset-1 ring-offset-foreground/30",
)}
data-mesh-device-state={device.state}
data-mesh-device-self={device.isSelf ? "true" : undefined}
/>
</span>
);
})}
{remainder > 0 ? (
<span className="ml-0.5 text-3xs font-medium text-muted-foreground">
+{remainder}
</span>
) : null}
</div>
{showSoloHint ? (
<span className="truncate text-3xs text-muted-foreground">
Waiting for another device
</span>
) : null}
</div>
);
}
@@ -0,0 +1,235 @@
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 { useMeshNodeStatus } from "../hooks/useMeshNodeStatus";
import { useMeshServingUsage } from "../hooks/useMeshServingUsage";
import { useMeshSnapshot } from "../hooks/useMeshSnapshot";
import { deriveMeshShareToggle } from "../shareToggleState";
import { deriveMeshCardModel, type MeshCardTone } from "../meshCardModel";
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? → "3 devices sharing · 42 GB"
*
* Deliberately NOT a model picker. Turning it on shares the hardware-appropriate
* curated recommendation (`catalog.recommended`); choosing a specific model,
* capping memory, and future split controls stay in Settings → Compute.
*
* 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<MeshCardTone, React.ReactNode> = {
idle: <Share2 aria-hidden className="h-4 w-4" />,
sharing: <Cpu aria-hidden className="h-4 w-4" />,
consuming: <Download aria-hidden className="h-4 w-4" />,
pending: <Loader2 aria-hidden className="h-4 w-4 animate-spin" />,
failed: <TriangleAlert aria-hidden className="h-4 w-4" />,
};
// Accent per role. Sharing (giving) and consuming (taking) must be
// distinguishable at a glance, not just by wording.
const TONE_ICON_CLASS: Record<MeshCardTone, string> = {
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<MeshCardTone, string> = {
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 [catalog, setCatalog] = React.useState<MeshModelCatalog | null>(null);
const [pendingAction, setPendingAction] = React.useState<
"start" | "stop" | null
>(null);
const [actionError, setActionError] = React.useState<string | null>(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);
const usage = useMeshServingUsage(toggle.isSharing);
const activeConsumers =
(usage?.remoteAttempts ?? 0) > 0 || (usage?.endpointAttempts ?? 0) > 0;
// One-shot catalog fetch: the card needs the hardware-appropriate
// recommendation so the switch can start sharing without a model picker.
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const value = await meshModelCatalog();
if (!cancelled) setCatalog(value);
} 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 recommended = catalog?.recommended ?? null;
const model = deriveMeshCardModel({
snapshot,
status,
toggle,
pendingAction,
canShare: Boolean(recommended),
activeConsumers,
});
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 (!recommended) {
throw new Error("No suitable model for this computer yet");
}
await meshStartNode({ mode: "serve", modelId: recommended });
} else {
await meshStopNode();
}
refreshStatus();
setSnapshotNonce((current) => current + 1);
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setPendingAction(null);
}
}
// A transport failure is worth showing; an empty mesh is not an error and is
// already expressed by the headline ("No compute shared yet").
const errorText = actionError ?? snapshotError;
return (
<motion.div
animate={{ opacity: 1, y: 0 }}
className={cn("w-full group-data-[collapsible=icon]:hidden", className)}
data-testid="sidebar-mesh-compute-card"
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 4 }}
transition={
shouldReduceMotion
? { duration: 0.08 }
: { duration: 0.24, ease: [0.22, 1, 0.36, 1] }
}
>
<div
className={cn(
"rounded-xl border px-3 py-2.5 shadow-xs transition-colors duration-200",
TONE_RING_CLASS[model.tone],
)}
data-mesh-tone={model.tone}
>
<div className="flex items-start gap-2.5">
<span
className={cn(
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center",
TONE_ICON_CLASS[model.tone],
)}
>
{TONE_ICON[model.tone]}
</span>
<div className="min-w-0 flex-1">
<p
className="truncate text-sm font-semibold leading-tight"
data-testid="mesh-card-headline"
>
{model.headline}
</p>
{model.detail ? (
<p
className="mt-0.5 line-clamp-2 text-2xs leading-snug text-muted-foreground"
data-testid="mesh-card-detail"
>
{model.detail}
</p>
) : null}
</div>
<Switch
aria-label={model.switchLabel}
checked={model.switchOn}
className="mt-0.5 shrink-0"
data-testid="mesh-card-share-toggle"
disabled={model.switchDisabled}
onCheckedChange={handleToggle}
/>
</div>
<MeshTopologyStrip
devices={model.devices}
showSoloHint={model.showSoloHint}
/>
{errorText ? (
<p
className="mt-2 line-clamp-2 text-2xs leading-snug text-destructive"
data-testid="mesh-card-error"
>
{errorText}
</p>
) : null}
{onOpenComputeSettings ? (
<button
className="mt-2 text-2xs font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="mesh-card-advanced-link"
onClick={onOpenComputeSettings}
type="button"
>
Compute settings
</button>
) : null}
</div>
</motion.div>
);
}
+12 -22
View File
@@ -50,14 +50,13 @@ import type {
CollapsibleSidebarGroup,
CreateChannelKind,
} from "@/features/sidebar/ui/AppSidebar.types";
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
import { SidebarFooterNotices } from "@/features/sidebar/ui/SidebarFooterNotices";
import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import {
SidebarLoadingContent,
useSidebarLoadingShape,
} from "@/features/sidebar/ui/sidebarLoadingSkeleton";
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
@@ -869,26 +868,17 @@ export function AppSidebar({
) : null}
<SidebarFooter>
{relayConnectionCard.showSidebarRelayConnectionCard &&
(isMobile ? openMobile : sidebarOpen) ? (
<SidebarRelayConnectionCard
className="mb-2"
isConnected={relayConnectionCard.isRelayConnectionSuccess}
isReconnectPending={relayConnectionCard.isRelayReconnectPending}
isWaitingOnReconnectHook={
relayConnectionCard.isWaitingOnReconnectHook
}
onDismiss={relayConnectionCard.onDismissRelayConnectionCard}
onReconnect={relayConnectionCard.onReconnectRelay}
/>
) : null}
{showSidebarUpdateCard ? (
<div className="mb-2 group-data-[collapsible=icon]:hidden">
<SidebarUpdateCard
onDismiss={() => setIsSidebarUpdateCardDismissed(true)}
/>
</div>
) : null}
<SidebarFooterNotices
expanded={isMobile ? openMobile : sidebarOpen}
onDismissRelayConnectionCard={
relayConnectionCard.onDismissRelayConnectionCard
}
onDismissUpdateCard={() => setIsSidebarUpdateCardDismissed(true)}
onOpenComputeSettings={() => onSelectSettings("compute")}
onReconnectRelay={relayConnectionCard.onReconnectRelay}
relayConnectionCard={relayConnectionCard}
showUpdateCard={showSidebarUpdateCard}
/>
<HuddleProfileControl
channels={channels}
onHuddleEnded={onHuddleEnded}
@@ -0,0 +1,68 @@
import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
import { SidebarMeshComputeCard } from "@/features/mesh-compute/ui/SidebarMeshComputeCard";
/**
* The stack of transient cards above the sidebar profile row.
*
* Extracted from `AppSidebar.tsx` because that file sits exactly at the
* 1000-line ceiling enforced by `desktop/scripts/check-file-sizes.mjs` — there
* was no room to add another card inline, and this is a self-contained concern.
*
* Ordering is a priority queue, most urgent first: a broken relay connection
* outranks an available update, which outranks the shared-compute card. Each
* card is individually responsible for rendering nothing when it has nothing to
* say, so the footer does not become permanent clutter.
*/
export function SidebarFooterNotices({
expanded,
onDismissRelayConnectionCard,
onDismissUpdateCard,
onOpenComputeSettings,
onReconnectRelay,
relayConnectionCard,
showUpdateCard,
}: {
/** Whether the sidebar is expanded (icon-collapse hides these cards). */
expanded: boolean;
onDismissRelayConnectionCard: () => void;
onDismissUpdateCard: () => void;
onOpenComputeSettings: () => void;
onReconnectRelay: () => void;
relayConnectionCard: ReturnType<typeof useSidebarRelayConnectionCard>;
showUpdateCard: boolean;
}) {
return (
<>
{relayConnectionCard.showSidebarRelayConnectionCard && expanded ? (
<SidebarRelayConnectionCard
className="mb-2"
isConnected={relayConnectionCard.isRelayConnectionSuccess}
isReconnectPending={relayConnectionCard.isRelayReconnectPending}
isWaitingOnReconnectHook={
relayConnectionCard.isWaitingOnReconnectHook
}
onDismiss={onDismissRelayConnectionCard}
onReconnect={onReconnectRelay}
/>
) : null}
{showUpdateCard ? (
<div className="mb-2 group-data-[collapsible=icon]:hidden">
<SidebarUpdateCard onDismiss={onDismissUpdateCard} />
</div>
) : 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 : (
<SidebarMeshComputeCard
className="mb-2"
onOpenComputeSettings={onOpenComputeSettings}
/>
)}
</>
);
}
+34
View File
@@ -112,3 +112,37 @@ export type MeshModelCatalog = {
export async function meshModelCatalog(): Promise<MeshModelCatalog> {
return await invokeTauri<MeshModelCatalog>("mesh_model_catalog");
}
/** Coarse per-device state. See Rust `MeshDeviceState`. */
export type MeshDeviceState = "serving" | "loading" | "standby" | "consuming";
export type MeshSnapshotDevice = {
deviceId: string | null;
label: string;
/** Shared AI memory in GB, honoring that member's own cap. */
capacityGb: number | null;
models: string[];
state: MeshDeviceState;
isSelf: boolean;
};
/**
* Community-wide shared-compute snapshot: who is sharing right now, how much
* they are sharing, and what is ready to use.
*
* Presentation only — routing still goes through the availability path. An
* empty mesh arrives as `reason` with zero counts, never as an error.
*/
export type MeshSnapshot = {
sharingDeviceCount: number;
/** `null` when no sharing device reported a figure — render the count only. */
sharedCapacityGb: number | null;
models: string[];
devices: MeshSnapshotDevice[];
includesSelf: boolean;
reason: string | null;
};
export async function meshSnapshot(): Promise<MeshSnapshot> {
return await invokeTauri<MeshSnapshot>("mesh_snapshot");
}