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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user