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