Skip to content

Commit 81d8d3b

Browse files
dorlugasigalCopilot
andcommitted
fix(sessions): clean global store on Hub deletes to prevent ghost session tabs
SessionsHub.handleDelete only updated its local React state via setSessions. Because the Zustand session store is module-global and survives across SPA navigation between Hub and TerminalApp, Hub-side deletions left orphan entries in store.sessions and tabOrder, and never added the id to deletedIds (which suppresses the 'Session not found' toast). On the next SPA-nav to /terminal, the orphan id rendered as a ghost tab, TerminalPane mounted, opened a WebSocket, and the server returned 'Session not found'. Two-layer fix: 1. SessionsHub.handleDelete: finalize now also calls useSessionStore.removeSession(id) (and any companion PTY). 2. TerminalApp.init(): defensive prune of orphans (sessions in store but not on the server) before the addSession + render pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 055d6fa commit 81d8d3b

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

src/frontend/src/components/SessionsHub/SessionsHub.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,22 @@ export default function SessionsHub() {
173173
element: element ?? null,
174174
color: session?.color ?? '#6ec1e4',
175175
apiDelete: () => deleteSession(id),
176-
finalize: () => setSessions((prev) => prev.filter((s) => s.id !== id)),
176+
finalize: () => {
177+
// Hub deletes must clear the GLOBAL session store too, not just our
178+
// local list. The global store may already hold this session if the
179+
// user previously visited TerminalApp during the same SPA session
180+
// (the Map persists across mount/unmount of TerminalApp). Without
181+
// this, a later SPA navigation back to /terminal would re-mount
182+
// TerminalPanes for the orphan ids in tabOrder, fail WS attach with
183+
// "Session not found", and leak the deleted id back into localStorage.
184+
// removeSession also adds the id to deletedIds so any in-flight
185+
// attach error toasts are suppressed.
186+
const store = useSessionStore.getState();
187+
const ms = store.sessions.get(id);
188+
if (ms?.companionTermId) store.removeSession(ms.companionTermId);
189+
store.removeSession(id);
190+
setSessions((prev) => prev.filter((s) => s.id !== id));
191+
},
177192
});
178193
toast.success(`Session "${session?.name ?? id}" deleted`);
179194
} catch (err) {

src/frontend/src/components/TerminalApp/TerminalApp.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ export function TerminalApp() {
169169
const list: Session[] = await fetchSessions();
170170
const store = useSessionStore.getState();
171171
const urlSessionId = getSessionIdFromUrl();
172+
const serverIds = new Set(list.map((s) => s.id));
173+
174+
// Defensive prune: the global session store survives across
175+
// unmount/remount of TerminalApp (Zustand state is module-global).
176+
// If a session was deleted while we were elsewhere (Hub view, code
177+
// viewer, etc.), the store may still hold its entry. Clean those up
178+
// BEFORE rendering — otherwise TerminalPane mounts for the orphan,
179+
// opens a WebSocket, and the server replies "Session not found".
180+
// Skip ids currently mid-disintegrate (animation in flight).
181+
for (const [id, ms] of store.sessions) {
182+
if (!serverIds.has(id) && !ms.exited && !store.dissolvingIds.has(id)) {
183+
if (ms.companionTermId) store.removeSession(ms.companionTermId);
184+
store.removeSession(id);
185+
}
186+
}
172187

173188
// Add ALL sessions from server (matching old UI behavior)
174189
for (const s of list) {

0 commit comments

Comments
 (0)