"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( () => cached?.tabs ?? [{ id: 1, session: "main" }], ); const [activeId, setActiveId] = useState(() => cached?.activeId ?? 1); const nextId = useRef(cached?.nextId ?? 2); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(""); const [saved, setSaved] = useState(false); const [dragId, setDragId] = useState(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( () => (
), // 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 (
{tabs.map((t, i) => { const label = t.name?.trim() || `Tab ${i + 1}`; const isActive = t.id === activeId; return (
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 ? ( 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" /> ) : ( )} {tabs.length > 1 && ( )}
); })}
{tabs.map((t) => ( ))}
); } /** 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("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 (
{/* Transport badge — shown only when the container is node-placed. */} {mode === "direct" || mode === "relayed" ? (
{mode === "direct" ? "⚡ direct" : "relayed"}
) : null}
); }