*remembers your termial session*

This commit is contained in:
lda
2026-07-15 18:30:42 +07:00 Verified
parent 1276de79d3
commit 1bfb1c17d7
3 changed files with 366 additions and 52 deletions
+284 -52
View File
@@ -6,6 +6,7 @@ import { Eraser, PlugZap, RotateCcw, Square, Terminal } from "lucide-react";
import { toast } from "sonner";
import {
APIError,
type Agent,
type TerminalSession,
attachTerminal,
@@ -35,11 +36,72 @@ type ConnectionState =
| "disconnected"
| "exited";
const REMEMBERED_TERMINAL_KEY = "wakey.active-terminal-id";
const ATTACH_RETRY_DELAY_MS = 150;
function mergeTerminalSession(
sessions: TerminalSession[],
next: TerminalSession,
): TerminalSession[] {
const remaining = sessions.filter(
(item) => item.terminal_id !== next.terminal_id,
);
return [next, ...remaining].sort(
(left, right) => right.created_at_unix - left.created_at_unix,
);
}
function restoreCandidates(
sessions: TerminalSession[],
rememberedId: string | null,
selectedAgentId: string,
): TerminalSession[] {
const remembered = sessions.find((item) => item.terminal_id === rememberedId);
const available = sessions.filter(
(item) => !item.operator_attached && item !== remembered,
);
available.sort((left, right) => {
const leftPreferred = left.agent_id === selectedAgentId ? 1 : 0;
const rightPreferred = right.agent_id === selectedAgentId ? 1 : 0;
return (
rightPreferred - leftPreferred ||
right.created_at_unix - left.created_at_unix
);
});
return remembered ? [remembered, ...available] : available;
}
async function attachWhenAvailable(
terminalId: string,
attempts: number,
): Promise<TerminalSession> {
for (let attempt = 0; ; attempt += 1) {
try {
return await attachTerminal(terminalId);
} catch (error) {
const attachmentBusy =
error instanceof APIError &&
error.code === "terminal_operator_already_attached";
if (!attachmentBusy || attempt + 1 >= attempts) throw error;
await new Promise((resolve) =>
window.setTimeout(resolve, ATTACH_RETRY_DELAY_MS),
);
}
}
}
function websocketUrl(path: string): string {
const scheme = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${scheme}//${window.location.host}${path}`;
}
function terminalCreatedTime(createdAtUnix: number): string {
return new Date(createdAtUnix * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
export function TerminalPage({
agents,
selectedAgentId,
@@ -49,18 +111,30 @@ export function TerminalPage({
const terminalRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const intentionalCloseRef = useRef(false);
const activeSessionRef = useRef<TerminalSession | null>(null);
const selectedAgentIdRef = useRef(selectedAgentId);
const lastTerminalSizeRef = useRef({ rows: 0, cols: 0 });
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [session, setSession] = useState<TerminalSession | null>(null);
const [connection, setConnection] = useState<ConnectionState>("idle");
activeSessionRef.current = session;
selectedAgentIdRef.current = selectedAgentId;
const selectedAgent = agents.find(
(agent) => agent.agent_id === selectedAgentId,
);
const canStart =
selectedAgent?.connected &&
selectedAgent.capabilities.includes("terminal") &&
connection === "idle";
connection !== "connecting" &&
sessions.filter((item) => item.agent_id === selectedAgentId).length < 2;
const detachTransport = useCallback(() => {
const socket = socketRef.current;
socketRef.current = null;
socket?.close();
}, []);
const sendResize = useCallback(() => {
const terminal = terminalRef.current;
@@ -92,8 +166,19 @@ export function TerminalPage({
"Control plane did not issue a terminal attachment token",
);
}
socketRef.current?.close();
intentionalCloseRef.current = false;
detachTransport();
setSession(nextSession);
activeSessionRef.current = nextSession;
setSessions((current) =>
mergeTerminalSession(current, {
...nextSession,
operator_attached: true,
}),
);
window.sessionStorage.setItem(
REMEMBERED_TERMINAL_KEY,
nextSession.terminal_id,
);
setConnection("connecting");
const socket = new WebSocket(websocketUrl(nextSession.websocket_url));
socket.binaryType = "arraybuffer";
@@ -110,6 +195,7 @@ export function TerminalPage({
sendResize();
};
socket.onmessage = (event) => {
if (socketRef.current !== socket) return;
if (typeof event.data !== "string") {
terminalRef.current?.write(new Uint8Array(event.data as ArrayBuffer));
return;
@@ -144,18 +230,26 @@ export function TerminalPage({
}
};
socket.onerror = () => {
if (socketRef.current !== socket) return;
terminalRef.current?.writeln("\r\n[terminal transport error]");
};
socket.onclose = () => {
if (socketRef.current !== socket) return;
socketRef.current = null;
setSessions((current) =>
current.map((item) =>
item.terminal_id === nextSession.terminal_id
? { ...item, operator_attached: false }
: item,
),
);
setConnection((current) => {
if (intentionalCloseRef.current || current === "exited")
return current;
if (current === "exited") return current;
return "disconnected";
});
};
},
[sendResize],
[detachTransport, sendResize],
);
useEffect(() => {
@@ -216,29 +310,44 @@ export function TerminalPage({
terminalRef.current = terminal;
fitRef.current = fit;
async function restoreDetachedSession() {
async function restoreTerminalSession() {
try {
const sessions = await listTerminals();
if (cancelled || sessions.length === 0) return;
const candidate =
sessions.find((item) => item.agent_id === selectedAgentId) ??
sessions[0];
setConnection("connecting");
const listed = await listTerminals();
if (cancelled) return;
setSessions(listed);
const rememberedId = window.sessionStorage.getItem(
REMEMBERED_TERMINAL_KEY,
);
const candidates = restoreCandidates(
listed,
rememberedId,
selectedAgentIdRef.current,
);
// The previous route's websocket may still be completing its close
// handshake. Brief retries keep navigation from surfacing that race.
for (let attempt = 0; attempt < 4; attempt += 1) {
for (const candidate of candidates) {
setConnection("connecting");
try {
const attached = await attachTerminal(candidate.terminal_id);
// A remembered session may still belong to this page's previous
// WebSocket while its close handshake reaches the control plane.
const attempts = candidate.terminal_id === rememberedId ? 8 : 1;
const attached = await attachWhenAvailable(
candidate.terminal_id,
attempts,
);
if (cancelled) return;
setSession(attached);
terminal.reset();
connect(attached);
return;
} catch (error) {
if (attempt === 3) throw error;
await new Promise((resolve) => window.setTimeout(resolve, 150));
if (
!(error instanceof APIError) ||
error.code !== "terminal_operator_already_attached"
) {
throw error;
}
}
}
setConnection("idle");
} catch (error) {
if (cancelled) return;
setConnection("idle");
@@ -278,24 +387,46 @@ export function TerminalPage({
});
});
resizeObserver.observe(hostRef.current);
void restoreDetachedSession();
void restoreTerminalSession();
const sessionRefresh = window.setInterval(() => {
void listTerminals()
.then((listed) => {
if (!cancelled) setSessions(listed);
})
.catch(() => {
// The attached terminal transport remains authoritative while a
// background list refresh is temporarily unavailable.
});
}, 5000);
return () => {
cancelled = true;
input.dispose();
resizeObserver.disconnect();
window.cancelAnimationFrame(resizeFrame);
socketRef.current?.close();
window.clearInterval(sessionRefresh);
detachTransport();
terminal.dispose();
terminalRef.current = null;
fitRef.current = null;
};
}, [connect, selectedAgentId, sendResize]);
}, [connect, detachTransport, sendResize]);
async function start() {
if (!selectedAgentId || !terminalRef.current) return;
const previousSession = activeSessionRef.current;
detachTransport();
if (previousSession) {
setSessions((current) =>
current.map((item) =>
item.terminal_id === previousSession.terminal_id
? { ...item, operator_attached: false }
: item,
),
);
}
setConnection("connecting");
terminalRef.current.clear();
terminalRef.current.reset();
try {
fitRef.current?.fit();
const created = await createTerminal(
@@ -303,23 +434,63 @@ export function TerminalPage({
terminalRef.current.rows,
terminalRef.current.cols,
);
setSession(created);
connect(created);
} catch (error) {
setSession(null);
activeSessionRef.current = null;
setConnection("idle");
toast.error("Could not start terminal", { description: String(error) });
}
}
async function reconnect() {
if (!session) return;
async function activateSession(nextSession: TerminalSession) {
if (nextSession.terminal_id === session?.terminal_id) return;
if (nextSession.operator_attached) return;
const previousSession = activeSessionRef.current;
detachTransport();
if (previousSession) {
setSessions((current) =>
current.map((item) =>
item.terminal_id === previousSession.terminal_id
? { ...item, operator_attached: false }
: item,
),
);
}
setSession(null);
activeSessionRef.current = null;
setConnection("connecting");
terminalRef.current?.reset();
window.sessionStorage.setItem(
REMEMBERED_TERMINAL_KEY,
nextSession.terminal_id,
);
try {
terminalRef.current?.reset();
const attached = await attachTerminal(session.terminal_id);
setSession(attached);
const attached = await attachWhenAvailable(nextSession.terminal_id, 4);
connect(attached);
} catch (error) {
setConnection("exited");
setConnection("idle");
toast.error("Could not attach terminal session", {
description: String(error),
});
void listTerminals()
.then(setSessions)
.catch(() => undefined);
}
}
async function reconnect() {
if (!session) return;
detachTransport();
try {
terminalRef.current?.reset();
setConnection("connecting");
const attached = await attachWhenAvailable(session.terminal_id, 8);
connect(attached);
} catch (error) {
setConnection("disconnected");
toast.error("Terminal session is no longer available", {
description: String(error),
});
@@ -328,15 +499,24 @@ export function TerminalPage({
async function close() {
if (!session) return;
intentionalCloseRef.current = true;
const closingId = session.terminal_id;
socketRef.current?.send(JSON.stringify({ type: "close" }));
socketRef.current?.close();
detachTransport();
try {
await closeTerminal(session.terminal_id);
await closeTerminal(closingId);
} catch (error) {
toast.error("Terminal cleanup failed", { description: String(error) });
} finally {
setSession(null);
activeSessionRef.current = null;
setSessions((current) =>
current.filter((item) => item.terminal_id !== closingId),
);
if (
window.sessionStorage.getItem(REMEMBERED_TERMINAL_KEY) === closingId
) {
window.sessionStorage.removeItem(REMEMBERED_TERMINAL_KEY);
}
setConnection("idle");
// xterm.clear() deliberately preserves the active cursor line. Closing
// a session should discard its complete screen and terminal modes.
@@ -360,15 +540,13 @@ export function TerminalPage({
)}
value={selectedAgentId}
onChange={onSelectAgent}
disabled={connection !== "idle"}
disabled={connection === "connecting"}
className="w-full min-w-0 sm:w-64"
/>
{connection === "idle" ? (
<Button type="button" onClick={start} disabled={!canStart}>
<PlugZap className="size-4" aria-hidden />
Connect
</Button>
) : null}
<Button type="button" onClick={start} disabled={!canStart}>
<PlugZap className="size-4" aria-hidden />
{sessions.length === 0 ? "Connect" : "New session"}
</Button>
</div>
</header>
@@ -384,13 +562,70 @@ export function TerminalPage({
<div className="terminal-frame">
<div className="terminal-framebar">
<div className="terminal-session-label">
<Terminal className="size-4" aria-hidden />
<span>
{session && selectedAgent
? displayAgentLabel(selectedAgent)
: "No active session"}
</span>
<div
className="terminal-tabs"
role="tablist"
aria-label="Terminal sessions"
>
{sessions.length === 0 ? (
<div className="terminal-session-label">
<Terminal className="size-4" aria-hidden />
<span>No active session</span>
</div>
) : (
sessions.map((item) => {
const agent = agents.find(
(candidate) => candidate.agent_id === item.agent_id,
);
const active = item.terminal_id === session?.terminal_id;
const locked = item.operator_attached && !active;
return (
<button
key={item.terminal_id}
type="button"
role="tab"
aria-selected={active}
className="terminal-tab"
data-active={active || undefined}
data-locked={locked || undefined}
disabled={locked || connection === "connecting"}
title={
locked
? "Attached in another browser"
: `Open ${agent ? displayAgentLabel(agent) : item.agent_id}`
}
onClick={() => void activateSession(item)}
>
<span
className="terminal-tab-dot"
data-state={
active
? connection
: locked
? "attached"
: item.agent_attached
? "detached"
: "disconnected"
}
aria-hidden
/>
<span>
{agent ? displayAgentLabel(agent) : item.agent_id}
</span>
<time
dateTime={new Date(
item.created_at_unix * 1000,
).toISOString()}
>
{terminalCreatedTime(item.created_at_unix)}
</time>
</button>
);
})
)}
</div>
<div className="terminal-frame-actions">
<Badge
variant="outline"
className="terminal-status"
@@ -399,9 +634,6 @@ export function TerminalPage({
<span aria-hidden />
{connection}
</Badge>
</div>
<div className="terminal-frame-actions">
{connection === "disconnected" ? (
<Button
type="button"
+80
View File
@@ -427,6 +427,86 @@
gap: 0.5rem;
}
.terminal-tabs {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.25rem;
overflow-x: auto;
scrollbar-width: none;
}
.terminal-tabs::-webkit-scrollbar {
display: none;
}
.terminal-tab {
display: inline-flex;
min-width: 0;
max-width: 13rem;
height: 1.85rem;
flex: 0 1 auto;
align-items: center;
gap: 0.45rem;
padding: 0 0.6rem;
border: 1px solid transparent;
border-radius: calc(var(--radius) - 2px);
background: transparent;
font-size: 0.75rem;
color: #aab7c4;
}
.terminal-tab > span:nth-child(2) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.terminal-tab time {
flex: 0 0 auto;
color: #7f8d9a;
font-size: 0.6875rem;
}
.terminal-tab[data-active] {
border-color: rgb(255 255 255 / 10%);
background: rgb(255 255 255 / 7%);
color: #f1f5f9;
}
.terminal-tab[data-locked] {
cursor: not-allowed;
opacity: 0.58;
}
.terminal-tab-dot {
width: 0.45rem;
height: 0.45rem;
flex: 0 0 auto;
border-radius: 999px;
background: var(--presence-unknown);
}
.terminal-tab-dot[data-state="ready"],
.terminal-tab-dot[data-state="attached"] {
background: var(--presence-online);
}
.terminal-tab-dot[data-state="connecting"],
.terminal-tab-dot[data-state="detached"] {
background: var(--presence-likely);
}
.terminal-tab-dot[data-state="disconnected"],
.terminal-tab-dot[data-state="exited"] {
background: var(--presence-offline);
}
.terminal-frame-actions {
flex: 0 0 auto;
}
.terminal-session-label > span:not(.terminal-status) {
overflow: hidden;
color: #dbe4ee;