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: [