herdr phase 2: Live Pane tab (xterm.js → node's herdr TUI)

The killer UX feature: click a mission's Live Pane tab and watch the
actual Herdr TUI on the target node in the browser — cursor, colors,
tool output, all live. WebRTC DataChannel direct where the browser
can reach the node peer-to-peer, WS-relayed fallback otherwise
(same auto-negotiation the INFRA node terminal already uses).

Zero new deployment infra — reuses the existing terminal_ticket +
terminal_ws + PTY-over-control-channel machinery. The one primitive
we grew: PtyTarget::Command variant so the node can spawn an
arbitrary program (\`herdr\`) in the PTY instead of the login shell.

Node daemon (clawmates-node):
  - PtyTarget grows a Command { argv } variant
  - spawn_command_pty resolves bare names against user + system bin
    dirs (matches how tool_update finds claude/kimi)
  - PtyTarget::from_frame reads the `command` array from the pty_open
    frame; precedence Command > Container > Host

cm-api:
  - NodeHub::open_pty grows an optional command argv; when set, the
    frame carries it and the daemon spawns the program directly.
  - routes::nodes::TermCtrl gains a `command: Vec<String>`; the
    fallback branch threads it through.

Frontend:
  - core.ts::webrtcConnector takes an optional commandOverride
    that ships inside the fallback frame
  - nodeHerdrConnector(nodeId) — mints the standard ticket + WS URL
    but overrides command to ["herdr"]
  - MissionCanvas grows a "pane" tab, visible only when
    runtime_kind='local_herdr'. LivePane subcomponent uses xterm.js
    (already a workspace dep) via useResilientTerminal, shows a
    connecting/relayed/direct pill in the corner.

To watch a mission live: pick "On a fleet node (Herdr)" + target
node in the wizard, launch, click Pane tab → node's Herdr TUI
appears. Navigate to the mission workspace in the Herdr sidebar
(mouse or prefix+w) to zoom into the mission's pane.

Focus-a-specific-pane-directly is a later enhancement — Herdr has
no CLI arg for it yet, so operator navigates the sidebar for now.

Verified: cargo check --workspace + tsc --noEmit both green.
This commit is contained in:
Omar Sobh
2026-07-20 10:58:45 -07:00
parent 2b3ec27757
commit a5588b0289
6 changed files with 227 additions and 14 deletions
@@ -30,6 +30,12 @@ import {
} from "@/lib/api/missions";
import { MarkdownBlock } from "./MarkdownBlock";
import { MissionWizard } from "./MissionWizard";
import {
nodeHerdrConnector,
useResilientTerminal,
type TermMode,
} from "@/components/computer/apps/terminal/core";
import "@xterm/xterm/css/xterm.css";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -70,7 +76,7 @@ const TEMPLATE_LABEL: Record<TemplateKind, string> = {
custom: "Custom",
};
type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks";
type Tab = "overview" | "phases" | "tasks" | "artifacts" | "benchmarks" | "pane";
export function MissionCanvas({
selectedId,
@@ -438,7 +444,16 @@ export function MissionCanvas({
/>
)}
<div style={{ display: "flex", gap: 4, marginTop: 4 }}>
{(["overview", "phases", "tasks", "artifacts", "benchmarks"] as Tab[]).map((t) => {
{(
[
"overview",
"phases",
"tasks",
"artifacts",
"benchmarks",
...(mission.runtime_kind === "local_herdr" ? (["pane"] as const) : []),
] as Tab[]
).map((t) => {
const active = tab === t;
const badge =
t === "tasks"
@@ -918,6 +933,13 @@ export function MissionCanvas({
)}
</div>
)}
{tab === "pane" && mission.runtime_kind === "local_herdr" && (
<LivePane
nodeId={mission.target_node_id}
visible={tab === "pane"}
/>
)}
</div>
</div>
);
@@ -970,6 +992,95 @@ function Empty({ label }: { label: string }) {
);
}
function LivePane({
nodeId,
visible,
}: {
nodeId: string | null;
visible: boolean;
}) {
if (!nodeId) {
return (
<div
style={{
padding: 40,
textAlign: "center",
color: "#8a8a92",
fontSize: 13,
}}
>
This mission has no target node.
</div>
);
}
return <LivePaneInner nodeId={nodeId} visible={visible} />;
}
function LivePaneInner({
nodeId,
visible,
}: {
nodeId: string;
visible: boolean;
}) {
const [mode, setMode] = useState<TermMode>("connecting");
const { hostRef, refit } = useResilientTerminal(
{
connect: nodeHerdrConnector(nodeId, setMode),
autoFocus: false,
visible: () => visible,
},
[nodeId],
);
useEffect(() => {
if (visible) refit();
}, [visible, refit]);
return (
<div
style={{
position: "relative",
height: "70vh",
minHeight: 480,
background: "#0a0a0d",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.06)",
overflow: "hidden",
}}
>
<div
style={{
position: "absolute",
top: 8,
right: 8,
zIndex: 5,
padding: "2px 8px",
borderRadius: 6,
fontFamily: mono,
fontSize: 10,
letterSpacing: ".1em",
textTransform: "uppercase",
color:
mode === "direct"
? "#5fd08a"
: mode === "relayed"
? "#8a8a92"
: "#e8b465",
background:
mode === "direct"
? "rgba(95,208,138,.12)"
: "rgba(255,255,255,.05)",
border: `1px solid ${
mode === "direct" ? "rgba(95,208,138,.3)" : "rgba(255,255,255,.1)"
}`,
}}
>
{mode === "direct" ? "direct" : mode === "relayed" ? "relayed" : "connecting…"}
</div>
<div ref={hostRef} style={{ position: "absolute", inset: 0, padding: 8 }} />
</div>
);
}
function EditMissionModal({
mission,
onClose,