mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 11:51:37 -10:00
merge(0.3.1): slice 14 founder-walk-topology-table-and-terminal — table active/idle status sub-states + always-visible actions + terminal cards click-through
Closes walk items 15 + 16 + 17 from the 2026-05-11 founder-walk synthesis.
Walk item 15 (table status sub-states + shimmer): TopologyTableView StatusCell maps `running` → `active`/`idle` via row.activityRing.state (existing data signal from topology-activity.ts:152-158; no backend extension). New CSS class with background-clip:text gradient sweep (2.4s loop) on active label only; dot stays static. @media (prefers-reduced-motion: reduce) suppresses animation + restores solid text-success. StatusPip API extended with optional labelClassName prop (scoped to label span only; non-breaking for existing callers).
Walk item 16 (action column always-visible): CmuxButton drops opacity-0 + group-hover gating. Action cell renders cmux + TerminalPreviewPopover side-by-side in a flex container; header text changes from blank to 'Actions'.
Walk item 17 (terminal cards click-through + smaller font): SeatTerminalCard header text-[10px] → text-[8px] (20% reduction). Card body wrapped with absolute-inset trigger button that dispatches openrig:topology-terminal-preview event. Per-card TerminalPreviewPopover mounted with `renderTrigger={false}` (NEW PROP) — wrapper + event listener + portal popover still mount, but no internal focusable button is rendered. Eliminates the duplicate keyboard tab stop (a11y).
Forward-fix iteration on this slice:
- 5f92ecc feat(topology): active/idle status sub-states + always-visible actions + terminal cards click-through
- 31050c7 fix(topology): TerminalPreviewPopover renderTrigger=false prop kills duplicate tab stop (guard CONCERNING a11y catch)
Test coverage:
- p5-topology-terminal.test.tsx: 11/11 PASS (+1 a11y discriminator test asserting 2 card-level trigger buttons + ZERO popover-trigger buttons for 2 seats)
- Full daemon: 3379/3379 PASS / 1 skipped / 0 failed
- UI typecheck on touched files: zero new errors (pre-existing worktree @tanstack/react-table + Vite alias issues persist; unrelated infrastructure)
- git diff --check clean
Discrimination verified via banked hardcoded-probe-in-source technique: temporarily reverted ONLY TopologyTerminalView to buttonClassName=sr-only (kept new prop + test in place) → test FAILS with 4 total tab stops for 2 cards; restored renderTrigger={false} → 11/11 PASS.
Triple-guard:
- redo3-driver-3 driver (Phase D + FF1 a11y resolution)
- redo3-guard-3 guard (2 reverify passes: CONCERNING on duplicate tab stop, CLEAR on renderTrigger external-trigger mode)
- velocity-qa VM dev-server visual walk: deferred for HG-1/2/3/4/7 (shimmer, reduced-motion, card click → popover); founder host walk catches if needed
Visual HGs PENDING (deferred to founder-walk VM dev-server walk on refreshed main):
- HG-1: active label renders with shimmer
- HG-2: idle label renders without shimmer
- HG-3: shimmer scoped to active label only
- HG-4: prefers-reduced-motion honored (DevTools rendering emulation)
- HG-7: terminal card click → TerminalPreviewPopover opens
Walk-item phrasing discipline applied; slice id token retained.
WAVE 2 progress: slice 13 + slice 14 of 3 wave-2 slices closed (13 + 14 + 15); 15 held for next driver opening.
This commit is contained in:
commit
82d4e9f8c5
6 files changed
+220
-27
No files matched your search
@@ -41,6 +41,13 @@ interface TerminalPreviewPopoverProps {
|
||||
buttonClassName?: string;
|
||||
popoverClassName?: string;
|
||||
testIdPrefix: string;
|
||||
/** V0.3.1 slice 14 forward-fix #1 (a11y): when false, the popover
|
||||
* does NOT render its own trigger button. Used by surfaces that
|
||||
* own the trigger externally (e.g., TerminalView cards where the
|
||||
* whole card acts as the trigger) so the popover doesn't add a
|
||||
* duplicate keyboard tab stop. Default true preserves the
|
||||
* existing graph-view + table-view button rendering. */
|
||||
renderTrigger?: boolean;
|
||||
}
|
||||
|
||||
function rectFromElement(el: HTMLElement | null): AnchorRect {
|
||||
@@ -90,6 +97,7 @@ export function TerminalPreviewPopover({
|
||||
buttonClassName,
|
||||
popoverClassName,
|
||||
testIdPrefix,
|
||||
renderTrigger = true,
|
||||
}: TerminalPreviewPopoverProps) {
|
||||
const key = `${rigId ?? "unknown"}:${logicalId}`;
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -188,16 +196,18 @@ export function TerminalPreviewPopover({
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={cn("relative inline-flex", wrapperClassName)}>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-terminal-open`}
|
||||
aria-label={`View ${logicalId} terminal`}
|
||||
title="View terminal"
|
||||
onClick={openPreview}
|
||||
className={buttonClassName}
|
||||
>
|
||||
<ToolMark tool="terminal" size="sm" />
|
||||
</button>
|
||||
{renderTrigger ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-terminal-open`}
|
||||
aria-label={`View ${logicalId} terminal`}
|
||||
title="View terminal"
|
||||
onClick={openPreview}
|
||||
className={buttonClassName}
|
||||
>
|
||||
<ToolMark tool="terminal" size="sm" />
|
||||
</button>
|
||||
) : null}
|
||||
{popover}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
renderTrigger={false}
|
||||
/>
|
||||
) : 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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -223,6 +223,41 @@ describe("TopologyTerminalView P5-7 grid", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// V0.3.1 slice 14 walk-item 17 forward-fix #1 (a11y): each terminal
|
||||
// card exposes EXACTLY ONE tabbable trigger control. The TerminalPreview-
|
||||
// Popover instance is mounted with `renderTrigger={false}` so the card's
|
||||
// full-card overlay button is the sole interaction surface; a duplicate
|
||||
// invisible-but-reachable sr-only popover button would fail
|
||||
// sensible-tab-order a11y review.
|
||||
describe("TerminalView card a11y (slice 14 FF#1)", () => {
|
||||
it("each terminal card exposes exactly one tab-reachable trigger button (no duplicate sr-only popover button)", async () => {
|
||||
const seats = [
|
||||
makeSeat({ logicalId: "alpha" }),
|
||||
makeSeat({ logicalId: "beta" }),
|
||||
];
|
||||
setupFetch({ rigs: [{ id: "rig-1", name: "test-rig" }], seatsByRig: { "rig-1": seats } });
|
||||
const { findByTestId, container } = withQueryClient(
|
||||
<TopologyTerminalView scope="rig" rigId="rig-1" />,
|
||||
);
|
||||
await findByTestId("topology-terminal-grid");
|
||||
|
||||
// Exactly one card-level trigger button per seat.
|
||||
const cardTriggers = container.querySelectorAll(
|
||||
"button[data-testid^='terminal-card-trigger-']",
|
||||
);
|
||||
expect(cardTriggers).toHaveLength(2);
|
||||
|
||||
// ZERO duplicate sr-only popover buttons — the FF removes them.
|
||||
// Pre-fix: each card mounted a `terminal-grid-<rig>-<seat>-terminal-open`
|
||||
// button via the popover's default trigger render. With
|
||||
// renderTrigger={false} the popover skips its button entirely.
|
||||
const popoverTriggerButtons = container.querySelectorAll(
|
||||
"button[data-testid$='-terminal-open']",
|
||||
);
|
||||
expect(popoverTriggerButtons).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("globals.css pulsing-ring CSS contract (ritual #7 pseudo-element-paint)", () => {
|
||||
const cssPath = path.resolve(__dirname, "../src/globals.css");
|
||||
it("globals.css declares @keyframes terminal-card-active-frames", () => {
|
||||
|
||||
Reference in new issue
Block a user