From ea389a4fb4f795a5d50bc1e280f21251a428f142 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Thu, 6 Aug 2026 14:04:23 +1000 Subject: [PATCH] fix(mesh): the shared-compute dot moves only when something is happening The sidebar dot pulsed constantly on a healthy row. Motion was wired to standing states as well as to events, and "capacity you have not joined" and "sharing with no agent using it" are both permanently true, so the dot moved forever and stopped meaning anything. Motion is now reserved for two facts: - activity - work in flight on this node (usage.inflight) - progress - a start or stop in flight Standing states say what they are through tone, tooltip, and the popover's own prompt, none of which animate. Amber now pulses while a model loads, where a still dot read as stuck through minutes of work. Brightness only, on the dot itself. An earlier revision animated a transparent halo ring scaled to 2x while fading to zero, which is imperceptible at 8px; the correction scaled the dot, which shifted the row's optical baseline and read as jitter. Opacity is what Tailwind's `pulse` animates and what Buzz already uses to mean "working" (ChannelWorkingBadge, AgentStatusBadge). Short work is no longer dropped: `inflight` is sampled every few seconds, so a request that starts and finishes between two polls was never seen at all. The last sighting is latched briefly - it never invents activity, it only stops forgetting it immediately. Both share/stop switches read one derived value, so the row and the popover cannot disagree about an action while a model loads. Signed-off-by: Michael Neale --- .../mesh-compute/hooks/useMeshComputeState.ts | 43 +++++ .../mesh-compute/meshRowModel.test.mjs | 107 +++++++++-- .../src/features/mesh-compute/meshRowModel.ts | 82 ++++++--- .../mesh-compute/ui/MeshComputePopover.tsx | 5 +- .../mesh-compute/ui/SidebarMeshComputeRow.tsx | 171 ++++++++++++------ desktop/tailwind.config.js | 29 ++- 6 files changed, 334 insertions(+), 103 deletions(-) diff --git a/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts b/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts index 54efb2d865..1f305b07a3 100644 --- a/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts +++ b/desktop/src/features/mesh-compute/hooks/useMeshComputeState.ts @@ -34,6 +34,14 @@ import { useMeshSnapshot } from "./useMeshSnapshot"; * "sharing" while the popover it opened still said "starting". So the state is * owned here and passed down. */ +/** + * How long a sighting of in-flight work keeps the dot moving. + * + * Longer than the 4s usage poll on purpose: a single short request should stay + * visible past the sample that caught it, or the pulse is a flicker nobody sees. + */ +const ACTIVITY_LATCH_MS = 12_000; + export function useMeshComputeState() { const { status, refresh: refreshStatus } = useMeshNodeStatus(); const selectedModel = useShareComputeModel(); @@ -123,6 +131,25 @@ export function useMeshComputeState() { }); const busyNow = (usage?.inflight ?? 0) > 0; + /** + * Recent work, and the moment sharing went live. + * + * `busyNow` alone under-reports badly. `inflight` is sampled every 4s, so a + * request that starts and finishes between two polls is never seen and the dot + * never moves -- which is why real activity looks like nothing is happening. + * Latching the last sighting stops us forgetting observed work immediately; it + * never invents any. + */ + const [recentActivity, setRecentActivity] = React.useState(false); + React.useEffect(() => { + if (busyNow) setRecentActivity(true); + }, [busyNow]); + React.useEffect(() => { + if (!recentActivity) return; + const timer = setTimeout(() => setRecentActivity(false), ACTIVITY_LATCH_MS); + return () => clearTimeout(timer); + }, [recentActivity]); + // `undefined` while the query is in flight, so the row can distinguish "no // mesh agent" from "not known yet" and never throb a nudge that then vanishes. const hasMeshAgent = @@ -130,6 +157,20 @@ export function useMeshComputeState() { ? undefined : managedAgents.some(usesMeshCompute); + /** + * What every share switch shows. + * + * Optimistic on purpose: `meshStartNode` does not resolve until the runtime is + * actually serving, which on a 17 GB model is minutes. A switch that springs + * back and sits off for that whole time reads as "the click did not work". + * + * Derived once and shared, because the row and the popover used to compute + * this independently -- the row optimistically, the popover from real status -- + * so the two controls for the same action disagreed for the entire load. + */ + const shareSwitchChecked = + pendingAction === "start" || (toggle.isSharing && pendingAction !== "stop"); + const row = deriveMeshRowModel({ snapshot, status, @@ -137,6 +178,7 @@ export function useMeshComputeState() { view, pendingAction, busyNow, + recentActivity, hasMeshAgent, }); @@ -198,6 +240,7 @@ export function useMeshComputeState() { busyNow, downloadProgress, pendingAction, + shareSwitchChecked, modelToShare, setSharing, // A transport failure is worth showing; an empty mesh is not an error and diff --git a/desktop/src/features/mesh-compute/meshRowModel.test.mjs b/desktop/src/features/mesh-compute/meshRowModel.test.mjs index bca44dbaac..d7c3ee4764 100644 --- a/desktop/src/features/mesh-compute/meshRowModel.test.mjs +++ b/desktop/src/features/mesh-compute/meshRowModel.test.mjs @@ -126,25 +126,40 @@ test("an activity pulse means real work, nothing else", () => { assert.equal(derive({ toggle: CONSUMING, busyNow: true }).pulse, "activity"); }); -test("capacity you have not joined throbs as an invitation", () => { - // The one state worth drawing the eye to unprompted: there is compute to tap - // into, and this machine is neither using nor adding to it. +test("capacity you have not joined does not animate", () => { + // A standing state: that capacity sits there whether or not anyone looks, so + // the condition is permanently true and motion would never stop. The slate + // tone and the capacity figure in the tooltip carry it instead. const model = derive({ snapshot: snapshot({ sharingDeviceCount: 3, sharedCapacityGb: 92 }), }); - assert.equal(model.pulse, "invite"); + assert.equal(model.tone, "available"); + assert.equal(model.pulse, "none"); + assert.match(model.tooltip, /92 GB/); }); -test("an invite is distinct from activity, so one animation never means two things", () => { - const invite = derive({ - snapshot: snapshot({ sharingDeviceCount: 1, sharedCapacityGb: 36 }), - }); - const activity = derive({ - toggle: SHARING, - view: view(), - busyNow: true, - }); - assert.notEqual(invite.pulse, activity.pulse); +test("only something happening moves the dot", () => { + // The rule that keeps the indicator worth glancing at. An earlier revision + // animated standing states -- capacity not joined, sharing with no consumer -- + // and because those are permanently true the dot moved forever beside a + // healthy row. Constant motion is not a signal. + const standing = [ + derive({ + snapshot: snapshot({ sharingDeviceCount: 1, sharedCapacityGb: 36 }), + }), + derive({ toggle: SHARING, view: view(), hasMeshAgent: false }), + derive({ toggle: SHARING, view: view() }), + derive({ toggle: CONSUMING }), + ]; + for (const model of standing) { + assert.equal(model.pulse, "none"); + } + // Only in-flight work and an in-flight start/stop animate. + assert.equal( + derive({ toggle: SHARING, view: view(), busyNow: true }).pulse, + "activity", + ); + assert.equal(derive({ pendingAction: "start" }).pulse, "progress"); }); test("an empty or unfetched community never throbs", () => { @@ -175,18 +190,18 @@ test("an unhealthy serve runtime reads failed, and can still be stopped", () => assert.match(model.tooltip, /failed/i); }); -test("sharing with no agent throbs, but stays green", () => { - // Blue already means *consuming* — taking from the mesh. Sharing with no - // consumer is the opposite, so reusing blue would make blue meaningless. The - // dot keeps stating the truth (we are sharing) and the throb says there is a - // step left. +test("sharing with no agent says so without animating", () => { + // Blue already means *consuming* -- taking from the mesh -- so this cannot + // borrow blue. It also must not pulse: the condition holds for as long as no + // agent uses the mesh, and a green dot moving forever beside a healthy row is + // noise. The tooltip states it and the popover offers the fix. const model = derive({ toggle: SHARING, view: view(), hasMeshAgent: false, }); assert.equal(model.tone, "sharing"); - assert.equal(model.pulse, "invite"); + assert.equal(model.pulse, "none"); assert.match(model.tooltip, /no agent is using it yet/); }); @@ -255,3 +270,55 @@ test("an empty mesh does not throb, and an unfetched one offers nothing", () => assert.equal(derive({ snapshot: null }).badge, null); assert.equal(derive({ snapshot: null }).pulse, "none"); }); + +test("short work is still visible once the poll has passed", () => { + // `inflight` is sampled every 4s, so a request that starts and finishes + // between two polls is never seen. Without the latch the dot never moves and + // real activity looks like nothing happening -- which is exactly how this read + // in practice. + assert.equal( + derive({ toggle: SHARING, view: view(), busyNow: false }).pulse, + "none", + ); + assert.equal( + derive({ + toggle: SHARING, + view: view(), + busyNow: false, + recentActivity: true, + }).pulse, + "activity", + "a recent sighting keeps the dot moving", + ); + assert.equal( + derive({ toggle: CONSUMING, busyNow: false, recentActivity: true }).pulse, + "activity", + "consuming latches the same way", + ); +}); + +test("going live needs no pulse of its own", () => { + // The tone changing amber -> green IS the confirmation, and it happens at + // exactly that moment. A second signal for one transition is redundant motion, + // and motion is reserved for work. + const live = derive({ toggle: SHARING, view: view() }); + assert.equal(live.tone, "sharing"); + assert.equal(live.pulse, "none"); +}); + +test("a start in flight moves the dot rather than sitting still", () => { + // `meshStartNode` does not resolve until the runtime is serving, which on a + // large model is minutes. A still amber dot for that whole stretch reads as + // stuck, so loading is exactly where motion earns its keep -- patient, because + // nothing is wrong and nothing needs a decision. + assert.equal(derive({ pendingAction: "start" }).pulse, "progress"); + assert.equal( + derive({ toggle: SHARING, pendingAction: "stop" }).pulse, + "progress", + ); + assert.equal( + derive({ toggle: SHARING, status: { state: "starting" } }).pulse, + "progress", + "a loading runtime moves even without a pending action", + ); +}); diff --git a/desktop/src/features/mesh-compute/meshRowModel.ts b/desktop/src/features/mesh-compute/meshRowModel.ts index 08be3b46c0..023e4ab2e3 100644 --- a/desktop/src/features/mesh-compute/meshRowModel.ts +++ b/desktop/src/features/mesh-compute/meshRowModel.ts @@ -41,26 +41,26 @@ export type MeshRowTone = /** * Why the dot is moving, not merely whether. * - * Two different facts want motion, and collapsing them into one boolean would - * make a throb ambiguous: + * **Motion means something is happening.** Only two facts qualify: * - * - `activity` — real work is in flight on this node right now - * - `invite` — there is something worth doing, and nothing is happening + * - `activity` — work is in flight on this node right now + * - `progress` — a start or stop is in flight * - * They are visually distinct (invite breathes slowly; activity uses the quick - * stock ping) so the same animation never means two things. + * Everything else is a *standing state* and must not animate. An earlier + * revision pulsed an "act here" invitation — capacity you have not joined, or + * sharing with no agent using it — and because those conditions are permanently + * true they produced a dot that moved forever beside a perfectly healthy row. + * Constant motion is not a signal; it is noise that trains people to ignore the + * indicator. Standing states belong in the tone, the tooltip, and the popover's + * own prompt, all of which already carry them. * - * `invite` covers two situations that share one meaning — "act here": - * - capacity exists and this machine is outside it - * - this machine shares, but no agent is set up to use the mesh - * - * The second deliberately does **not** get its own colour. Blue already means - * *consuming*, i.e. taking from the mesh; sharing-with-no-consumer is the - * opposite, so painting it blue would make blue meaningless. The dot keeps - * saying what state we are in (green: sharing, truthfully) while the throb says - * there is an action worth taking, and the tooltip names which. + * Both kinds animate brightness only, on the dot itself. No scaling: a growing + * dot shifts the row's optical baseline and reads as jitter. Opacity is what + * Tailwind's `pulse` animates and what Buzz already uses to mean "working" + * (`ChannelWorkingBadge`, `AgentStatusBadge`). They differ in tempo, not + * character — 1s for happening-now, 1.8s for in-progress. */ -export type MeshRowPulse = "none" | "activity" | "invite"; +export type MeshRowPulse = "none" | "activity" | "progress"; export type MeshRowModel = { tone: MeshRowTone; @@ -108,6 +108,24 @@ function liveCapacityGb(view: MeshLiveView | null): number { ].reduce((total, gb) => total + gb, 0); } +/** + * Which pulse a participating node shows. + * + * Only real work moves the dot. `recentActivity` is not a second kind of signal: + * `inflight` is sampled every few seconds, so a request that starts and finishes + * between two polls is never seen at all. Latching the last sighting stops us + * forgetting observed work immediately — it never invents any. + */ +function pulseForWork({ + busyNow, + recentActivity, +}: { + busyNow: boolean; + recentActivity: boolean; +}): MeshRowPulse { + return busyNow || recentActivity ? "activity" : "none"; +} + export function deriveMeshRowModel({ snapshot, status, @@ -115,6 +133,7 @@ export function deriveMeshRowModel({ view, pendingAction, busyNow, + recentActivity = false, hasMeshAgent, }: { snapshot: MeshSnapshot | null; @@ -124,6 +143,16 @@ export function deriveMeshRowModel({ pendingAction: "start" | "stop" | null; /** Work in flight on this node, inbound or outbound. */ busyNow: boolean; + /** + * Work was in flight within the last few seconds. + * + * `busyNow` alone under-reports badly: `inflight` is sampled every 4s, so any + * request that starts and finishes between two polls is never seen and the dot + * never moves. Latching the last sighting makes real short work visible + * instead of silently dropping it. Still a fact about observed work — it never + * invents activity, it only stops forgetting it immediately. + */ + recentActivity?: boolean; /** * Any agent resolves to the shared-compute provider. * @@ -145,7 +174,12 @@ export function deriveMeshRowModel({ pendingAction === "stop" ? "Stopping sharing…" : "Starting to share…", badge: null, callToAction: null, - pulse: "none", + // Loading is exactly when motion earns its keep. `meshStartNode` does not + // resolve until the runtime is serving, which on a 17 GB model is minutes; + // a still amber dot for that whole stretch reads as stuck rather than + // working. `progress` is patient rather than insistent, because nothing is + // wrong and nothing needs a decision. + pulse: "progress", }; } @@ -175,7 +209,11 @@ export function deriveMeshRowModel({ tooltip: "Sharing compute · no agent is using it yet", badge, callToAction: null, - pulse: "invite", + // Standing state, so it does not animate. This condition stays true for + // as long as no agent uses the mesh, and a green dot pulsing forever + // beside a healthy row is noise. The tooltip says it, and the popover + // offers the fix. + pulse: "none", }; } return { @@ -187,7 +225,7 @@ export function deriveMeshRowModel({ : "Sharing compute · waiting for another device", badge, callToAction: null, - pulse: busyNow ? "activity" : "none", + pulse: pulseForWork({ busyNow, recentActivity }), }; } @@ -199,7 +237,7 @@ export function deriveMeshRowModel({ tooltip: "Using shared compute from the mesh", badge: gb > 0 ? formatCapacityGb(gb) : null, callToAction: null, - pulse: busyNow ? "activity" : "none", + pulse: pulseForWork({ busyNow, recentActivity }), }; } @@ -253,6 +291,8 @@ export function deriveMeshRowModel({ ? `${count} ${plural(count, "device")}` : formatCapacityGb(gb), callToAction: null, - pulse: "invite", + // Standing state: this capacity sits there whether or not anyone looks. The + // slate tone and the capacity figure in the tooltip are the invitation. + pulse: "none", }; } diff --git a/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx b/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx index 28a471417b..0242a186a4 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputePopover.tsx @@ -49,6 +49,7 @@ export function MeshComputePopover({ inboundWork, downloadProgress, pendingAction, + shareSwitchChecked, modelToShare, setSharing, error, @@ -105,7 +106,9 @@ export function MeshComputePopover({ ? "Stop sharing this computer's compute" : "Share this computer's compute" } - checked={toggle.isSharing} + // Shared with the row switch: these are two controls for one + // action and must not disagree during a multi-minute model load. + checked={shareSwitchChecked} className="mt-0.5 shrink-0" data-testid="mesh-popover-share-toggle" disabled={pendingAction !== null} diff --git a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx index 87ac30fe7f..70b2d72dcb 100644 --- a/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx +++ b/desktop/src/features/mesh-compute/ui/SidebarMeshComputeRow.tsx @@ -1,5 +1,8 @@ +import { Waypoints } from "lucide-react"; + import { SidebarMenuButton, SidebarMenuItem } from "@/shared/ui/sidebar"; import { SidebarMenuLabel } from "@/shared/ui/sidebar-menu-label"; +import { Switch } from "@/shared/ui/switch"; import { cn } from "@/shared/lib/cn"; import { useMeshComputeState } from "../hooks/useMeshComputeState"; @@ -7,11 +10,12 @@ import type { MeshRowPulse, MeshRowTone } from "../meshRowModel"; import { MeshComputePopover } from "./MeshComputePopover"; /** - * The sidebar MeshLLM row. + * The sidebar Shared Compute row. * - * Replaces the ~120px footer card with one menu line beside Inbox and Agents, - * because front-page sidebar space is premium. The row carries a single signal - * — a coloured dot — and everything else lives in the popover it opens. + * Shaped like Inbox and Agents: a subject icon on the left, the name beside it. + * The status signal rides next to the name rather than standing in for the icon + * — an earlier revision made the dot the leading element, which read as a bullet + * point instead of a peer of the other rows. * * The dot distinguishes six states, and the pair that matters most is `unknown` * vs `available`: "nobody is sharing" and "there is 115 GB here you have not @@ -30,31 +34,60 @@ const TONE_DOT: Record = { failed: "bg-transparent border-2 border-destructive", }; -const TONE_PING: Record = { - unknown: "", - // Slate rather than a tone colour: this is an invitation, not activity. - available: "bg-slate-400 dark:bg-slate-500", - sharing: "bg-emerald-500 dark:bg-emerald-400", - consuming: "bg-sky-500 dark:bg-sky-400", - starting: "bg-amber-500 dark:bg-amber-400", - failed: "", +/** + * How the dot moves. Brightness only. + * + * No scaling: a dot that grows shifts the row's optical baseline and reads as + * jitter rather than as a signal. Opacity is what Tailwind's `pulse` animates and + * what Buzz already uses to mean "working" (`ChannelWorkingBadge`, + * `AgentStatusBadge`). + * + * Both kinds share one curve and differ only in tempo, because the difference + * between "happening now" and "in progress" is urgency, not character. There is + * no third kind: standing states do not animate at all. + * + * (An earlier revision animated a separate halo ring, scaling it to 2x while + * fading to zero. At 8px that is imperceptible, which is why the pulse could not + * be seen. Then it over-corrected into scaling the dot, which jittered.) + */ +const DOT_PULSE_CLASS: Record = { + none: "", + activity: "animate-mesh-activity", + progress: "animate-mesh-progress", }; -/** - * Two facts want motion, so they must not look alike. - * - * `activity` is real work in flight — it uses Tailwind's stock ping, which is - * quick and insistent, because something is happening right now. - * - * `invite` says there is compute here to tap into and this machine is outside - * it. Nothing is happening, so it breathes slowly instead: noticeable on a - * second glance, never competing with unread badges. - */ -const PULSE_CLASS: Record = { - none: "", - activity: "animate-ping", - invite: "animate-mesh-invite", -}; +/** The status dot. Shared by both placements. */ +function MeshPulseDot({ + pulse, + tone, + size = "default", + testId, +}: { + pulse: MeshRowPulse; + tone: MeshRowTone; + /** `small` is the icon-collapsed variant, which has to fit on the glyph. */ + size?: "default" | "small"; + testId?: string; +}) { + const box = size === "small" ? "h-1.5 w-1.5" : "h-2 w-2"; + return ( + + + + ); +} export function SidebarMeshComputeRow({ onOpenComputeSettings, @@ -62,7 +95,13 @@ export function SidebarMeshComputeRow({ onOpenComputeSettings?: () => void; }) { const mesh = useMeshComputeState(); - const { row } = mesh; + const { row, toggle, pendingAction, shareSwitchChecked, setSharing } = mesh; + + // The row offers joining; the popover owns managing. Once this machine is + // sharing there is nothing left to invite, and the popover's labelled switch + // is the place to stop — a bare unlabelled switch is a poor control for an + // action with consequences. + const showJoinToggle = !toggle.isSharing; return ( @@ -71,7 +110,12 @@ export function SidebarMeshComputeRow({ onOpenComputeSettings={onOpenComputeSettings} > + {/* - The dot is in every state, without exception. It is the row's - reason to exist: hollow when nothing is known, slate when there is - capacity to join, green while sharing, blue while consuming. An - earlier revision swapped it for a Share2 glyph in the cold state, - which threw away the signal and made the row read as a network - widget rather than a status line. + Icon-collapsed only. The label and the dot beside it are both + clipped in icon mode, so without this the row would lose its status + signal entirely at exactly the width where the tooltip is the only + other affordance. */} - - {row.pulse === "none" ? null : ( - - )} - + + {row.label} + {/* + Right-aligned, the same position and mechanism a channel row uses for + its unread dot (`UnreadDotBadge className="ml-auto"`). Beside the name + it competed with the label for one optical line; here it reads as this + row's indicator and matches the sidebar's existing vocabulary. + + When the join switch is present the button carries `pr-11`, so the dot + lands clear of it rather than underneath. + */} + + + + + {/* + A sibling of the button, never a child: `PopoverTrigger asChild` makes the + button itself the trigger, so a switch nested inside would be a button + within a button and every toggle would also open the popover. + */} + {showJoinToggle ? ( +
+ span]:h-3 [&>span]:w-3 [&>span]:data-[state=checked]:translate-x-3", + "data-[state=unchecked]:bg-foreground/25", + )} + data-testid="mesh-row-share-toggle" + disabled={pendingAction !== null} + onCheckedChange={setSharing} + /> +
+ ) : null}
); } diff --git a/desktop/tailwind.config.js b/desktop/tailwind.config.js index f2d3b6865f..491cd87063 100644 --- a/desktop/tailwind.config.js +++ b/desktop/tailwind.config.js @@ -38,19 +38,28 @@ export default { 4.5: "1.125rem", }, keyframes: { - // The mesh-invite pulse: there is shared compute in the community and - // this machine is neither using nor adding to it. Deliberately unlike - // Tailwind's stock `ping`, which is quick and insistent because it - // signals something happening *now*. Nothing is happening here, so this - // breathes slowly and never reaches full opacity — noticeable on a - // second glance, never competing with unread badges. - "mesh-invite": { - "0%, 100%": { transform: "scale(1)", opacity: "0.55" }, - "50%": { transform: "scale(2)", opacity: "0" }, + // Brightness only, on the dot itself. + // + // No scaling: a dot that grows shifts the row's optical baseline and + // reads as jitter rather than as a signal. Opacity is what Tailwind's own + // `pulse` animates, and it is what Buzz already uses to mean "working" + // (`ChannelWorkingBadge`, `AgentStatusBadge`). It dips further than stock + // `pulse` (0.25 vs 0.5) because 8px of colour needs the extra contrast to + // register at a glance. + // + // Two durations, one curve: the difference between "happening now" and + // "in progress" is tempo, not character. + "mesh-dim": { + "0%, 100%": { opacity: "1" }, + "50%": { opacity: "0.25" }, }, }, animation: { - "mesh-invite": "mesh-invite 2.8s cubic-bezier(0.4, 0, 0.6, 1) infinite", + // Work in flight right now. + "mesh-activity": "mesh-dim 1s cubic-bezier(0.4, 0, 0.6, 1) infinite", + // A start or stop in flight. Slower: nothing is wrong, and nothing needs + // a decision. + "mesh-progress": "mesh-dim 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite", }, fontFamily: { sans: [