mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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(),
|
||||
))
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user