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]>
318 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|