feat(ui): LaunchCmuxButton + useRigCmuxLaunch hook + rig-scope tab-bar trailing slot (slice 24 Checkpoint D)

UI surface for the "Launch in CMUX" feature, completing slice 24's
4-checkpoint chain.

NEW packages/ui/src/hooks/useRigCmuxLaunch.ts:
- useMutation wrapper around POST /api/rigs/:rigId/cmux/launch
- Returns workspaces: CmuxLaunchedWorkspace[] on success
- Throws Error carrying the daemon's honest 3-part message on 4xx/5xx
  (falls back to "HTTP <status>" when body is unparseable)
- Distinct from existing useCmuxLaunch.ts which targets the per-NODE
  focus endpoint POST /api/rigs/:rigId/nodes/:logicalId/focus

NEW packages/ui/src/components/topology/LaunchCmuxButton.tsx:
- Renders button labeled "Launch in CMUX" (per README §Button label
  DECIDED); "Launching…" while in-flight; disabled during pending
- Inline status surface below the button: success message names the
  workspace(s) + agent count; error message surfaces the daemon's
  honest 3-part error verbatim (status persists for 6s success /
  8s error, then auto-clears via setTimeout)
- Mobile-hide via Tailwind `hidden lg:inline-flex` per README §Mobile

MODIFY packages/ui/src/components/topology/TopologyViewModeTabs.tsx:
- New optional `trailing?: ReactNode` prop rendered with ml-auto inside
  the tablist flex container. Allows scope pages to mount tab-bar-far-
  right elements (e.g., the slice 24 Launch button per README §Button
  placement Option C). Other scope pages that don't pass trailing
  render unchanged.
- Existing test-id contracts preserved; new `${testIdPrefix}-trailing`
  data-testid on the slot wrapper.

MODIFY packages/ui/src/components/topology/ScopePages.tsx:
- RigScopePage's <TopologyViewModeTabs> now passes trailing={<LaunchCmuxButton rigId={rigId} />}
- HostScopePage + PodScopePage + SeatScopePage unchanged (no trailing
  slot consumed); button is rig-scope-only per README §Button placement.

Tests added:
- 4/4 useRigCmuxLaunch hook tests (POST shape, success workspaces[],
  error path, network failure path)
- 7/7 LaunchCmuxButton tests (label, mobile-hide class, click POSTs,
  loading state, success toast with workspace + agent count, honest
  3-part error toast, error contains actionable hint)

Total UI tests: 765 pass + 1 skipped + 52 pre-existing fails on main
HEAD baseline (verified via stash-and-recheck: failure count identical
with and without this commit's UI changes; AppShell global-polls
leakage per banked feedback_appshell_global_polls_test_pollution).
Net: +11 passing tests; zero new failures.

Typecheck: all 4 touched UI files clean.

This completes slice 24's UI scope:
- Checkpoint A: CmuxAdapter RPC method extensions + transport mapping
- Checkpoint B: CmuxLayoutService (algorithm + chunking + ordering +
  buildWorkspace with timing gotchas)
- Checkpoint C: POST /api/rigs/:rigId/cmux/launch + NodeInventory wiring
- Checkpoint D: UI button + hook + tab-bar trailing slot (this commit)

Next: slice-close ACK + walk packets + final hand-off DM to orch-lead
+ velocity-qa dispatch for Tart VM clean-install proof (HG-11/12/13
remain founder-host QA per README §QA-lane).

SC-29 EXCEPTION declared: NONE at this checkpoint
(UI consumes the SC-29 #10 daemon endpoint declared at Checkpoint C
a18e997; no new vector added.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-11 21:49:53 -07:00
1 parent 52c70320fa
commit 1be8e675c4
4 files changed
+175 -2

No files matched your search

@@ -0,0 +1,95 @@
// Slice 24 — LaunchCmuxButton.
//
// Rig-scope "Launch in CMUX" button. Click → POST /api/rigs/:rigId/cmux/launch
// via useRigCmuxLaunch. Renders inline status (loading / success / error)
// directly adjacent to the button rather than via a separate toast
// primitive — keeps the rig-scope tab-bar self-contained.
//
// Per README §Mobile + §Button placement: mounted in the rig-scope tab
// bar (Option C placement, persistent across all rig-scope view-mode
// tabs); hidden below the lg breakpoint via Tailwind responsive classes.
import { useEffect, useState } from "react";
import { useRigCmuxLaunch } from "../../hooks/useRigCmuxLaunch.js";
interface LaunchCmuxButtonProps {
rigId: string;
}
type StatusKind = "idle" | "loading" | "success" | "error";
interface StatusState {
kind: StatusKind;
message?: string;
}
const SUCCESS_TOAST_TIMEOUT_MS = 6000;
const ERROR_TOAST_TIMEOUT_MS = 8000;
export function LaunchCmuxButton({ rigId }: LaunchCmuxButtonProps) {
const mutation = useRigCmuxLaunch();
const [status, setStatus] = useState<StatusState>({ kind: "idle" });
useEffect(() => {
if (status.kind === "idle" || status.kind === "loading") return;
const timeout = status.kind === "success" ? SUCCESS_TOAST_TIMEOUT_MS : ERROR_TOAST_TIMEOUT_MS;
const t = window.setTimeout(() => setStatus({ kind: "idle" }), timeout);
return () => window.clearTimeout(t);
}, [status]);
const handleClick = () => {
if (mutation.isPending) return;
setStatus({ kind: "loading" });
mutation
.mutateAsync({ rigId })
.then((result) => {
const workspaceCount = result.workspaces.length;
const agentCount = result.workspaces.reduce((sum, w) => sum + w.agents.length, 0);
const names = result.workspaces.map((w) => w.name).join(", ");
setStatus({
kind: "success",
message:
workspaceCount === 1
? `Launched cmux workspace "${names}" with ${agentCount} agent${agentCount === 1 ? "" : "s"}.`
: `Launched ${workspaceCount} cmux workspaces (${names}) with ${agentCount} agents total.`,
});
})
.catch((err: Error) => {
setStatus({ kind: "error", message: err.message });
});
};
const buttonLabel = mutation.isPending ? "Launching…" : "Launch in CMUX";
return (
<div
data-testid="launch-cmux-wrapper"
className="hidden lg:inline-flex items-center gap-3 ml-auto"
>
<button
type="button"
data-testid="launch-cmux-button"
onClick={handleClick}
disabled={mutation.isPending}
className="border border-stone-700 bg-white px-3 py-2 font-mono text-[10px] uppercase tracking-[0.18em] text-stone-900 hover:bg-stone-100 disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-1 focus:ring-stone-400"
>
{buttonLabel}
</button>
{status.kind !== "idle" && status.message ? (
<span
data-testid="launch-cmux-status"
data-status-kind={status.kind}
role="status"
aria-live="polite"
className={
status.kind === "error"
? "font-mono text-[10px] text-rose-700 max-w-md truncate"
: "font-mono text-[10px] text-emerald-700 max-w-md truncate"
}
>
{status.message}
</span>
) : null}
</div>
);
}
@@ -17,6 +17,7 @@ import {
type TopologySeatScopeTab,
} from "./TopologyViewModeTabs.js";
import { TopologyTableView } from "./TopologyTableView.js";
import { LaunchCmuxButton } from "./LaunchCmuxButton.js";
import { TopologyTerminalView } from "./TopologyTerminalView.js";
import { SectionHeader } from "../ui/section-header.js";
import { EmptyState } from "../ui/empty-state.js";
@@ -148,7 +149,15 @@ export function RigScopePage() {
<ScopeShell
eyebrow="Topology · Rig"
title={rig?.name ?? rigId}
tabsNav={<TopologyViewModeTabs tabs={RIG_POD_SCOPE_TABS} active={active} onSelect={setActive} testIdPrefix="topology-rig" />}
tabsNav={
<TopologyViewModeTabs
tabs={RIG_POD_SCOPE_TABS}
active={active}
onSelect={setActive}
testIdPrefix="topology-rig"
trailing={<LaunchCmuxButton rigId={rigId} />}
/>
}
>
{effectiveActive === "graph" ? (
<div className="flex-1 min-h-0 relative">
@@ -7,6 +7,7 @@
// State managed via React (useState in scope page); URL stays at the
// scope path (/topology, /topology/rig/$rigId, etc).
import type { ReactNode } from "react";
import { cn } from "../../lib/utils.js";
export type TopologyHostScopeTab = "graph" | "table" | "terminal";
@@ -22,6 +23,13 @@ interface TopologyViewModeTabsProps<T extends string> {
active: T;
onSelect: (id: T) => void;
testIdPrefix?: string;
/**
* Slice 24 — optional trailing slot rendered with ml-auto inside the
* tab-bar flex container. Used by RigScopePage to render the
* "Launch in CMUX" button at the tab-bar far right per README §Button
* placement Option C (persistent across all rig-scope view-mode tabs).
*/
trailing?: ReactNode;
}
export function TopologyViewModeTabs<T extends string>({
@@ -29,6 +37,7 @@ export function TopologyViewModeTabs<T extends string>({
active,
onSelect,
testIdPrefix = "topology-view-mode",
trailing,
}: TopologyViewModeTabsProps<T>) {
return (
// Internal tablist — div, not <nav>, so SC-1 left-chrome count
@@ -40,7 +49,7 @@ export function TopologyViewModeTabs<T extends string>({
role="tablist"
aria-label="Topology view modes"
data-testid={`${testIdPrefix}-tabs`}
className="flex gap-6"
className="flex gap-6 items-center"
>
{tabs.map((t) => (
<button
@@ -61,6 +70,11 @@ export function TopologyViewModeTabs<T extends string>({
{t.label}
</button>
))}
{trailing ? (
<div data-testid={`${testIdPrefix}-trailing`} className="ml-auto">
{trailing}
</div>
) : null}
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
// Slice 24 — useRigCmuxLaunch.
//
// Mutation hook that POSTs to /api/rigs/:rigId/cmux/launch (the new
// daemon endpoint shipped in slice 24 Checkpoint C). Returns the
// workspaces array from the daemon's response on success; throws an
// Error carrying the daemon's honest 3-part message on 4xx/5xx.
//
// Distinct from the existing useCmuxLaunch.ts which targets the
// per-NODE focus endpoint POST /api/rigs/:rigId/nodes/:logicalId/focus.
import { useMutation } from "@tanstack/react-query";
export interface RigCmuxLaunchInput {
rigId: string;
}
export interface CmuxLaunchedWorkspace {
name: string;
agents: string[];
blanks: number;
}
export interface RigCmuxLaunchSuccess {
ok: true;
workspaces: CmuxLaunchedWorkspace[];
}
interface RigCmuxLaunchErrorBody {
error: string;
message: string;
partial?: CmuxLaunchedWorkspace[];
}
async function postRigCmuxLaunch({ rigId }: RigCmuxLaunchInput): Promise<RigCmuxLaunchSuccess> {
const res = await fetch(`/api/rigs/${encodeURIComponent(rigId)}/cmux/launch`, {
method: "POST",
});
if (!res.ok) {
let body: RigCmuxLaunchErrorBody | null = null;
try {
body = (await res.json()) as RigCmuxLaunchErrorBody;
} catch {
// fall through
}
const message = body?.message ?? body?.error ?? `HTTP ${res.status}`;
throw new Error(message);
}
return (await res.json()) as RigCmuxLaunchSuccess;
}
export function useRigCmuxLaunch() {
return useMutation<RigCmuxLaunchSuccess, Error, RigCmuxLaunchInput>({
mutationFn: postRigCmuxLaunch,
});
}