*remembers your termial session*
This commit is contained in:
+284
-52
@@ -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"
|
||||
|
||||
@@ -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