From aff87f92572e0e8866de3332af3f8fe014676369 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 6 Aug 2026 22:38:20 +0800 Subject: [PATCH 1/2] fix(workstation): close tabs at canonical resource owners --- src/contexts/contexts_organization.md | 6 +- src/contexts/workstation/BrowserContext.tsx | 192 ++-------- src/contexts/workstation/TerminalContext.tsx | 287 -------------- src/contexts/workstation/index.ts | 2 - src/engines/ChatPanel/index.tsx | 6 +- .../ChatPanel/panels/ProjectOrgPanelView.tsx | 4 +- src/engines/TerminalCore/exports.ts | 1 - .../hooks/useTerminalContextAdapter.ts | 67 ---- src/engines/TerminalCore/index.tsx | 5 +- .../Org2Cloud/useOrg2CloudGuestShareAccess.ts | 14 +- src/hooks/ui/tabs/index.ts | 3 +- src/hooks/ui/tabs/useEditorCache.ts | 7 +- src/hooks/ui/tabs/useSyncGlobalTabs.ts | 58 +-- .../AppShell/WorkstationTabBar.test.ts | 144 +++++++ .../AppShell/hooks/useTerminalTabTeardown.ts | 30 -- src/modules/WorkStation/AppShell/index.tsx | 4 - .../BrowserLayout/useBrowserTabSync.ts | 25 +- .../content/WebViewportContent/index.tsx | 15 +- .../SessionReplay/useBrowserReplayTabs.ts | 3 +- .../TabContent/renderers/projectOrg.tsx | 6 +- .../renderers/projectOrgSettings.tsx | 6 +- src/modules/index.tsx | 112 +++--- .../rustSessionDeleteReceipt.test.ts | 9 - .../connectors/rustSessionDeleteReceipt.ts | 6 - .../useWorkstationSidebarHandlers.ts | 15 - .../chatPanelSessionInvalidationAtom.test.ts | 74 ++++ .../chatPanelSessionInvalidationAtom.ts | 54 +++ .../chatPanelTabPresentationAtoms.ts | 2 +- src/store/chatPanel/chatPanelTabsAtom.ts | 1 + ...rojectOrgPresentationLifecycleAtom.test.ts | 113 ++++++ .../projectOrgPresentationLifecycleAtom.ts | 20 + .../sessionAtom/__tests__/mutations.test.ts | 82 ++++ src/store/session/sessionAtom/mutations.ts | 6 + .../sessionPresentationLifecycleAtom.ts | 39 ++ src/store/workstation/browser/index.ts | 1 + .../workstation/browser/sessionState.test.ts | 107 ++++++ src/store/workstation/browser/sessionState.ts | 192 ++++++++++ .../workstation/browser/sessionTitles.ts | 3 + .../sharedWorkspaceIntegration.test.ts | 89 ++++- src/store/workstation/browser/tabs/index.ts | 74 +++- .../terminal/__tests__/terminalAtoms.test.ts | 81 ++++ .../workstation/codeEditor/terminal/index.ts | 175 ++++++--- .../workstation/tabRegistry/atoms.test.ts | 357 +++++++++++++----- src/store/workstation/tabRegistry/atoms.ts | 29 +- src/store/workstation/tabRegistry/index.ts | 1 + .../tabs/__tests__/storage.test.ts | 42 ++- .../tabs/__tests__/workspaceState.test.ts | 206 ++++++++++ src/store/workstation/tabs/atoms.ts | 224 ++++++----- src/store/workstation/tabs/index.ts | 6 + src/store/workstation/tabs/lifecycle.ts | 188 +++++++++ src/store/workstation/tabs/storage.ts | 71 +++- src/store/workstation/tabs/types.ts | 157 +++++++- 52 files changed, 2359 insertions(+), 1062 deletions(-) delete mode 100644 src/contexts/workstation/TerminalContext.tsx delete mode 100644 src/engines/TerminalCore/hooks/useTerminalContextAdapter.ts create mode 100644 src/modules/WorkStation/AppShell/WorkstationTabBar.test.ts delete mode 100644 src/modules/WorkStation/AppShell/hooks/useTerminalTabTeardown.ts create mode 100644 src/store/chatPanel/chatPanelSessionInvalidationAtom.test.ts create mode 100644 src/store/chatPanel/chatPanelSessionInvalidationAtom.ts create mode 100644 src/store/projectOrgPresentationLifecycleAtom.test.ts create mode 100644 src/store/projectOrgPresentationLifecycleAtom.ts create mode 100644 src/store/session/sessionPresentationLifecycleAtom.ts create mode 100644 src/store/workstation/browser/sessionState.test.ts create mode 100644 src/store/workstation/browser/sessionState.ts create mode 100644 src/store/workstation/browser/sessionTitles.ts create mode 100644 src/store/workstation/tabs/lifecycle.ts diff --git a/src/contexts/contexts_organization.md b/src/contexts/contexts_organization.md index 36c1d9e2af..acd9247e93 100644 --- a/src/contexts/contexts_organization.md +++ b/src/contexts/contexts_organization.md @@ -36,7 +36,9 @@ Contexts for Workstation pages. Each provides session/state management. | `BrowserContext` | Browser tab sessions | | `EditorContext` | Editor repo selection | | `FilesContext` | Document files management | -| `TerminalContext` | Terminal sessions | + +Terminal sessions are owned by the workstation terminal atoms rather than a +parallel React context. ### `session/` - Session Contexts @@ -101,7 +103,7 @@ Reorganized on 2026-01-29: - Moved `GitStatusContext/` → `git/GitStatusContext/` - Moved `MultiRepoGitStatusContext` → `git/` -- Moved `AutomationContext`, `BrowserContext`, `EditorContext`, `FilesContext`, `TerminalContext` → `workstation/` +- Moved `AutomationContext`, `BrowserContext`, `EditorContext`, `FilesContext` → `workstation/` - Moved `SessionListContext`, `RecentFilesContext` → `session/` - Moved `ToolbarThemeContext` → `ui/` - Kept `workspace/` as-is (already organized) diff --git a/src/contexts/workstation/BrowserContext.tsx b/src/contexts/workstation/BrowserContext.tsx index 5eda4cdb38..ee00a1e21d 100644 --- a/src/contexts/workstation/BrowserContext.tsx +++ b/src/contexts/workstation/BrowserContext.tsx @@ -5,8 +5,9 @@ * * Performance optimizations: * - Uses startTransition for non-urgent state updates to avoid blocking UI - * - Defers cascading state updates with queueMicrotask + * - Stores session state in one Jotai source so every close path is coherent */ +import { useAtomValue, useSetAtom } from "jotai"; import React, { createContext, startTransition, @@ -17,14 +18,17 @@ import React, { useRef, useState, } from "react"; -import { v4 as uuidv4 } from "uuid"; import { useGlobalBrowserTabs } from "@src/hooks/ui/tabs/useGlobalTabs"; import { useSyncBrowserTabs } from "@src/hooks/ui/tabs/useSyncGlobalTabs"; import { - NEW_PRIVATE_TAB_TITLE, - NEW_TAB_TITLE, -} from "@src/store/workstation/browser/tabs"; + addBrowserSessionAtom, + browserSessionStateAtom, + closeBrowserSessionAtom, + forceSaveBrowserSessionsAtom, + setActiveBrowserSessionAtom, + updateBrowserSessionAtom, +} from "@src/store/workstation/browser/sessionState"; import type { BrowserSession } from "@src/types/ui/tabs"; interface BrowserContextValue { @@ -42,83 +46,16 @@ interface BrowserContextValue { const BrowserContext = createContext(null); -// Helper function to extract title from URL -const getTitleFromUrl = (url: string): string => { - if (!url) return NEW_TAB_TITLE; - try { - const urlObj = new URL(url); - return urlObj.hostname || NEW_TAB_TITLE; - } catch { - return NEW_TAB_TITLE; - } -}; - -// Browser sessions are the durable source for live browser resources. The -// WorkStation `browserTabsAtom` is a shared-resource projection synchronized -// from this state; it must never be used as a second persistence owner. -const BROWSER_SESSIONS_STORAGE_KEY = "browser-explorer-sessions"; - -// Load sessions from localStorage -const loadFromStorage = (): { - sessions: BrowserSession[]; - activeSessionId: string; -} | null => { - try { - const stored = localStorage.getItem(BROWSER_SESSIONS_STORAGE_KEY); - const parsed = stored ? JSON.parse(stored) : null; - const sessions = Array.isArray(parsed?.sessions) ? parsed.sessions : []; - const activeSessionId = - typeof parsed?.activeSessionId === "string" ? parsed.activeSessionId : ""; - if (sessions.length > 0) { - const validActiveSessionId = sessions.some( - (session: BrowserSession) => session.id === activeSessionId - ) - ? activeSessionId - : (sessions[0]?.id ?? ""); - return { sessions, activeSessionId: validActiveSessionId }; - } - } catch { - return null; - } - return null; -}; - -// Save sessions to localStorage -const saveToStorage = (sessions: BrowserSession[], activeSessionId: string) => { - try { - localStorage.setItem( - BROWSER_SESSIONS_STORAGE_KEY, - JSON.stringify({ sessions, activeSessionId }) - ); - } catch { - // Ignore storage errors - } -}; - -// Default initial state for browser tab - starts empty like CodeEditor -const getDefaultState = (): { - sessions: BrowserSession[]; - activeSessionId: string; - filterValue: string; -} => { - // Try to load from localStorage first - const stored = loadFromStorage(); - if (stored) { - return { ...stored, filterValue: "" }; - } - - // No default session - user clicks + to create tabs - return { - sessions: [], - activeSessionId: "", - filterValue: "", - }; -}; - export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const { removeBrowserTab } = useGlobalBrowserTabs(); + const { sessions, activeSessionId } = useAtomValue(browserSessionStateAtom); + const addBrowserSession = useSetAtom(addBrowserSessionAtom); + const setActiveBrowserSession = useSetAtom(setActiveBrowserSessionAtom); + const closeBrowserSession = useSetAtom(closeBrowserSessionAtom); + const updateBrowserSession = useSetAtom(updateBrowserSessionAtom); + const forceSaveBrowserSessions = useSetAtom(forceSaveBrowserSessionsAtom); const sessionsRef = useRef([]); const removeBrowserTabRef = useRef(removeBrowserTab); @@ -128,12 +65,6 @@ export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({ removeBrowserTabRef.current = removeBrowserTab; }, [removeBrowserTab]); - const [sessions, setSessions] = useState( - () => getDefaultState().sessions - ); - const [activeSessionId, setActiveSessionId] = useState( - () => getDefaultState().activeSessionId - ); const [filterValue, setFilterValue] = useState(""); // Keep sessionsRef up to date @@ -154,86 +85,29 @@ export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({ // ✨ Sync to global tabs state (for components that use navigationSidebarTabsAtom) useSyncBrowserTabs(sessions, activeSessionId); - // Ensure active session exists (or is empty if no sessions) - useEffect(() => { - const activeSessionExists = sessions.some( - (session) => session.id === activeSessionId - ); - if (!activeSessionExists) { - // Use startTransition to avoid blocking UI during correction - startTransition(() => { - setActiveSessionId(sessions.length > 0 ? sessions[0].id : ""); - }); - } - }, [sessions, activeSessionId]); - - // Persist state to localStorage - useEffect(() => { - if (sessions.length > 0) { - saveToStorage(sessions, activeSessionId); - } else { - // Clear storage when all sessions are closed - localStorage.removeItem(BROWSER_SESSIONS_STORAGE_KEY); - } - }, [sessions, activeSessionId]); - // Add a new session - const handleAddSession = useCallback((url?: string, incognito = false) => { - const newSessionId = uuidv4(); - const newSession: BrowserSession = { - id: newSessionId, - title: url - ? getTitleFromUrl(url) - : incognito - ? NEW_PRIVATE_TAB_TITLE - : NEW_TAB_TITLE, - url: url || "", - history: url ? [url] : [], - historyIndex: url ? 0 : -1, - historyEntries: url - ? [{ url, title: getTitleFromUrl(url), visitedAt: Date.now() }] - : [], - isLoading: false, - error: null, - incognito, - }; - - // Keep session list + active id in the same update. Deferring only setSessions - // (e.g. via startTransition) while setting activeSessionId eagerly lets the - // "ensure active session exists" effect run with the new id before the new - // row exists and resets focus to sessions[0]. - setSessions((prev) => [...prev, newSession]); - setActiveSessionId(newSessionId); - return newSessionId; - }, []); + const handleAddSession = useCallback( + (url?: string, incognito = false) => addBrowserSession({ url, incognito }), + [addBrowserSession] + ); // Switch to a session - const handleSessionClick = useCallback((sessionId: string) => { - setActiveSessionId(sessionId); - }, []); + const handleSessionClick = useCallback( + (sessionId: string) => { + setActiveBrowserSession(sessionId); + }, + [setActiveBrowserSession] + ); // Close a session const handleCloseSession = useCallback( (sessionId: string) => { // Use startTransition to avoid blocking UI during state update startTransition(() => { - setSessions((prev) => { - const filtered = prev.filter((session) => session.id !== sessionId); - - // If closing the active session, activate the first remaining session (or clear if none) - if (sessionId === activeSessionId) { - if (filtered.length > 0) { - setActiveSessionId(filtered[0].id); - } else { - setActiveSessionId(""); - } - } - - return filtered; - }); + closeBrowserSession(sessionId); }); }, - [activeSessionId] + [closeBrowserSession] ); // Update a specific session @@ -241,20 +115,16 @@ export const BrowserProvider: React.FC<{ children: React.ReactNode }> = ({ (sessionId: string, updates: Partial) => { // Use startTransition for non-urgent updates (like URL/title changes) startTransition(() => { - setSessions((prev) => { - return prev.map((session) => - session.id === sessionId ? { ...session, ...updates } : session - ); - }); + updateBrowserSession({ sessionId, updates }); }); }, - [] + [updateBrowserSession] ); // Force save to localStorage (for when switching away from browser mode) const forceSave = useCallback(() => { - saveToStorage(sessions, activeSessionId); - }, [sessions, activeSessionId]); + forceSaveBrowserSessions(); + }, [forceSaveBrowserSessions]); const value = useMemo( () => ({ diff --git a/src/contexts/workstation/TerminalContext.tsx b/src/contexts/workstation/TerminalContext.tsx deleted file mode 100644 index 255402d142..0000000000 --- a/src/contexts/workstation/TerminalContext.tsx +++ /dev/null @@ -1,287 +0,0 @@ -/** - * Terminal Context - * - * Provides terminal session state management across Terminal page and TerminalExtraSidebar - */ -import { useAtomValue } from "jotai"; -import React, { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { getSettingsDefaults } from "@src/config/settingsSchema"; -import { createLogger } from "@src/hooks/logger"; -import { useGlobalTerminalTabs } from "@src/hooks/ui/tabs/useGlobalTabs"; -import { useSyncTerminalSessions } from "@src/hooks/ui/tabs/useSyncGlobalTabs"; -import { settingsAtom } from "@src/store/settings/settingsAtom"; -import { - notifyTerminalCreationCooldown, - tryBeginTerminalCreation, -} from "@src/util/ui/terminal/creationThrottle"; -import { - defaultTerminalLabelBaseFromSettings, - generateUniqueLabelFromBase, -} from "@src/util/ui/terminal/naming"; -import { toBackendPtySessionId } from "@src/util/ui/terminal/ptySessionId"; - -const log = createLogger("TerminalContext"); - -interface TerminalSession { - id: string; - name: string; - isActive: boolean; -} - -interface TerminalContextValue { - sessions: TerminalSession[]; - activeSessionId: string; - filterValue: string; - initializedSessions: Set; - setFilterValue: (value: string) => void; - handleSessionClick: (sessionId: string) => void; - handleAddSession: () => void; - handleCloseSession: (sessionId: string, e?: React.MouseEvent) => void; -} - -const TerminalContext = createContext(null); - -// Default initial state for terminal tab -const getDefaultState = (): { - sessions: TerminalSession[]; - activeSessionId: string; - filterValue: string; - initializedSessionIds: string[]; -} => { - const defaultBase = defaultTerminalLabelBaseFromSettings( - getSettingsDefaults() - ); - const initialName = generateUniqueLabelFromBase(defaultBase, []); - return { - sessions: [{ id: "1", name: initialName, isActive: true }], - activeSessionId: "1", - filterValue: "", - initializedSessionIds: ["1"], - }; -}; - -export const TerminalProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => { - const { removeTerminalSession } = useGlobalTerminalTabs(); - const settings = useAtomValue(settingsAtom); - const defaultLabelBase = useMemo( - () => defaultTerminalLabelBaseFromSettings(settings), - [settings] - ); - - const sessionsRef = useRef([]); - const removeTerminalSessionRef = useRef(removeTerminalSession); - - // Keep removeTerminalSession ref up to date - useEffect(() => { - removeTerminalSessionRef.current = removeTerminalSession; - }, [removeTerminalSession]); - - const [sessions, setSessions] = useState( - () => getDefaultState().sessions - ); - const [activeSessionId, setActiveSessionId] = useState( - () => getDefaultState().activeSessionId - ); - const [filterValue, setFilterValue] = useState( - () => getDefaultState().filterValue - ); - const [initializedSessions, setInitializedSessions] = useState>( - () => new Set(getDefaultState().initializedSessionIds) - ); - - // ✨ Sync to global tabs state (for components that use navigationSidebarTabsAtom) - useSyncTerminalSessions(sessions, activeSessionId); - - // Keep sessionsRef up to date - useEffect(() => { - sessionsRef.current = sessions; - }, [sessions]); - - // Cleanup PTY sessions when terminal tab is closed (provider unmounted) - useEffect(() => { - return () => { - // When the terminal tab is actually closed (not just navigated away), - // kill all PTY sessions and clear global atom - const currentSessions = sessionsRef.current; - if (currentSessions.length > 0) { - const cleanupAllPTY = async () => { - try { - const { isTauriDesktop } = await import("@src/util/platform/tauri"); - if (isTauriDesktop()) { - const { invoke } = await import("@tauri-apps/api/core"); - // Kill all PTY sessions in parallel - const cleanupPromises = currentSessions.map(async (session) => { - try { - await invoke("close_pty", { - sessionId: toBackendPtySessionId(session.id), - }); - } catch { - // Ignore errors - PTY might already be closed - } - }); - await Promise.all(cleanupPromises); - } - } catch (error) { - log.error("[TerminalContext] PTY cleanup error:", error); - } - }; - cleanupAllPTY(); - - // Also clear from global atom - currentSessions.forEach((session) => { - removeTerminalSessionRef.current(session.id); - }); - } - }; - }, []); // Empty deps - only run on unmount - - // Add a new session - const handleAddSession = useCallback(() => { - if (!tryBeginTerminalCreation()) { - notifyTerminalCreationCooldown(); - return; - } - - const newId = Date.now().toString(); - const newName = generateUniqueLabelFromBase( - defaultLabelBase, - sessions.map((session) => session.name) - ); - - setSessions((prev) => [ - ...prev.map((session) => ({ ...session, isActive: false })), - { id: newId, name: newName, isActive: true }, - ]); - setActiveSessionId(newId); - - // Mark as initialized immediately - setInitializedSessions((prev) => new Set([...prev, newId])); - }, [sessions, defaultLabelBase]); - - // Switch to a session - const handleSessionClick = useCallback((sessionId: string) => { - setActiveSessionId(sessionId); - setSessions((prev) => - prev.map((session) => ({ - ...session, - isActive: session.id === sessionId, - })) - ); - }, []); - - // Close a session - const handleCloseSession = useCallback( - (sessionId: string, e?: React.MouseEvent) => { - e?.stopPropagation(); - - // Kill the PTY process immediately (force cleanup) - const killPTY = async () => { - try { - const { isTauriDesktop } = await import("@src/util/platform/tauri"); - if (isTauriDesktop()) { - const { invoke } = await import("@tauri-apps/api/core"); - await invoke("close_pty", { - sessionId: toBackendPtySessionId(sessionId), - }); - } - } catch (err) { - log.error(`[TerminalContext] Failed to kill PTY:`, err); - } - }; - killPTY(); - - // Check if this is the last session BEFORE the state update - // (React's setState callback is async, so we can't check inside it) - const isLastSession = sessions.length === 1; - - if (isLastSession) { - // Closing the last session - create a new default one - const newId = Date.now().toString(); - const newName = generateUniqueLabelFromBase(defaultLabelBase, []); - const newSession = { id: newId, name: newName, isActive: true }; - - // Set all states together - no race conditions - setSessions([newSession]); - setActiveSessionId(newId); - setInitializedSessions(new Set([newId])); - } else { - // Not the last session - just remove it - setSessions((prev) => { - const filtered = prev.filter((session) => session.id !== sessionId); - - // If closing the active session, activate the first remaining session - if (sessionId === activeSessionId && filtered.length > 0) { - const newActiveId = filtered[0].id; - setActiveSessionId(newActiveId); - return filtered.map((session) => ({ - ...session, - isActive: session.id === newActiveId, - })); - } - - return filtered; - }); - - // Remove closed session from initialized set - setInitializedSessions((prev) => { - const newSet = new Set(prev); - newSet.delete(sessionId); - return newSet; - }); - } - }, - [activeSessionId, sessions, defaultLabelBase] - ); - - const value = useMemo( - () => ({ - sessions, - activeSessionId, - filterValue, - initializedSessions, - setFilterValue, - handleSessionClick, - handleAddSession, - handleCloseSession, - }), - [ - sessions, - activeSessionId, - filterValue, - initializedSessions, - handleSessionClick, - handleAddSession, - handleCloseSession, - ] - ); - - return ( - - {children} - - ); -}; - -export const useTerminalContext = () => { - const context = useContext(TerminalContext); - if (!context) { - throw new Error("useTerminalContext must be used within TerminalProvider"); - } - return context; -}; - -// Optional version that doesn't throw - for GlobalTabsSidebar -export const useTerminalContextOptional = () => { - return useContext(TerminalContext); -}; diff --git a/src/contexts/workstation/index.ts b/src/contexts/workstation/index.ts index 0f51d92912..2694dbe72f 100644 --- a/src/contexts/workstation/index.ts +++ b/src/contexts/workstation/index.ts @@ -8,5 +8,3 @@ export { BrowserProvider, useBrowserContext } from "./BrowserContext"; export { EditorProvider } from "./EditorContext"; - -export { TerminalProvider, useTerminalContext } from "./TerminalContext"; diff --git a/src/engines/ChatPanel/index.tsx b/src/engines/ChatPanel/index.tsx index 29733164d1..fbbabb3a6c 100644 --- a/src/engines/ChatPanel/index.tsx +++ b/src/engines/ChatPanel/index.tsx @@ -29,7 +29,6 @@ import { getChatPanelBackgroundStyle } from "@src/modules/shared/layouts/viewCon import { installAvailableAppUpdate } from "@src/scaffold/AppUpdater"; import { closeOrganizationChatPanelTabAtom, - closeProjectOrgChatPanelTabsAtom, closeRevokedCloudChannelChatPanelTabsAtom, openRuntimeInChatPanelTabAtom, openSessionInNewChatTabAtom, @@ -37,6 +36,7 @@ import { syncActiveChatPanelTabStateAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; import { projectListRefreshAtom } from "@src/store/project/projectAtom"; +import { invalidateProjectOrgPresentationAtom } from "@src/store/projectOrgPresentationLifecycleAtom"; import { sessionCreatorStateAtom } from "@src/store/session"; import { type SessionContinuation, @@ -148,7 +148,9 @@ const ChatPanel: React.FC = memo( const cloudOrgs = useAtomValue(org2CloudOrgsAtom); const cloudOrgsLoaded = useAtomValue(org2CloudOrgsLoadedAtom); const closeOrganizationTab = useSetAtom(closeOrganizationChatPanelTabAtom); - const closeProjectOrgTabs = useSetAtom(closeProjectOrgChatPanelTabsAtom); + const closeProjectOrgTabs = useSetAtom( + invalidateProjectOrgPresentationAtom + ); const closeRevokedCloudChannelTabs = useSetAtom( closeRevokedCloudChannelChatPanelTabsAtom ); diff --git a/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx b/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx index 435fe5d340..27cb400869 100644 --- a/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx +++ b/src/engines/ChatPanel/panels/ProjectOrgPanelView.tsx @@ -13,12 +13,12 @@ import { createLogger } from "@src/hooks/logger"; import { ProjectOrgHubContent } from "@src/modules/ProjectManager/ProjectManagerLayout/components/ProjectOrgHubContent"; import { ProjectOrgSurfacePillSwitch } from "@src/modules/ProjectManager/ProjectManagerLayout/components/ProjectOrgSurfacePillSwitch"; import { - closeProjectOrgChatPanelTabsAtom, openCreateTargetInChatPanelStartPageAtom, openOrganizationInChatPanelTabAtom, openProjectInChatPanelTabAtom, openWorkItemInChatPanelTabAtom, } from "@src/store/chatPanel/chatPanelTabsAtom"; +import { invalidateProjectOrgPresentationAtom } from "@src/store/projectOrgPresentationLifecycleAtom"; import { CHAT_PANEL_CREATE_TARGET, type ChatPanelSelectedProjectOrg, @@ -47,7 +47,7 @@ export const ProjectOrgPanelView: React.FC = ({ ); const openProjectTab = useSetAtom(openProjectInChatPanelTabAtom); const openWorkItemTab = useSetAtom(openWorkItemInChatPanelTabAtom); - const closeProjectOrgTabs = useSetAtom(closeProjectOrgChatPanelTabsAtom); + const closeProjectOrgTabs = useSetAtom(invalidateProjectOrgPresentationAtom); const openOrganizationTab = useSetAtom(openOrganizationInChatPanelTabAtom); const [orgView, setOrgView] = useState( selectedProjectOrg.initialView ?? PROJECT_ORG_SURFACE_VIEW.WORK_ITEMS diff --git a/src/engines/TerminalCore/exports.ts b/src/engines/TerminalCore/exports.ts index f03196052e..02a4a26175 100644 --- a/src/engines/TerminalCore/exports.ts +++ b/src/engines/TerminalCore/exports.ts @@ -4,7 +4,6 @@ export { default } from "./index"; export { TerminalCore } from "./index"; export { useTerminalState } from "./hooks/useTerminalState"; -export { useTerminalContextAdapter } from "./hooks/useTerminalContextAdapter"; export type { AddSessionOptions, TerminalSession, diff --git a/src/engines/TerminalCore/hooks/useTerminalContextAdapter.ts b/src/engines/TerminalCore/hooks/useTerminalContextAdapter.ts deleted file mode 100644 index 7247394cad..0000000000 --- a/src/engines/TerminalCore/hooks/useTerminalContextAdapter.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * useTerminalContextAdapter - * - * Adapter hook to convert TerminalContext to UseTerminalStateReturn interface. - * Allows TerminalCore to work seamlessly with the existing TerminalContext. - */ -import { useMemo } from "react"; - -import { useTerminalContext } from "@src/contexts/workstation"; - -import type { UseTerminalStateReturn } from "../types"; - -/** - * Adapter hook that converts TerminalContext to UseTerminalStateReturn - */ -export function useTerminalContextAdapter(): UseTerminalStateReturn { - const { - sessions, - activeSessionId, - initializedSessions, - handleSessionClick, - handleAddSession, - handleCloseSession, - } = useTerminalContext(); - - const activeSession = sessions.find((s) => s.id === activeSessionId); - - return useMemo( - () => ({ - sessions, - activeSessionId, - activeSession, - initializedSessions, - addSession: () => { - handleAddSession(); - // Return the next ID (will be max + 1) - const maxId = sessions.reduce((max, session) => { - const sessionIdNum = Number.parseInt(session.id, 10); - return Number.isNaN(sessionIdNum) ? max : Math.max(max, sessionIdNum); - }, 0); - return String(maxId + 1); - }, - closeSession: (sessionId: string) => { - handleCloseSession(sessionId); - }, - setActiveSession: handleSessionClick, - markSessionInitialized: () => { - // Context manages initialization internally, no-op here - }, - updateSessionInfo: () => { - // Context does not track PID/shell/cwd, no-op here - }, - renameSession: () => { - // Context does not support rename, no-op here - }, - }), - [ - sessions, - activeSessionId, - activeSession, - initializedSessions, - handleAddSession, - handleCloseSession, - handleSessionClick, - ] - ); -} diff --git a/src/engines/TerminalCore/index.tsx b/src/engines/TerminalCore/index.tsx index 2a9c1fca77..d266b90fbe 100644 --- a/src/engines/TerminalCore/index.tsx +++ b/src/engines/TerminalCore/index.tsx @@ -1,9 +1,8 @@ /** * TerminalCore Component * - * Reusable terminal component that can work with: - * 1. TerminalContext (for main terminal page) - * 2. Prop-based state (for simulator or standalone use) + * Reusable prop-driven terminal component for Workstation, ChatPanel, or + * standalone surfaces. Session ownership lives in the caller's atom layer. * * Features: * - Multiple sessions (tabs) diff --git a/src/features/Org2Cloud/useOrg2CloudGuestShareAccess.ts b/src/features/Org2Cloud/useOrg2CloudGuestShareAccess.ts index 3c430c3031..ce19f100f9 100644 --- a/src/features/Org2Cloud/useOrg2CloudGuestShareAccess.ts +++ b/src/features/Org2Cloud/useOrg2CloudGuestShareAccess.ts @@ -14,11 +14,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { deleteSession as deleteLocalSession } from "@src/api/tauri/agent"; import { deleteOrgtrackCollaborationSession } from "@src/api/tauri/lineage"; import { createLogger } from "@src/hooks/logger"; -import { - activeChatPanelTabAtom, - chatPanelTabsAtom, - closeChatPanelTabAtom, -} from "@src/store/chatPanel/chatPanelTabsAtom"; +import { activeChatPanelTabAtom } from "@src/store/chatPanel/chatPanelTabsAtom"; import { sessionsAtom } from "@src/store/session/sessionAtom/atoms"; import { removeSession } from "@src/store/session/sessionAtom/mutations"; import { persistSessions } from "@src/store/session/sessionAtom/persistence"; @@ -113,14 +109,6 @@ export function useOrg2CloudGuestShareAccess(): void { ); for (const session of revokedCopies) { - const tabs = store - .get(chatPanelTabsAtom) - .tabs.filter( - (tab) => - tab.type === "session" && tab.sessionId === session.session_id - ); - for (const tab of tabs) store.set(closeChatPanelTabAtom, tab.id); - // Remove every readable frontend/persisted projection synchronously; // native deletion follows best-effort so a slow IPC cannot extend the // revoked capability's visible lifetime. diff --git a/src/hooks/ui/tabs/index.ts b/src/hooks/ui/tabs/index.ts index 0c1976efd9..93577eb482 100644 --- a/src/hooks/ui/tabs/index.ts +++ b/src/hooks/ui/tabs/index.ts @@ -9,10 +9,9 @@ export { useGlobalShortcutTabs, useGlobalTabCounts, } from "./useGlobalTabs"; -// Tab sync functions (for browser/terminal/editor contexts) +// Tab sync functions (for browser/editor/document contexts) export { useSyncBrowserTabs, - useSyncTerminalSessions, useSyncEditorRepos, useSyncDocumentFiles, } from "./useSyncGlobalTabs"; diff --git a/src/hooks/ui/tabs/useEditorCache.ts b/src/hooks/ui/tabs/useEditorCache.ts index 3e5c70c135..56f3644ae3 100644 --- a/src/hooks/ui/tabs/useEditorCache.ts +++ b/src/hooks/ui/tabs/useEditorCache.ts @@ -14,8 +14,6 @@ import { useCallback, useMemo } from "react"; import { // Types type EditorRepoCache, - // Constants - FILE_TAB_TYPES, MAX_EDITOR_CACHE_REPOS, MAX_FILE_TABS_PER_REPO, type WorkStationTab, @@ -26,6 +24,7 @@ import { // Editor cache atoms editorCacheAtom, editorCacheSizeAtom, + getWorkstationTabRepoAffinity, mainPaneStateAtom, saveRepoCacheAtom, switchActiveRepoAtom, @@ -41,14 +40,14 @@ import { * Check if a tab is a file tab (cached per-repo) */ function isFileTab(tab: WorkStationTab): boolean { - return FILE_TAB_TYPES.includes(tab.type as (typeof FILE_TAB_TYPES)[number]); + return getWorkstationTabRepoAffinity(tab.type) === "repo-scoped"; } /** * Check if a tab is a tool tab (global, not cached) */ function isToolTab(tab: WorkStationTab): boolean { - return !isFileTab(tab); + return getWorkstationTabRepoAffinity(tab.type) === "repo-independent"; } // ============================================ diff --git a/src/hooks/ui/tabs/useSyncGlobalTabs.ts b/src/hooks/ui/tabs/useSyncGlobalTabs.ts index 3220b31079..6c30189690 100644 --- a/src/hooks/ui/tabs/useSyncGlobalTabs.ts +++ b/src/hooks/ui/tabs/useSyncGlobalTabs.ts @@ -4,8 +4,8 @@ * Syncs local context state with global tabs state. * Use this hook in your context providers to automatically track tabs globally. * - * These hooks sync local state (from contexts like BrowserContext, TerminalContext) - * to the navigationSidebarTabsAtom for display in the GlobalTabsSidebar. + * These hooks sync context-owned browser, editor, and document state to the + * navigationSidebarTabsAtom for display in the GlobalTabsSidebar. * * PERFORMANCE: Each sync hook now uses focused hooks instead of the combined * useGlobalTabs hook, preventing unnecessary re-renders when other tab categories change. @@ -19,7 +19,6 @@ import { useGlobalBrowserTabs, useGlobalDocumentTabs, useGlobalEditorTabs, - useGlobalTerminalTabs, } from "./useGlobalTabs"; /** @@ -117,59 +116,6 @@ export const useSyncBrowserTabs = ( }, [activeSessionId, activeBrowser?.id, setActiveBrowserTab]); }; -/** - * Sync terminal sessions to global state - * - * Used by: TerminalContext - * - * ONE-WAY sync: TerminalContext sessions -> navigationSidebarTabsAtom.terminal - */ -export const useSyncTerminalSessions = ( - sessions: Array<{ id: string; name: string; isActive?: boolean }>, - activeSessionId: string -) => { - const { - activeTerminal, - addTerminalSession, - setActiveTerminalSession, - removeTerminalSession, - } = useGlobalTerminalTabs(); - - // Track synced session IDs to detect additions/removals - const syncedSessionIdsRef = useRef>(new Set()); - - useEffect(() => { - const currentSessionIds = new Set(sessions.map((session) => session.id)); - const syncedIds = syncedSessionIdsRef.current; - - // Add new sessions - sessions.forEach((session) => { - if (!syncedIds.has(session.id)) { - addTerminalSession({ - id: session.id, - name: session.name, - isActive: session.id === activeSessionId, - }); - syncedIds.add(session.id); - } - }); - - // Remove sessions that no longer exist - syncedIds.forEach((id) => { - if (!currentSessionIds.has(id)) { - removeTerminalSession(id); - syncedIds.delete(id); - } - }); - }, [sessions, activeSessionId, addTerminalSession, removeTerminalSession]); - - useEffect(() => { - if (activeSessionId && activeTerminal?.id !== activeSessionId) { - setActiveTerminalSession(activeSessionId); - } - }, [activeSessionId, activeTerminal?.id, setActiveTerminalSession]); -}; - /** * Sync editor repos to global state * diff --git a/src/modules/WorkStation/AppShell/WorkstationTabBar.test.ts b/src/modules/WorkStation/AppShell/WorkstationTabBar.test.ts new file mode 100644 index 0000000000..85ef2c7160 --- /dev/null +++ b/src/modules/WorkStation/AppShell/WorkstationTabBar.test.ts @@ -0,0 +1,144 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { + browserSessionStateAtom, + persistBrowserSessionState, +} from "@src/store/workstation/browser/sessionState"; +import { createBrowserSessionTab } from "@src/store/workstation/browser/tabs"; +import { tabRegistryAtom } from "@src/store/workstation/tabRegistry"; +import { + type WorkStationTab, + workstationTabsStateAtom, +} from "@src/store/workstation/tabs"; +import { emptyWorkstationTabsState } from "@src/store/workstation/tabs/storage"; + +import WorkstationTabBar from "./WorkstationTabBar"; + +const tabBarMock = vi.hoisted(() => ({ + props: null as null | { + tabs: WorkStationTab[]; + onTabClose: (tabId: string) => void; + }, +})); + +vi.mock("@src/modules/WorkStation/shared", () => ({ + TabBar: (props: typeof tabBarMock.props) => { + tabBarMock.props = props; + return null; + }, + WorkStationTabBarLeading: () => null, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("./useWorkstationTrailingSlot", () => ({ + useWorkstationTrailingSlot: () => ({ trailingSlot: null }), +})); + +const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +describe("WorkstationTabBar browser resource close", () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + localStorage.clear(); + tabBarMock.props = null; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(reactActEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("routes the visible close control through authoritative session teardown", async () => { + const store = createStore(); + const browserTab = createBrowserSessionTab("browser-1", "Example", { + url: "https://example.com", + }); + const state = emptyWorkstationTabsState(); + state.shared.tabs = [browserTab]; + state.globalWorkspace = { + tabs: [], + activeTabRef: { partition: "shared", tabId: browserTab.id }, + tabOrder: [{ partition: "shared", tabId: browserTab.id }], + }; + store.set(workstationTabsStateAtom, state); + const sessionState = { + sessions: [ + { + id: "browser-1", + title: "Example", + url: "https://example.com", + history: ["https://example.com"], + historyIndex: 0, + isLoading: false, + error: null, + incognito: false, + }, + ], + activeSessionId: "browser-1", + }; + store.set(browserSessionStateAtom, sessionState); + persistBrowserSessionState(sessionState); + expect(store.get(tabRegistryAtom).map((entry) => entry.tab.id)).toEqual([ + browserTab.id, + ]); + + await act(async () => { + root.render( + React.createElement( + Provider, + { store }, + React.createElement(WorkstationTabBar, { host: "browser" }) + ) + ); + }); + + expect(store.get(tabRegistryAtom).map((entry) => entry.tab.id)).toEqual([ + browserTab.id, + ]); + expect(tabBarMock.props?.tabs.map((tab) => tab.id)).toEqual([ + browserTab.id, + ]); + + await act(async () => { + tabBarMock.props?.onTabClose(browserTab.id); + }); + + expect(store.get(browserSessionStateAtom)).toEqual({ + sessions: [], + activeSessionId: "", + }); + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); + expect(tabBarMock.props?.tabs).toEqual([]); + }); +}); diff --git a/src/modules/WorkStation/AppShell/hooks/useTerminalTabTeardown.ts b/src/modules/WorkStation/AppShell/hooks/useTerminalTabTeardown.ts deleted file mode 100644 index 4477704ba1..0000000000 --- a/src/modules/WorkStation/AppShell/hooks/useTerminalTabTeardown.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * useTerminalTabTeardown - * - * VS Code-style terminal lifecycle: closing the Terminal tab kills every - * running PTY (dev servers, agents, shells). We watch the unified tab pool and, - * when the terminal tab transitions from present to absent (any close path — - * the pill's ✕, close-all, close-others), tear down all sessions. The store - * keeps one fresh default, so reopening the Terminal tab starts clean. - */ -import { useAtomValue, useSetAtom } from "jotai"; -import { useEffect, useRef } from "react"; - -import { closeAllTerminalSessionsAtom } from "@src/store/workstation/codeEditor/terminal"; -import { mainPaneTabsAtom } from "@src/store/workstation/tabs"; - -export function useTerminalTabTeardown(): void { - const tabs = useAtomValue(mainPaneTabsAtom); - const closeAllTerminalSessions = useSetAtom(closeAllTerminalSessionsAtom); - - const hasTerminalTab = tabs.some((tab) => tab.type === "terminal"); - const prevHadTerminalTabRef = useRef(hasTerminalTab); - - useEffect(() => { - const prevHad = prevHadTerminalTabRef.current; - prevHadTerminalTabRef.current = hasTerminalTab; - if (prevHad && !hasTerminalTab) { - void closeAllTerminalSessions(); - } - }, [hasTerminalTab, closeAllTerminalSessions]); -} diff --git a/src/modules/WorkStation/AppShell/index.tsx b/src/modules/WorkStation/AppShell/index.tsx index e1bce96731..5df4bd01f4 100644 --- a/src/modules/WorkStation/AppShell/index.tsx +++ b/src/modules/WorkStation/AppShell/index.tsx @@ -31,7 +31,6 @@ import { useAppShellSimulatorPanelSync } from "./hooks/useAppShellSimulatorPanel import { useAppShellStationMode } from "./hooks/useAppShellStationMode"; import { useAppShellStatusBar } from "./hooks/useAppShellStatusBar"; import { useLaunchpadTab } from "./hooks/useLaunchpadTab"; -import { useTerminalTabTeardown } from "./hooks/useTerminalTabTeardown"; interface AppShellProps { /** Whether the routed WorkStation surface is currently visible */ @@ -79,9 +78,6 @@ const AppShell = React.memo( // its own surface). useLaunchpadTab(!isAgentStation); - // Closing the Terminal tab kills all running PTYs (VS Code-style). - useTerminalTabTeardown(); - const workStationPanels = useWorkStationPanels(); useAppShellSimulatorPanelSync({ isAgentStation, workStationPanels }); diff --git a/src/modules/WorkStation/Browser/BrowserLayout/useBrowserTabSync.ts b/src/modules/WorkStation/Browser/BrowserLayout/useBrowserTabSync.ts index 34be3dec97..09e48a27ff 100644 --- a/src/modules/WorkStation/Browser/BrowserLayout/useBrowserTabSync.ts +++ b/src/modules/WorkStation/Browser/BrowserLayout/useBrowserTabSync.ts @@ -4,10 +4,11 @@ * Bidirectional synchronisation between the live browser sessions held in * BrowserContext and the WorkStation tab strip stored in browserTabsAtom. * - * Three effects: - * 1. sessions → tabs (new/removed sessions drive tab creation/removal) - * 2. tab ↔ session (active tab and active session stay aligned) - * 3. tabs → sessions (closed tabs tear down the live session) + * Four effects: + * 1. reconciliation (stale persisted tab resources are discarded) + * 2. sessions → tabs (new/removed sessions drive tab creation/removal) + * 3. tab ↔ session (active tab and active session stay aligned) + * 4. tabs → sessions (closed tabs tear down the live session) */ import { useAtomValue, useSetAtom } from "jotai"; import { type MutableRefObject, useEffect, useRef } from "react"; @@ -21,6 +22,7 @@ import { extractSessionId, getBrowserSessionDisplayTitle, isBrowserSessionTab, + removeBrowserResourceTabsAtom, sharedBrowserTabsAtom, translatePlaceholderBrowserSessionTitle, } from "@src/store/workstation/browser/tabs"; @@ -52,6 +54,7 @@ export function useBrowserTabSync({ const setBrowserTabs = useSetAtom(browserTabsAtom); const browserTabsState = useAtomValue(browserTabsAtom); const sharedBrowserTabs = useAtomValue(sharedBrowserTabsAtom); + const removeBrowserResourceTabs = useSetAtom(removeBrowserResourceTabsAtom); const prevSessionIdsRef = useRef>(new Set()); const sessionSyncInitializedRef = useRef(false); @@ -60,6 +63,20 @@ export function useBrowserTabSync({ const prevBrowserLayoutActiveRef = useRef(false); const prevTabbedSessionIdsRef = useRef>(new Set()); + // BrowserSessionState is authoritative. Clean legacy/crash residue from the + // persisted shared-tab projection whenever it references a missing session. + useEffect(() => { + const liveSessionIds = new Set( + browserState.sessions.map((session) => session.id) + ); + const staleTabIds = sharedBrowserTabs + .filter((tab) => !liveSessionIds.has(extractSessionId(tab.id))) + .map((tab) => tab.id); + if (staleTabIds.length > 0) { + removeBrowserResourceTabs(staleTabIds); + } + }, [browserState.sessions, removeBrowserResourceTabs, sharedBrowserTabs]); + // ---------------------------------------------------------------- // Effect 1: sessions → tabs // ---------------------------------------------------------------- diff --git a/src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/WebViewportContent/index.tsx b/src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/WebViewportContent/index.tsx index 15dca8fef7..1f7b6f56f0 100644 --- a/src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/WebViewportContent/index.tsx +++ b/src/modules/WorkStation/Browser/Panels/BrowserMainPane/content/WebViewportContent/index.tsx @@ -91,13 +91,8 @@ export const WebViewport: React.FC = memo( respectModalBlocking = true, manageWebviews = true, }) => { - const { - sessions, - activeSessionId, - setActiveSession, - closeSession, - updateSession, - } = browserState; + const { sessions, activeSessionId, setActiveSession, updateSession } = + browserState; const { t } = useTranslation(); // Also drive browserTabsAtom so My Station Browser's reverse-sync effect @@ -160,14 +155,12 @@ export const WebViewport: React.FC = memo( [setActiveSession, switchBrowserTab] ); - // Handle tab close - extract session ID and close + // The tab command closes the authoritative session and every projection. const handleTabClose = useCallback( (tabId: string) => { - const sessionId = extractSessionId(tabId); closeBrowserTab(tabId); - closeSession(sessionId); }, - [closeBrowserTab, closeSession] + [closeBrowserTab] ); // Handle tab reorder (not supported for browser sessions yet) diff --git a/src/modules/WorkStation/Browser/SessionReplay/useBrowserReplayTabs.ts b/src/modules/WorkStation/Browser/SessionReplay/useBrowserReplayTabs.ts index 1b847355f1..0c3d9b1aa2 100644 --- a/src/modules/WorkStation/Browser/SessionReplay/useBrowserReplayTabs.ts +++ b/src/modules/WorkStation/Browser/SessionReplay/useBrowserReplayTabs.ts @@ -254,9 +254,8 @@ export function useBrowserReplayTabs({ const handleCloseMyTabsSession = useCallback( (sessionId: string) => { closeBrowserTab(createBrowserSessionTabId(sessionId)); - myTabsBrowserState.closeSession(sessionId); }, - [closeBrowserTab, myTabsBrowserState] + [closeBrowserTab] ); const handleOpenMyTabsHistoryUrl = useCallback( diff --git a/src/modules/WorkStation/TabContent/renderers/projectOrg.tsx b/src/modules/WorkStation/TabContent/renderers/projectOrg.tsx index 5052215a8a..db3b9de8ba 100644 --- a/src/modules/WorkStation/TabContent/renderers/projectOrg.tsx +++ b/src/modules/WorkStation/TabContent/renderers/projectOrg.tsx @@ -16,7 +16,7 @@ import { getTabDataString, } from "@src/modules/ProjectManager/ProjectManagerLayout/components/projectManagerRouterUtils"; import { useProjectHostContext } from "@src/modules/ProjectManager/ProjectManagerLayout/context/projectHostContext"; -import { closeProjectOrgWorkStationTabsAtom } from "@src/store/workstation/tabRegistry"; +import { invalidateProjectOrgPresentationAtom } from "@src/store/projectOrgPresentationLifecycleAtom"; import { STORY_ORG_SCOPE, normalizeProjectOrgSurfaceView, @@ -28,7 +28,9 @@ import type { UnifiedTabContentProps } from "../types"; const ProjectOrgTabRenderer: React.FC = memo( ({ tab }) => { const { t } = useTranslation("projects"); - const closeProjectOrgTabs = useSetAtom(closeProjectOrgWorkStationTabsAtom); + const closeProjectOrgTabs = useSetAtom( + invalidateProjectOrgPresentationAtom + ); const { onUpdateTabData, onSelectProject, diff --git a/src/modules/WorkStation/TabContent/renderers/projectOrgSettings.tsx b/src/modules/WorkStation/TabContent/renderers/projectOrgSettings.tsx index bc72e04d81..cac34655dd 100644 --- a/src/modules/WorkStation/TabContent/renderers/projectOrgSettings.tsx +++ b/src/modules/WorkStation/TabContent/renderers/projectOrgSettings.tsx @@ -14,7 +14,7 @@ import { getTabDataString, } from "@src/modules/ProjectManager/ProjectManagerLayout/components/projectManagerRouterUtils"; import { useProjectHostContext } from "@src/modules/ProjectManager/ProjectManagerLayout/context/projectHostContext"; -import { closeProjectOrgWorkStationTabsAtom } from "@src/store/workstation/tabRegistry"; +import { invalidateProjectOrgPresentationAtom } from "@src/store/projectOrgPresentationLifecycleAtom"; import { PROJECT_ORG_SURFACE_VIEW, STORY_ORG_SCOPE, @@ -26,7 +26,9 @@ import type { UnifiedTabContentProps } from "../types"; const ProjectOrgSettingsTabRenderer: React.FC = memo( ({ tab }) => { const { t } = useTranslation("projects"); - const closeProjectOrgTabs = useSetAtom(closeProjectOrgWorkStationTabsAtom); + const closeProjectOrgTabs = useSetAtom( + invalidateProjectOrgPresentationAtom + ); const { onUpdateTabData, onSelectProject, diff --git a/src/modules/index.tsx b/src/modules/index.tsx index 27de1d8f90..fa612ff094 100644 --- a/src/modules/index.tsx +++ b/src/modules/index.tsx @@ -26,7 +26,7 @@ import { HOST_DESKTOP, resolveHostDesktop, } from "@src/config/windowChromeRadius"; -import { BrowserProvider, TerminalProvider } from "@src/contexts/workstation"; +import { BrowserProvider } from "@src/contexts/workstation"; import { useAgentADEActions } from "@src/engines/SessionCore/hooks/useAgentADEActions"; import { useProjectDataChangedListener } from "@src/hooks/project"; import { useBackgroundImage } from "@src/hooks/theme/useBackgroundImage"; @@ -389,64 +389,62 @@ const AppShell = () => { const effectiveChatFocus = chatPanelMaximized; return ( - - - - - - - -
+ + + + + +
+ {!HOST_USES_NATIVE_BACKDROP && } + + {/* Main layout with sidebar, toolbar, content, and chat panel */} + } + floatingSidebar={} + showChatPanel + chatPosition={chatPosition} + chatPanelMaximized={effectiveChatFocus} + chatPanelMode={chatPanelMode} + sessionSidebarWidth={sessionSidebarWidth} > - {!HOST_USES_NATIVE_BACKDROP && } - - {/* Main layout with sidebar, toolbar, content, and chat panel */} - } - floatingSidebar={} - showChatPanel - chatPosition={chatPosition} - chatPanelMaximized={effectiveChatFocus} - chatPanelMode={chatPanelMode} - sessionSidebarWidth={sessionSidebarWidth} - > -
-
- }> - - -
+
+
+ }> + +
- - - - setTutorialsModalOpen(false)} - /> - setGeneralLayoutTourOpen(false)} - /> - setCodeEditorTourOpen(false)} - /> - -
- - +
+
+ + + setTutorialsModalOpen(false)} + /> + setGeneralLayoutTourOpen(false)} + /> + setCodeEditorTourOpen(false)} + /> + +
+ ); }; diff --git a/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.test.ts b/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.test.ts index 14963ad3be..389cb9ad58 100644 --- a/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.test.ts @@ -6,9 +6,6 @@ function cleanupSpies() { return { removeSession: vi.fn(), removeForkRelayEntry: vi.fn(), - disposeWorkstationWorkspace: vi.fn(), - clearPendingFileOpens: vi.fn(), - clearPendingCodeEditorTab: vi.fn(), evictEventStore: vi.fn().mockResolvedValue(undefined), }; } @@ -31,9 +28,6 @@ describe("applyRustSessionDeleteReceipt", () => { for (const cleanupFn of [ cleanup.removeSession, cleanup.removeForkRelayEntry, - cleanup.disposeWorkstationWorkspace, - cleanup.clearPendingFileOpens, - cleanup.clearPendingCodeEditorTab, ]) { expect(cleanupFn.mock.calls).toEqual([["worker-a"], ["worker-b"]]); } @@ -60,9 +54,6 @@ describe("applyRustSessionDeleteReceipt", () => { expect(deletedActiveSession).toBe(true); expect(cleanup.removeSession).not.toHaveBeenCalled(); expect(cleanup.removeForkRelayEntry).not.toHaveBeenCalled(); - expect(cleanup.disposeWorkstationWorkspace).not.toHaveBeenCalled(); - expect(cleanup.clearPendingFileOpens).not.toHaveBeenCalled(); - expect(cleanup.clearPendingCodeEditorTab).not.toHaveBeenCalled(); expect(cleanup.evictEventStore).not.toHaveBeenCalled(); }); diff --git a/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts b/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts index ab026cfbf1..6e80b39e64 100644 --- a/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts +++ b/src/scaffold/NavigationSidebar/connectors/rustSessionDeleteReceipt.ts @@ -3,9 +3,6 @@ import type { DeleteSessionReceipt } from "@src/api/tauri/agent"; interface RustSessionDeleteCleanup { removeSession: (sessionId: string) => void; removeForkRelayEntry: (sessionId: string) => void; - disposeWorkstationWorkspace: (sessionId: string) => void; - clearPendingFileOpens: (sessionId: string) => void; - clearPendingCodeEditorTab: (sessionId: string) => void; evictEventStore: (sessionId: string) => Promise; } @@ -36,9 +33,6 @@ export async function applyRustSessionDeleteReceipt({ if (deletedSessionId === requestedSessionId) continue; cleanup.removeSession(deletedSessionId); cleanup.removeForkRelayEntry(deletedSessionId); - cleanup.disposeWorkstationWorkspace(deletedSessionId); - cleanup.clearPendingFileOpens(deletedSessionId); - cleanup.clearPendingCodeEditorTab(deletedSessionId); } if (isAgentOrgRoot || deletedSessionIds.length > 1) { diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts index 34248d73a2..ae080f0076 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts @@ -54,11 +54,6 @@ import { CHAT_PANEL_SURFACE_KIND, chatPanelNavigateAtom, } from "@src/store/ui/chatPanelAtom"; -import { - clearPendingFileOpensForSession, - disposeWorkstationWorkspaceAtom, -} from "@src/store/workstation/tabs"; -import { clearPendingCodeEditorTabForSession } from "@src/store/workstation/tabs/pendingCodeEditorTab"; import { invokeTauri } from "@src/util/platform/tauri/init"; import { isCliSession, @@ -148,9 +143,6 @@ export function useWorkstationSidebarHandlers({ const setBenchmarkActiveBatchTaskId = useSetAtom( benchmarkActiveBatchTaskIdAtom ); - const disposeWorkstationWorkspace = useSetAtom( - disposeWorkstationWorkspaceAtom - ); const pagination = useAtomValue(sessionPaginationAtom); const cloudAuth = useAtomValue(org2CloudAuthAtom); const setCloudAuth = useSetAtom(org2CloudAuthAtom); @@ -229,9 +221,6 @@ export function useWorkstationSidebarHandlers({ cleanup: { removeSession, removeForkRelayEntry, - disposeWorkstationWorkspace, - clearPendingFileOpens: clearPendingFileOpensForSession, - clearPendingCodeEditorTab: clearPendingCodeEditorTabForSession, evictEventStore: (deletedSessionId) => eventStoreProxy .evictSession(deletedSessionId) @@ -246,9 +235,6 @@ export function useWorkstationSidebarHandlers({ } removeSession(sessionId); removeForkRelayEntry(sessionId); - disposeWorkstationWorkspace(sessionId); - clearPendingFileOpensForSession(sessionId); - clearPendingCodeEditorTabForSession(sessionId); if (sessionId === activeSessionId || deletedActiveRustSession) { goToNewSession(); @@ -263,7 +249,6 @@ export function useWorkstationSidebarHandlers({ cloudAuth, setCloudAuth, cloudOrgs, - disposeWorkstationWorkspace, goToNewSession, onCloseChatPanelTab, sessionMap, diff --git a/src/store/chatPanel/chatPanelSessionInvalidationAtom.test.ts b/src/store/chatPanel/chatPanelSessionInvalidationAtom.test.ts new file mode 100644 index 0000000000..0ac73c07c1 --- /dev/null +++ b/src/store/chatPanel/chatPanelSessionInvalidationAtom.test.ts @@ -0,0 +1,74 @@ +import { createStore } from "jotai/vanilla"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { invalidateSessionChatPanelTabsAtom } from "./chatPanelSessionInvalidationAtom"; +import { chatPanelTabsAtom } from "./chatPanelTabsState"; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("invalidateSessionChatPanelTabsAtom", () => { + it("removes every matching projection and activates the nearest survivor", () => { + const store = createStore(); + store.set(chatPanelTabsAtom, { + tabs: [ + { id: "launch", type: "start-page", title: "Launchpad" }, + { + id: "deleted-one", + type: "session", + title: "Deleted one", + sessionId: "deleted", + }, + { + id: "live", + type: "runtime", + title: "Runtime", + }, + { + id: "deleted-two", + type: "session", + title: "Deleted two", + sessionId: "deleted", + }, + ], + activeTabId: "deleted-two", + }); + + store.set(invalidateSessionChatPanelTabsAtom, "deleted"); + + expect(store.get(chatPanelTabsAtom)).toEqual({ + tabs: [ + { id: "launch", type: "start-page", title: "Launchpad" }, + { + id: "live", + type: "runtime", + title: "Runtime", + }, + ], + activeTabId: "live", + }); + }); + + it("restores Launchpad when the deleted session owned the final tab", () => { + const store = createStore(); + store.set(chatPanelTabsAtom, { + tabs: [ + { + id: "deleted", + type: "session", + title: "Deleted", + sessionId: "deleted", + }, + ], + activeTabId: "deleted", + }); + + store.set(invalidateSessionChatPanelTabsAtom, "deleted"); + + const state = store.get(chatPanelTabsAtom); + expect(state.tabs).toHaveLength(1); + expect(state.tabs[0].type).toBe("start-page"); + expect(state.activeTabId).toBe(state.tabs[0].id); + }); +}); diff --git a/src/store/chatPanel/chatPanelSessionInvalidationAtom.ts b/src/store/chatPanel/chatPanelSessionInvalidationAtom.ts new file mode 100644 index 0000000000..5b52395ef0 --- /dev/null +++ b/src/store/chatPanel/chatPanelSessionInvalidationAtom.ts @@ -0,0 +1,54 @@ +import { atom } from "jotai"; + +import { buildDefaultLaunchpadTab } from "./chatPanelTabFactories"; +import { + activateChatPanelTabAtom, + transitionChatPanelTabPresentationAtom, +} from "./chatPanelTabPresentationAtoms"; +import { chatPanelTabsAtom } from "./chatPanelTabsState"; + +/** Remove every ChatPanel projection backed by one deleted agent session. */ +export const invalidateSessionChatPanelTabsAtom = atom( + null, + (get, set, sessionId: string) => { + const state = get(chatPanelTabsAtom); + const removedTabIds = new Set( + state.tabs + .filter((tab) => tab.type === "session" && tab.sessionId === sessionId) + .map((tab) => tab.id) + ); + if (removedTabIds.size === 0) return; + + const previousTab = + state.tabs.find((tab) => tab.id === state.activeTabId) ?? null; + const activeWasRemoved = removedTabIds.has(state.activeTabId); + const survivingTabs = state.tabs.filter( + (tab) => !removedTabIds.has(tab.id) + ); + const nextTabs = + survivingTabs.length > 0 ? survivingTabs : [buildDefaultLaunchpadTab()]; + + if (!activeWasRemoved) { + set(chatPanelTabsAtom, { ...state, tabs: nextTabs }); + return; + } + + const previousIndex = Math.max( + 0, + state.tabs.findIndex((tab) => tab.id === state.activeTabId) + ); + const nextTab = + nextTabs[Math.min(Math.max(0, previousIndex - 1), nextTabs.length - 1)]; + set(transitionChatPanelTabPresentationAtom, { + previousTab, + nextTab, + }); + set(chatPanelTabsAtom, { + tabs: nextTabs, + activeTabId: nextTab.id, + }); + set(activateChatPanelTabAtom, nextTab.id); + } +); +invalidateSessionChatPanelTabsAtom.debugLabel = + "invalidateSessionChatPanelTabs"; diff --git a/src/store/chatPanel/chatPanelTabPresentationAtoms.ts b/src/store/chatPanel/chatPanelTabPresentationAtoms.ts index f07b0bc5e7..85315bd5ad 100644 --- a/src/store/chatPanel/chatPanelTabPresentationAtoms.ts +++ b/src/store/chatPanel/chatPanelTabPresentationAtoms.ts @@ -1,6 +1,6 @@ import { atom } from "jotai"; -import { sessionByIdAtom } from "@src/store/session/sessionAtom"; +import { sessionByIdAtom } from "@src/store/session/sessionAtom/atoms"; import { activeSessionIdAtom, jumpToSessionAtom, diff --git a/src/store/chatPanel/chatPanelTabsAtom.ts b/src/store/chatPanel/chatPanelTabsAtom.ts index 1f62b19289..161b15d8de 100644 --- a/src/store/chatPanel/chatPanelTabsAtom.ts +++ b/src/store/chatPanel/chatPanelTabsAtom.ts @@ -25,6 +25,7 @@ export { toggleChatPanelTabTuiModeAtom, type ReconcileDiscussionChannelTabsInput, } from "./chatPanelTabLifecycleAtoms"; +export { invalidateSessionChatPanelTabsAtom } from "./chatPanelSessionInvalidationAtom"; export { addChatPanelLaunchpadTabAtom, addChatPanelTerminalTabAtom, diff --git a/src/store/projectOrgPresentationLifecycleAtom.test.ts b/src/store/projectOrgPresentationLifecycleAtom.test.ts new file mode 100644 index 0000000000..cefe4725ab --- /dev/null +++ b/src/store/projectOrgPresentationLifecycleAtom.test.ts @@ -0,0 +1,113 @@ +import { createStore } from "jotai/vanilla"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { chatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsState"; +import { workstationTabsStateAtom } from "@src/store/workstation/tabs/atoms"; +import { emptyWorkstationTabsState } from "@src/store/workstation/tabs/storage"; + +import { invalidateProjectOrgPresentationAtom } from "./projectOrgPresentationLifecycleAtom"; + +beforeEach(() => { + localStorage.clear(); +}); + +describe("invalidateProjectOrgPresentationAtom", () => { + it("removes a deleted org from ChatPanel and every WorkStation workspace", () => { + const store = createStore(); + store.set(chatPanelTabsAtom, { + tabs: [ + { id: "launch", type: "start-page", title: "Launchpad" }, + { + id: "project-deleted", + type: "project", + title: "Deleted", + project: { + project: { + id: "project-deleted", + name: "Deleted", + status: "in_progress", + priority: "medium", + health: "on_track", + createdAt: "2026-08-05T00:00:00.000Z", + updatedAt: "2026-08-05T00:00:00.000Z", + }, + projectSlug: "deleted", + orgId: "org-deleted", + }, + }, + { + id: "project-live", + type: "project", + title: "Live", + project: { + project: { + id: "project-live", + name: "Live", + status: "in_progress", + priority: "medium", + health: "on_track", + createdAt: "2026-08-05T00:00:00.000Z", + updatedAt: "2026-08-05T00:00:00.000Z", + }, + projectSlug: "live", + orgId: "org-live", + }, + }, + ], + activeTabId: "launch", + }); + + const workstation = emptyWorkstationTabsState(); + workstation.shared.tabs = [ + { + id: "project-org:deleted", + type: "project-org", + title: "Deleted", + data: { orgId: "org-deleted" }, + }, + { + id: "project-org:live", + type: "project-org", + title: "Live", + data: { orgId: "org-live" }, + }, + ]; + workstation.sessionWorkspaces.A = { + tabs: [], + activeTabRef: { + partition: "shared", + tabId: "project-org:deleted", + }, + tabOrder: [ + { partition: "shared", tabId: "project-org:deleted" }, + { partition: "shared", tabId: "project-org:live" }, + ], + }; + workstation.sessionWorkspaces.B = { + tabs: [], + activeTabRef: { + partition: "shared", + tabId: "project-org:deleted", + }, + tabOrder: [ + { partition: "shared", tabId: "project-org:deleted" }, + { partition: "shared", tabId: "project-org:live" }, + ], + }; + store.set(workstationTabsStateAtom, workstation); + + store.set(invalidateProjectOrgPresentationAtom, "org-deleted"); + + expect(store.get(chatPanelTabsAtom).tabs.map((tab) => tab.id)).toEqual([ + "launch", + "project-live", + ]); + const next = store.get(workstationTabsStateAtom); + expect(next.shared.tabs.map((tab) => tab.id)).toEqual(["project-org:live"]); + for (const workspace of Object.values(next.sessionWorkspaces)) { + expect(workspace.tabOrder).toEqual([ + { partition: "shared", tabId: "project-org:live" }, + ]); + } + }); +}); diff --git a/src/store/projectOrgPresentationLifecycleAtom.ts b/src/store/projectOrgPresentationLifecycleAtom.ts new file mode 100644 index 0000000000..5bb535b2b0 --- /dev/null +++ b/src/store/projectOrgPresentationLifecycleAtom.ts @@ -0,0 +1,20 @@ +import { atom } from "jotai"; + +import { closeProjectOrgChatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabLifecycleAtoms"; +import { closeProjectOrgWorkStationTabsAtom } from "@src/store/workstation/tabRegistry/atoms"; + +/** Remove a deleted/revoked project org from every rendered tab surface. */ +export const invalidateProjectOrgPresentationAtom = atom( + null, + (_get, set, orgIdOrIds: string | readonly string[]) => { + const orgIds = + typeof orgIdOrIds === "string" ? [orgIdOrIds] : [...orgIdOrIds]; + if (orgIds.length === 0) return; + set(closeProjectOrgChatPanelTabsAtom, orgIds); + for (const orgId of orgIds) { + set(closeProjectOrgWorkStationTabsAtom, orgId); + } + } +); +invalidateProjectOrgPresentationAtom.debugLabel = + "invalidateProjectOrgPresentationAtom"; diff --git a/src/store/session/sessionAtom/__tests__/mutations.test.ts b/src/store/session/sessionAtom/__tests__/mutations.test.ts index 6eed640e01..9b16dda05b 100644 --- a/src/store/session/sessionAtom/__tests__/mutations.test.ts +++ b/src/store/session/sessionAtom/__tests__/mutations.test.ts @@ -235,6 +235,76 @@ describe("removeSession", () => { upsertSession(makeSession({ session_id: "sess-x" })); + const { chatPanelTabsAtom } = + await import("@src/store/chatPanel/chatPanelTabsState"); + const { workstationTabsStateAtom } = + await import("@src/store/workstation/tabs/atoms"); + const { emptyWorkstationTabsState } = + await import("@src/store/workstation/tabs/storage"); + store.set(chatPanelTabsAtom, { + tabs: [ + { id: "launch", type: "start-page", title: "Launchpad" }, + { + id: "session-x", + type: "session", + title: "Deleted", + sessionId: "sess-x", + }, + { + id: "session-live", + type: "session", + title: "Live", + sessionId: "sess-live", + }, + ], + activeTabId: "launch", + }); + const workstationState = emptyWorkstationTabsState(); + workstationState.shared.tabs = [ + { + id: "chat-session:sess-x", + type: "chat-session", + title: "Deleted", + data: { sessionId: "sess-x" }, + }, + { + id: "chat-session:sess-live", + type: "chat-session", + title: "Live", + data: { sessionId: "sess-live" }, + }, + ]; + workstationState.sessionWorkspaces["sess-x"] = { + tabs: [ + { + id: "canvas-preview:sess-x", + type: "canvas-preview", + title: "Canvas", + data: { sessionId: "sess-x" }, + }, + ], + activeTabRef: { + partition: "shared", + tabId: "chat-session:sess-x", + }, + tabOrder: [ + { partition: "shared", tabId: "chat-session:sess-x" }, + { partition: "workspace", tabId: "canvas-preview:sess-x" }, + ], + }; + workstationState.sessionWorkspaces.other = { + tabs: [], + activeTabRef: { + partition: "shared", + tabId: "chat-session:sess-live", + }, + tabOrder: [ + { partition: "shared", tabId: "chat-session:sess-x" }, + { partition: "shared", tabId: "chat-session:sess-live" }, + ], + }; + store.set(workstationTabsStateAtom, workstationState); + // Seed per-turn streaming-stop state that only the deletion path can free. streamHelpers.noteSessionStreamingTurn("sess-x", "turn-1"); streamHelpers.markSessionStreamingStopped("sess-x"); @@ -250,5 +320,17 @@ describe("removeSession", () => { expect(streamHelpers.isSessionStreamingStopped("sess-x", "turn-1")).toBe( false ); + expect(store.get(chatPanelTabsAtom).tabs.map((tab) => tab.id)).toEqual([ + "launch", + "session-live", + ]); + const nextWorkstation = store.get(workstationTabsStateAtom); + expect(nextWorkstation.sessionWorkspaces["sess-x"]).toBeUndefined(); + expect(nextWorkstation.shared.tabs.map((tab) => tab.id)).toEqual([ + "chat-session:sess-live", + ]); + expect(nextWorkstation.sessionWorkspaces.other.tabOrder).toEqual([ + { partition: "shared", tabId: "chat-session:sess-live" }, + ]); }); }); diff --git a/src/store/session/sessionAtom/mutations.ts b/src/store/session/sessionAtom/mutations.ts index 4cda96054b..e7885f0f44 100644 --- a/src/store/session/sessionAtom/mutations.ts +++ b/src/store/session/sessionAtom/mutations.ts @@ -34,8 +34,11 @@ */ import { disposeSessionStreamingState } from "@src/engines/SessionCore/sync/adapters/rustAgent/eventHandlers/streamHelpers"; import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; +import { invalidateSessionPresentationAtom } from "@src/store/session/sessionPresentationLifecycleAtom"; import { tuiModeAtom } from "@src/store/session/tuiModeAtom"; import { clearTodosForSessionAtom } from "@src/store/ui/todoAtom"; +import { clearPendingCodeEditorTabForSession } from "@src/store/workstation/tabs/pendingCodeEditorTab"; +import { clearPendingFileOpensForSession } from "@src/store/workstation/tabs/pendingFileOpens"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { @@ -175,6 +178,7 @@ export const applyImportedSessionTimestamps = ( */ export const removeSession = (sessionId: string) => { const store = getStore(); + store.set(invalidateSessionPresentationAtom, sessionId); store.set(sessionsAtom, (prev) => prev.filter((session) => session.session_id !== sessionId) ); @@ -187,6 +191,8 @@ export const removeSession = (sessionId: string) => { localStorage.removeItem(`orgii:tuiMode:${sessionId}`); } store.set(clearTodosForSessionAtom, sessionId); + clearPendingFileOpensForSession(sessionId); + clearPendingCodeEditorTabForSession(sessionId); removeGuestImportedSession(sessionId); // Rust-agent streaming-stop state (per-turn stop markers etc.). This single // chokepoint covers every removal path — sidebar delete, cloud remove, fork diff --git a/src/store/session/sessionPresentationLifecycleAtom.ts b/src/store/session/sessionPresentationLifecycleAtom.ts new file mode 100644 index 0000000000..05b046314a --- /dev/null +++ b/src/store/session/sessionPresentationLifecycleAtom.ts @@ -0,0 +1,39 @@ +import { atom } from "jotai"; + +import { clearSessionAtom } from "@src/engines/SessionCore/core/atoms/actions"; +import { invalidateSessionChatPanelTabsAtom } from "@src/store/chatPanel/chatPanelSessionInvalidationAtom"; +import { removeAgentSessionTerminalAtom } from "@src/store/workstation/codeEditor/terminal"; +import { + disposeWorkstationWorkspaceAtom, + removeSessionWorkstationTabsAtom, +} from "@src/store/workstation/tabs/atoms"; + +import { + activeSessionIdAtom, + workstationActiveSessionIdAtom, +} from "./viewAtom"; + +/** + * Invalidate every frontend projection of a deleted agent session in one + * synchronous Jotai transaction. The backend/session list remains owned by + * the caller that confirmed deletion. + */ +export const invalidateSessionPresentationAtom = atom( + null, + (get, set, sessionId: string) => { + set(invalidateSessionChatPanelTabsAtom, sessionId); + set(removeSessionWorkstationTabsAtom, sessionId); + set(disposeWorkstationWorkspaceAtom, sessionId); + set(removeAgentSessionTerminalAtom, sessionId); + + if (get(workstationActiveSessionIdAtom) === sessionId) { + set(workstationActiveSessionIdAtom, null); + } + if (get(activeSessionIdAtom) === sessionId) { + set(clearSessionAtom); + set(activeSessionIdAtom, null); + } + } +); +invalidateSessionPresentationAtom.debugLabel = + "invalidateSessionPresentationAtom"; diff --git a/src/store/workstation/browser/index.ts b/src/store/workstation/browser/index.ts index fac8901242..77d3a23f68 100644 --- a/src/store/workstation/browser/index.ts +++ b/src/store/workstation/browser/index.ts @@ -7,6 +7,7 @@ // Browser tabs (sessions, previews, token categories) export * from "./tabs"; +export * from "./sessionState"; // Design tokens export * from "./tokens"; diff --git a/src/store/workstation/browser/sessionState.test.ts b/src/store/workstation/browser/sessionState.test.ts new file mode 100644 index 0000000000..d88f231f13 --- /dev/null +++ b/src/store/workstation/browser/sessionState.test.ts @@ -0,0 +1,107 @@ +import { createStore } from "jotai/vanilla"; +import { beforeEach, describe, expect, it } from "vitest"; + +import type { BrowserSession } from "@src/types/ui/tabs"; + +import { + BROWSER_SESSIONS_STORAGE_KEY, + addBrowserSessionAtom, + browserSessionStateAtom, + closeBrowserSessionAtom, + closeBrowserSessionsAtom, + loadBrowserSessionState, + persistBrowserSessionState, + setActiveBrowserSessionAtom, + updateBrowserSessionAtom, +} from "./sessionState"; + +function session( + id: string, + url = `https://${id}.example.com` +): BrowserSession { + return { + id, + title: id, + url, + history: [url], + historyIndex: 0, + historyEntries: [{ url, title: id, visitedAt: 1 }], + isLoading: false, + error: null, + incognito: false, + }; +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe("browserSessionStateAtom", () => { + it("closes the authoritative active session and persists the fallback selection", () => { + const store = createStore(); + store.set(browserSessionStateAtom, { + sessions: [session("one"), session("two")], + activeSessionId: "two", + }); + + store.set(closeBrowserSessionAtom, "two"); + + expect(store.get(browserSessionStateAtom)).toEqual({ + sessions: [session("one")], + activeSessionId: "one", + }); + expect( + JSON.parse(localStorage.getItem(BROWSER_SESSIONS_STORAGE_KEY)!) + ).toEqual(store.get(browserSessionStateAtom)); + }); + + it("batch-closes sessions in one transition and clears durable storage", () => { + const store = createStore(); + const initial = { + sessions: [session("one"), session("two")], + activeSessionId: "one", + }; + store.set(browserSessionStateAtom, initial); + persistBrowserSessionState(initial); + + store.set(closeBrowserSessionsAtom, ["one", "two", "missing"]); + + expect(store.get(browserSessionStateAtom)).toEqual({ + sessions: [], + activeSessionId: "", + }); + expect(localStorage.getItem(BROWSER_SESSIONS_STORAGE_KEY)).toBeNull(); + }); + + it("keeps the active-session invariant across add, select, and update", () => { + const store = createStore(); + const firstId = store.set(addBrowserSessionAtom, { + url: "https://first.example.com/path", + }); + const secondId = store.set(addBrowserSessionAtom, {}); + + store.set(setActiveBrowserSessionAtom, firstId); + store.set(updateBrowserSessionAtom, { + sessionId: firstId, + updates: { title: "First", id: "cannot-replace-id" }, + }); + store.set(setActiveBrowserSessionAtom, "missing"); + + const state = store.get(browserSessionStateAtom); + expect(state.activeSessionId).toBe(firstId); + expect(state.sessions.map((item) => item.id)).toEqual([firstId, secondId]); + expect(state.sessions[0]?.title).toBe("First"); + }); + + it("hydrates a valid fallback when persisted active selection is stale", () => { + localStorage.setItem( + BROWSER_SESSIONS_STORAGE_KEY, + JSON.stringify({ + sessions: [session("one"), session("two")], + activeSessionId: "missing", + }) + ); + + expect(loadBrowserSessionState().activeSessionId).toBe("one"); + }); +}); diff --git a/src/store/workstation/browser/sessionState.ts b/src/store/workstation/browser/sessionState.ts new file mode 100644 index 0000000000..2595efa0e3 --- /dev/null +++ b/src/store/workstation/browser/sessionState.ts @@ -0,0 +1,192 @@ +import { atom } from "jotai"; +import { v4 as uuidv4 } from "uuid"; + +import type { BrowserSession } from "@src/types/ui/tabs"; + +import { NEW_PRIVATE_TAB_TITLE, NEW_TAB_TITLE } from "./sessionTitles"; + +export const BROWSER_SESSIONS_STORAGE_KEY = "browser-explorer-sessions"; + +export interface BrowserSessionState { + sessions: BrowserSession[]; + activeSessionId: string; +} + +const EMPTY_BROWSER_SESSION_STATE: BrowserSessionState = { + sessions: [], + activeSessionId: "", +}; + +function getTitleFromUrl(url: string): string { + if (!url) return NEW_TAB_TITLE; + try { + return new URL(url).hostname || NEW_TAB_TITLE; + } catch { + return NEW_TAB_TITLE; + } +} + +export function loadBrowserSessionState(): BrowserSessionState { + try { + const stored = localStorage.getItem(BROWSER_SESSIONS_STORAGE_KEY); + const parsed = stored ? JSON.parse(stored) : null; + const sessions = Array.isArray(parsed?.sessions) + ? (parsed.sessions as BrowserSession[]) + : []; + if (sessions.length === 0) return EMPTY_BROWSER_SESSION_STATE; + + const requestedActiveSessionId = + typeof parsed?.activeSessionId === "string" ? parsed.activeSessionId : ""; + const activeSessionId = sessions.some( + (session) => session.id === requestedActiveSessionId + ) + ? requestedActiveSessionId + : (sessions[0]?.id ?? ""); + return { sessions, activeSessionId }; + } catch { + return EMPTY_BROWSER_SESSION_STATE; + } +} + +export function persistBrowserSessionState(state: BrowserSessionState): void { + try { + if (state.sessions.length === 0) { + localStorage.removeItem(BROWSER_SESSIONS_STORAGE_KEY); + return; + } + localStorage.setItem(BROWSER_SESSIONS_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Browser sessions remain usable in memory when storage is unavailable. + } +} + +function commitBrowserSessionState( + set: ( + atom: typeof browserSessionStateAtom, + state: BrowserSessionState + ) => void, + state: BrowserSessionState +): void { + set(browserSessionStateAtom, state); + persistBrowserSessionState(state); +} + +/** + * Authoritative browser-session state. WorkStation browser tabs are a derived + * presentation of this resource list and must not outlive a removed session. + */ +export const browserSessionStateAtom = atom( + loadBrowserSessionState() +); +browserSessionStateAtom.debugLabel = "browserSessionStateAtom"; + +export const addBrowserSessionAtom = atom( + null, + (_get, set, request: { url?: string; incognito?: boolean } = {}): string => { + const url = request.url ?? ""; + const incognito = request.incognito ?? false; + const sessionId = uuidv4(); + const session: BrowserSession = { + id: sessionId, + title: url + ? getTitleFromUrl(url) + : incognito + ? NEW_PRIVATE_TAB_TITLE + : NEW_TAB_TITLE, + url, + history: url ? [url] : [], + historyIndex: url ? 0 : -1, + historyEntries: url + ? [{ url, title: getTitleFromUrl(url), visitedAt: Date.now() }] + : [], + isLoading: false, + error: null, + incognito, + }; + set(browserSessionStateAtom, (previous) => { + const next = { + sessions: [...previous.sessions, session], + activeSessionId: sessionId, + }; + persistBrowserSessionState(next); + return next; + }); + return sessionId; + } +); +addBrowserSessionAtom.debugLabel = "addBrowserSessionAtom"; + +export const setActiveBrowserSessionAtom = atom( + null, + (get, set, sessionId: string) => { + const previous = get(browserSessionStateAtom); + if ( + previous.activeSessionId === sessionId || + !previous.sessions.some((session) => session.id === sessionId) + ) { + return; + } + commitBrowserSessionState(set, { + ...previous, + activeSessionId: sessionId, + }); + } +); +setActiveBrowserSessionAtom.debugLabel = "setActiveBrowserSessionAtom"; + +export const closeBrowserSessionsAtom = atom( + null, + (get, set, sessionIds: readonly string[]) => { + const previous = get(browserSessionStateAtom); + const removedIds = new Set(sessionIds); + if (!previous.sessions.some((session) => removedIds.has(session.id))) { + return; + } + + const sessions = previous.sessions.filter( + (session) => !removedIds.has(session.id) + ); + const activeSessionId = removedIds.has(previous.activeSessionId) + ? (sessions[0]?.id ?? "") + : previous.activeSessionId; + commitBrowserSessionState(set, { sessions, activeSessionId }); + } +); +closeBrowserSessionsAtom.debugLabel = "closeBrowserSessionsAtom"; + +export const closeBrowserSessionAtom = atom( + null, + (_get, set, sessionId: string) => { + set(closeBrowserSessionsAtom, [sessionId]); + } +); +closeBrowserSessionAtom.debugLabel = "closeBrowserSessionAtom"; + +export const updateBrowserSessionAtom = atom( + null, + ( + get, + set, + request: { sessionId: string; updates: Partial } + ) => { + const previous = get(browserSessionStateAtom); + const targetIndex = previous.sessions.findIndex( + (session) => session.id === request.sessionId + ); + if (targetIndex === -1) return; + + const sessions = [...previous.sessions]; + sessions[targetIndex] = { + ...sessions[targetIndex], + ...request.updates, + id: request.sessionId, + }; + commitBrowserSessionState(set, { ...previous, sessions }); + } +); +updateBrowserSessionAtom.debugLabel = "updateBrowserSessionAtom"; + +export const forceSaveBrowserSessionsAtom = atom(null, (get) => { + persistBrowserSessionState(get(browserSessionStateAtom)); +}); +forceSaveBrowserSessionsAtom.debugLabel = "forceSaveBrowserSessionsAtom"; diff --git a/src/store/workstation/browser/sessionTitles.ts b/src/store/workstation/browser/sessionTitles.ts new file mode 100644 index 0000000000..6a66f24d7e --- /dev/null +++ b/src/store/workstation/browser/sessionTitles.ts @@ -0,0 +1,3 @@ +/** Stable browser-session placeholder values persisted on disk. */ +export const NEW_TAB_TITLE = "New Tab"; +export const NEW_PRIVATE_TAB_TITLE = "New Private Tab"; diff --git a/src/store/workstation/browser/tabs/__tests__/sharedWorkspaceIntegration.test.ts b/src/store/workstation/browser/tabs/__tests__/sharedWorkspaceIntegration.test.ts index cc596632f5..523b47a650 100644 --- a/src/store/workstation/browser/tabs/__tests__/sharedWorkspaceIntegration.test.ts +++ b/src/store/workstation/browser/tabs/__tests__/sharedWorkspaceIntegration.test.ts @@ -2,12 +2,14 @@ import { createStore } from "jotai/vanilla"; import { beforeEach, describe, expect, it } from "vitest"; import { workstationActiveSessionIdAtom } from "@src/store/session/viewAtom"; +import { browserSessionStateAtom } from "@src/store/workstation/browser/sessionState"; import { workstationTabsStateAtom } from "@src/store/workstation/tabs/atoms"; import { emptyWorkstationTabsState } from "@src/store/workstation/tabs/storage"; import type { WorkStationTab, WorkstationWorkspaceState, } from "@src/store/workstation/tabs/types"; +import type { BrowserSession } from "@src/types/ui/tabs"; import { browserTabsAtom, @@ -19,6 +21,21 @@ import { sharedBrowserTabsAtom, } from "../index"; +function browserSession(id: string): BrowserSession { + const url = `https://${id}.example.com`; + return { + id, + title: id, + url, + history: [url], + historyIndex: 0, + historyEntries: [{ url, title: id, visitedAt: 1 }], + isLoading: false, + error: null, + incognito: false, + }; +} + function localWorkspace(tabId: string): WorkstationWorkspaceState { const tab: WorkStationTab = { id: tabId, @@ -192,40 +209,78 @@ describe("browserTabsAtom shared-resource integration", () => { }; store.set(workstationTabsStateAtom, state); store.set(workstationActiveSessionIdAtom, "A"); + store.set(browserSessionStateAtom, { + sessions: [browserSession("browser-1")], + activeSessionId: "browser-1", + }); store.set(closeBrowserTabAtom, browserTab.id); expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); expect(store.get(sharedBrowserTabsAtom)).toEqual([]); + expect(store.get(browserSessionStateAtom)).toEqual({ + sessions: [], + activeSessionId: "", + }); }); - it("removes a browser resource globally only through the explicit owner action", () => { + it("batch close commands remove authoritative sessions and all workspace refs", () => { const store = createStore(); const state = emptyWorkstationTabsState(); - const browserTab = createBrowserSessionTab("browser-1", "Example"); - state.shared.tabs = [browserTab]; - state.sessionWorkspaces.A = localWorkspace("file:/a.ts"); + const one = createBrowserSessionTab("one", "One"); + const two = createBrowserSessionTab("two", "Two"); + state.shared.tabs = [one, two]; + state.sessionWorkspaces.A = { + tabs: [], + activeTabRef: { partition: "shared", tabId: two.id }, + tabOrder: [ + { partition: "shared", tabId: one.id }, + { partition: "shared", tabId: two.id }, + ], + }; + state.sessionWorkspaces.B = { + tabs: [], + activeTabRef: { partition: "shared", tabId: two.id }, + tabOrder: [ + { partition: "shared", tabId: one.id }, + { partition: "shared", tabId: two.id }, + ], + }; store.set(workstationTabsStateAtom, state); + store.set(workstationActiveSessionIdAtom, "A"); + store.set(browserSessionStateAtom, { + sessions: [browserSession("one"), browserSession("two")], + activeSessionId: "two", + }); - store.set(removeBrowserResourceTabAtom, browserTab.id); + store.set(closeOtherBrowserTabsAtom, one.id); + + expect( + store.get(browserSessionStateAtom).sessions.map(({ id }) => id) + ).toEqual(["one"]); + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([one]); + for (const current of Object.values( + store.get(workstationTabsStateAtom).sessionWorkspaces + )) { + expect(current.tabOrder.map((ref) => ref.tabId)).toEqual([one.id]); + } + // A second bulk command is idempotent and leaves the kept session alive. + store.set(closeSavedBrowserTabsAtom); + expect(store.get(browserSessionStateAtom).sessions).toEqual([]); expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); }); - it("tears down omitted resources through browser-owned bulk close actions", () => { + it("removes a browser resource globally only through the explicit owner action", () => { const store = createStore(); - const browserA = createBrowserSessionTab("browser-1", "One"); - const browserB = createBrowserSessionTab("browser-2", "Two"); - const browserC = createBrowserSessionTab("browser-3", "Three"); - store.set(browserTabsAtom, { - tabs: [browserA, browserB, browserC], - activeTabId: browserB.id, - }); + const state = emptyWorkstationTabsState(); + const browserTab = createBrowserSessionTab("browser-1", "Example"); + state.shared.tabs = [browserTab]; + state.sessionWorkspaces.A = localWorkspace("file:/a.ts"); + store.set(workstationTabsStateAtom, state); - store.set(closeOtherBrowserTabsAtom, browserA.id); - expect(store.get(sharedBrowserTabsAtom)).toEqual([browserA]); + store.set(removeBrowserResourceTabAtom, browserTab.id); - store.set(closeSavedBrowserTabsAtom); - expect(store.get(sharedBrowserTabsAtom)).toEqual([]); + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); }); }); diff --git a/src/store/workstation/browser/tabs/index.ts b/src/store/workstation/browser/tabs/index.ts index 45347cc522..67d6444b5d 100644 --- a/src/store/workstation/browser/tabs/index.ts +++ b/src/store/workstation/browser/tabs/index.ts @@ -13,9 +13,10 @@ * it never removes browser resources or makes a workspace switch look like a * browser-tab close. */ -import { atom } from "jotai"; +import { type Setter, atom } from "jotai"; import { getSiteNameFromUrl } from "@src/store/ui/navigationSidebarTabsAtom"; +import { closeBrowserSessionsAtom } from "@src/store/workstation/browser/sessionState"; import type { PanelState } from "@src/store/workstation/tabs"; import { removeSharedWorkstationTabAtom, @@ -26,6 +27,7 @@ import { import { closeOtherTabs as closeOtherTabsMutation, closeSavedTabs as closeSavedTabsMutation, + closeTab as closeTabMutation, openTab as openTabMutation, reorderTabs as reorderTabsMutation, switchTab as switchTabMutation, @@ -35,6 +37,10 @@ import type { WorkStationTabType, } from "@src/store/workstation/tabs/types"; +import { NEW_PRIVATE_TAB_TITLE, NEW_TAB_TITLE } from "../sessionTitles"; + +export { NEW_PRIVATE_TAB_TITLE, NEW_TAB_TITLE } from "../sessionTitles"; + // ============================================ // Types // ============================================ @@ -72,16 +78,14 @@ export function extractSessionId(tabId: string): string { * "no real title yet" by every display site so URL-derived fallbacks * win, and translated to the user's locale when shown as-is. * - * IMPORTANT: these strings must stay in English on disk — they're the + * IMPORTANT: the exported values must stay in English on disk — they're the * wire / localStorage representation. The i18n layer maps them to the * locale-specific label at render time * (`common:controlTower.sidebar.newTab` / * `common:controlTower.sidebar.newPrivateTab`). Changing them here - * would invalidate every persisted browser session. + * would invalidate every persisted browser session. Their definitions live + * in `sessionTitles.ts` so the session owner does not import this tab module. */ -export const NEW_TAB_TITLE = "New Tab"; -export const NEW_PRIVATE_TAB_TITLE = "New Private Tab"; - /** True when `title` is one of the placeholder sentinels above. */ export function isPlaceholderBrowserSessionTitle( title: string | undefined @@ -335,13 +339,45 @@ export const removeBrowserResourceTabAtom = atom( set(removeSharedWorkstationTabAtom, tabId); } ); +removeBrowserResourceTabAtom.debugLabel = "removeBrowserResourceTabAtom"; + +export const removeBrowserResourceTabsAtom = atom( + null, + (_get, set, tabIds: readonly string[]) => { + set(removeSharedWorkstationTabsAtom, tabIds); + } +); +removeBrowserResourceTabsAtom.debugLabel = "removeBrowserResourceTabsAtom"; + +function closeBrowserResources( + set: Setter, + tabs: readonly WorkStationTab[] +): void { + if (tabs.length === 0) return; + const sessionIds = tabs.flatMap((tab) => { + const sessionId = tab.data.sessionId; + return typeof sessionId === "string" && sessionId ? [sessionId] : []; + }); + if (sessionIds.length > 0) set(closeBrowserSessionsAtom, sessionIds); + set( + removeBrowserResourceTabsAtom, + tabs.map((tab) => tab.id) + ); +} /** - * Close a browser tab in the current workspace. The live BrowserContext owner - * observes the disappearance and then removes the global resource explicitly. + * Close a browser resource at its authoritative session owner and remove every + * WorkStation projection. The reverse sync remains only as a crash fallback. */ -export const closeBrowserTabAtom = atom(null, (_get, set, tabId: string) => { - set(removeBrowserResourceTabAtom, tabId); +export const closeBrowserTabAtom = atom(null, (get, set, tabId: string) => { + const visibleState = get(browserTabsAtom); + if (visibleState.tabs.some((tab) => tab.id === tabId)) { + set(browserTabsAtom, closeTabMutation(visibleState, tabId)); + } + const tab = get(workstationTabsStateAtom).shared.tabs.find( + (candidate) => candidate.id === tabId && isBrowserFamilyTab(candidate) + ); + if (tab) closeBrowserResources(set, [tab]); }); /** @@ -375,11 +411,10 @@ export const closeOtherBrowserTabsAtom = atom( (get, set, tabId: string) => { const state = get(browserTabsAtom); const next = closeOtherTabsMutation(state, tabId); - const nextIds = new Set(next.tabs.map((tab) => tab.id)); - set( - removeSharedWorkstationTabsAtom, - state.tabs.filter((tab) => !nextIds.has(tab.id)).map((tab) => tab.id) - ); + const liveIds = new Set(next.tabs.map((tab) => tab.id)); + const removed = state.tabs.filter((tab) => !liveIds.has(tab.id)); + set(browserTabsAtom, next); + closeBrowserResources(set, removed); } ); @@ -389,11 +424,10 @@ export const closeOtherBrowserTabsAtom = atom( export const closeSavedBrowserTabsAtom = atom(null, (get, set) => { const state = get(browserTabsAtom); const next = closeSavedTabsMutation(state); - const nextIds = new Set(next.tabs.map((tab) => tab.id)); - set( - removeSharedWorkstationTabsAtom, - state.tabs.filter((tab) => !nextIds.has(tab.id)).map((tab) => tab.id) - ); + const liveIds = new Set(next.tabs.map((tab) => tab.id)); + const removed = state.tabs.filter((tab) => !liveIds.has(tab.id)); + set(browserTabsAtom, next); + closeBrowserResources(set, removed); }); /** diff --git a/src/store/workstation/codeEditor/terminal/__tests__/terminalAtoms.test.ts b/src/store/workstation/codeEditor/terminal/__tests__/terminalAtoms.test.ts index cc4303606d..a8198f0f88 100644 --- a/src/store/workstation/codeEditor/terminal/__tests__/terminalAtoms.test.ts +++ b/src/store/workstation/codeEditor/terminal/__tests__/terminalAtoms.test.ts @@ -7,6 +7,7 @@ import { createStore } from "jotai/vanilla"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invokeTauri, isTauriReady } from "@src/util/platform/tauri/init"; import { notifyTerminalCreationCooldown, tryBeginTerminalCreation, @@ -14,17 +15,20 @@ import { import { activeTerminalIdAtom, + closeAllTerminalSessionsAtom, closeTerminalSessionAtom, createAgentSessionTerminalAtom, editorActiveTerminalSessionAtom, editorAddTerminalSessionAtom, initializedTerminalIdsAtom, markTerminalInitializedAtom, + markTerminalSurfaceOpenedAtom, removeAgentSessionTerminalAtom, renameTerminalSessionAtom, setActiveTerminalAtom, terminalSessionCountAtom, terminalSessionsAtom, + terminalSurfaceLifecycleAtom, updateTerminalSessionInfoAtom, } from "../index"; @@ -51,6 +55,7 @@ describe("terminal atoms", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(isTauriReady).mockReturnValue(false); vi.mocked(tryBeginTerminalCreation).mockReturnValue(true); store = createStore(); // Initialize with a clean state @@ -59,6 +64,10 @@ describe("terminal atoms", () => { ]); store.set(activeTerminalIdAtom, "initial-1"); store.set(initializedTerminalIdsAtom, new Set(["initial-1"])); + store.set(terminalSurfaceLifecycleAtom, { + generation: 0, + phase: "open", + }); }); describe("editorAddTerminalSessionAtom", () => { @@ -159,6 +168,78 @@ describe("terminal atoms", () => { }); }); + describe("WorkStation Terminal surface teardown", () => { + it("rotates WorkStation sessions immediately and preserves other terminal owners", async () => { + store.set(terminalSessionsAtom, [ + { id: "workstation-old", name: "Old", isActive: true }, + { id: "chatpanel-live", name: "Chat", isActive: false }, + { id: "agent-pty-live", name: "Agent PTY", isActive: false }, + ]); + store.set(activeTerminalIdAtom, "workstation-old"); + store.set( + initializedTerminalIdsAtom, + new Set(["workstation-old", "chatpanel-live", "agent-pty-live"]) + ); + + await store.set(closeAllTerminalSessionsAtom); + + const sessions = store.get(terminalSessionsAtom); + expect(sessions.map(({ id }) => id)).toContain("chatpanel-live"); + expect(sessions.map(({ id }) => id)).toContain("agent-pty-live"); + expect(sessions.map(({ id }) => id)).not.toContain("workstation-old"); + const fresh = sessions.find( + (session) => + session.id !== "chatpanel-live" && session.id !== "agent-pty-live" + ); + expect(fresh?.id).toMatch(/^terminal-/); + expect(store.get(activeTerminalIdAtom)).toBe(fresh?.id); + expect(store.get(terminalSurfaceLifecycleAtom).phase).toBe("closed"); + + const persisted = JSON.parse( + localStorage.getItem("work_station_terminal_state")! + ); + expect(persisted.sessions.map(({ id }: { id: string }) => id)).toEqual([ + fresh?.id, + ]); + }); + + it("discards a stale teardown completion after rapid reopen", async () => { + let resolveClose!: () => void; + const closeGate = new Promise((resolve) => { + resolveClose = resolve; + }); + vi.mocked(isTauriReady).mockReturnValue(true); + vi.mocked(invokeTauri).mockImplementation(() => closeGate as never); + store.set(terminalSessionsAtom, [ + { id: "workstation-old", name: "Old", isActive: true }, + ]); + store.set(activeTerminalIdAtom, "workstation-old"); + + const closing = store.set(closeAllTerminalSessionsAtom); + const freshId = store.get(activeTerminalIdAtom); + const closingGeneration = store.get( + terminalSurfaceLifecycleAtom + ).generation; + expect(store.get(terminalSurfaceLifecycleAtom).phase).toBe("closing"); + expect(freshId).not.toBe("workstation-old"); + + store.set(markTerminalSurfaceOpenedAtom); + expect(store.get(terminalSurfaceLifecycleAtom)).toEqual({ + generation: closingGeneration + 1, + phase: "open", + }); + + resolveClose(); + await closing; + + expect(store.get(terminalSurfaceLifecycleAtom).phase).toBe("open"); + expect(store.get(activeTerminalIdAtom)).toBe(freshId); + expect(store.get(terminalSessionsAtom).map(({ id }) => id)).toContain( + freshId + ); + }); + }); + describe("setActiveTerminalAtom", () => { it("switches the active session", () => { const secondId = store.set(editorAddTerminalSessionAtom, undefined); diff --git a/src/store/workstation/codeEditor/terminal/index.ts b/src/store/workstation/codeEditor/terminal/index.ts index fb90125d39..2f80272901 100644 --- a/src/store/workstation/codeEditor/terminal/index.ts +++ b/src/store/workstation/codeEditor/terminal/index.ts @@ -130,6 +130,23 @@ function getDefaultState(): { }; } +function isWorkstationTerminalSession(session: TerminalSession): boolean { + return !isAgentPtySessionId(session.id) && !isChatPanelTerminalId(session.id); +} + +function createFreshWorkstationTerminal( + get: Getter, + existingNames: readonly string[] = [] +): TerminalSession { + const defaultBase = defaultTerminalLabelBaseFromSettings(get(settingsAtom)); + return { + id: `terminal-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + name: generateUniqueLabelFromBase(defaultBase, [...existingNames]), + isActive: true, + isDefaultSession: true, + }; +} + // Initialize from persisted or default const persisted = loadPersistedState(); const initialState = persisted @@ -160,6 +177,18 @@ export const initializedTerminalIdsAtom = atom>( ); initializedTerminalIdsAtom.debugLabel = "initializedTerminalIdsAtom"; +export interface TerminalSurfaceLifecycle { + generation: number; + phase: "open" | "closing" | "closed"; +} + +/** Async teardown episode for the shared WorkStation Terminal surface. */ +export const terminalSurfaceLifecycleAtom = atom({ + generation: 0, + phase: "open", +}); +terminalSurfaceLifecycleAtom.debugLabel = "terminalSurfaceLifecycleAtom"; + // ============================================ // Derived Atoms (computed, no extra storage) // ============================================ @@ -190,9 +219,19 @@ export const isTerminalInitializedAtom = atom((get) => { /** Persist state to localStorage on changes */ export const terminalPersistAtom = atom(null, (get) => { - const sessions = get(terminalSessionsAtom); - const activeSessionId = get(activeTerminalIdAtom); - const initializedSessionIds = get(initializedTerminalIdsAtom); + const sessions = get(terminalSessionsAtom).filter( + isWorkstationTerminalSession + ); + const requestedActiveSessionId = get(activeTerminalIdAtom); + const activeSessionId = sessions.some( + (session) => session.id === requestedActiveSessionId + ) + ? requestedActiveSessionId + : (sessions[0]?.id ?? ""); + const liveIds = new Set(sessions.map((session) => session.id)); + const initializedSessionIds = [...get(initializedTerminalIdsAtom)].filter( + (sessionId) => liveIds.has(sessionId) + ); try { localStorage.setItem( @@ -200,7 +239,7 @@ export const terminalPersistAtom = atom(null, (get) => { JSON.stringify({ sessions, activeSessionId, - initializedSessionIds: [...initializedSessionIds], + initializedSessionIds, }) ); } catch { @@ -220,38 +259,37 @@ function removeTerminalSessionLocalOnly( ): void { const sessions = get(terminalSessionsAtom); const activeId = get(activeTerminalIdAtom); - - if (sessions.length === 1) { - const newId = Date.now().toString(); - const defaultBase = defaultTerminalLabelBaseFromSettings(get(settingsAtom)); - const newSession: TerminalSession = { - id: newId, - name: generateUniqueLabelFromBase(defaultBase, []), - isActive: true, - isDefaultSession: true, - }; - set(terminalSessionsAtom, [newSession]); - set(activeTerminalIdAtom, newId); - set(initializedTerminalIdsAtom, new Set([newId])); - set(terminalPersistAtom); - return; + const target = sessions.find((session) => session.id === sessionId); + if (!target) return; + + let filtered = sessions.filter((session) => session.id !== sessionId); + if ( + isWorkstationTerminalSession(target) && + !filtered.some(isWorkstationTerminalSession) + ) { + filtered = [ + ...filtered, + createFreshWorkstationTerminal( + get, + filtered.map((session) => session.name) + ), + ]; } - const filtered = sessions.filter((session) => session.id !== sessionId); - - if (sessionId === activeId && filtered.length > 0) { - const newActiveId = filtered[0].id; - set( - terminalSessionsAtom, - filtered.map((session) => ({ - ...session, - isActive: session.id === newActiveId, - })) - ); - set(activeTerminalIdAtom, newActiveId); - } else { - set(terminalSessionsAtom, filtered); - } + const requestedActiveId = sessionId === activeId ? undefined : activeId; + const nextActiveId = + filtered.find((session) => session.id === requestedActiveId)?.id ?? + filtered.find(isWorkstationTerminalSession)?.id ?? + filtered[0]?.id ?? + ""; + set( + terminalSessionsAtom, + filtered.map((session) => ({ + ...session, + isActive: session.id === nextActiveId, + })) + ); + set(activeTerminalIdAtom, nextActiveId); set(initializedTerminalIdsAtom, (prev) => { const next = new Set(prev); @@ -269,6 +307,7 @@ function removeTerminalSessionLocalOnly( export const editorAddTerminalSessionAtom = atom( null, (get, set, options?: AddSessionOptions) => { + set(markTerminalSurfaceOpenedAtom); if (!options?.bypassCreationCooldown && !tryBeginTerminalCreation()) { notifyTerminalCreationCooldown(); return get(activeTerminalIdAtom); @@ -310,22 +349,73 @@ editorAddTerminalSessionAtom.debugLabel = "editorAddTerminalSessionAtom"; export const closeTerminalSessionAtom = atom( null, async (get, set, sessionId: string) => { - await killPty(sessionId); removeTerminalSessionLocalOnly(get, set, sessionId); + await killPty(sessionId); } ); closeTerminalSessionAtom.debugLabel = "closeTerminalSessionAtom"; /** - * Kill every terminal session's PTY. Fired when the Terminal tab is closed — - * closing the tab tears down all running shells (dev servers, agents, …). The - * store always keeps one session, so this leaves a single fresh default that - * only spins up a PTY when the Terminal tab is reopened. + * Invalidate an in-flight close when the WorkStation Terminal surface opens. + */ +export const markTerminalSurfaceOpenedAtom = atom(null, (get, set) => { + const current = get(terminalSurfaceLifecycleAtom); + if (current.phase === "open") return; + set(terminalSurfaceLifecycleAtom, { + generation: current.generation + 1, + phase: "open", + }); +}); +markTerminalSurfaceOpenedAtom.debugLabel = "markTerminalSurfaceOpenedAtom"; + +/** + * Close WorkStation-owned terminal sessions when the shared Terminal tab is + * closed. ChatPanel and backend agent PTYs are separate resources and survive. + * Local state rotates to a fresh ID before IPC, so a rapid reopen can never + * attach to an old PTY that is still being torn down. */ export const closeAllTerminalSessionsAtom = atom(null, async (get, set) => { - const ids = get(terminalSessionsAtom).map((session) => session.id); - for (const id of ids) { - await set(closeTerminalSessionAtom, id); + const targets = get(terminalSessionsAtom).filter( + isWorkstationTerminalSession + ); + if (targets.length === 0) return; + + const previousLifecycle = get(terminalSurfaceLifecycleAtom); + const generation = previousLifecycle.generation + 1; + set(terminalSurfaceLifecycleAtom, { generation, phase: "closing" }); + + const removedIds = new Set(targets.map((session) => session.id)); + const survivors = get(terminalSessionsAtom).filter( + (session) => !removedIds.has(session.id) + ); + const fresh = createFreshWorkstationTerminal( + get, + survivors.map((session) => session.name) + ); + set(terminalSessionsAtom, [ + ...survivors.map((session) => ({ ...session, isActive: false })), + fresh, + ]); + set(activeTerminalIdAtom, fresh.id); + set(initializedTerminalIdsAtom, (previous) => { + const next = new Set(previous); + for (const sessionId of removedIds) next.delete(sessionId); + return next; + }); + set(terminalPersistAtom); + + await Promise.all( + targets + .filter((session) => !session.readOnly) + .map((session) => killPty(session.id)) + ); + + const currentLifecycle = get(terminalSurfaceLifecycleAtom); + if ( + currentLifecycle.generation === generation && + currentLifecycle.phase === "closing" + ) { + set(terminalSurfaceLifecycleAtom, { generation, phase: "closed" }); } }); closeAllTerminalSessionsAtom.debugLabel = "closeAllTerminalSessionsAtom"; @@ -421,6 +511,7 @@ export const removeAgentSessionTerminalAtom = atom( (get, set, agentSessionId: string) => { const tabId = `agent-session-${agentSessionId}`; const sessions = get(terminalSessionsAtom); + if (!sessions.some((session) => session.id === tabId)) return; const activeId = get(activeTerminalIdAtom); const filtered = sessions.filter((session) => session.id !== tabId); diff --git a/src/store/workstation/tabRegistry/atoms.test.ts b/src/store/workstation/tabRegistry/atoms.test.ts index 217c265f72..ae9dd529f8 100644 --- a/src/store/workstation/tabRegistry/atoms.test.ts +++ b/src/store/workstation/tabRegistry/atoms.test.ts @@ -1,24 +1,36 @@ import { createStore } from "jotai/vanilla"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { workstationActiveSessionIdAtom } from "@src/store/session/viewAtom"; +import { + BROWSER_SESSIONS_STORAGE_KEY, + browserSessionStateAtom, + persistBrowserSessionState, +} from "@src/store/workstation/browser/sessionState"; import { createBrowserSessionTab } from "@src/store/workstation/browser/tabs"; import { - type WorkStationTab, + activeTerminalIdAtom, + initializedTerminalIdsAtom, + terminalSessionsAtom, + terminalSurfaceLifecycleAtom, +} from "@src/store/workstation/codeEditor/terminal"; +import { workstationLayoutAtom, workstationTabsStateAtom, } from "@src/store/workstation/tabs"; -import { - WORKSTATION_V3_SHARED_KEY, - emptyWorkstationTabsState, -} from "@src/store/workstation/tabs/storage"; +import type { WorkStationTab } from "@src/store/workstation/tabs"; +import { emptyWorkstationTabsState } from "@src/store/workstation/tabs/storage"; +import type { WorkstationWorkspaceState } from "@src/store/workstation/tabs/types"; +import type { BrowserSession } from "@src/types/ui/tabs"; +import { createInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { - closeActiveWorkStationTabAtom, + closeAllWorkstationTabsAtom, closeOtherTabsAtom, closeProjectOrgWorkStationTabsAtom, closeSavedTabsAtom, closeTabAtom, + tabRegistryAtom, } from "./atoms"; function tab(id: string, orgId?: string): WorkStationTab { @@ -30,9 +42,116 @@ function tab(id: string, orgId?: string): WorkStationTab { }; } +function fileTab(id: string, hasUnsavedChanges = false): WorkStationTab { + return { + id, + type: "file", + title: id, + data: { filePath: id.replace("file:", "") }, + hasUnsavedChanges, + }; +} + +function browserSession(id: string): BrowserSession { + const url = `https://${id}.example.com`; + return { + id, + title: id, + url, + history: [url], + historyIndex: 0, + historyEntries: [{ url, title: id, visitedAt: 1 }], + isLoading: false, + error: null, + incognito: false, + }; +} + +function workspaceWithRefs( + localTabs: WorkStationTab[], + sharedTabIds: string[], + activeTabId: string +): WorkstationWorkspaceState { + const localIds = new Set(localTabs.map((item) => item.id)); + return { + tabs: localTabs, + activeTabRef: { + partition: localIds.has(activeTabId) ? "workspace" : "shared", + tabId: activeTabId, + }, + tabOrder: [ + ...sharedTabIds.map((tabId) => ({ + partition: "shared" as const, + tabId, + })), + ...localTabs.map((item) => ({ + partition: "workspace" as const, + tabId: item.id, + })), + ], + }; +} + +function setupBrowserCloseState() { + const store = createStore(); + const state = emptyWorkstationTabsState(); + const browserTab = createBrowserSessionTab("browser-1", "Example", { + url: "https://example.com", + }); + const settingsTab: WorkStationTab = { + id: "settings:main", + type: "settings", + title: "Settings", + data: {}, + }; + state.shared.tabs = [browserTab, settingsTab]; + state.sessionWorkspaces.A = workspaceWithRefs( + [fileTab("file:/a.ts", true)], + [browserTab.id, settingsTab.id], + browserTab.id + ); + state.sessionWorkspaces.B = workspaceWithRefs( + [fileTab("file:/b.ts", true)], + [browserTab.id, settingsTab.id], + browserTab.id + ); + store.set(workstationTabsStateAtom, state); + store.set(workstationActiveSessionIdAtom, "A"); + + const sessionState = { + sessions: [browserSession("browser-1")], + activeSessionId: "browser-1", + }; + store.set(browserSessionStateAtom, sessionState); + persistBrowserSessionState(sessionState); + return { store, browserTab, settingsTab }; +} + +function expectBrowserResourceClosed( + store: ReturnType, + browserTabId: string +) { + const state = store.get(workstationTabsStateAtom); + expect(state.shared.tabs.map((item) => item.id)).not.toContain(browserTabId); + for (const workspace of Object.values(state.sessionWorkspaces)) { + expect(workspace.tabOrder.map((ref) => ref.tabId)).not.toContain( + browserTabId + ); + } + expect(store.get(browserSessionStateAtom)).toEqual({ + sessions: [], + activeSessionId: "", + }); + expect(localStorage.getItem(BROWSER_SESSIONS_STORAGE_KEY)).toBeNull(); +} + +beforeEach(() => { + localStorage.clear(); +}); + describe("closeProjectOrgWorkStationTabsAtom", () => { it("closes every surface for the deleted org and keeps other tabs", () => { - const store = createStore(); + const store = createInstrumentedStore(); store.set(workstationLayoutAtom, { mainPane: { tabs: [ @@ -54,122 +173,166 @@ describe("closeProjectOrgWorkStationTabsAtom", () => { "live-org" ); }); -}); -describe("live shared-resource close semantics", () => { - function fileTab(id: string, hasUnsavedChanges = false): WorkStationTab { - return { - id, - type: "file", - title: id, - data: { filePath: id.replace("file:", "") }, - hasUnsavedChanges, - }; - } - - function sharedTab( - id: string, - type: "settings" | "terminal" - ): WorkStationTab { - return { id, type, title: id, data: {} }; - } - - it("tears down a browser resource through the unified TabBar close path", () => { + it("invalidates the deleted org across non-presented workspaces", () => { const store = createStore(); const state = emptyWorkstationTabsState(); - const browser = createBrowserSessionTab("browser-1", "Example"); - const local = fileTab("file:/a.ts"); - state.shared.tabs = [browser]; - state.sessionWorkspaces.A = { - tabs: [local], - activeTabRef: { partition: "shared", tabId: browser.id }, - tabOrder: [ - { partition: "shared", tabId: browser.id }, - { partition: "workspace", tabId: local.id }, - ], - }; - state.sessionWorkspaces.B = { - tabs: [], - activeTabRef: { partition: "shared", tabId: browser.id }, - tabOrder: [{ partition: "shared", tabId: browser.id }], - }; + const deletedA = tab("deleted-a", "org-deleted"); + const deletedB = tab("deleted-b", "org-deleted"); + const live = tab("live", "org-live"); + state.shared.tabs = [deletedA, deletedB, live]; + state.globalWorkspace = workspaceWithRefs([], [deletedA.id], deletedA.id); + state.sessionWorkspaces.A = workspaceWithRefs( + [fileTab("file:/a.ts")], + [deletedA.id, live.id], + deletedA.id + ); + state.sessionWorkspaces.B = workspaceWithRefs( + [fileTab("file:/b.ts")], + [deletedB.id, live.id], + deletedB.id + ); + state.legacySeed = workspaceWithRefs( + [], + [deletedA.id, live.id], + deletedA.id + ); store.set(workstationTabsStateAtom, state); store.set(workstationActiveSessionIdAtom, "A"); - store.set(closeTabAtom, { tabId: browser.id }); + store.set(closeProjectOrgWorkStationTabsAtom, "org-deleted"); const next = store.get(workstationTabsStateAtom); - expect(next.shared.tabs).toEqual([]); - expect(next.sessionWorkspaces.A.tabOrder).toEqual([ - { partition: "workspace", tabId: local.id }, + expect(next.shared.tabs).toEqual([live]); + expect(next.globalWorkspace.tabOrder).toEqual([]); + expect(next.sessionWorkspaces.A.tabOrder.map((ref) => ref.tabId)).toEqual([ + live.id, + "file:/a.ts", ]); - expect(next.sessionWorkspaces.B.tabOrder).toEqual([]); - expect( - JSON.parse(localStorage.getItem(WORKSTATION_V3_SHARED_KEY) ?? "null") - ).toEqual({ tabs: [] }); + expect(next.sessionWorkspaces.B.tabOrder.map((ref) => ref.tabId)).toEqual([ + live.id, + "file:/b.ts", + ]); + expect(next.legacySeed?.tabOrder.map((ref) => ref.tabId)).toEqual([ + live.id, + ]); + }); +}); + +describe("resource-aware WorkStation close commands", () => { + it("closes a browser tab at its authoritative session source", () => { + const { store, browserTab, settingsTab } = setupBrowserCloseState(); + // This is the command used by the visible unified TabBar close button. + store.set(closeTabAtom, { tabId: browserTab.id }); + + expectBrowserResourceClosed(store, browserTab.id); + expect(store.get(workstationTabsStateAtom).shared.tabs).toContainEqual( + settingsTab + ); + expect(store.get(tabRegistryAtom).map((entry) => entry.tab.id)).toEqual([ + settingsTab.id, + "file:/a.ts", + ]); + + // Switching workspaces cannot resurrect the removed shared resource. store.set(workstationActiveSessionIdAtom, "B"); - expect(store.get(workstationLayoutAtom).mainPane.tabs).toEqual([]); + expect(store.get(tabRegistryAtom).map((entry) => entry.tab.id)).toEqual([ + settingsTab.id, + "file:/b.ts", + ]); }); - it("uses the same resource teardown for the active-tab shortcut path", () => { - const store = createStore(); - const browser = createBrowserSessionTab("browser-1", "Example"); - store.set(workstationLayoutAtom, { - mainPane: { tabs: [browser], activeTabId: browser.id }, - }); + it("applies the same resource teardown to close-other", () => { + const { store, browserTab } = setupBrowserCloseState(); - expect(store.set(closeActiveWorkStationTabAtom)).toBe(true); + store.set(closeOtherTabsAtom, { keepTabId: "file:/a.ts" }); - expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); + expectBrowserResourceClosed(store, browserTab.id); + expect(store.get(tabRegistryAtom).map((entry) => entry.tab.id)).toEqual([ + "file:/a.ts", + ]); }); - it("batch-closes live resources once while retaining hidden lightweight shared tabs", () => { - const store = createStore(); - const browserA = createBrowserSessionTab("browser-1", "One"); - const browserB = createBrowserSessionTab("browser-2", "Two"); - const terminal = sharedTab("terminal:main", "terminal"); - const settings = sharedTab("settings:main", "settings"); - const dirtyFile = fileTab("file:/dirty.ts", true); - store.set(workstationLayoutAtom, { - mainPane: { - tabs: [browserA, browserB, terminal, settings, dirtyFile], - activeTabId: browserB.id, - }, - }); + it("applies the same resource teardown to close-saved", () => { + const { store, browserTab } = setupBrowserCloseState(); store.set(closeSavedTabsAtom); - const next = store.get(workstationTabsStateAtom); - expect(next.shared.tabs).toEqual([settings]); - expect(store.get(workstationLayoutAtom).mainPane).toEqual({ - tabs: [dirtyFile], - activeTabId: dirtyFile.id, - }); + expectBrowserResourceClosed(store, browserTab.id); + expect(store.get(tabRegistryAtom).map((entry) => entry.tab.id)).toEqual([ + "file:/a.ts", + ]); }); - it("removes every omitted live resource through Close Others", () => { + it("applies the same resource teardown to close-all", () => { + const { store, browserTab } = setupBrowserCloseState(); + + store.set(closeAllWorkstationTabsAtom); + + expectBrowserResourceClosed(store, browserTab.id); + expect(store.get(tabRegistryAtom)).toEqual([]); + }); + + it("tears down the Terminal owner once and removes every workspace ref", async () => { const store = createStore(); - const browserA = createBrowserSessionTab("browser-1", "One"); - const browserB = createBrowserSessionTab("browser-2", "Two"); - const terminal = sharedTab("terminal:main", "terminal"); - const settings = sharedTab("settings:main", "settings"); - store.set(workstationLayoutAtom, { - mainPane: { - tabs: [browserA, browserB, terminal, settings], - activeTabId: browserB.id, - }, + const state = emptyWorkstationTabsState(); + const terminalTab: WorkStationTab = { + id: "terminal:main", + type: "terminal", + title: "Terminal", + data: { sessionId: "main" }, + }; + state.shared.tabs = [terminalTab]; + state.sessionWorkspaces.A = workspaceWithRefs( + [fileTab("file:/a.ts")], + [terminalTab.id], + terminalTab.id + ); + state.sessionWorkspaces.B = workspaceWithRefs( + [fileTab("file:/b.ts")], + [terminalTab.id], + terminalTab.id + ); + store.set(workstationTabsStateAtom, state); + store.set(workstationActiveSessionIdAtom, "A"); + store.set(terminalSessionsAtom, [ + { id: "shell-old", name: "Shell", isActive: true }, + { id: "chatpanel-live", name: "Chat", isActive: false }, + ]); + store.set(activeTerminalIdAtom, "shell-old"); + store.set( + initializedTerminalIdsAtom, + new Set(["shell-old", "chatpanel-live"]) + ); + store.set(terminalSurfaceLifecycleAtom, { + generation: 0, + phase: "open", }); - store.set(closeOtherTabsAtom, { keepTabId: browserA.id }); + store.set(closeTabAtom, { tabId: terminalTab.id }); - expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([ - browserA, - settings, + const next = store.get(workstationTabsStateAtom); + expect(next.shared.tabs).toEqual([]); + expect(next.sessionWorkspaces.A.tabOrder.map((ref) => ref.tabId)).toEqual([ + "file:/a.ts", + ]); + expect(next.sessionWorkspaces.B.tabOrder.map((ref) => ref.tabId)).toEqual([ + "file:/b.ts", + ]); + expect(store.get(terminalSessionsAtom).map(({ id }) => id)).toContain( + "chatpanel-live" + ); + expect(store.get(terminalSessionsAtom).map(({ id }) => id)).not.toContain( + "shell-old" + ); + + store.set(workstationActiveSessionIdAtom, "B"); + expect(store.get(tabRegistryAtom).map((entry) => entry.tab.id)).toEqual([ + "file:/b.ts", ]); - expect(store.get(workstationLayoutAtom).mainPane).toEqual({ - tabs: [browserA], - activeTabId: browserA.id, + await vi.waitFor(() => { + expect(store.get(terminalSurfaceLifecycleAtom).phase).toBe("closed"); }); }); }); diff --git a/src/store/workstation/tabRegistry/atoms.ts b/src/store/workstation/tabRegistry/atoms.ts index 93e07ed4f7..e1b3fcfa1c 100644 --- a/src/store/workstation/tabRegistry/atoms.ts +++ b/src/store/workstation/tabRegistry/atoms.ts @@ -12,11 +12,13 @@ import { type Getter, type Setter, atom } from "jotai"; import { type PanelState, type WorkStationLayoutState, + closeAllTabs as closeAllTabsMutation, closeOtherTabs as closeOtherTabsMutation, closeSavedTabs as closeSavedTabsMutation, closeTab as closeTabMutation, closeWorkstationTabsAtom, presentedWorkstationWorkspaceKeyAtom, + removeProjectOrgWorkstationTabsAtom, reorderTabs as reorderTabsMutation, switchTab as switchTabMutation, workstationLayoutAtom, @@ -115,19 +117,8 @@ closeActiveWorkStationTabAtom.debugLabel = "closeActiveWorkStationTabAtom"; /** Close every WorkStation surface whose durable payload belongs to an org. */ export const closeProjectOrgWorkStationTabsAtom = atom( null, - (get, set, orgId: string) => { - const layout = get(workstationLayoutAtom); - if (!layout) return; - const tabIds = layout.mainPane.tabs - .filter((tab) => tab.data.orgId === orgId) - .map((tab) => tab.id); - if (tabIds.length === 0) return; - - let nextPane = layout.mainPane; - for (const tabId of tabIds) { - nextPane = closeTabMutation(nextPane, tabId); - } - closePresentedTabs(get, set, nextPane, layout.mainPane); + (_get, set, orgId: string) => { + set(removeProjectOrgWorkstationTabsAtom, orgId); } ); closeProjectOrgWorkStationTabsAtom.debugLabel = @@ -181,3 +172,15 @@ export const closeSavedTabsAtom = atom(null, (get, set) => { ); }); closeSavedTabsAtom.debugLabel = "closeSavedTabsAtom"; + +export const closeAllWorkstationTabsAtom = atom(null, (get, set) => { + const layout = get(workstationLayoutAtom); + if (!layout) return; + closePresentedTabs( + get, + set, + closeAllTabsMutation(layout.mainPane), + layout.mainPane + ); +}); +closeAllWorkstationTabsAtom.debugLabel = "closeAllWorkstationTabsAtom"; diff --git a/src/store/workstation/tabRegistry/index.ts b/src/store/workstation/tabRegistry/index.ts index 98d0a94132..978ea2acaf 100644 --- a/src/store/workstation/tabRegistry/index.ts +++ b/src/store/workstation/tabRegistry/index.ts @@ -14,5 +14,6 @@ export { closeProjectOrgWorkStationTabsAtom, closeOtherTabsAtom, closeSavedTabsAtom, + closeAllWorkstationTabsAtom, reorderTabAtom, } from "./atoms"; diff --git a/src/store/workstation/tabs/__tests__/storage.test.ts b/src/store/workstation/tabs/__tests__/storage.test.ts index cab32d9225..74c624c0c5 100644 --- a/src/store/workstation/tabs/__tests__/storage.test.ts +++ b/src/store/workstation/tabs/__tests__/storage.test.ts @@ -159,7 +159,7 @@ describe("v2 migration", () => { expect(localStorage.getItem(WORKSTATION_V3_LEGACY_SEED_KEY)).not.toBeNull(); }); - it("does not expose a seed when v2 contains only shared resources", () => { + it("preserves a shared-only v2 layout in the claimable seed", () => { localStorage.setItem( LAYOUT_STORAGE_KEY, JSON.stringify({ @@ -172,9 +172,18 @@ describe("v2 migration", () => { const migrated = loadWorkstationTabsState(); - expect(migrated.shared.tabs).toHaveLength(1); - expect(migrated.legacySeed).toBeNull(); - expect(localStorage.getItem(LAYOUT_STORAGE_KEY)).toBeNull(); + expect(migrated.shared.tabs.map((item) => item.id)).toEqual([ + "settings:main", + ]); + expect(migrated.legacySeed?.tabs).toEqual([]); + expect(migrated.legacySeed?.activeTabRef).toEqual({ + partition: "shared", + tabId: "settings:main", + }); + expect(migrated.legacySeed?.tabOrder).toEqual([ + { partition: "shared", tabId: "settings:main" }, + ]); + expect(localStorage.getItem(LAYOUT_STORAGE_KEY)).not.toBeNull(); }); it("prefers committed v3 state over a leftover v2 recovery source", () => { @@ -228,6 +237,10 @@ describe("v3 persistence keys", () => { const state = emptyWorkstationTabsState(); state.shared.tabs = [tab("settings:main", "settings")]; state.sessionWorkspaces.A = workspace([tab("file:/same.ts")]); + state.sessionWorkspaces.A.tabOrder.unshift({ + partition: "shared", + tabId: "settings:main", + }); state.sessionWorkspaces.B = workspace([ tab("file:/same.ts", "file", { data: { owner: "B" }, @@ -244,6 +257,27 @@ describe("v3 persistence keys", () => { expect(loaded.sessionWorkspaces.B.tabs[0].data).toEqual({ owner: "B" }); }); + it("writes only changed scopes after the initial commit", () => { + const previous = emptyWorkstationTabsState(); + previous.globalWorkspace = workspace([tab("file:/global.ts")]); + previous.sessionWorkspaces.A = workspace([tab("file:/a.ts")]); + previous.sessionWorkspaces.B = workspace([tab("file:/b.ts")]); + expect(persistWorkstationTabsState(previous)).toBe(true); + + const setItem = vi.spyOn(localStorage, "setItem"); + const next = { + ...previous, + sessionWorkspaces: { + ...previous.sessionWorkspaces, + A: workspace([tab("file:/a-next.ts")]), + }, + }; + + expect(persistWorkstationTabsState(next, previous)).toBe(true); + + expect(setItem.mock.calls.map(([key]) => key)).toEqual([sessionKey("A")]); + }); + it("removes only the requested persisted session workspace", () => { localStorage.setItem(sessionKey("A"), "A"); localStorage.setItem(sessionKey("B"), "B"); diff --git a/src/store/workstation/tabs/__tests__/workspaceState.test.ts b/src/store/workstation/tabs/__tests__/workspaceState.test.ts index 023d3f38b7..53fd110c7c 100644 --- a/src/store/workstation/tabs/__tests__/workspaceState.test.ts +++ b/src/store/workstation/tabs/__tests__/workspaceState.test.ts @@ -10,8 +10,11 @@ import { import { GLOBAL_WORKSTATION_WORKSPACE_KEY, claimLegacyWorkstationSeedAtom, + closeWorkstationTabAtom, disposeWorkstationWorkspaceAtom, openWorkstationTabAtom, + removeProjectOrgWorkstationTabsAtom, + removeSessionWorkstationTabsAtom, removeSharedWorkstationTabAtom, selectWorkstationPanel, sessionWorkstationWorkspaceKey, @@ -22,10 +25,13 @@ import { type WorkStationTab, type WorkStationTabType, type WorkstationTabOwnership, + type WorkstationTabRepoAffinity, type WorkstationTabsStateV3, type WorkstationWorkspaceState, closesSharedResourceOnDismiss, + getWorkstationSharedTabRetention, getWorkstationTabOwnership, + getWorkstationTabRepoAffinity, } from "../types"; const EXPECTED_OWNERSHIP: Record = @@ -71,6 +77,32 @@ const EXPECTED_OWNERSHIP: Record = start: "shared-resource", }; +const REPO_SCOPED_TAB_TYPES = new Set([ + "file", + "directory", + "git-diff", + "source-control", + "timeline-diff", + "git-log", + "git-commit-detail", + "git-stash-detail", + "terminal-content", + "dom-component-preview", + "search", + "lint-scan", + "github-issue-detail", + "github-pr-detail", +]); + +const EXPECTED_REPO_AFFINITY = Object.fromEntries( + Object.keys(EXPECTED_OWNERSHIP).map((type) => [ + type, + REPO_SCOPED_TAB_TYPES.has(type as WorkStationTabType) + ? "repo-scoped" + : "repo-independent", + ]) +) as Record; + function tab( id: string, type: WorkStationTabType = "file", @@ -137,6 +169,24 @@ describe("WorkStation tab ownership policy", () => { expect(closesSharedResourceOnDismiss("terminal")).toBe(true); expect(closesSharedResourceOnDismiss("settings")).toBe(false); }); + + it("declares retention and repo-switch policy without a negative default", () => { + expect(getWorkstationSharedTabRetention("browser-session")).toBe( + "resource-owned" + ); + expect(getWorkstationSharedTabRetention("terminal")).toBe("resource-owned"); + expect(getWorkstationSharedTabRetention("settings")).toBe( + "while-referenced" + ); + expect(getWorkstationSharedTabRetention("file")).toBeNull(); + + const affinityResults = Object.entries(EXPECTED_REPO_AFFINITY).map( + ([type, expected]) => + getWorkstationTabRepoAffinity(type as WorkStationTabType) === expected + ); + expect(affinityResults).toHaveLength(39); + expect(affinityResults.every(Boolean)).toBe(true); + }); }); describe("workspace projection and isolation", () => { @@ -264,6 +314,162 @@ describe("workspace projection and isolation", () => { tabId: settings.id, }); }); + + it("collects ordinary shared presentation after its last workspace ref closes", () => { + const store = createStore(); + store.set(workstationTabsStateAtom, stateWithWorkspaces()); + const settings = tab("settings:main", "settings"); + + store.set(openWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("A"), + tab: settings, + }); + store.set(closeWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("A"), + tabId: settings.id, + }); + + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); + }); + + it("stabilizes shared presentation across repeated open and close cycles", () => { + const store = createStore(); + store.set(workstationTabsStateAtom, stateWithWorkspaces()); + const settings = tab("settings:main", "settings"); + + for (let cycle = 0; cycle < 25; cycle += 1) { + store.set(openWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("A"), + tab: settings, + }); + store.set(closeWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("A"), + tabId: settings.id, + }); + } + + const state = store.get(workstationTabsStateAtom); + expect(state.shared.tabs).toEqual([]); + expect(state.sessionWorkspaces.A.tabOrder).not.toContainEqual({ + partition: "shared", + tabId: settings.id, + }); + }); + + it("keeps shared presentation until every workspace ref is gone", () => { + const store = createStore(); + store.set(workstationTabsStateAtom, stateWithWorkspaces()); + const settings = tab("settings:main", "settings"); + + for (const sessionId of ["A", "B"]) { + store.set(openWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey(sessionId), + tab: settings, + }); + } + store.set(closeWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("A"), + tabId: settings.id, + }); + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([settings]); + + store.set(closeWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("B"), + tabId: settings.id, + }); + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); + }); + + it("keeps a resource-owned browser tab when its workspace is disposed", () => { + const store = createStore(); + store.set(workstationTabsStateAtom, stateWithWorkspaces()); + const browser = tab("browser:one", "browser-session", { + sessionId: "one", + }); + + store.set(openWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("A"), + tab: browser, + }); + store.set(disposeWorkstationWorkspaceAtom, "A"); + + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([browser]); + }); + + it("collects shared presentation when its owning workspace is disposed", () => { + const store = createStore(); + store.set(workstationTabsStateAtom, stateWithWorkspaces()); + const settings = tab("settings:main", "settings"); + store.set(openWorkstationTabAtom, { + workspace: sessionWorkstationWorkspaceKey("A"), + tab: settings, + }); + + store.set(disposeWorkstationWorkspaceAtom, "A"); + + expect(store.get(workstationTabsStateAtom).shared.tabs).toEqual([]); + }); + + it("invalidates session and org projections across every workspace", () => { + const store = createStore(); + const state = emptyWorkstationTabsState(); + const deletedSession = tab("chat-session:deleted", "chat-session", { + sessionId: "deleted", + }); + const liveSession = tab("chat-session:live", "chat-session", { + sessionId: "live", + }); + const deletedOrg = tab("project-org:deleted", "project-org", { + orgId: "org-deleted", + }); + const liveOrg = tab("project-org:live", "project-org", { + orgId: "org-live", + }); + state.shared.tabs = [deletedSession, liveSession, deletedOrg, liveOrg]; + state.sessionWorkspaces.A = { + tabs: [ + tab("canvas-preview:deleted", "canvas-preview", { + sessionId: "deleted", + }), + ], + activeTabRef: { partition: "shared", tabId: deletedSession.id }, + tabOrder: [ + { partition: "shared", tabId: deletedSession.id }, + { partition: "shared", tabId: deletedOrg.id }, + { partition: "workspace", tabId: "canvas-preview:deleted" }, + ], + }; + state.sessionWorkspaces.B = { + tabs: [], + activeTabRef: { partition: "shared", tabId: liveSession.id }, + tabOrder: [ + { partition: "shared", tabId: deletedSession.id }, + { partition: "shared", tabId: liveSession.id }, + { partition: "shared", tabId: deletedOrg.id }, + { partition: "shared", tabId: liveOrg.id }, + ], + }; + store.set(workstationTabsStateAtom, state); + + store.set(removeSessionWorkstationTabsAtom, "deleted"); + store.set(removeProjectOrgWorkstationTabsAtom, "org-deleted"); + + const next = store.get(workstationTabsStateAtom); + expect(next.shared.tabs.map((item) => item.id)).toEqual([ + liveSession.id, + liveOrg.id, + ]); + expect(next.sessionWorkspaces.A.tabs).toEqual([]); + for (const current of Object.values(next.sessionWorkspaces)) { + expect(current.tabOrder.map((ref) => ref.tabId)).not.toContain( + deletedSession.id + ); + expect(current.tabOrder.map((ref) => ref.tabId)).not.toContain( + deletedOrg.id + ); + } + }); + it("disposes workspace tabs and its remembered Terminal target together", () => { const store = createStore(); const state = stateWithWorkspaces(); diff --git a/src/store/workstation/tabs/atoms.ts b/src/store/workstation/tabs/atoms.ts index 98d9362d13..4dc4d70e72 100644 --- a/src/store/workstation/tabs/atoms.ts +++ b/src/store/workstation/tabs/atoms.ts @@ -1,8 +1,18 @@ -import { atom } from "jotai"; +import { type Setter, atom } from "jotai"; import { workstationActiveSessionIdAtom } from "@src/store/session/viewAtom"; +import { closeBrowserSessionsAtom } from "@src/store/workstation/browser/sessionState"; +import { + closeAllTerminalSessionsAtom, + markTerminalSurfaceOpenedAtom, +} from "@src/store/workstation/codeEditor/terminal"; import { clearTerminalTargetForWorkspaceAtom } from "@src/store/workstation/codeEditor/terminalTargetAtom"; +import { + pruneUnreferencedSharedTabs, + removeSharedTabsById, + removeWorkstationTabsMatching, +} from "./lifecycle"; import { deletePersistedWorkstationWorkspace, loadWorkstationTabsState, @@ -150,20 +160,22 @@ function splitPanel( activeTabRef, tabOrder, }; - return key.kind === "global" - ? { - ...previous, - shared: { tabs: nextSharedTabs }, - globalWorkspace: nextWorkspace, - } - : { - ...previous, - shared: { tabs: nextSharedTabs }, - sessionWorkspaces: { - ...previous.sessionWorkspaces, - [key.sessionId]: nextWorkspace, - }, - }; + const nextState = + key.kind === "global" + ? { + ...previous, + shared: { tabs: nextSharedTabs }, + globalWorkspace: nextWorkspace, + } + : { + ...previous, + shared: { tabs: nextSharedTabs }, + sessionWorkspaces: { + ...previous.sessionWorkspaces, + [key.sessionId]: nextWorkspace, + }, + }; + return pruneUnreferencedSharedTabs(nextState); } function setAndPersist( @@ -171,10 +183,25 @@ function setAndPersist( atom: typeof workstationTabsStateAtom, value: WorkstationTabsStateV3 ) => void, + previous: WorkstationTabsStateV3, next: WorkstationTabsStateV3 ): void { set(workstationTabsStateAtom, next); - persistWorkstationTabsState(next); + persistWorkstationTabsState(next, previous); +} + +function hasTerminalSurface(panel: PanelState): boolean { + return panel.tabs.some((tab) => tab.type === "terminal"); +} + +function markTerminalOpenedOnTransition( + set: (atom: typeof markTerminalSurfaceOpenedAtom) => void, + previous: PanelState, + next: PanelState +): void { + if (!hasTerminalSurface(previous) && hasTerminalSurface(next)) { + set(markTerminalSurfaceOpenedAtom); + } } /** @@ -204,10 +231,9 @@ export const workstationLayoutAtom = atom< typeof nextOrUpdater === "function" ? nextOrUpdater(previousLayout) : nextOrUpdater; - setAndPersist( - set, - splitPanel(state, key, nextLayout.mainPane ?? EMPTY_PANEL) - ); + const nextPanel = nextLayout.mainPane ?? EMPTY_PANEL; + markTerminalOpenedOnTransition(set, previousLayout.mainPane, nextPanel); + setAndPersist(set, state, splitPanel(state, key, nextPanel)); } ); workstationLayoutAtom.debugLabel = "workstationLayoutAtom"; @@ -225,7 +251,12 @@ export const claimLegacyWorkstationSeedAtom = atom(null, (get, set) => { }, legacySeed: null, }; - setAndPersist(set, next); + markTerminalOpenedOnTransition( + set, + composePanel(state, key), + composePanel(next, key) + ); + setAndPersist(set, state, next); }); claimLegacyWorkstationSeedAtom.debugLabel = "claimLegacyWorkstationSeedAtom"; @@ -240,7 +271,11 @@ export const disposeWorkstationWorkspaceAtom = atom( if (!state.sessionWorkspaces[sessionId]) return; const sessionWorkspaces = { ...state.sessionWorkspaces }; delete sessionWorkspaces[sessionId]; - setAndPersist(set, { ...state, sessionWorkspaces }); + const next = pruneUnreferencedSharedTabs({ + ...state, + sessionWorkspaces, + }); + setAndPersist(set, state, next); } ); disposeWorkstationWorkspaceAtom.debugLabel = "disposeWorkstationWorkspaceAtom"; @@ -278,60 +313,40 @@ export const openWorkstationTabAtom = atom( null, (get, set, request: ScopedWorkstationTabRequest) => { const state = get(workstationTabsStateAtom); - setAndPersist( + const previousPanel = composePanel(state, request.workspace); + const next = updateScopedPanel(state, request.workspace, (panel) => + openTabMutation(panel, request.tab) + ); + markTerminalOpenedOnTransition( set, - updateScopedPanel(state, request.workspace, (panel) => - openTabMutation(panel, request.tab) - ) + previousPanel, + composePanel(next, request.workspace) ); + setAndPersist(set, state, next); } ); openWorkstationTabAtom.debugLabel = "openWorkstationTabAtom"; -function removeSharedTabsFromState( - state: WorkstationTabsStateV3, - tabIds: ReadonlySet -): WorkstationTabsStateV3 { - if ( - tabIds.size === 0 || - !state.shared.tabs.some((tab) => tabIds.has(tab.id)) - ) { - return state; +function closeOwnedResources( + set: Setter, + tabs: readonly WorkStationTab[] +): void { + const browserSessionIds = tabs.flatMap((tab) => { + if (tab.type !== "browser-session") return []; + const sessionId = tab.data.sessionId; + return typeof sessionId === "string" && sessionId ? [sessionId] : []; + }); + if (browserSessionIds.length > 0) { + set(closeBrowserSessionsAtom, browserSessionIds); + } + if (tabs.some((tab) => tab.type === "terminal")) { + void set(closeAllTerminalSessionsAtom); } - - const removeRefs = ( - workspace: WorkstationWorkspaceState - ): WorkstationWorkspaceState => { - const tabOrder = workspace.tabOrder.filter( - (ref) => !(ref.partition === "shared" && tabIds.has(ref.tabId)) - ); - const activeTabRef = - workspace.activeTabRef?.partition === "shared" && - tabIds.has(workspace.activeTabRef.tabId) - ? (tabOrder[0] ?? null) - : workspace.activeTabRef; - return { ...workspace, activeTabRef, tabOrder }; - }; - - return { - ...state, - shared: { - tabs: state.shared.tabs.filter((tab) => !tabIds.has(tab.id)), - }, - globalWorkspace: removeRefs(state.globalWorkspace), - sessionWorkspaces: Object.fromEntries( - Object.entries(state.sessionWorkspaces).map(([sessionId, workspace]) => [ - sessionId, - removeRefs(workspace), - ]) - ), - }; } /** - * Canonical explicit-close path. Workspace-local and lightweight shared tabs - * are removed only from the requested workspace. Live Browser/Terminal tabs - * also lose their global resource record, which drives owner teardown. + * Canonical explicit-close path. Lightweight shared tabs close only in the + * requested workspace; Browser and Terminal close their live owner globally. */ export const closeWorkstationTabsAtom = atom( null, @@ -347,26 +362,26 @@ export const closeWorkstationTabsAtom = atom( nextPanel = closeTabMutation(nextPanel, tab.id); } if (request.activeTabId !== undefined) { - const requestedActiveExists = nextPanel.tabs.some( - (tab) => tab.id === request.activeTabId - ); nextPanel = { ...nextPanel, - activeTabId: requestedActiveExists ? request.activeTabId : null, + activeTabId: nextPanel.tabs.some( + (tab) => tab.id === request.activeTabId + ) + ? request.activeTabId + : null, }; } - const resourceIds = new Set( - tabsToClose - .filter((tab) => closesSharedResourceOnDismiss(tab.type)) - .map((tab) => tab.id) + const resourceTabs = tabsToClose.filter((tab) => + closesSharedResourceOnDismiss(tab.type) ); - - const nextState = removeSharedTabsFromState( - splitPanel(state, request.workspace, nextPanel), - resourceIds + const scopedState = splitPanel(state, request.workspace, nextPanel); + const nextState = removeSharedTabsById( + scopedState, + new Set(resourceTabs.map((tab) => tab.id)) ); - setAndPersist(set, nextState); + setAndPersist(set, state, nextState); + closeOwnedResources(set, resourceTabs); } ); closeWorkstationTabsAtom.debugLabel = "closeWorkstationTabsAtom"; @@ -391,6 +406,17 @@ closeWorkstationTabAtom.debugLabel = "closeWorkstationTabAtom"; * Resource owners (Browser/Terminal) use this after their durable resource is * explicitly closed. */ +export const removeSharedWorkstationTabsAtom = atom( + null, + (get, set, tabIds: readonly string[]) => { + const state = get(workstationTabsStateAtom); + const next = removeSharedTabsById(state, new Set(tabIds)); + if (next === state) return; + setAndPersist(set, state, next); + } +); +removeSharedWorkstationTabsAtom.debugLabel = "removeSharedWorkstationTabsAtom"; + export const removeSharedWorkstationTabAtom = atom( null, (_get, set, tabId: string) => { @@ -399,17 +425,42 @@ export const removeSharedWorkstationTabAtom = atom( ); removeSharedWorkstationTabAtom.debugLabel = "removeSharedWorkstationTabAtom"; -/** Remove multiple shared resources and every workspace reference in one write. */ -export const removeSharedWorkstationTabsAtom = atom( +/** Remove every WorkStation projection backed by a deleted agent session. */ +export const removeSessionWorkstationTabsAtom = atom( null, - (get, set, tabIds: readonly string[]) => { + (get, set, sessionId: string) => { const state = get(workstationTabsStateAtom); - const next = removeSharedTabsFromState(state, new Set(tabIds)); + const next = removeWorkstationTabsMatching(state, (tab) => { + if (tab.type === "chat-session" || tab.type === "canvas-preview") { + return tab.data.sessionId === sessionId; + } + return ( + tab.type === "subagent-detail" && + tab.data.subagentSessionId === sessionId + ); + }); if (next === state) return; - setAndPersist(set, next); + setAndPersist(set, state, next); } ); -removeSharedWorkstationTabsAtom.debugLabel = "removeSharedWorkstationTabsAtom"; +removeSessionWorkstationTabsAtom.debugLabel = + "removeSessionWorkstationTabsAtom"; + +/** Remove an org's tabs from Global, every session workspace, and seed. */ +export const removeProjectOrgWorkstationTabsAtom = atom( + null, + (get, set, orgId: string) => { + const state = get(workstationTabsStateAtom); + const next = removeWorkstationTabsMatching( + state, + (tab) => tab.data.orgId === orgId + ); + if (next === state) return; + setAndPersist(set, state, next); + } +); +removeProjectOrgWorkstationTabsAtom.debugLabel = + "removeProjectOrgWorkstationTabsAtom"; export const focusWorkstationTabAtom = atom( null, @@ -421,6 +472,7 @@ export const focusWorkstationTabAtom = atom( const state = get(workstationTabsStateAtom); setAndPersist( set, + state, updateScopedPanel(state, request.workspace, (panel) => switchTabMutation(panel, request.tabId) ) @@ -443,6 +495,7 @@ export const updateWorkstationTabDataAtom = atom( const state = get(workstationTabsStateAtom); setAndPersist( set, + state, updateScopedPanel(state, request.workspace, (panel) => updateTabDataMutation(panel, request.tabId, request.data) ) @@ -465,6 +518,7 @@ export const reorderWorkstationTabsAtom = atom( const state = get(workstationTabsStateAtom); setAndPersist( set, + state, updateScopedPanel(state, request.workspace, (panel) => reorderTabsMutation(panel, request.startIndex, request.endIndex) ) diff --git a/src/store/workstation/tabs/index.ts b/src/store/workstation/tabs/index.ts index 6ee356b49d..dfaa4c33c5 100644 --- a/src/store/workstation/tabs/index.ts +++ b/src/store/workstation/tabs/index.ts @@ -25,6 +25,8 @@ export type { WorkstationSharedState, WorkstationTabsStateV3, WorkstationTabOwnership, + WorkstationSharedTabRetention, + WorkstationTabRepoAffinity, TimelineDiffCommitInfo, // Editor cache types EditorRepoCache, @@ -39,6 +41,8 @@ export { FILE_TAB_TYPES, TOOL_TAB_TYPES, getWorkstationTabOwnership, + getWorkstationSharedTabRetention, + getWorkstationTabRepoAffinity, closesSharedResourceOnDismiss, } from "./types"; @@ -59,6 +63,8 @@ export { closeWorkstationTabAtom, removeSharedWorkstationTabsAtom, removeSharedWorkstationTabAtom, + removeSessionWorkstationTabsAtom, + removeProjectOrgWorkstationTabsAtom, focusWorkstationTabAtom, updateWorkstationTabDataAtom, reorderWorkstationTabsAtom, diff --git a/src/store/workstation/tabs/lifecycle.ts b/src/store/workstation/tabs/lifecycle.ts new file mode 100644 index 0000000000..6d5defc473 --- /dev/null +++ b/src/store/workstation/tabs/lifecycle.ts @@ -0,0 +1,188 @@ +import type { + WorkStationTab, + WorkstationTabRef, + WorkstationTabsStateV3, + WorkstationWorkspaceState, +} from "./types"; +import { getWorkstationSharedTabRetention } from "./types"; + +function refIdentity(ref: WorkstationTabRef): string { + return `${ref.partition}:${ref.tabId}`; +} + +function allWorkspaces( + state: WorkstationTabsStateV3 +): WorkstationWorkspaceState[] { + return [ + state.globalWorkspace, + ...Object.values(state.sessionWorkspaces), + ...(state.legacySeed ? [state.legacySeed] : []), + ]; +} + +/** Shared tab IDs currently presented by at least one workspace. */ +export function collectReferencedSharedTabIds( + state: WorkstationTabsStateV3 +): Set { + const referenced = new Set(); + for (const workspace of allWorkspaces(state)) { + for (const ref of workspace.tabOrder) { + if (ref.partition === "shared") referenced.add(ref.tabId); + } + } + return referenced; +} + +/** + * Remove ownerless shared presentation records. Browser and Terminal records + * are retained because their independent resource stores own their lifetime. + */ +export function pruneUnreferencedSharedTabs( + state: WorkstationTabsStateV3 +): WorkstationTabsStateV3 { + const referenced = collectReferencedSharedTabIds(state); + const tabs = state.shared.tabs.filter((tab) => { + const retention = getWorkstationSharedTabRetention(tab.type); + return retention === "resource-owned" || referenced.has(tab.id); + }); + if (tabs.length === state.shared.tabs.length) return state; + return { ...state, shared: { tabs } }; +} + +function filterWorkspace( + workspace: WorkstationWorkspaceState, + removedSharedIds: ReadonlySet, + matchesLocalTab: (tab: WorkStationTab) => boolean +): WorkstationWorkspaceState { + const removedLocalIds = new Set( + workspace.tabs.filter(matchesLocalTab).map((tab) => tab.id) + ); + const hasRemovedSharedRef = workspace.tabOrder.some( + (ref) => ref.partition === "shared" && removedSharedIds.has(ref.tabId) + ); + if (!hasRemovedSharedRef && removedLocalIds.size === 0) { + return workspace; + } + + const tabs = + removedLocalIds.size === 0 + ? workspace.tabs + : workspace.tabs.filter((tab) => !removedLocalIds.has(tab.id)); + const activeIdentity = workspace.activeTabRef + ? refIdentity(workspace.activeTabRef) + : null; + const activeIndex = activeIdentity + ? workspace.tabOrder.findIndex((ref) => refIdentity(ref) === activeIdentity) + : -1; + const tabOrder = workspace.tabOrder.filter((ref) => + ref.partition === "shared" + ? !removedSharedIds.has(ref.tabId) + : !removedLocalIds.has(ref.tabId) + ); + const activeRemoved = workspace.activeTabRef + ? workspace.activeTabRef.partition === "shared" + ? removedSharedIds.has(workspace.activeTabRef.tabId) + : removedLocalIds.has(workspace.activeTabRef.tabId) + : false; + const isRemovedRef = (ref: WorkstationTabRef): boolean => + ref.partition === "shared" + ? removedSharedIds.has(ref.tabId) + : removedLocalIds.has(ref.tabId); + const activeTabRef = activeRemoved + ? (workspace.tabOrder + .slice(activeIndex + 1) + .find((ref) => !isRemovedRef(ref)) ?? + workspace.tabOrder + .slice(0, Math.max(activeIndex, 0)) + .reverse() + .find((ref) => !isRemovedRef(ref)) ?? + null) + : workspace.activeTabRef; + + return { tabs, activeTabRef, tabOrder }; +} + +function mapSessionWorkspaces( + workspaces: Record, + updater: (workspace: WorkstationWorkspaceState) => WorkstationWorkspaceState +): Record { + let changed = false; + const entries = Object.entries(workspaces).map(([sessionId, workspace]) => { + const next = updater(workspace); + if (next !== workspace) changed = true; + return [sessionId, next] as const; + }); + return changed ? Object.fromEntries(entries) : workspaces; +} + +/** Remove explicit shared resources and every reference to them. */ +export function removeSharedTabsById( + state: WorkstationTabsStateV3, + requestedIds: ReadonlySet +): WorkstationTabsStateV3 { + const removedSharedIds = new Set( + state.shared.tabs + .filter((tab) => requestedIds.has(tab.id)) + .map((tab) => tab.id) + ); + if (removedSharedIds.size === 0) return state; + + const updateWorkspace = (workspace: WorkstationWorkspaceState) => + filterWorkspace(workspace, removedSharedIds, () => false); + const next: WorkstationTabsStateV3 = { + ...state, + shared: { + tabs: state.shared.tabs.filter((tab) => !removedSharedIds.has(tab.id)), + }, + globalWorkspace: updateWorkspace(state.globalWorkspace), + sessionWorkspaces: mapSessionWorkspaces( + state.sessionWorkspaces, + updateWorkspace + ), + legacySeed: state.legacySeed + ? updateWorkspace(state.legacySeed) + : state.legacySeed, + }; + return pruneUnreferencedSharedTabs(next); +} + +/** Remove matching shared/local tabs across Global, every session, and seed. */ +export function removeWorkstationTabsMatching( + state: WorkstationTabsStateV3, + matches: (tab: WorkStationTab) => boolean +): WorkstationTabsStateV3 { + const removedSharedIds = new Set( + state.shared.tabs.filter(matches).map((tab) => tab.id) + ); + const updateWorkspace = (workspace: WorkstationWorkspaceState) => + filterWorkspace(workspace, removedSharedIds, matches); + const globalWorkspace = updateWorkspace(state.globalWorkspace); + const sessionWorkspaces = mapSessionWorkspaces( + state.sessionWorkspaces, + updateWorkspace + ); + const legacySeed = state.legacySeed + ? updateWorkspace(state.legacySeed) + : state.legacySeed; + const sharedTabs = + removedSharedIds.size === 0 + ? state.shared.tabs + : state.shared.tabs.filter((tab) => !removedSharedIds.has(tab.id)); + + if ( + globalWorkspace === state.globalWorkspace && + sessionWorkspaces === state.sessionWorkspaces && + legacySeed === state.legacySeed && + sharedTabs === state.shared.tabs + ) { + return state; + } + + return pruneUnreferencedSharedTabs({ + ...state, + shared: { tabs: sharedTabs }, + globalWorkspace, + sessionWorkspaces, + legacySeed, + }); +} diff --git a/src/store/workstation/tabs/storage.ts b/src/store/workstation/tabs/storage.ts index 25138cfa76..e03a3cb05e 100644 --- a/src/store/workstation/tabs/storage.ts +++ b/src/store/workstation/tabs/storage.ts @@ -1,5 +1,6 @@ import { createLogger } from "@src/hooks/logger"; +import { pruneUnreferencedSharedTabs } from "./lifecycle"; import { type WorkStationTab, type WorkStationTabType, @@ -217,7 +218,7 @@ function migrateLegacyV2(): WorkstationTabsStateV3 { (tab) => getWorkstationTabOwnership(tab.type) === "workspace-local" ); state.shared.tabs = shared; - if (local.length > 0) { + if (tabs.length > 0) { const activeId = typeof panel.activeTabId === "string" ? panel.activeTabId : null; state.legacySeed = { @@ -238,8 +239,9 @@ function migrateLegacyV2(): WorkstationTabsStateV3 { })), }; } - persistWorkstationTabsState(state); - return state; + const migrated = pruneUnreferencedSharedTabs(state); + persistWorkstationTabsState(migrated); + return migrated; } export function loadWorkstationTabsState(): WorkstationTabsStateV3 { @@ -258,7 +260,7 @@ export function loadWorkstationTabsState(): WorkstationTabsStateV3 { ); } const rawLegacySeed = readJson(WORKSTATION_V3_LEGACY_SEED_KEY); - return { + return pruneUnreferencedSharedTabs({ version: 3, shared: { tabs: sanitizeSharedTabs(readJson(WORKSTATION_V3_SHARED_KEY)) }, globalWorkspace: sanitizeWorkspaceState( @@ -266,30 +268,59 @@ export function loadWorkstationTabsState(): WorkstationTabsStateV3 { ), sessionWorkspaces, legacySeed: rawLegacySeed ? sanitizeWorkspaceState(rawLegacySeed) : null, - }; + }); } export function persistWorkstationTabsState( - state: WorkstationTabsStateV3 + state: WorkstationTabsStateV3, + previous?: WorkstationTabsStateV3 ): boolean { if (!hasLocalStorage()) return false; const sessionIds = Object.keys(state.sessionWorkspaces); - const writes = [ - writeJson(WORKSTATION_V3_SHARED_KEY, state.shared), - writeJson(WORKSTATION_V3_GLOBAL_KEY, state.globalWorkspace), - state.legacySeed - ? writeJson(WORKSTATION_V3_LEGACY_SEED_KEY, state.legacySeed) - : (() => { - localStorage.removeItem(WORKSTATION_V3_LEGACY_SEED_KEY); - return true; - })(), - ...sessionIds.map((id) => - writeJson(sessionStorageKey(id), state.sessionWorkspaces[id]) - ), - ]; + const previousSessionIds = previous + ? Object.keys(previous.sessionWorkspaces) + : []; + const writes: boolean[] = []; + + if (!previous || state.shared !== previous.shared) { + writes.push(writeJson(WORKSTATION_V3_SHARED_KEY, state.shared)); + } + if (!previous || state.globalWorkspace !== previous.globalWorkspace) { + writes.push(writeJson(WORKSTATION_V3_GLOBAL_KEY, state.globalWorkspace)); + } + if (!previous || state.legacySeed !== previous.legacySeed) { + if (state.legacySeed) { + writes.push(writeJson(WORKSTATION_V3_LEGACY_SEED_KEY, state.legacySeed)); + } else { + localStorage.removeItem(WORKSTATION_V3_LEGACY_SEED_KEY); + } + } + for (const id of sessionIds) { + if ( + !previous || + state.sessionWorkspaces[id] !== previous.sessionWorkspaces[id] + ) { + writes.push( + writeJson(sessionStorageKey(id), state.sessionWorkspaces[id]) + ); + } + } + if (previous) { + const liveIds = new Set(sessionIds); + for (const id of previousSessionIds) { + if (!liveIds.has(id)) deletePersistedWorkstationWorkspace(id); + } + } if (writes.some((ok) => !ok)) return false; + + const manifestChanged = + !previous || + sessionIds.length !== previousSessionIds.length || + sessionIds.some((id, index) => id !== previousSessionIds[index]); const manifest: ManifestV3 = { version: 3, sessionIds }; - const committed = writeJson(WORKSTATION_V3_MANIFEST_KEY, manifest); + const committed = manifestChanged + ? writeJson(WORKSTATION_V3_MANIFEST_KEY, manifest) + : true; // Keep v2 as a recovery source until its workspace-local seed has been // successfully claimed by an explicit session. if (committed && state.legacySeed === null) { diff --git a/src/store/workstation/tabs/types.ts b/src/store/workstation/tabs/types.ts index ec8a481be7..9223d7fb93 100644 --- a/src/store/workstation/tabs/types.ts +++ b/src/store/workstation/tabs/types.ts @@ -208,15 +208,13 @@ export interface WorkstationTabsStateV3 { export type WorkstationTabOwnership = "workspace-local" | "shared-resource"; /** - * Closing most shared tabs only hides them from the current task workspace. - * Browser and Terminal tabs are different: they own live sessions, so an - * explicit user close must tear the resource down globally as well. + * Shared tabs are either durable resource projections (Browser / Terminal) + * or ordinary shared presentation records. Ordinary records have no owner + * outside the WorkStation tab graph and are collected with their last ref. */ -export function closesSharedResourceOnDismiss( - type: WorkStationTabType -): boolean { - return type === "browser-session" || type === "terminal"; -} +export type WorkstationSharedTabRetention = + | "resource-owned" + | "while-referenced"; /** * Exhaustive ownership policy. There is intentionally no default: adding a @@ -271,6 +269,123 @@ export function getWorkstationTabOwnership( } } +/** + * Exhaustive retention policy for shared tabs. Workspace-local tabs return + * `null` because their lifetime is already bounded by their workspace. + */ +export function getWorkstationSharedTabRetention( + type: WorkStationTabType +): WorkstationSharedTabRetention | null { + switch (type) { + case "file": + case "directory": + case "explorer": + case "git-diff": + case "source-control": + case "timeline-diff": + case "git-log": + case "git-commit-detail": + case "git-stash-detail": + case "terminal-content": + case "dom-component-preview": + case "output": + case "search": + case "lint-scan": + case "ai-impact": + case "search-sessions": + case "url-preview": + case "subagent-detail": + case "canvas-preview": + case "github-issue-detail": + case "github-pr-detail": + return null; + + case "browser-session": + case "terminal": + return "resource-owned"; + + case "settings": + case "benchmark": + case "devtools": + case "project-dashboard": + case "project-work-items": + case "project-linear-projects": + case "project-linear-work-items": + case "project-settings": + case "project-org": + case "project-org-settings": + case "project-git-sync-review": + case "project-workitems": + case "workItem-detail": + case "chat-session": + case "agent-config": + case "start": + return "while-referenced"; + } +} + +/** Whether an explicit dismiss must close the live shared resource globally. */ +export function closesSharedResourceOnDismiss( + type: WorkStationTabType +): boolean { + return getWorkstationSharedTabRetention(type) === "resource-owned"; +} + +export type WorkstationTabRepoAffinity = "repo-scoped" | "repo-independent"; + +/** + * Exhaustive repository-switch policy. New tab types must declare whether a + * repo switch swaps them with the repo cache or leaves them in place. + */ +export function getWorkstationTabRepoAffinity( + type: WorkStationTabType +): WorkstationTabRepoAffinity { + switch (type) { + case "file": + case "directory": + case "git-diff": + case "source-control": + case "timeline-diff": + case "git-log": + case "git-commit-detail": + case "git-stash-detail": + case "terminal-content": + case "dom-component-preview": + case "search": + case "lint-scan": + case "github-issue-detail": + case "github-pr-detail": + return "repo-scoped"; + + case "explorer": + case "terminal": + case "output": + case "settings": + case "ai-impact": + case "search-sessions": + case "benchmark": + case "url-preview": + case "browser-session": + case "devtools": + case "project-dashboard": + case "project-work-items": + case "project-linear-projects": + case "project-linear-work-items": + case "project-settings": + case "project-org": + case "project-org-settings": + case "project-git-sync-review": + case "project-workitems": + case "workItem-detail": + case "chat-session": + case "subagent-detail": + case "agent-config": + case "canvas-preview": + case "start": + return "repo-independent"; + } +} + /** * Root workstation layout state — a single tab pool. There is no longer * any notion of split panes, a pane tree, host-specific pane buckets, or @@ -452,13 +567,13 @@ export interface AgentConfigTabData { } // ============================================ -// Editor Cache Types (Per-Repo for FILES only) +// Editor Cache Types (Per-Repo scoped tabs) // ============================================ /** - * Cached FILE tabs for a single repo + * Cached repository-scoped tabs for a single repo. * - * IMPORTANT: Only FILE tabs are cached per-repo. + * IMPORTANT: Only repository-scoped tabs are cached per-repo. * Terminal and Browser tabs are GLOBAL and NOT affected by repo switching. * * When switching repos: @@ -468,7 +583,7 @@ export interface AgentConfigTabData { export interface EditorRepoCache { /** Repo path (key) */ repoPath: string; - /** File tabs only (type: "file", "git-diff", "source-control", "timeline-diff") */ + /** Repository-scoped editor tabs. Kept as `fileTabs` for storage compatibility. */ fileTabs: WorkStationTab[]; /** Active file tab ID (null if no file tab was active) */ activeFileTabId: string | null; @@ -485,9 +600,10 @@ export type EditorCacheMap = Record; // Tab Type Classification // ============================================ -/** Tab types that are FILE tabs (cached per-repo) */ +/** Tab types cached per repository. Name retained for API/storage compatibility. */ export const FILE_TAB_TYPES = [ "file", + "directory", "git-diff", "source-control", "timeline-diff", @@ -496,20 +612,25 @@ export const FILE_TAB_TYPES = [ "git-stash-detail", "terminal-content", "dom-component-preview", + "search", + "lint-scan", + "github-issue-detail", + "github-pr-detail", ] as const; /** Tab types that are TOOL tabs (global, not cached per-repo) */ export const TOOL_TAB_TYPES = [ + "explorer", "terminal", "output", "settings", - "search", - "lint-scan", "ai-impact", "search-sessions", + "benchmark", "url-preview", // Browser tabs "browser-session", + "devtools", // Project Manager tabs "project-dashboard", "project-work-items", @@ -518,15 +639,13 @@ export const TOOL_TAB_TYPES = [ "project-settings", "project-org", "project-org-settings", + "project-git-sync-review", "project-workitems", "workItem-detail", "chat-session", "subagent-detail", "agent-config", - // GitHub Issues detail - "github-issue-detail", - // GitHub Pull Request detail - "github-pr-detail", + "canvas-preview", // Start page launcher "start", ] as const; From a27f0e9ab000401db64eaf35177521e7727864b0 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:12:33 +0800 Subject: [PATCH 2/2] fix(ui): tolerate missing ResizeObserver Pre-commit hook ran. Total eslint: 0, total circular: 0 --- .../ui/layout/useElementDimensions.test.ts | 103 ++++++++++++++++++ src/hooks/ui/layout/useElementDimensions.ts | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 src/hooks/ui/layout/useElementDimensions.test.ts diff --git a/src/hooks/ui/layout/useElementDimensions.test.ts b/src/hooks/ui/layout/useElementDimensions.test.ts new file mode 100644 index 0000000000..8a4b0f9992 --- /dev/null +++ b/src/hooks/ui/layout/useElementDimensions.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +import React, { act, createElement, useRef } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { useElementDimensions } from "./useElementDimensions"; + +function DimensionProbe(): React.ReactNode { + const elementRef = useRef(null); + const dimensions = useElementDimensions(elementRef); + + // eslint-disable-next-line react-hooks/refs -- createElement is required because Vitest only includes `.test.ts`; this is a normal React ref prop. + return createElement("div", { + ref: elementRef, + "data-testid": "dimension-probe", + "data-dimensions": `${dimensions.width}x${dimensions.height}`, + }); +} + +describe("useElementDimensions", () => { + let container: HTMLDivElement; + let root: Root; + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("keeps the window resize fallback when ResizeObserver is unavailable", () => { + vi.stubGlobal("ResizeObserver", undefined); + const addWindowListener = vi.spyOn(window, "addEventListener"); + const removeWindowListener = vi.spyOn(window, "removeEventListener"); + + expect(() => { + act(() => root.render(createElement(DimensionProbe))); + }).not.toThrow(); + + expect(addWindowListener).toHaveBeenCalledWith( + "resize", + expect.any(Function) + ); + + act(() => root.unmount()); + root = createRoot(container); + + expect(removeWindowListener).toHaveBeenCalledWith( + "resize", + expect.any(Function) + ); + }); + + it("disconnects the observer when the measured element unmounts", () => { + const observe = vi.fn(); + const disconnect = vi.fn(); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + observe = observe; + unobserve = vi.fn(); + disconnect = disconnect; + } + ); + + act(() => root.render(createElement(DimensionProbe))); + + expect(observe).toHaveBeenCalledWith( + container.querySelector('[data-testid="dimension-probe"]') + ); + + act(() => root.unmount()); + root = createRoot(container); + + expect(disconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/hooks/ui/layout/useElementDimensions.ts b/src/hooks/ui/layout/useElementDimensions.ts index f65ba34a18..36d2001cef 100644 --- a/src/hooks/ui/layout/useElementDimensions.ts +++ b/src/hooks/ui/layout/useElementDimensions.ts @@ -122,7 +122,7 @@ export function useElementDimensions( // Set up ResizeObserver for accurate tracking let resizeObserver: ResizeObserver | null = null; - if (element) { + if (element && typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(measureDimensions); resizeObserver.observe(element); }