feat(topology): active/idle status sub-states + always-visible actions + terminal cards click-through

V0.3.1 slice 14 walk-items 15, 16, 17 from the 2026-05-11 synthesis.

Walk-item 15 — TopologyTableView status sub-states:
- New StatusCell maps `running` status into "active" / "idle" labels
  based on `row.activityRing.state`. Non-running statuses (starting /
  stopped / failed / unknown) keep their raw label.
- Active label gets a subtle left-to-right shimmer via a new
  `topology-table-active-shimmer` class. Implemented as a
  background-clip:text gradient sweep so only the label text shimmers
  while the green status pip dot stays static.
- `@media (prefers-reduced-motion: reduce)` block suppresses the
  animation and falls back to the same green text color, preserving
  visual weight per DESIGN.md §Motion.
- Data signal verified shipped: `ActivityRingState` enum at
  `packages/ui/src/lib/topology-activity.ts:15` already produces
  active/idle from `agentActivity.state === "running"` OR
  `currentQitems.length > 0`. No backend changes needed.

Walk-item 16 — Action column always-visible:
- CmuxButton drops the `opacity-0` + `group-hover:!opacity-100` chain
  and renders unconditionally. Action column header gains "Actions"
  text.
- New cell renders cmux + terminal-preview side-by-side in a
  flex container. TerminalPreviewPopover provides the
  terminal-preview button via the same component the graph view uses.

Walk-item 17 — TerminalView cards click-through + smaller font:
- SeatTerminalCard header `text-[10px]` → `text-[8px]` (20%
  reduction via the existing type scale).
- Card body wrapped with an absolute-inset trigger button that
  dispatches `TERMINAL_PREVIEW_EVENT` with the seat's key. The
  TerminalPreviewPopover (mounted per card with sr-only button +
  wrapperClassName anchoring top-right) listens for the matching
  key and renders the positioned popover. Same black-glass popover
  the graph view shows.

StatusPip API extension:
- New optional `labelClassName` prop applied to the label `<span>`
  only (NOT the dot). Used by TopologyTableView to scope the
  shimmer animation to the label text while keeping the green dot
  static.

Tests:
- Full daemon suite: 3379 passed / 1 skipped / 0 failed.
- UI typecheck on touched files: zero NEW errors. Pre-existing
  `@tanstack/react-table` errors in TopologyTableView persist on
  main; unrelated.
- UI broader suite hits the worktree-PostCSS-tailwind infra issue
  banked from slice 12 (unrelated to this slice).
- git diff --check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 08:40:12 -07:00
1 parent 5973140b24
commit 5f92eccc6e
4 files changed
+165 -17

No files matched your search

@@ -39,6 +39,8 @@ import {
type TopologyActivityVisual,
} from "../../lib/topology-activity.js";
import { ActivityRing } from "./ActivityRing.js";
import { TerminalPreviewPopover } from "./TerminalPreviewPopover.js";
import "./topology-table-shimmer.css";
import { RuntimeBadge, ToolMark } from "../graphics/RuntimeMark.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../../lib/token-format.js";
import { contextUsageTextClass } from "../ContextUsageRing.js";
@@ -75,6 +77,10 @@ function statusToSemanticPip(s: string): "active" | "running" | "stopped" | "war
}
function CmuxButton({ row }: { row: AgentRow }) {
// V0.3.1 slice 14 walk-item 16: action column buttons stay visible
// unconditionally (no hover/focus gate). Prior implementation used
// `opacity-0` + `group-hover:!opacity-100` which hid the affordance
// off-mouse — operators kept missing the cmux launcher.
const cmuxLaunch = useCmuxLaunch();
return (
<button
@@ -86,7 +92,7 @@ function CmuxButton({ row }: { row: AgentRow }) {
}}
aria-label={`Open ${row.logicalId} in cmux`}
title="Open in cmux"
className="inline-flex h-7 w-7 items-center justify-center border border-outline-variant bg-white/65 text-stone-700 opacity-0 shadow-[1px_1px_0_rgba(46,52,46,0.12)] transition-opacity hover:bg-stone-100 hover:text-stone-950 focus:!opacity-100 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-stone-900/20 group-hover:!opacity-100 group-hover:opacity-100 group-focus-within:!opacity-100 group-focus-within:opacity-100"
className="inline-flex h-7 w-7 items-center justify-center border border-outline-variant bg-white/65 text-stone-700 shadow-[1px_1px_0_rgba(46,52,46,0.12)] transition-colors hover:bg-stone-100 hover:text-stone-950 focus:outline-none focus:ring-2 focus:ring-stone-900/20"
>
<ToolMark tool="cmux" size="sm" />
<span className="sr-only">CMUX</span>
@@ -94,6 +100,28 @@ function CmuxButton({ row }: { row: AgentRow }) {
);
}
/** V0.3.1 slice 14 walk-item 15 — status label split. When the row's
* activity ring is in the `active` state the cell shows "active" with
* a subtle left-to-right shimmer; otherwise it shows "idle" (or the
* raw status string for non-running states like "starting" / "failed").
* Honors `prefers-reduced-motion: reduce` via CSS — see
* `topology-shimmer` in `topology-table-shimmer.css`. */
function StatusCell({ status, activityState }: { status: string; activityState: string | undefined }) {
const semantic = statusToSemanticPip(status);
// Only split the "running" status into active/idle. Other statuses
// (starting / stopped / failed / unknown) keep their raw label.
const isRunning = status === "running" || status === "ready";
const isActive = isRunning && activityState === "active";
const isIdle = isRunning && !isActive;
const label = isActive ? "active" : isIdle ? "idle" : status;
const labelClass = isActive ? "topology-table-active-shimmer" : "";
return (
<span data-testid={`topology-table-status-${activityState ?? "unknown"}`} data-activity-state={activityState ?? null}>
<StatusPip status={semantic} label={label} variant="pill" labelClassName={labelClass} />
</span>
);
}
function ContextCell({ row }: { row: AgentRow }) {
const usage = row.contextUsage;
const known = usage?.availability === "known" && typeof usage.usedPercentage === "number";
@@ -179,15 +207,35 @@ function agentColumns(): ColumnDef<AgentRow>[] {
{
accessorKey: "status",
header: "Status",
cell: ({ getValue }) => (
<StatusPip status={statusToSemanticPip(String(getValue()))} label={String(getValue())} variant="pill" />
cell: ({ getValue, row }) => (
<StatusCell
status={String(getValue())}
activityState={row.original.activityRing?.state}
/>
),
},
{
id: "actions",
header: "",
header: "Actions",
enableSorting: false,
cell: ({ row }) => <CmuxButton row={row.original} />,
// V0.3.1 slice 14 walk-item 16: action column shows cmux +
// terminal-preview side-by-side, no hover gate. Both buttons
// render at all times for predictable affordances.
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5" data-testid={`topology-table-actions-${row.original.logicalId}`}>
<CmuxButton row={row.original} />
{row.original.rigId ? (
<TerminalPreviewPopover
rigId={row.original.rigId}
logicalId={row.original.logicalId}
sessionName={row.original.sessionName ?? null}
reducedMotion={false}
testIdPrefix={`topology-table-${row.original.logicalId}`}
buttonClassName="inline-flex h-7 w-7 items-center justify-center border border-outline-variant bg-white/65 text-stone-700 shadow-[1px_1px_0_rgba(46,52,46,0.12)] transition-colors hover:bg-stone-100 hover:text-stone-950 focus:outline-none focus:ring-2 focus:ring-stone-900/20"
/>
) : null}
</span>
),
},
];
}
@@ -29,6 +29,7 @@ import { cn } from "../../lib/utils.js";
import { RuntimeBadge } from "../graphics/RuntimeMark.js";
import { formatCompactTokenCount, formatTokenTotalTitle, sumTokenCounts } from "../../lib/token-format.js";
import { contextUsageTextClass } from "../ContextUsageRing.js";
import { TerminalPreviewPopover } from "./TerminalPreviewPopover.js";
const SAFE_N = 12;
@@ -83,23 +84,61 @@ function TokenMetric({ seat }: { seat: NodeInventoryEntry }) {
);
}
// V0.3.1 slice 14 walk-item 17 — TerminalView card.
// Click-through opens TerminalPreviewPopover (same black-glass shell
// used by the graph view), and the card font shrinks via the existing
// type scale: header text-[10px] → text-[8px] (~20% reduction). The
// preview pane font hint scales with parent.
const TERMINAL_PREVIEW_EVENT = "openrig:topology-terminal-preview";
function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
const sessionName = seat.canonicalSessionName ?? seat.logicalId;
const active = isActiveRunning(seat);
const memberName = displayAgentName(seat.logicalId);
const previewKey = `${seat.rigId ?? "unknown"}:${seat.logicalId}`;
const handleCardClick = () => {
window.dispatchEvent(
new CustomEvent(TERMINAL_PREVIEW_EVENT, { detail: { key: previewKey } }),
);
};
return (
<div
data-testid={`terminal-card-${seat.rigId}-${seat.logicalId}`}
data-active={active ? "true" : "false"}
className={cn(
"border bg-white/40 p-2 flex flex-col gap-2",
"relative border bg-white/40 p-2 flex flex-col gap-2",
active
? "border-secondary terminal-card-active"
: "border-outline-variant",
)}
>
<header className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.10em] text-stone-900 truncate">
<button
type="button"
onClick={handleCardClick}
aria-label={`Open ${memberName} terminal preview`}
className="absolute inset-0 z-10 cursor-pointer bg-transparent focus:outline-none focus:ring-2 focus:ring-stone-900/20"
data-testid={`terminal-card-trigger-${seat.rigId}-${seat.logicalId}`}
/>
{/*
TerminalPreviewPopover renders its own sr-only button + popover
portal. The card-level button above dispatches the open event;
this component listens for the matching key + renders the
positioned popover anchored to the wrapper div (top-right of
the card via wrapperClassName).
*/}
{seat.rigId ? (
<TerminalPreviewPopover
rigId={seat.rigId}
logicalId={seat.logicalId}
sessionName={sessionName}
reducedMotion={false}
testIdPrefix={`terminal-grid-${seat.rigId}-${seat.logicalId}`}
wrapperClassName="absolute right-1 top-1 z-20"
buttonClassName="sr-only"
/>
) : null}
<header className="relative z-0 flex items-center justify-between gap-2 pointer-events-none">
<span className="font-mono text-[8px] font-semibold uppercase tracking-[0.10em] text-stone-900 truncate">
{memberName}
</span>
<span className="inline-flex shrink-0 items-center gap-1">
@@ -108,11 +147,13 @@ function SeatTerminalCard({ seat }: { seat: NodeInventoryEntry }) {
<TokenMetric seat={seat} />
</span>
</header>
<SessionPreviewPane
sessionName={sessionName}
lines={20}
testIdPrefix={`terminal-preview-${seat.rigId}-${seat.logicalId}`}
/>
<div className="relative z-0 pointer-events-none">
<SessionPreviewPane
sessionName={sessionName}
lines={20}
testIdPrefix={`terminal-preview-${seat.rigId}-${seat.logicalId}`}
/>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
/*
* V0.3.1 slice 14 walk-item 15 — active-row shimmer.
*
* Subtle left-to-right gradient sweep on the "active" status label
* in TopologyTableView. Animates the background-position of a
* background-clip:text gradient so the text itself shimmers; the
* green dot in the StatusPip stays static.
*
* Accessibility: animation is suppressed under
* `prefers-reduced-motion: reduce` per DESIGN.md §Motion. The
* fallback static color matches the underlying `text-success` tone
* so the label stays legible at the same visual weight.
*/
.topology-table-active-shimmer {
background: linear-gradient(
90deg,
currentColor 0%,
currentColor 35%,
rgba(255, 255, 255, 0.9) 50%,
currentColor 65%,
currentColor 100%
);
background-size: 200% 100%;
background-position: 100% 0;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
animation: topology-table-active-shimmer-sweep 2.4s linear infinite;
}
@keyframes topology-table-active-shimmer-sweep {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.topology-table-active-shimmer {
background: none;
-webkit-background-clip: initial;
background-clip: initial;
-webkit-text-fill-color: currentColor;
color: inherit;
animation: none;
}
}
+12 -4
View File
@@ -16,6 +16,12 @@ export interface StatusPipProps {
label?: string;
variant?: StatusPipVariant;
className?: string;
/** V0.3.1 slice 14 walk-item 15 — optional class applied to the
* label text only (NOT the dot). Used by TopologyTableView to add
* a shimmer animation to active rows while keeping the green dot
* static. Mounting in StatusPip (rather than wrapping externally)
* keeps the label markup consistent across variants. */
labelClassName?: string;
testId?: string;
}
@@ -42,8 +48,10 @@ export function StatusPip({
label,
variant = "dot",
className,
labelClassName,
testId,
}: StatusPipProps) {
const labelText = label ?? status;
if (variant === "pill") {
return (
<span
@@ -54,10 +62,10 @@ export function StatusPip({
className,
)}
role="status"
aria-label={label ?? status}
aria-label={labelText}
>
<span className={cn("w-1.5 h-1.5 rounded-full", toneDot[status])} aria-hidden="true" />
{label ?? status}
<span className={labelClassName}>{labelText}</span>
</span>
);
}
@@ -66,11 +74,11 @@ export function StatusPip({
data-testid={testId}
className={cn("inline-flex items-center gap-1.5", className)}
role="status"
aria-label={label ?? status}
aria-label={labelText}
>
<span className={cn("w-2 h-2 rounded-full", toneDot[status])} aria-hidden="true" />
{label ? (
<span className="font-mono text-[9px] uppercase tracking-wide text-on-surface-variant">
<span className={cn("font-mono text-[9px] uppercase tracking-wide text-on-surface-variant", labelClassName)}>
{label}
</span>
) : null}