Agent computer: terminal (tmux + drives + tabs), Obsidian vault, UI polish
Terminal app (xterm ⇄ WebSocket ⇄ per-agent themed container):
- zsh + oh-my-zsh + powerlevel10k image (agent-terminal), runs as uid 65532 to
share read-write ownership of the file-drive volume with the server.
- Interactive PTY in cm-sandbox (bollard exec tty/attach + resize) + a
TerminalManager; ticket-authed WS bridge routed straight to the backend via a
Traefik PathRegexp(/ws) rule. MOTD greets the user by name.
- tmux resumable sessions; multi-tab (one tmux session per tab, same container),
drag-to-reorder, rename, and a Save that persists named tabs to the server
(terminal_tabs, migration 0014) so they survive logout / a new device.
- Files drives mounted per-agent (subpath) at ~/drives/{documents,received,
shared}; a reconciler keeps the Files app's index in sync with terminal writes.
Storage moved to a shared `filedata` volume (CLAWMATES_STORAGE__DATA_DIR).
Obsidian vault (a markdown "second brain" per agent):
- New `vault` FileDrive (migration 0015) mounted into the terminal at ~/obsidian;
a file-content read route; a purple Obsidian tile + a vault viewer app.
Computer UI:
- Draggable computer-panel width (min = phone preset) keeping the size presets.
- Green Terminal glyph, "Claw Chat" → "Chat", colored gradient-outline app icons.
- Agent page: avatar↔activity-grid spacing + larger, uniform section fonts with
colored section-tinted tag chips.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
671df7c622
commit
e61724ff82
@@ -0,0 +1,434 @@
|
||||
"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 "@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. Kept mounted
|
||||
* (hidden when inactive) so its session + processes keep running. */
|
||||
function TerminalTab({
|
||||
agent,
|
||||
session,
|
||||
active,
|
||||
}: {
|
||||
agent: Agent;
|
||||
session: string;
|
||||
active: boolean;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<import("@xterm/xterm").Terminal | null>(null);
|
||||
const fitRef = useRef<import("@xterm/addon-fit").FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
// Latest `active` for callbacks without re-running the connect effect.
|
||||
const activeRef = useRef(active);
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let ro: ResizeObserver | null = null;
|
||||
let retry: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
(async () => {
|
||||
const [{ Terminal }, { FitAddon }] = await Promise.all([
|
||||
import("@xterm/xterm"),
|
||||
import("@xterm/addon-fit"),
|
||||
]);
|
||||
if (disposed || !hostRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
fontFamily: '"MesloLGS NF", "JetBrains Mono", ui-monospace, monospace',
|
||||
fontSize: 13,
|
||||
cursorBlink: true,
|
||||
allowProposedApi: true,
|
||||
scrollback: 5000,
|
||||
theme: {
|
||||
background: "#0b0b0e",
|
||||
foreground: "#e6e6ea",
|
||||
cursor: "#ff8a7a",
|
||||
cursorAccent: "#0b0b0e",
|
||||
selectionBackground: "rgba(255,138,122,.28)",
|
||||
black: "#1c1c22",
|
||||
red: "#ff6f61",
|
||||
green: "#5fd08a",
|
||||
yellow: "#e8b465",
|
||||
blue: "#5ec8d8",
|
||||
magenta: "#c98af0",
|
||||
cyan: "#6fd0c0",
|
||||
white: "#cfcfd5",
|
||||
brightBlack: "#5a5a62",
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(hostRef.current);
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
// Only fit when actually visible (a hidden tab has zero size).
|
||||
if (hostRef.current.clientWidth > 0) {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* not laid out */
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
term.onData((d) => {
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) ws.send(encoder.encode(d));
|
||||
});
|
||||
|
||||
const sendResize = () => {
|
||||
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const ws = wsRef.current;
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
};
|
||||
ro = new ResizeObserver(() => sendResize());
|
||||
ro.observe(hostRef.current);
|
||||
|
||||
const connect = async () => {
|
||||
if (disposed) return;
|
||||
try {
|
||||
const res = await fetch(`/api/terminal/${agent.id}/ticket`, { method: "POST" });
|
||||
if (!res.ok) throw new Error(`ticket request failed (${res.status})`);
|
||||
const { ticket } = (await res.json()) as { ticket: string };
|
||||
if (disposed) return;
|
||||
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const ws = new WebSocket(
|
||||
`${proto}//${location.host}/api/terminal/${agent.id}/ws?ticket=${encodeURIComponent(
|
||||
ticket,
|
||||
)}&session=${encodeURIComponent(session)}`,
|
||||
);
|
||||
ws.binaryType = "arraybuffer";
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => {
|
||||
sendResize();
|
||||
if (activeRef.current) term.focus();
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
if (typeof ev.data === "string") term.write(ev.data);
|
||||
else term.write(new Uint8Array(ev.data as ArrayBuffer));
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null;
|
||||
if (disposed) return;
|
||||
term.write("\r\n\x1b[90m[disconnected — reconnecting…]\x1b[0m\r\n");
|
||||
retry = setTimeout(connect, 1500);
|
||||
};
|
||||
} catch (e) {
|
||||
if (disposed) return;
|
||||
term.write(`\r\n\x1b[31m[terminal: ${String(e)}]\x1b[0m\r\n`);
|
||||
retry = setTimeout(connect, 2500);
|
||||
}
|
||||
};
|
||||
void connect();
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (retry) clearTimeout(retry);
|
||||
ro?.disconnect();
|
||||
wsRef.current?.close();
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [agent.id, session]);
|
||||
|
||||
// Re-fit + focus when this tab becomes visible (it may have had zero size).
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setTimeout(() => {
|
||||
const term = termRef.current;
|
||||
const fit = fitRef.current;
|
||||
const ws = wsRef.current;
|
||||
if (!hostRef.current || hostRef.current.clientWidth === 0) return;
|
||||
try {
|
||||
fit?.fit();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (term && ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
term?.focus();
|
||||
}, 40);
|
||||
return () => clearTimeout(id);
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className="absolute inset-0 px-3 py-2"
|
||||
style={{ display: active ? "block" : "none" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user