topology make activity states visible

This commit is contained in:
mvschwarz committed 2026-05-07 01:01:44 -07:00
1 parent b4b93f4d69
commit 714932c491
6 files changed
+176 -9

No files matched your search

+14 -3
View File
@@ -3,6 +3,7 @@ import { Handle, Position } from "@xyflow/react";
import { PanelsTopLeft } from "lucide-react";
import { copyText } from "../lib/copy-text.js";
import { displayAgentName } from "../lib/display-name.js";
import { cn } from "../lib/utils.js";
import {
getActivityState,
getActivityLabel,
@@ -14,6 +15,7 @@ import {
import type { AgentActivitySummary, CurrentQitemSummary } from "../hooks/useNodeInventory.js";
import { ContextUsageRing } from "./ContextUsageRing.js";
import { ActivityRing } from "./topology/ActivityRing.js";
import { getActivityCardClasses, getActivityCardSignal } from "./topology/activity-card-visuals.js";
import type { TopologyActivityVisual } from "../lib/topology-activity.js";
interface RigNodeData {
@@ -93,6 +95,7 @@ export function RigNode({ data }: { data: RigNodeData }) {
const activityBgClass = getActivityBgClass(activityState);
const activityAnimClass = getActivityAnimationClass(activityState);
const activityIsStale = isActivityStale(data.agentActivity);
const activityCard = getActivityCardSignal({ activityRing: data.activityRing, activityState });
const placementChipLabel = data.placementState === "selected" ? "target" : data.placementState === "available" ? "avail" : null;
@@ -167,13 +170,21 @@ export function RigNode({ data }: { data: RigNodeData }) {
const card = (
<div
className={`group bg-white border min-w-[200px] hard-shadow relative ${
className={cn(
"group relative min-w-[200px] border hard-shadow transition-[background-color,border-color,box-shadow] duration-300",
getActivityCardClasses({
state: activityCard.state,
flash: activityCard.flash,
reducedMotion: data.reducedMotion,
}),
data.placementState === "selected"
? "border-emerald-600 ring-2 ring-emerald-400/70 shadow-[0_0_0_3px_rgba(52,211,153,0.12)]"
: data.placementState === "available"
? "border-emerald-500 ring-1 ring-emerald-300/70"
: "border-stone-900"
}`}
: "border-stone-900",
)}
data-activity-card-state={activityCard.state}
data-activity-card-flash={activityCard.flash ?? "none"}
data-testid="rig-node"
title={hoverHint || undefined}
>
@@ -50,7 +50,10 @@ export function HotPotatoEdge({
data-reduced-motion="true"
cx={targetX}
cy={targetY}
r={crossRig ? 2.5 : 3.25}
r={crossRig ? 5 : 6.5}
stroke="rgba(255,255,255,0.88)"
strokeWidth={crossRig ? 1.75 : 2.25}
vectorEffect="non-scaling-stroke"
className={crossRig ? "fill-stone-500" : "fill-emerald-600"}
/>
) : (
@@ -58,7 +61,10 @@ export function HotPotatoEdge({
key={packet.id}
data-testid={`hot-potato-packet-${packet.id}`}
data-reduced-motion="false"
r={crossRig ? 2.25 : 3}
r={crossRig ? 5 : 6.5}
stroke="rgba(255,255,255,0.88)"
strokeWidth={crossRig ? 1.75 : 2.25}
vectorEffect="non-scaling-stroke"
className={crossRig ? "fill-stone-500 hot-potato-packet-cross" : "fill-emerald-600 hot-potato-packet"}
>
<animateMotion
@@ -12,6 +12,7 @@ import type { AgentActivitySummary } from "../../hooks/useNodeInventory.js";
import { cn } from "../../lib/utils.js";
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
import { ActivityRing } from "./ActivityRing.js";
import { getActivityCardClasses, getActivityCardSignal } from "./activity-card-visuals.js";
import type { TopologyActivityVisual } from "../../lib/topology-activity.js";
interface HybridPodGroupNodeData {
@@ -89,6 +90,7 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
const activityBgClass = getActivityBgClass(activityState);
const activityAnimClass = getActivityAnimationClass(activityState);
const activityStale = isActivityStale(data.agentActivity);
const activityCard = getActivityCardSignal({ activityRing: data.activityRing, activityState });
const runtimeModel = [data.runtime, data.model].filter(Boolean).join(" / ");
const contextKnown = data.contextAvailability === "known" && typeof data.contextUsedPercentage === "number";
@@ -100,8 +102,15 @@ export function HybridAgentNode({ data }: { data: HybridAgentNodeData }) {
`activity: ${activityLabel}${activityStale ? " (stale)" : ""}`,
runtimeModel || null,
].filter(Boolean).join("\n")}
data-activity-card-state={activityCard.state}
data-activity-card-flash={activityCard.flash ?? "none"}
className={cn(
"group relative h-full w-full select-none border bg-white hard-shadow",
"group relative h-full w-full select-none border hard-shadow transition-[background-color,border-color,box-shadow] duration-300",
getActivityCardClasses({
state: activityCard.state,
flash: activityCard.flash,
reducedMotion: data.reducedMotion,
}),
data.startupStatus === "failed"
? "border-red-700"
: data.startupStatus === "attention_required"
@@ -0,0 +1,35 @@
import type { ActivityState } from "../../lib/activity-visuals.js";
import type { ActivityFlash, ActivityRingState, TopologyActivityVisual } from "../../lib/topology-activity.js";
import { cn } from "../../lib/utils.js";
export function fallbackActivityCardState(activityState: ActivityState): ActivityRingState {
if (activityState === "running") return "active";
if (activityState === "needs_input") return "needs_input";
return "idle";
}
export function getActivityCardSignal(input: {
activityRing?: TopologyActivityVisual | null;
activityState: ActivityState;
}): { state: ActivityRingState; flash: ActivityFlash } {
return {
state: input.activityRing?.state ?? fallbackActivityCardState(input.activityState),
flash: input.activityRing?.flash ?? null,
};
}
export function getActivityCardClasses(input: {
state: ActivityRingState;
flash?: ActivityFlash;
reducedMotion?: boolean;
}): string {
return cn(
"activity-card-surface",
input.state === "active" && "activity-card-active",
input.state === "needs_input" && "activity-card-needs-input",
input.state === "blocked" && "activity-card-blocked",
input.flash === "source" && "activity-card-source-flash",
input.flash === "target" && "activity-card-target-flash",
input.reducedMotion && "activity-card-reduced-motion",
);
}
+89 -3
View File
@@ -315,6 +315,84 @@
0% { opacity: 1; box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.42); }
100% { opacity: 0.82; box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.0); }
}
@keyframes activity-card-active-frames {
0%, 100% {
background-color: rgba(220, 252, 231, 0.82);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(16, 185, 129, 0.16);
}
50% {
background-color: rgba(187, 247, 208, 0.96);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(16, 185, 129, 0.30);
}
}
@keyframes activity-card-needs-input-frames {
0%, 100% {
background-color: rgba(254, 243, 199, 0.88);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(245, 158, 11, 0.22);
}
50% {
background-color: rgba(253, 230, 138, 0.98);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(245, 158, 11, 0.38);
}
}
@keyframes activity-card-blocked-frames {
0%, 100% {
background-color: rgba(254, 226, 226, 0.88);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(220, 38, 38, 0.26);
}
50% {
background-color: rgba(254, 202, 202, 0.96);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(220, 38, 38, 0.38);
}
}
@keyframes activity-card-source-flash-frames {
0% {
background-color: rgba(167, 243, 208, 0.98);
box-shadow: 2px 2px 0px #2e342e, 0 0 0 5px rgba(16, 185, 129, 0.34), 0 0 24px rgba(16, 185, 129, 0.30);
}
100% {
box-shadow: 2px 2px 0px #2e342e, 0 0 0 0 rgba(16, 185, 129, 0);
}
}
@keyframes activity-card-target-flash-frames {
0% {
background-color: rgba(110, 231, 183, 1);
box-shadow: 2px 2px 0px #2e342e, 0 0 0 7px rgba(16, 185, 129, 0.42), 0 0 32px rgba(16, 185, 129, 0.38);
}
100% {
box-shadow: 2px 2px 0px #2e342e, 0 0 0 0 rgba(16, 185, 129, 0);
}
}
.activity-card-surface {
background-color: rgba(255, 255, 255, 0.96);
}
.activity-card-active {
background-color: rgba(220, 252, 231, 0.88);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(16, 185, 129, 0.18);
}
.activity-card-needs-input {
background-color: rgba(254, 243, 199, 0.92);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(245, 158, 11, 0.24);
}
.activity-card-blocked {
background-color: rgba(254, 226, 226, 0.92);
box-shadow: 2px 2px 0px #2e342e, inset 0 0 0 1px rgba(220, 38, 38, 0.28);
}
.activity-card-active:not(.activity-card-reduced-motion) {
animation: activity-card-active-frames 2.4s ease-in-out infinite;
}
.activity-card-needs-input:not(.activity-card-reduced-motion) {
animation: activity-card-needs-input-frames 2.1s ease-in-out infinite;
}
.activity-card-blocked:not(.activity-card-reduced-motion) {
animation: activity-card-blocked-frames 3.4s ease-in-out infinite;
}
.activity-card-source-flash:not(.activity-card-reduced-motion) {
animation: activity-card-source-flash-frames 760ms ease-out 1;
}
.activity-card-target-flash:not(.activity-card-reduced-motion) {
animation: activity-card-target-flash-frames 900ms ease-out 1;
}
.activity-ring-active {
animation: activity-ring-active-frames 2.2s ease-in-out infinite;
}
@@ -331,15 +409,18 @@
.hot-potato-edge-active .react-flow__edge-path,
.react-flow__edge.hot-potato-edge-active .react-flow__edge-path {
filter: drop-shadow(0 0 6px rgba(16, 185, 129, 0.42)) !important;
stroke-width: 4px !important;
transition: stroke-width 180ms ease-out, filter 180ms ease-out;
vector-effect: non-scaling-stroke;
}
.hot-potato-packet,
.hot-potato-packet-cross {
filter: drop-shadow(0 0 3px rgba(16, 185, 129, 0.32));
filter: drop-shadow(0 0 8px rgba(16, 185, 129, 0.52));
}
.hot-potato-packet-cross {
opacity: 0.72;
filter: drop-shadow(0 0 2px rgba(120, 113, 108, 0.22));
opacity: 0.86;
filter: drop-shadow(0 0 6px rgba(120, 113, 108, 0.36));
}
@keyframes rig-activity-frame-frames {
@@ -368,6 +449,11 @@
.activity-ring-blocked,
.activity-ring-source-flash,
.activity-ring-target-flash,
.activity-card-active,
.activity-card-needs-input,
.activity-card-blocked,
.activity-card-source-flash,
.activity-card-target-flash,
.rig-activity-frame-pulse {
animation: none;
opacity: 1;
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import path from "node:path";
import { ActivityRing } from "../src/components/topology/ActivityRing.js";
import { HotPotatoEdge } from "../src/components/topology/HotPotatoEdge.js";
import { fallbackActivityCardState, getActivityCardClasses } from "../src/components/topology/activity-card-visuals.js";
import {
HOT_POTATO_WITHIN_RIG_DURATION_MS,
type HotPotatoPacket,
@@ -95,11 +96,23 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(screen.getByTestId("hot-potato-packet-packet-1").getAttribute("data-reduced-motion")).toBe("true");
});
it("whole-card activity classes make active and handoff states visible", () => {
expect(fallbackActivityCardState("running")).toBe("active");
expect(fallbackActivityCardState("idle")).toBe("idle");
expect(getActivityCardClasses({ state: "active" })).toContain("activity-card-active");
expect(getActivityCardClasses({ state: "needs_input" })).toContain("activity-card-needs-input");
expect(getActivityCardClasses({ state: "blocked" })).toContain("activity-card-blocked");
expect(getActivityCardClasses({ state: "active", flash: "target" })).toContain("activity-card-target-flash");
expect(getActivityCardClasses({ state: "active", flash: "source", reducedMotion: true })).toContain("activity-card-reduced-motion");
});
it("source scan keeps activity and packet layers production-reachable", () => {
const srcRoot = path.resolve(__dirname, "../src");
const host = readFileSync(path.join(srcRoot, "components/topology/HostMultiRigGraph.tsx"), "utf8");
const rigGraph = readFileSync(path.join(srcRoot, "components/RigGraph.tsx"), "utf8");
const rigNode = readFileSync(path.join(srcRoot, "components/RigNode.tsx"), "utf8");
const hybridNodes = readFileSync(path.join(srcRoot, "components/topology/HybridTopologyNodes.tsx"), "utf8");
const activityCards = readFileSync(path.join(srcRoot, "components/topology/activity-card-visuals.ts"), "utf8");
const table = readFileSync(path.join(srcRoot, "components/topology/TopologyTableView.tsx"), "utf8");
const ring = readFileSync(path.join(srcRoot, "components/topology/ActivityRing.tsx"), "utf8");
const edge = readFileSync(path.join(srcRoot, "components/topology/HotPotatoEdge.tsx"), "utf8");
@@ -114,6 +127,10 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(rigGraph).toMatch(/applyHotPotatoEdges/);
expect(rigGraph).toMatch(/edgeTypes=\{edgeTypes\}/);
expect(hybridNodes).toMatch(/ActivityRing/);
expect(hybridNodes).toMatch(/getActivityCardClasses/);
expect(rigNode).toMatch(/getActivityCardClasses/);
expect(activityCards).toMatch(/activity-card-active/);
expect(rigGraph).toMatch(/activityRing/);
expect(table).toMatch(/ActivityRing/);
expect(table).toMatch(/group-hover:opacity-100/);
expect(ring).not.toMatch(/StatusPip/);
@@ -126,6 +143,9 @@ describe("P5.3 ActivityRing and HotPotatoEdge", () => {
expect(sourceFiles.join("\n").match(/"6 7"/g) ?? []).toHaveLength(1);
expect(css).toMatch(/@media \(prefers-reduced-motion: reduce\)/);
expect(css).toMatch(/activity-ring-active/);
expect(css).toMatch(/activity-card-active/);
expect(css).toMatch(/activity-card-target-flash/);
expect(edge).toMatch(/non-scaling-stroke/);
expect(css).toMatch(/rig-activity-frame-pulse/);
});
});