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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user