fix(terminal): reconcile tabs with server sessions and keep shown terminals alive

The tab list lived only in per-tab sessionStorage, so a new browser
tab, another device, or cleared storage showed an empty terminal
sidebar while PTYs kept running server-side, and orphans leaked until
the idle sweep. Add GET /api/terminal/sessions and adopt unknown
server sessions into the local tab projection (additive only; a failed
listing changes nothing).

The idle sweep also reaped terminals in background tabs because only
the active tab holds a WebSocket attachment. Add POST
/api/terminal/touch and have open clients periodically refresh
activity for every session their tabs reference.
This commit is contained in:
Bohdan Triapitsyn
2026-08-24 14:30:00 +03:00
parent 0918bee566
commit 2f27f0ec4b
9 changed files with 249 additions and 2 deletions
@@ -58,6 +58,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
const closeTab = useTerminalStore((s) => s.closeTab);
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions);
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
const setConnecting = useTerminalStore((s) => s.setConnecting);
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
@@ -176,6 +177,50 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
directoryRef.current = effectiveDirectory;
}, [effectiveDirectory]);
// The tab list is a per-client projection, so ask the server what actually
// exists for this directory and adopt sessions no local tab references
// (another device, a fresh browser tab, or a reload with cleared storage).
// A failed listing changes nothing: adoption is additive only.
React.useEffect(() => {
if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) {
return;
}
let cancelled = false;
const directory = effectiveDirectory;
void terminal.listSessions(directory)
.then((serverSessions) => {
if (cancelled || directoryRef.current !== directory) return;
adoptServerSessions(directory, serverSessions);
})
.catch(() => { /* keep local tabs; the next mount or directory switch retries */ });
return () => {
cancelled = true;
};
}, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]);
// The server reaps terminals with no attached socket after an idle timeout,
// but only the active tab holds an attachment. While this client is open,
// periodically mark every session its tabs reference as active so
// background tabs (and other directories' terminals) are not reaped.
React.useEffect(() => {
if (!terminal.touchSessions) {
return;
}
const touch = () => {
if (typeof navigator !== 'undefined' && !navigator.onLine) return;
const ids: string[] = [];
for (const dirState of useTerminalStore.getState().sessions.values()) {
for (const tab of dirState.tabs) {
if (tab.terminalSessionId) ids.push(tab.terminalSessionId);
}
}
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
};
touch();
const interval = setInterval(touch, 10 * 60 * 1000);
return () => clearInterval(interval);
}, [terminal]);
React.useEffect(() => {
if (!showQuickKeys && activeModifier !== null) {
setActiveModifier(null);