shell-redesign V1 polish slice Phase 5.1 P5.1-7: table CMUX column + row click navigation

V1 founder-walk surfaced two gaps in the topology table view:
- Missing per-row CMUX launcher (the table view had no quick-launch
  affordance equivalent to the graph view's node-click cmux focus)
- Row click did not navigate to the agent detail page (table behaved
  as a read-only grid rather than a clickable surface)

NEW actions column with per-row CmuxButton component:
- Uses existing useCmuxLaunch hook (Phase 4) — POSTs to
  /api/rigs/$rigId/nodes/$logicalId/focus.
- onClick stopPropagation so the row's navigate handler doesn't fire
  alongside the cmux launch (cmux is the explicit action).
- Tactical aesthetic: 1px outline-variant border, font-mono uppercase
  tracking, hover:bg-stone-100/60.
- enableSorting=false on the actions column.

ROW click handler navigates to /topology/seat/$rigId/$logicalId (parity
with graph node click + Explorer tree click + (P5.1-2) the unified
seat-detail navigation contract). Cursor-pointer on rows for affordance.

NEW packages/ui/test/p5-1-table-cmux.test.tsx (4 tests):
- Actions column renders with CMUX button per row.
- CMUX button click → POST to /api/rigs/.../focus (mock fetch).
- CMUX button stopPropagation prevents row navigation.
- Row click (away from action) → navigate(/topology/seat/$rigId/$logicalId)
  (vi.mock useNavigate at file scope; spy assertion).

Verification:
- pnpm -F @openrig/ui exec tsc --noEmit: clean
- vitest p5-1-table-cmux: 4/4 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-06 20:11:36 -07:00
1 parent 49108fea01
commit a3a76a8f40
2 files changed
+161 -1

No files matched your search

@@ -14,6 +14,7 @@
// single hook call regardless of array length.
import { useMemo, useState } from "react";
import { useNavigate } from "@tanstack/react-router";
import {
type ColumnDef,
flexRender,
@@ -29,6 +30,7 @@ import type { NodeInventoryEntry } from "../../hooks/useNodeInventory.js";
import { VellumInput } from "../ui/vellum-input.js";
import { StatusPip } from "../ui/status-pip.js";
import { inferPodName } from "../../lib/display-name.js";
import { useCmuxLaunch } from "../../hooks/useCmuxLaunch.js";
async function fetchNodeInventory(rigId: string): Promise<NodeInventoryEntry[]> {
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/nodes`);
@@ -56,6 +58,27 @@ function statusToSemanticPip(s: string): "active" | "running" | "stopped" | "war
return "info";
}
// V1 polish slice Phase 5.1 P5.1-7: actions column with per-row cmux
// button. Cell stops click propagation so the row's navigate handler
// doesn't also fire (cmux button is the explicit action; row click is
// the implicit "open detail" navigation per row-click contract).
function CmuxButton({ row }: { row: AgentRow }) {
const cmuxLaunch = useCmuxLaunch();
return (
<button
type="button"
data-testid={`topology-table-cmux-${row.logicalId}`}
onClick={(e) => {
e.stopPropagation();
cmuxLaunch.mutate({ rigId: row.rigId, logicalId: row.logicalId });
}}
className="px-2 py-1 border border-outline-variant bg-white/30 font-mono text-[9px] uppercase tracking-wide text-stone-700 hover:bg-stone-100/60 hover:text-stone-900"
>
CMUX
</button>
);
}
const COLUMNS: ColumnDef<AgentRow>[] = [
{ accessorKey: "rigName", header: "Rig", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
{ accessorKey: "podName", header: "Pod", cell: ({ getValue }) => <span className="font-mono text-xs">{String(getValue())}</span> },
@@ -68,9 +91,21 @@ const COLUMNS: ColumnDef<AgentRow>[] = [
<StatusPip status={statusToSemanticPip(String(getValue()))} label={String(getValue())} variant="pill" />
),
},
// V1 polish slice Phase 5.1 P5.1-7: actions column with cmux launcher
// per-row (founder-noted topology-table-view.md L54 column).
{
id: "actions",
header: "Actions",
enableSorting: false,
cell: ({ row }) => <CmuxButton row={row.original} />,
},
];
export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
// V1 polish slice Phase 5.1 P5.1-7: row click navigates to seat-scope
// center page (parity with graph node click + Explorer tree click +
// Topology Tree details-icon-retired contract).
const navigate = useNavigate();
const { data: rigs } = useRigSummary();
const scopedRigs = useMemo(
() =>
@@ -182,7 +217,16 @@ export function TopologyTableView({ rigIdScope }: { rigIdScope?: string }) {
<tr
key={row.id}
data-testid={`topology-table-row-${row.original.logicalId}`}
className="border-b border-outline-variant last:border-b-0 hover:bg-surface-low"
onClick={() =>
navigate({
to: "/topology/seat/$rigId/$logicalId",
params: {
rigId: row.original.rigId,
logicalId: encodeURIComponent(row.original.logicalId),
},
})
}
className="border-b border-outline-variant last:border-b-0 hover:bg-surface-low cursor-pointer"
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2">
+116
View File
@@ -0,0 +1,116 @@
// V1 polish slice Phase 5.1 P5.1-7 — TopologyTableView CMUX column +
// row click navigation regression guard.
//
// Founder direction at V1 founder-walk:
// > "[Table view] is missing some columns... missing the CMUX button
// > where you can basically click a button to launch cmux. Also you
// > should be able to click one of the agents and it should open up
// > the agent detail page."
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const navigateSpy = vi.fn();
vi.mock("@tanstack/react-router", async (importActual) => {
const actual = await importActual<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: () => navigateSpy,
};
});
import { TopologyTableView } from "../src/components/topology/TopologyTableView.js";
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
beforeEach(() => {
navigateSpy.mockClear();
mockFetch.mockReset();
mockFetch.mockImplementation(async (url: string) => {
if (url.includes("/api/rigs/summary")) {
return new Response(JSON.stringify([{ id: "rig-1", name: "test-rig" }]));
}
if (url.includes("/api/rigs/rig-1/nodes")) {
return new Response(
JSON.stringify([
{
rigId: "rig-1",
rigName: "test-rig",
logicalId: "orch.lead",
podId: "orch",
podNamespace: "orch",
canonicalSessionName: "orch-lead@test-rig",
nodeKind: "agent",
runtime: "claude-code",
sessionStatus: "running",
startupStatus: "ready",
restoreOutcome: "n-a",
tmuxAttachCommand: null,
resumeCommand: null,
latestError: null,
},
]),
);
}
return new Response("[]");
});
});
afterEach(() => {
cleanup();
});
function withQueryClient(ui: React.ReactNode) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}
describe("TopologyTableView P5.1-7 CMUX column + row click", () => {
it("renders the actions column with a CMUX button per agent row", async () => {
const { findByTestId } = withQueryClient(<TopologyTableView />);
const cmux = await findByTestId("topology-table-cmux-orch.lead");
expect(cmux).toBeTruthy();
expect((cmux as HTMLButtonElement).textContent).toContain("CMUX");
});
it("CMUX button click POSTs to /api/rigs/.../focus (existing useCmuxLaunch hook)", async () => {
const { findByTestId } = withQueryClient(<TopologyTableView />);
const cmux = await findByTestId("topology-table-cmux-orch.lead");
fireEvent.click(cmux);
await waitFor(() => {
const focusCall = mockFetch.mock.calls.find(
(c: unknown[]) => typeof c[0] === "string" && (c[0] as string).includes("/focus"),
);
expect(focusCall).toBeDefined();
expect(focusCall![0]).toBe("/api/rigs/rig-1/nodes/orch.lead/focus");
});
});
it("CMUX button click does NOT trigger row navigation (stopPropagation)", async () => {
const { findByTestId } = withQueryClient(<TopologyTableView />);
const cmux = await findByTestId("topology-table-cmux-orch.lead");
fireEvent.click(cmux);
// Allow one tick for any propagation that wasn't stopped.
await new Promise((r) => setTimeout(r, 10));
expect(navigateSpy).not.toHaveBeenCalled();
});
it("row click (away from action button) navigates to /topology/seat/$rigId/$logicalId", async () => {
const { findByTestId } = withQueryClient(<TopologyTableView />);
const row = await findByTestId("topology-table-row-orch.lead");
fireEvent.click(row);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith(
expect.objectContaining({
to: "/topology/seat/$rigId/$logicalId",
params: {
rigId: "rig-1",
logicalId: encodeURIComponent("orch.lead"),
},
}),
);
});
});
});