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
@@ -12,6 +12,47 @@ const buffer = (tabId: string) => useTerminalStore.getState().getBuffer('/repo',
describe('terminal state reconciliation', () => {
afterEach(() => useTerminalStore.getState().clearAll());
test('adopts unknown server sessions into the fresh placeholder tab', () => {
setup();
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
{ sessionId: 'srv-2', status: 'exited', createdAt: null },
]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.tabs.map((tab) => tab.id)).toEqual(['srv-1', 'srv-2']);
expect(state.tabs[0].terminalSessionId).toBe('srv-1');
expect(state.tabs[0].lifecycle).toBe('running');
expect(state.tabs[1].lifecycle).toBe('exited');
expect(state.activeTabId).toBe('srv-1');
});
test('adoption is additive: existing tabs and referenced sessions survive', () => {
const tabId = setup();
useTerminalStore.getState().appendToBuffer('/repo', tabId, 'output', 1);
useTerminalStore.getState().setTabSessionId('/repo', tabId, 'srv-live');
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-live', status: 'running', createdAt: 1 },
{ sessionId: 'srv-orphan', status: 'running', createdAt: 2 },
]);
const state = useTerminalStore.getState().getDirectoryState('/repo')!;
expect(state.tabs).toHaveLength(2);
expect(state.tabs[0].id).toBe(tabId);
expect(state.tabs[1].id).toBe('srv-orphan');
expect(state.activeTabId).toBe(tabId);
});
test('re-adopting the same sessions changes nothing', () => {
setup();
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
]);
const before = useTerminalStore.getState().sessions;
useTerminalStore.getState().adoptServerSessions('/repo', [
{ sessionId: 'srv-1', status: 'running', createdAt: 100 },
]);
expect(useTerminalStore.getState().sessions).toBe(before);
});
test('applies snapshots atomically and deduplicates output by sequence', () => {
const tabId = setup();
useTerminalStore.getState().replaceBuffer('/repo', tabId, 'prompt', 4);
@@ -71,6 +71,10 @@ interface TerminalStore {
getBuffer: (directory: string, tabId: string) => TerminalBuffer;
createTab: (directory: string) => string;
adoptServerSessions: (
directory: string,
serverSessions: Array<{ sessionId: string; status: 'running' | 'exited'; createdAt: number | null }>,
) => void;
setActiveTab: (directory: string, tabId: string) => void;
setTabLabel: (directory: string, tabId: string, label: string) => void;
setTabIconKey: (directory: string, tabId: string, iconKey: string | null) => void;
@@ -334,6 +338,60 @@ export const useTerminalStore = create<TerminalStore>()(
return tabId;
},
/**
* The server owns which terminal sessions exist; the local tab list is
* only this client's projection. Adoption is strictly additive: server
* sessions no local tab references become tabs (id = session id, the
* create/attach contract), and nothing is ever removed here, so a
* failed or partial listing cannot destroy local tabs.
*/
adoptServerSessions: (directory, serverSessions) => {
const key = normalizeDirectory(directory);
if (!key || serverSessions.length === 0) return;
set((state) => {
const existing = state.sessions.get(key);
const knownIds = new Set<string>();
for (const tab of existing?.tabs ?? []) {
knownIds.add(tab.id);
if (tab.terminalSessionId) knownIds.add(tab.terminalSessionId);
}
const newcomers = serverSessions.filter((session) => !knownIds.has(session.sessionId));
if (newcomers.length === 0) return state;
const tabs = [...(existing?.tabs ?? [])];
// A single untouched placeholder tab (fresh directory state) is
// replaced by the first adopted session instead of sitting next to it.
const placeholder = tabs.length === 1
&& tabs[0].terminalSessionId === null
&& tabs[0].lifecycle === 'idle'
&& !state.buffers.has(bufferKey(key, tabs[0].id))
? tabs[0]
: null;
if (placeholder) tabs.length = 0;
for (const session of newcomers) {
const tab: TerminalTab = {
...createEmptyTab(session.sessionId, placeholder && tabs.length === 0 ? placeholder.label : nextDefaultTabLabel(tabs)),
terminalSessionId: session.sessionId,
lifecycle: session.status,
createdAt: session.createdAt ?? Date.now(),
};
tabs.push(tab);
}
const previousActive = existing?.activeTabId ?? null;
const activeTabId = previousActive && tabs.some((tab) => tab.id === previousActive)
? previousActive
: tabs[0]?.id ?? null;
const newSessions = new Map(state.sessions);
newSessions.set(key, { tabs, activeTabId });
return { sessions: newSessions };
});
},
setActiveTab: (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
set((state) => {