mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 17:31:56 -10:00
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:
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">
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user