Files
clawmates/frontend/src/components/computer/apps/TerminalApp.tsx
T
Omar SobhandClaude Opus 4.8 10c89f5157 Node-placed agent terminal: container PTY on the agent's node + WebRTC, shared node-local drives
Completes "agent on a node" (single-node): when an agent's placement points at a
fleet node, its terminal container runs there and the browser reaches it over a
direct WebRTC DataChannel (LAN speed), sharing a node-local volume with the
sandbox. gw-04-local agents are byte-identical to before.

- cm-sandbox/docker.rs: empty drive subpath → mount the whole volume at the target
  (volume_options None), so a per-agent node-local volume auto-creates at ~/drives.
- cm-api/fleet.rs: NodeHub.open_pty/webrtc_offer carry optional container+session
  (injected only when Some); node-terminal caller passes None (host shell unchanged).
- cm-runtime/terminals.rs: TerminalManager gains node_provider + placement
  (mirrors SandboxManager, draining-aware); node_local_drive_mount(agent) =
  clawmates_agent_<id> at ~/drives; placement_for() ensures + locates the container;
  attach uses driver_for(node) (local byte-identical).
- cm-runtime/sandboxes.rs: a node-placed agent sandbox mounts the same per-agent
  volume → shares files with the terminal on that node.
- cm-api/routes/terminal.rs: ticket response gains `node`; ws() bridges node-placed
  agents through the NodeHub relay (WebRTC + fallback) execing into the container;
  local path unchanged. server main wires with_node_provider.
- frontend: agentTerminalConnector mints the ticket then picks WebRTC (node-placed,
   direct / relayed badge) vs WS (local); webrtcConnector generalized to be
  endpoint-agnostic (node terminal reuses it).

Known follow-up: terminal (uid 65532) and sandbox (uid 10001) share the volume but
differ in uid — cross-container writes need an aligned uid/gid (group-writable).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-26 09:11:18 -07:00

318 lines
11 KiB
TypeScript

"use client";
// Multi-tab interactive terminal in the agent computer. Each tab is its own
// xterm.js ⇄ WebSocket ⇄ a distinct tmux session, but every tab shares the SAME
// per-agent container (so the same ~/drives are mounted + shared between them).
// The header gets a back arrow (→ the computer home) and a "+" to add tabs (max 5).
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, Plus, Save, X } from "lucide-react";
import { useQueryStates } from "nuqs";
import type { Agent } from "@/lib/api/schemas";
import { panelParsers } from "@/lib/url/panel-params";
import { useSubHeader } from "./AppShell";
import { agentTerminalConnector, useResilientTerminal, type TermMode } from "./terminal/core";
import "@xterm/xterm/css/xterm.css";
const MAX_TABS = 5;
type Tab = { id: number; session: string; name?: string };
type TabState = { tabs: Tab[]; activeId: number; nextId: number };
// Open tabs persist in localStorage (keyed by agent) so they — and their tmux
// sessions — are restored when the app is reopened, including after a full page
// reload. (The sessions live in the per-agent container; a reaped one just comes
// back as a fresh shell when the tab reattaches.)
const TABS_KEY = "cm.terminal.tabs";
function loadTabState(agentId: string): TabState | null {
if (typeof window === "undefined") return null;
try {
const all = JSON.parse(window.localStorage.getItem(TABS_KEY) || "{}");
const v = all?.[agentId];
if (v && Array.isArray(v.tabs) && v.tabs.length) return v as TabState;
} catch {
/* ignore */
}
return null;
}
function saveTabState(agentId: string, v: TabState) {
if (typeof window === "undefined") return;
try {
const all = JSON.parse(window.localStorage.getItem(TABS_KEY) || "{}");
all[agentId] = v;
window.localStorage.setItem(TABS_KEY, JSON.stringify(all));
} catch {
/* ignore */
}
}
export default function TerminalApp({ agent }: { agent: Agent }) {
const [, setParams] = useQueryStates(panelParsers, { shallow: true });
// Tab 1 attaches to the resumable "main" session; extra tabs get their own.
const cached = useMemo(() => loadTabState(agent.id), [agent.id]);
const [tabs, setTabs] = useState<Tab[]>(
() => cached?.tabs ?? [{ id: 1, session: "main" }],
);
const [activeId, setActiveId] = useState(() => cached?.activeId ?? 1);
const nextId = useRef(cached?.nextId ?? 2);
const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState("");
const [saved, setSaved] = useState(false);
const [dragId, setDragId] = useState<number | null>(null);
// Persist the tab set to localStorage (survives reload in this browser).
useEffect(() => {
saveTabState(agent.id, { tabs, activeId, nextId: nextId.current });
}, [tabs, activeId, agent.id]);
// If nothing's in this browser, restore the user's server-saved layout (so a
// new device / cleared storage / a logout-login still brings the tabs back).
useEffect(() => {
if (cached) return;
let cancelled = false;
(async () => {
try {
const res = await fetch(`/api/terminal/${agent.id}/tabs`);
if (!res.ok) return;
const data = (await res.json()) as { tabs?: { name?: string; session?: string }[] } | null;
if (cancelled || !data || !Array.isArray(data.tabs) || data.tabs.length === 0) return;
const restored: Tab[] = data.tabs.slice(0, MAX_TABS).map((t, i) => ({
id: i + 1,
session: t.session || (i === 0 ? "main" : `tab${i + 1}`),
name: t.name || undefined,
}));
setTabs(restored);
setActiveId(restored[0].id);
nextId.current = restored.length + 1;
} catch {
/* ignore */
}
})();
return () => {
cancelled = true;
};
}, [agent.id, cached]);
const addTab = () => {
if (tabs.length >= MAX_TABS) return;
const id = nextId.current++;
setTabs((cur) => [...cur, { id, session: `tab${id}` }]);
setActiveId(id);
};
const closeTab = (id: number) => {
if (tabs.length <= 1) return;
const next = tabs.filter((t) => t.id !== id);
setTabs(next);
if (id === activeId) setActiveId(next[next.length - 1].id);
};
const commitRename = (id: number, name: string) => {
setTabs((cur) =>
cur.map((t) => (t.id === id ? { ...t, name: name.trim() || undefined } : t)),
);
setEditingId(null);
};
// Drag a tab onto another to reorder (the session mapping stays with the tab).
const reorder = (fromId: number, toId: number) => {
if (fromId === toId) return;
setTabs((cur) => {
const from = cur.findIndex((t) => t.id === fromId);
const to = cur.findIndex((t) => t.id === toId);
if (from < 0 || to < 0) return cur;
const next = [...cur];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
};
// Save the named tabs + their sessions to the server (account-tied, durable).
const saveLayout = async () => {
const layout = {
tabs: tabs.map((t) => ({ name: t.name ?? null, session: t.session })),
};
try {
await fetch(`/api/terminal/${agent.id}/tabs`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ layout }),
});
setSaved(true);
setTimeout(() => setSaved(false), 1800);
} catch {
/* ignore */
}
};
// Save + "new tab" buttons at the right edge of the panel header.
const headerRight = useMemo(
() => (
<div className="flex items-center gap-0.5">
<button
type="button"
aria-label="Save tabs"
title="Save these tabs (restored after you log back in)"
onClick={saveLayout}
className={`flex size-7 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 ${
saved ? "text-emerald-400" : "text-neutral-300"
}`}
>
{saved ? <Check aria-hidden size={17} /> : <Save aria-hidden size={16} />}
</button>
<button
type="button"
aria-label="New terminal tab"
title={tabs.length >= MAX_TABS ? "Tab limit reached" : "New tab"}
disabled={tabs.length >= MAX_TABS}
onClick={addTab}
className="flex size-7 items-center justify-center rounded-full text-neutral-300 transition-colors hover:bg-neutral-800 disabled:opacity-40"
>
<Plus aria-hidden size={17} />
</button>
</div>
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tabs, saved],
);
// Back arrow (→ computer home) + the "+" on the right, in the panel header.
useSubHeader(true, "Terminal", () => setParams({ app: "home" }), headerRight);
return (
<div
className="flex h-full w-full flex-col bg-[#0b0b0e]"
style={{ minHeight: "70vh" }}
>
<div className="flex shrink-0 items-center gap-1.5 overflow-x-auto border-b border-white/10 px-2.5 py-2">
{tabs.map((t, i) => {
const label = t.name?.trim() || `Tab ${i + 1}`;
const isActive = t.id === activeId;
return (
<div
key={t.id}
draggable={editingId !== t.id}
onDragStart={() => setDragId(t.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (dragId != null) reorder(dragId, t.id);
setDragId(null);
}}
onDragEnd={() => setDragId(null)}
className={`group flex shrink-0 cursor-grab items-center gap-2 rounded-lg pl-3 pr-2 py-1.5 text-sm transition-colors active:cursor-grabbing ${
dragId === t.id ? "opacity-50" : ""
} ${
isActive
? "bg-white/[0.14] text-white shadow-sm"
: "text-neutral-400 hover:bg-white/5"
}`}
>
{editingId === t.id ? (
<input
autoFocus
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={() => commitRename(t.id, editValue)}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename(t.id, editValue);
if (e.key === "Escape") setEditingId(null);
}}
maxLength={24}
className="w-28 border-b border-white/40 bg-transparent text-sm text-white outline-none"
/>
) : (
<button
type="button"
onClick={() => setActiveId(t.id)}
onDoubleClick={() => {
setEditingId(t.id);
setEditValue(label);
}}
title="Double-click to rename"
className="max-w-[180px] truncate font-medium"
>
{label}
</button>
)}
{tabs.length > 1 && (
<button
type="button"
aria-label={`Close ${label}`}
onClick={() => closeTab(t.id)}
className="flex size-5 items-center justify-center rounded opacity-50 transition hover:bg-white/10 hover:opacity-100"
>
<X aria-hidden size={13} />
</button>
)}
</div>
);
})}
</div>
<div className="relative min-h-0 flex-1">
{tabs.map((t) => (
<TerminalTab
key={t.id}
agent={agent}
session={t.session}
active={t.id === activeId}
/>
))}
</div>
</div>
);
}
/** One tab: an xterm bridged to a tmux session over a WebSocket (the shared
* resilient-terminal core). Kept mounted (hidden when inactive) so its session +
* processes keep running. */
function TerminalTab({
agent,
session,
active,
}: {
agent: Agent;
session: string;
active: boolean;
}) {
// Latest `active` for the core's callbacks without re-running the connect.
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
});
const [mode, setMode] = useState<TermMode>("connecting");
const { hostRef, refit } = useResilientTerminal(
{
connect: agentTerminalConnector(agent.id, session, setMode),
visible: () => activeRef.current,
autoFocus: true,
},
[agent.id, session],
);
// Re-fit + focus when this tab becomes visible (it may have had zero size).
useEffect(() => {
if (!active) return;
const id = setTimeout(refit, 40);
return () => clearTimeout(id);
}, [active, refit]);
return (
<div className="absolute inset-0" style={{ display: active ? "block" : "none" }}>
{/* Transport badge — shown only when the container is node-placed. */}
{mode === "direct" || mode === "relayed" ? (
<div
className="pointer-events-none absolute right-2 top-1.5 z-10 flex items-center gap-1 rounded px-1.5 py-0.5 font-mono text-[9px]"
style={{
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)"}`,
color: mode === "direct" ? "#5fd08a" : "#8a8a92",
}}
>
{mode === "direct" ? "⚡ direct" : "relayed"}
</div>
) : null}
<div ref={hostRef} className="absolute inset-0 px-3 py-2" />
</div>
);
}