*remembers your termial session*
This commit is contained in:
@@ -70,6 +70,8 @@ Terminal sessions allow only one attached operator. Their lifetime belongs to th
|
||||
|
||||
When the operator socket disconnects, the session becomes detached. Navigation, browser closure, and network loss do not terminate the PTY. A protected operator request may discover the live session and obtain a fresh, single-use attachment credential. The control plane retains only a bounded in-memory output buffer during the gap and replays it before live output on reattachment.
|
||||
|
||||
The operator UI lists live sessions as tabs and stores only the active terminal ID in browser-tab-scoped `sessionStorage`. On reload it prefers that ID and briefly waits for the previous WebSocket to detach before requesting a fresh attachment credential. The stored ID is a navigation hint, not an ownership credential; another attached operator still locks the session at the control plane.
|
||||
|
||||
After operator attachment, the control plane sends a `refresh` hint. The agent queries the PTY's current foreground process group and sends it `SIGWINCH`, allowing full-screen applications to redraw without assuming that the original shell remains in the foreground. Refresh failure is logged but never terminates the session.
|
||||
|
||||
The agent keeps its terminal manager outside the control-WebSocket reconnect loop. If a dedicated terminal relay disconnects, the PTY continues draining output into a bounded local replay buffer while waiting for replacement relay credentials. On every authenticated control connection, the agent reports its in-memory live terminal IDs and creation times. The control plane reconciles that inventory, adopts sessions missing from its volatile registry, and issues fresh relay credentials. This allows sessions to survive control-plane restart without persisting terminal state in SQLite.
|
||||
|
||||
+279
-47
@@ -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");
|
||||
|
||||
// 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) {
|
||||
try {
|
||||
const attached = await attachTerminal(candidate.terminal_id);
|
||||
const listed = await listTerminals();
|
||||
if (cancelled) return;
|
||||
setSession(attached);
|
||||
setSessions(listed);
|
||||
const rememberedId = window.sessionStorage.getItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
);
|
||||
const candidates = restoreCandidates(
|
||||
listed,
|
||||
rememberedId,
|
||||
selectedAgentIdRef.current,
|
||||
);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
setConnection("connecting");
|
||||
try {
|
||||
// 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;
|
||||
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;
|
||||
try {
|
||||
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();
|
||||
const attached = await attachTerminal(session.terminal_id);
|
||||
setSession(attached);
|
||||
window.sessionStorage.setItem(
|
||||
REMEMBERED_TERMINAL_KEY,
|
||||
nextSession.terminal_id,
|
||||
);
|
||||
|
||||
try {
|
||||
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
|
||||
{sessions.length === 0 ? "Connect" : "New session"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -384,13 +562,70 @@ export function TerminalPage({
|
||||
|
||||
<div className="terminal-frame">
|
||||
<div className="terminal-framebar">
|
||||
<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>
|
||||
{session && selectedAgent
|
||||
? displayAgentLabel(selectedAgent)
|
||||
: "No active session"}
|
||||
{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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user