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:
Omar Sobh
2026-06-23 16:52:35 -07:00
co-authored by Claude Opus 4.8
parent 671df7c622
commit e61724ff82
46 changed files with 2247 additions and 95 deletions
@@ -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" }}
/>
);
}