diff --git a/CODEBASE_DOCUMENTATION.md b/CODEBASE_DOCUMENTATION.md index bbcaa9f1..661b8579 100644 --- a/CODEBASE_DOCUMENTATION.md +++ b/CODEBASE_DOCUMENTATION.md @@ -233,6 +233,7 @@ client/app.js - Config pre-fetching & caching client/app.js - Main client application ├─ Manages: UI state, socket connections, terminal grid ├─ Features: 16-terminal layout, real-time updates, session switching +├─ Experimental workspace UI persistence: optional per-workspace restore for hidden worktrees plus View/Tier filters, and optional restore of the open workspace tab set across reloads and app restarts ├─ Command Palette: header `⌘ Commands` button + `Ctrl/Cmd+K` searchable command launcher for command-catalog actions ├─ Intent hints: compact "intent haiku" strip above each agent terminal, refreshed from `POST /api/sessions/intent-haiku` ├─ Projects + Chats automation: `project-chats-new` Commander/voice action supports explicit workspace + repository targeting @@ -377,6 +378,7 @@ package.json - Node.js dependencies and scripts user-settings.json - User preferences and workspace settings user-settings.default.json - Default user settings template +├─ Experimental UI state: `global.ui.experimental.persistWorkspaceSidebarState` gates per-workspace sidebar snapshots stored in `workspaceSidebarStateByWorkspace` ``` ### Workspace Templates & Scripts diff --git a/client/app.js b/client/app.js index 1133fa74..85e0e85d 100644 --- a/client/app.js +++ b/client/app.js @@ -57,6 +57,12 @@ class ClaudeOrchestrator { this.settings = this.loadSettings(); this.appInfo = null; this.userSettings = null; // Will be loaded from server + this.workspaceSidebarStatePersistTimer = null; + this.workspaceSidebarStatePersistChain = Promise.resolve(); + this.pendingWorkspaceSidebarStateSaves = new Map(); + this.workspaceTabStatePersistTimer = null; + this.workspaceTabStatePersistChain = Promise.resolve(); + this.pendingWorkspaceTabStateSave = null; this.simpleModeStartupTriggered = false; this.desktopDevtoolsKeydownHandler = null; this.currentLayout = '2x4'; @@ -497,6 +503,300 @@ class ClaudeOrchestrator { this.saveSessionVisibilityOverrides(); } + isWorkspaceSidebarStatePersistenceEnabled() { + return this.userSettings?.global?.ui?.experimental?.persistWorkspaceSidebarState === true; + } + + normalizeWorkspaceSidebarStateViewMode(value) { + const normalized = String(value || '').trim().toLowerCase(); + return ['all', 'claude', 'server'].includes(normalized) ? normalized : 'all'; + } + + normalizeWorkspaceSidebarStateTierFilter(value) { + const raw = String(value ?? '').trim().toLowerCase(); + if (!raw || raw === 'all') return 'all'; + if (raw === 'none') return 'none'; + const tier = Number.parseInt(raw, 10); + return (tier >= 1 && tier <= 4) ? String(tier) : 'all'; + } + + getPersistedWorkspaceSidebarState(workspaceId = null) { + const workspaceKey = String(workspaceId || this.currentWorkspace?.id || '').trim(); + if (!workspaceKey) return null; + const states = this.userSettings?.global?.ui?.experimental?.workspaceSidebarStateByWorkspace; + if (!states || typeof states !== 'object') return null; + const snapshot = states[workspaceKey]; + return snapshot && typeof snapshot === 'object' ? snapshot : null; + } + + normalizeWorkspaceTabState(snapshot) { + const rawOpenWorkspaceIds = Array.isArray(snapshot?.openWorkspaceIds) + ? snapshot.openWorkspaceIds + : (Array.isArray(snapshot?.workspaceIds) ? snapshot.workspaceIds : []); + const openWorkspaceIds = Array.from(new Set( + rawOpenWorkspaceIds.map((value) => String(value || '').trim()).filter(Boolean) + )); + const activeWorkspaceId = String(snapshot?.activeWorkspaceId || '').trim(); + return { + openWorkspaceIds, + activeWorkspaceId + }; + } + + getPersistedWorkspaceTabState() { + if (!this.isWorkspaceSidebarStatePersistenceEnabled()) return null; + const snapshot = this.userSettings?.global?.ui?.experimental?.workspaceTabState; + if (!snapshot || typeof snapshot !== 'object') return null; + return this.normalizeWorkspaceTabState(snapshot); + } + + captureWorkspaceTabStateSnapshot() { + if (!this.tabManager) { + return this.normalizeWorkspaceTabState(null); + } + + const openWorkspaceIds = Array.from(this.tabManager.tabs.values()) + .map((tab) => String(tab?.workspaceId || '').trim()) + .filter(Boolean); + const activeWorkspaceId = String(this.tabManager.getActiveTab?.()?.workspaceId || this.currentWorkspace?.id || '').trim(); + return this.normalizeWorkspaceTabState({ + openWorkspaceIds, + activeWorkspaceId + }); + } + + queueWorkspaceTabStatePersistence({ snapshot = null, immediate = false } = {}) { + if (!this.isWorkspaceSidebarStatePersistenceEnabled()) return; + + const nextSnapshot = this.normalizeWorkspaceTabState(snapshot || this.captureWorkspaceTabStateSnapshot()); + this.pendingWorkspaceTabStateSave = nextSnapshot; + + const flush = () => { + if (!this.pendingWorkspaceTabStateSave) return; + const queuedSnapshot = this.pendingWorkspaceTabStateSave; + this.pendingWorkspaceTabStateSave = null; + this.workspaceTabStatePersistChain = this.workspaceTabStatePersistChain + .catch(() => {}) + .then(() => this.persistWorkspaceTabStateSnapshot(queuedSnapshot)); + }; + + if (this.workspaceTabStatePersistTimer) { + clearTimeout(this.workspaceTabStatePersistTimer); + this.workspaceTabStatePersistTimer = null; + } + + if (immediate) { + flush(); + return; + } + + this.workspaceTabStatePersistTimer = setTimeout(() => { + this.workspaceTabStatePersistTimer = null; + flush(); + }, 150); + } + + async persistWorkspaceTabStateSnapshot(snapshot) { + if (!this.isWorkspaceSidebarStatePersistenceEnabled()) return; + + const normalizedSnapshot = this.normalizeWorkspaceTabState(snapshot); + const existingSnapshot = this.normalizeWorkspaceTabState(this.userSettings?.global?.ui?.experimental?.workspaceTabState); + if (JSON.stringify(existingSnapshot) === JSON.stringify(normalizedSnapshot)) { + return; + } + + if (this.userSettings?.global?.ui?.experimental) { + this.userSettings.global.ui.experimental.workspaceTabState = normalizedSnapshot; + } + + await this.updateGlobalUserSetting('ui.experimental.workspaceTabState', normalizedSnapshot); + } + + captureWorkspaceSidebarStateSnapshot({ workspaceId = null } = {}) { + const workspaceKey = String(workspaceId || this.currentWorkspace?.id || '').trim(); + if (!workspaceKey) return null; + + const worktreeKeys = new Map(); + for (const [sessionId, session] of this.sessions) { + if (!sessionId || !session) continue; + const sessionWorkspaceId = String(session?.workspace || this.currentWorkspace?.id || '').trim(); + if (workspaceKey && sessionWorkspaceId && sessionWorkspaceId !== workspaceKey) continue; + const worktreeKey = this.getSessionWorktreeKey(sessionId, session); + if (!worktreeKey) continue; + if (!worktreeKeys.has(worktreeKey)) worktreeKeys.set(worktreeKey, []); + worktreeKeys.get(worktreeKey).push(String(sessionId)); + } + + const hiddenWorktreeKeys = Array.from(worktreeKeys.entries()) + .filter(([, sessionIds]) => sessionIds.every((sessionId) => !this.visibleTerminals.has(sessionId))) + .map(([worktreeKey]) => worktreeKey) + .sort((a, b) => a.localeCompare(b)); + + return { + viewMode: this.normalizeWorkspaceSidebarStateViewMode(this.viewMode), + tierFilter: this.normalizeWorkspaceSidebarStateTierFilter(this.tierFilter), + hiddenWorktreeKeys + }; + } + + queueWorkspaceSidebarStatePersistence({ workspaceId = null, snapshot = null, immediate = false } = {}) { + if (!this.isWorkspaceSidebarStatePersistenceEnabled()) return; + + const workspaceKey = String(workspaceId || this.currentWorkspace?.id || '').trim(); + if (!workspaceKey) return; + + const nextSnapshot = snapshot || this.captureWorkspaceSidebarStateSnapshot({ workspaceId: workspaceKey }); + if (!nextSnapshot) return; + + this.pendingWorkspaceSidebarStateSaves.set(workspaceKey, nextSnapshot); + + const flush = () => { + if (!this.pendingWorkspaceSidebarStateSaves.size) return; + const queuedEntries = Array.from(this.pendingWorkspaceSidebarStateSaves.entries()); + this.pendingWorkspaceSidebarStateSaves.clear(); + this.workspaceSidebarStatePersistChain = this.workspaceSidebarStatePersistChain + .catch(() => {}) + .then(async () => { + for (const [queuedWorkspaceId, queuedSnapshot] of queuedEntries) { + await this.persistWorkspaceSidebarStateSnapshot(queuedWorkspaceId, queuedSnapshot); + } + }); + }; + + if (this.workspaceSidebarStatePersistTimer) { + clearTimeout(this.workspaceSidebarStatePersistTimer); + this.workspaceSidebarStatePersistTimer = null; + } + + if (immediate) { + flush(); + return; + } + + this.workspaceSidebarStatePersistTimer = setTimeout(() => { + this.workspaceSidebarStatePersistTimer = null; + flush(); + }, 150); + } + + async persistWorkspaceSidebarStateSnapshot(workspaceId, snapshot) { + const workspaceKey = String(workspaceId || '').trim(); + if (!workspaceKey || !snapshot || typeof snapshot !== 'object') return; + if (!this.isWorkspaceSidebarStatePersistenceEnabled()) return; + + const currentMap = (this.userSettings?.global?.ui?.experimental?.workspaceSidebarStateByWorkspace + && typeof this.userSettings.global.ui.experimental.workspaceSidebarStateByWorkspace === 'object') + ? this.userSettings.global.ui.experimental.workspaceSidebarStateByWorkspace + : {}; + + const normalizedSnapshot = { + viewMode: this.normalizeWorkspaceSidebarStateViewMode(snapshot.viewMode), + tierFilter: this.normalizeWorkspaceSidebarStateTierFilter(snapshot.tierFilter), + hiddenWorktreeKeys: Array.isArray(snapshot.hiddenWorktreeKeys) + ? Array.from(new Set(snapshot.hiddenWorktreeKeys.map((value) => String(value || '').trim()).filter(Boolean))).sort((a, b) => a.localeCompare(b)) + : [] + }; + + const existing = currentMap[workspaceKey]; + if (existing && JSON.stringify(existing) === JSON.stringify(normalizedSnapshot)) { + return; + } + + const nextMap = { + ...currentMap, + [workspaceKey]: normalizedSnapshot + }; + + if (this.userSettings?.global?.ui?.experimental) { + this.userSettings.global.ui.experimental.workspaceSidebarStateByWorkspace = nextMap; + } + + await this.updateGlobalUserSetting('ui.experimental.workspaceSidebarStateByWorkspace', nextMap); + } + + applyPersistedWorkspaceSidebarState({ workspaceId = null } = {}) { + if (!this.isWorkspaceSidebarStatePersistenceEnabled()) return false; + + const workspaceKey = String(workspaceId || this.currentWorkspace?.id || '').trim(); + if (!workspaceKey) return false; + + const snapshot = this.getPersistedWorkspaceSidebarState(workspaceKey); + if (!snapshot) return false; + + const hiddenWorktreeKeys = new Set( + Array.isArray(snapshot.hiddenWorktreeKeys) + ? snapshot.hiddenWorktreeKeys.map((value) => String(value || '').trim()).filter(Boolean) + : [] + ); + + const nextVisibleTerminals = new Set(); + let matchedSession = false; + for (const [sessionId, session] of this.sessions) { + if (!sessionId || !session) continue; + const sessionWorkspaceId = String(session?.workspace || this.currentWorkspace?.id || '').trim(); + if (workspaceKey && sessionWorkspaceId && sessionWorkspaceId !== workspaceKey) continue; + const worktreeKey = this.getSessionWorktreeKey(sessionId, session); + if (!worktreeKey) { + nextVisibleTerminals.add(sessionId); + continue; + } + matchedSession = true; + if (!hiddenWorktreeKeys.has(worktreeKey)) { + nextVisibleTerminals.add(sessionId); + } + } + + this.viewMode = this.normalizeWorkspaceSidebarStateViewMode(snapshot.viewMode); + const normalizedTier = this.normalizeWorkspaceSidebarStateTierFilter(snapshot.tierFilter); + this.tierFilter = normalizedTier === 'all' || normalizedTier === 'none' + ? normalizedTier + : Number.parseInt(normalizedTier, 10); + + if (matchedSession) { + this.visibleTerminals = nextVisibleTerminals; + } + + return true; + } + + restoreCurrentWorkspaceSidebarStateFromPersistence() { + const workspaceId = String(this.currentWorkspace?.id || '').trim(); + if (!workspaceId || this.sessions.size === 0) return false; + + const applied = this.applyPersistedWorkspaceSidebarState({ workspaceId }); + if (!applied) return false; + + this.updateViewModeButtons(); + this.updateTierFilterButtons(); + this.ensureFilterToggleExists(); + this.updateTerminalGrid(); + this.buildSidebar(); + return true; + } + + async restoreWorkspaceTabsFromPersistence() { + if (!this.tabManager || !this.currentWorkspace) return false; + if (!Array.isArray(this.availableWorkspaces) || this.availableWorkspaces.length === 0) return false; + if (!this.isWorkspaceSidebarStatePersistenceEnabled()) return false; + + const persistedTabState = this.getPersistedWorkspaceTabState(); + if (!persistedTabState || persistedTabState.openWorkspaceIds.length === 0) return false; + + const activeWorkspaceId = persistedTabState.activeWorkspaceId || String(this.currentWorkspace?.id || '').trim(); + const restoredTabId = this.tabManager.restorePersistedTabs(this.availableWorkspaces, { activeWorkspaceId }); + if (!restoredTabId) return false; + + const targetTab = this.tabManager.getTab(restoredTabId); + if (!targetTab) return false; + + if (this.currentTabId !== restoredTabId || this.tabManager.activeTabId !== restoredTabId) { + await this.tabManager.switchTab(restoredTabId); + } + + this.currentTabId = restoredTabId; + return true; + } + isSessionVisibleByWorktreeSelection(sessionId, session = null) { const sid = String(sessionId || '').trim(); if (!sid) return false; @@ -1198,6 +1498,8 @@ class ClaudeOrchestrator { this.applyTheme(); this.syncSettingsUI(); this.applySimpleModeConfig(); + await this.loadUserSettings(); + this.applySidebarDesktopCollapsedFromPrefs(); // Connect to server await this.connectToServer(); @@ -1206,9 +1508,6 @@ class ClaudeOrchestrator { // Hook panels that depend on socket events this.activityFeedPanel?.onSocketConnected?.(this.socket); - // Load user settings from server - await this.loadUserSettings(); - this.applySidebarDesktopCollapsedFromPrefs(); this.refreshLicenseStatus?.().catch(() => {}); this.syncTerminalFiltersFromUserSettings(); this.syncWorkflowModeFromUserSettings(); @@ -1391,6 +1690,9 @@ class ClaudeOrchestrator { // Hide + persist dismissal so it doesn't resurrect on refresh/worktree-add this.hideStartupUI(sessionId); this.scheduleAutoPromptFallback(sessionId, 'claude'); + if (this.isWorkspaceSidebarStatePersistenceEnabled()) { + setTimeout(() => this.restoreCurrentWorkspaceSidebarStateFromPersistence(), 0); + } // Enable the start button now that Claude has started const startBtn = document.getElementById(`claude-start-btn-${sessionId}`); @@ -1404,13 +1706,16 @@ class ClaudeOrchestrator { this.socket.on('agent-started', ({ sessionId, config }) => { this.hideStartupUI(sessionId); this.scheduleAutoPromptFallback(sessionId, config?.agentId); + if (this.isWorkspaceSidebarStatePersistenceEnabled()) { + setTimeout(() => this.restoreCurrentWorkspaceSidebarStateFromPersistence(), 0); + } }); this.socket.on('claude-update-required', (updateInfo) => { this.showClaudeUpdateRequired(updateInfo); }); - this.socket.on('user-settings-updated', (settings) => { + this.socket.on('user-settings-updated', async (settings) => { console.log('User settings updated:', settings); this.userSettings = settings; this.syncUserSettingsUI(); @@ -1419,6 +1724,7 @@ class ClaudeOrchestrator { this.applyDesktopDevtoolsConfig(); this.maybeAutoOpenSimpleMode(); this.applyUiVisibility(); + await this.restoreWorkspaceTabsFromPersistence(); }); // Workspace events @@ -1467,10 +1773,9 @@ class ClaudeOrchestrator { if (mainContainer) mainContainer.classList.remove('hidden'); if (sidebar) sidebar.classList.remove('hidden'); - // Create tab for the active workspace - // Note: sessions will come later in the 'sessions' event - const tabId = this.tabManager.createTab(active, []); - console.log(`Created initial tab ${tabId} for workspace ${active.name}`); + const restoredTabId = this.tabManager.restorePersistedTabs(available, { activeWorkspaceId: active.id }); + const tabId = restoredTabId || this.tabManager.createTab(active, []); + console.log(`Created/restored initial tab set, focusing ${tabId} for workspace ${active.name}`); // Set currentTabId so subsequent sessions event knows which tab to use this.currentTabId = tabId; @@ -1528,6 +1833,7 @@ class ClaudeOrchestrator { } if (existingTab) { + const shouldForcePersistedSidebarRestore = existingTab.needsPersistedWorkspaceSidebarRestore === true; // Switch to existing tab console.log(`Workspace ${nextWorkspace.name} already open, switching to tab`); existingTab.workspace = nextWorkspace; @@ -1555,7 +1861,7 @@ class ClaudeOrchestrator { // CRITICAL: Set lastSessionsWorkspaceId BEFORE handleInitialSessions so that // it treats this as a same-workspace refresh and preserves the worktree // visibility state that was just restored from the tab (fix #786). - this.lastSessionsWorkspaceId = nextWorkspace.id; + this.lastSessionsWorkspaceId = shouldForcePersistedSidebarRestore ? null : nextWorkspace.id; await this.maybePlanWorkspaceRecovery(nextWorkspace.id, { interactive: true }); this.handleInitialSessions(sessions); @@ -2580,6 +2886,29 @@ class ClaudeOrchestrator { }); } + const experimentalPersistSidebarState = document.getElementById('experimental-persist-sidebar-state'); + if (experimentalPersistSidebarState) { + experimentalPersistSidebarState.addEventListener('change', async (e) => { + const enabled = !!e.target.checked; + await this.updateGlobalUserSetting('ui.experimental.persistWorkspaceSidebarState', enabled); + if (!enabled) { + if (this.workspaceSidebarStatePersistTimer) { + clearTimeout(this.workspaceSidebarStatePersistTimer); + this.workspaceSidebarStatePersistTimer = null; + } + if (this.workspaceTabStatePersistTimer) { + clearTimeout(this.workspaceTabStatePersistTimer); + this.workspaceTabStatePersistTimer = null; + } + this.pendingWorkspaceSidebarStateSaves.clear(); + this.pendingWorkspaceTabStateSave = null; + return; + } + this.queueWorkspaceSidebarStatePersistence({ immediate: true }); + this.queueWorkspaceTabStatePersistence({ immediate: true }); + }); + } + // Discord services auto-start (server-persisted) const discordAutoEnsure = document.getElementById('discord-auto-ensure-services'); if (discordAutoEnsure) { @@ -2988,7 +3317,11 @@ class ClaudeOrchestrator { this.updateTerminalGrid(); if (persist) { - this.updateGlobalUserSetting('ui.terminals.viewMode', normalized); + if (this.isWorkspaceSidebarStatePersistenceEnabled()) { + this.queueWorkspaceSidebarStatePersistence(); + } else { + this.updateGlobalUserSetting('ui.terminals.viewMode', normalized); + } } } @@ -3022,7 +3355,11 @@ class ClaudeOrchestrator { if (persist) { const next = (normalized === 'all' || normalized === 'none') ? String(normalized) : String(Number(normalized)); - this.updateGlobalUserSetting('ui.terminals.tierFilter', next); + if (this.isWorkspaceSidebarStatePersistenceEnabled()) { + this.queueWorkspaceSidebarStatePersistence(); + } else { + this.updateGlobalUserSetting('ui.terminals.tierFilter', next); + } } } @@ -3209,6 +3546,10 @@ class ClaudeOrchestrator { } syncTerminalFiltersFromUserSettings() { + if (this.isWorkspaceSidebarStatePersistenceEnabled()) { + return; + } + const t = this.userSettings?.global?.ui?.terminals || {}; const viewMode = String(t?.viewMode || '').trim().toLowerCase(); const tierRaw = t?.tierFilter; @@ -3875,10 +4216,15 @@ class ClaudeOrchestrator { // receive a sessions refresh for the SAME workspace (e.g. after adding a // worktree). Never carry visibility between different workspaces. const currentWorkspaceId = this.currentWorkspace?.id || null; - const previousWorkspaceId = this.lastSessionsWorkspaceId || null; - const preserveVisibility = !!(currentWorkspaceId && previousWorkspaceId && currentWorkspaceId === previousWorkspaceId); const previousSessionIds = new Set(this.sessions.keys()); const previousVisibleSessionIds = new Set(this.visibleTerminals); + const previousWorkspaceId = this.lastSessionsWorkspaceId || null; + const preserveVisibility = !!( + currentWorkspaceId + && previousWorkspaceId + && currentWorkspaceId === previousWorkspaceId + && previousSessionIds.size > 0 + ); const nextSessionIds = new Set(Object.keys(sessionStates || {})); const removedSessionIds = Array.from(previousSessionIds).filter((sessionId) => !nextSessionIds.has(sessionId)); @@ -3949,8 +4295,19 @@ class ClaudeOrchestrator { this.pruneIntentHaikuState(new Set(Object.keys(sessionStates))); + if (this.tabManager && this.currentTabId) { + const currentTab = this.tabManager.getTab(this.currentTabId); + if (currentTab) { + currentTab.needsPersistedWorkspaceSidebarRestore = false; + } + } + this.lastSessionsWorkspaceId = currentWorkspaceId; + if (!preserveVisibility) { + this.applyPersistedWorkspaceSidebarState({ workspaceId: currentWorkspaceId }); + } + // Hide loading message FIRST const loadingMessage = document.getElementById('loading-message'); if (loadingMessage) { @@ -5392,6 +5749,7 @@ class ClaudeOrchestrator { } this.updateTerminalGrid(); this.buildSidebar(); + this.queueWorkspaceSidebarStatePersistence(); } toggleWorktreeVisibility(worktreeIdOrKey) { @@ -5463,6 +5821,7 @@ class ClaudeOrchestrator { // This will re-render with correct data-visible-count and apply proper CSS grid this.updateTerminalGrid(); this.buildSidebar(); + this.queueWorkspaceSidebarStatePersistence(); } showWorktree(worktreeIdOrKey) { @@ -5475,6 +5834,7 @@ class ClaudeOrchestrator { this.updateTerminalGrid(); this.buildSidebar(); + this.queueWorkspaceSidebarStatePersistence(); } showAllTerminals() { @@ -5485,6 +5845,7 @@ class ClaudeOrchestrator { this.updateTerminalGrid(); this.buildSidebar(); + this.queueWorkspaceSidebarStatePersistence(); } /** @@ -5787,6 +6148,7 @@ class ClaudeOrchestrator { }); this.updateTerminalGrid(); this.buildSidebar(); + this.queueWorkspaceSidebarStatePersistence(); } renderTerminals(sessionIds) { @@ -16705,6 +17067,12 @@ class ClaudeOrchestrator { } } + if (this.isWorkspaceSidebarStatePersistenceEnabled()) { + setTimeout(() => { + this.restoreCurrentWorkspaceSidebarStateFromPersistence(); + }, 600); + } + this.showTemporaryMessage(`Recovered ${recovery.sessions.length} session(s)`, 'success'); } @@ -17652,6 +18020,8 @@ class ClaudeOrchestrator { this.applyUiVisibility(); this.refreshBranchLabels(); this.updateTierFilterButtons(); + this.restoreCurrentWorkspaceSidebarStateFromPersistence(); + await this.restoreWorkspaceTabsFromPersistence(); } else { console.error('Failed to load user settings:', response.statusText); } @@ -17962,6 +18332,11 @@ class ClaudeOrchestrator { } this.applySimpleModeConfig(); + const experimentalPersistSidebarState = document.getElementById('experimental-persist-sidebar-state'); + if (experimentalPersistSidebarState) { + experimentalPersistSidebarState.checked = this.userSettings.global?.ui?.experimental?.persistWorkspaceSidebarState === true; + } + const desktopDevtoolsEnabled = document.getElementById('desktop-devtools-enabled'); if (desktopDevtoolsEnabled) { desktopDevtoolsEnabled.checked = this.userSettings.global?.ui?.desktop?.devtools?.enabled === true; diff --git a/client/index.html b/client/index.html index 2e88a612..1741cbb8 100644 --- a/client/index.html +++ b/client/index.html @@ -471,6 +471,17 @@
Per-Terminal Overrides
+ +
+

Experimental

+
+ +

Off by default. Saves per-workspace worktree visibility, the left-sidebar Tier and View filters, and the set of open workspace tabs across tabs, reloads, and app/server restarts.

+
+
diff --git a/client/workspace-tab-manager.js b/client/workspace-tab-manager.js index 2541365b..7c7d2f23 100644 --- a/client/workspace-tab-manager.js +++ b/client/workspace-tab-manager.js @@ -30,6 +30,104 @@ class WorkspaceTabManager { this.setupKeyboardShortcuts(); } + getTabPersistenceStorageKey() { + return 'agent-workspace-open-workspace-tabs-v1'; + } + + isTabPersistenceEnabled() { + return this.orchestrator?.isWorkspaceSidebarStatePersistenceEnabled?.() === true; + } + + buildPersistedTabStateSnapshot() { + const openWorkspaceIds = Array.from(this.tabs.values()) + .map((tab) => String(tab?.workspaceId || '').trim()) + .filter(Boolean); + const activeWorkspaceId = String(this.getActiveTab()?.workspaceId || '').trim(); + return { + openWorkspaceIds: Array.from(new Set(openWorkspaceIds)), + activeWorkspaceId + }; + } + + loadPersistedTabState() { + if (!this.isTabPersistenceEnabled()) { + return { workspaceIds: [], activeWorkspaceId: '' }; + } + + const persistedFromSettings = this.orchestrator?.getPersistedWorkspaceTabState?.(); + if (persistedFromSettings) { + return { + workspaceIds: Array.isArray(persistedFromSettings.openWorkspaceIds) ? persistedFromSettings.openWorkspaceIds : [], + activeWorkspaceId: String(persistedFromSettings.activeWorkspaceId || '').trim() + }; + } + + try { + const raw = localStorage.getItem(this.getTabPersistenceStorageKey()); + const parsed = raw ? JSON.parse(raw) : null; + if (!parsed || typeof parsed !== 'object') return { workspaceIds: [], activeWorkspaceId: '' }; + const workspaceIds = Array.isArray(parsed.workspaceIds) + ? Array.from(new Set(parsed.workspaceIds.map((value) => String(value || '').trim()).filter(Boolean))) + : []; + const activeWorkspaceId = String(parsed.activeWorkspaceId || '').trim(); + return { workspaceIds, activeWorkspaceId }; + } catch { + return { workspaceIds: [], activeWorkspaceId: '' }; + } + } + + savePersistedTabState() { + const snapshot = this.buildPersistedTabStateSnapshot(); + + try { + if (this.isTabPersistenceEnabled()) { + localStorage.setItem(this.getTabPersistenceStorageKey(), JSON.stringify(snapshot)); + } else { + localStorage.removeItem(this.getTabPersistenceStorageKey()); + } + } catch { + // ignore storage failures + } + + if (this.isTabPersistenceEnabled()) { + this.orchestrator?.queueWorkspaceTabStatePersistence?.({ snapshot }); + } + } + + restorePersistedTabs(availableWorkspaces = [], { activeWorkspaceId = '' } = {}) { + const availableById = new Map( + (Array.isArray(availableWorkspaces) ? availableWorkspaces : []) + .map((workspace) => [String(workspace?.id || '').trim(), workspace]) + .filter(([workspaceId, workspace]) => workspaceId && workspace) + ); + + const persisted = this.loadPersistedTabState(); + const orderedWorkspaceIds = Array.from(new Set([ + ...persisted.workspaceIds.filter((workspaceId) => availableById.has(workspaceId)), + ...(activeWorkspaceId && availableById.has(activeWorkspaceId) ? [activeWorkspaceId] : []) + ])); + + if (!orderedWorkspaceIds.length) { + return null; + } + + orderedWorkspaceIds.forEach((workspaceId) => { + const workspace = availableById.get(workspaceId); + if (!workspace) return; + const tabId = this.createTab(workspace, [], { autoActivate: false }); + const tabState = this.getTab(tabId); + if (tabState && tabState.sessions.size === 0) { + tabState.needsPersistedWorkspaceSidebarRestore = true; + } + }); + + if (activeWorkspaceId) { + return this.findTabByWorkspaceId(activeWorkspaceId)?.id || null; + } + + return this.findTabByWorkspaceId(orderedWorkspaceIds[0])?.id || null; + } + createTabBarContainer() { const mainContainer = document.querySelector('.main-container'); if (!mainContainer) { @@ -97,7 +195,7 @@ class WorkspaceTabManager { /** * Create a new tab for a workspace */ - createTab(workspace, sessions = []) { + createTab(workspace, sessions = [], { autoActivate = null } = {}) { // Prevent duplicate tabs for the same workspace const existingTab = this.findTabByWorkspaceId(workspace?.id); if (existingTab) { @@ -133,6 +231,7 @@ class WorkspaceTabManager { // UI/filter state uiState: this.createDefaultUIState(), + needsPersistedWorkspaceSidebarRestore: false, // Observer for resize handling resizeObserver: null, @@ -175,11 +274,13 @@ class WorkspaceTabManager { // Store tab this.tabs.set(tabId, tabState); + this.savePersistedTabState(); console.log(`Created tab ${tabId} for workspace ${workspace.name}`); // If this is the first tab, activate it - if (this.tabs.size === 1) { + const shouldAutoActivate = autoActivate === null ? this.tabs.size === 1 : !!autoActivate; + if (shouldAutoActivate) { this.switchTab(tabId); } @@ -436,6 +537,7 @@ class WorkspaceTabManager { // Update active state this.activeTabId = tabId; + this.savePersistedTabState(); // Update UI this.updateTabUI(); @@ -510,6 +612,11 @@ class WorkspaceTabManager { // Persist UI/filter state (visible terminals, filters, etc.) for later restoration this.saveTabUIState(tabState); + this.orchestrator.queueWorkspaceSidebarStatePersistence?.({ + workspaceId: tabWorkspaceId, + snapshot: this.orchestrator.captureWorkspaceSidebarStateSnapshot?.({ workspaceId: tabWorkspaceId }), + immediate: true + }); for (const [sessionId, termData] of Array.from(tabState.terminals.entries())) { if (currentSessionIds.has(sessionId)) continue; @@ -878,6 +985,7 @@ class WorkspaceTabManager { // Remove from tabs map this.tabs.delete(tabId); + this.savePersistedTabState(); // If we closed the active tab, switch to another if (wasActive) { @@ -921,6 +1029,8 @@ class WorkspaceTabManager { } } + this.savePersistedTabState(); + return tabsToRemove.length; } @@ -1099,6 +1209,7 @@ class WorkspaceTabManager { // Remove from tabs map this.tabs.delete(tabState.id); + this.savePersistedTabState(); } /** diff --git a/server/userSettingsService.js b/server/userSettingsService.js index ff849dd4..92610e9f 100644 --- a/server/userSettingsService.js +++ b/server/userSettingsService.js @@ -303,6 +303,14 @@ class UserSettingsService { // 'all' | 'none' | '1' | '2' | '3' | '4' tierFilter: 'all' }, + experimental: { + persistWorkspaceSidebarState: false, + workspaceSidebarStateByWorkspace: {}, + workspaceTabState: { + openWorkspaceIds: [], + activeWorkspaceId: '' + } + }, worktrees: { autoCreateExtraWhenBusy: true, autoCreateMinNumber: 9, @@ -806,10 +814,10 @@ class UserSettingsService { }; } - if (ui.workflow) { - const defaultsWorkflow = (uiDefaults.workflow || {}); - const wf = ui.workflow || {}; - merged.global.ui.workflow = { + if (ui.workflow) { + const defaultsWorkflow = (uiDefaults.workflow || {}); + const wf = ui.workflow || {}; + merged.global.ui.workflow = { ...defaultsWorkflow, ...wf, focus: { @@ -823,6 +831,23 @@ class UserSettingsService { }; } + if (ui.experimental && typeof ui.experimental === 'object') { + const defaultsExperimental = (uiDefaults.experimental || {}); + const nextExperimental = ui.experimental || {}; + merged.global.ui.experimental = { + ...defaultsExperimental, + ...nextExperimental, + workspaceSidebarStateByWorkspace: { + ...(defaultsExperimental.workspaceSidebarStateByWorkspace || {}), + ...(nextExperimental.workspaceSidebarStateByWorkspace || {}) + }, + workspaceTabState: { + ...(defaultsExperimental.workspaceTabState || {}), + ...(nextExperimental.workspaceTabState || {}) + } + }; + } + if (ui.tasks) { const defaultsTasks = tasksDefaults || {}; const tasks = ui.tasks || {}; @@ -1083,6 +1108,22 @@ class UserSettingsService { ...newGlobal.ui.diffViewer }; } + if (newGlobal.ui.experimental) { + const defaultsExperimental = this.getDefaultSettings().global.ui.experimental || {}; + const nextExperimental = newGlobal.ui.experimental || {}; + this.settings.global.ui.experimental = { + ...defaultsExperimental, + ...nextExperimental, + workspaceSidebarStateByWorkspace: { + ...(defaultsExperimental.workspaceSidebarStateByWorkspace || {}), + ...(nextExperimental.workspaceSidebarStateByWorkspace || {}) + }, + workspaceTabState: { + ...(defaultsExperimental.workspaceTabState || {}), + ...(nextExperimental.workspaceTabState || {}) + } + }; + } } const saved = this.saveSettings(); diff --git a/tests/unit/userSettingsDefaults.test.js b/tests/unit/userSettingsDefaults.test.js index c9a399d9..0382de5c 100644 --- a/tests/unit/userSettingsDefaults.test.js +++ b/tests/unit/userSettingsDefaults.test.js @@ -46,6 +46,18 @@ describe('UserSettingsService defaults', () => { expect(defaults.global.ui.workflow.notifications.mode).toBeTruthy(); }); + test('includes ui.experimental workspace sidebar persistence defaults', () => { + const defaults = UserSettingsService.prototype.getDefaultSettings.call({}); + const experimental = defaults?.global?.ui?.experimental; + expect(experimental).toBeTruthy(); + expect(experimental.persistWorkspaceSidebarState).toBe(false); + expect(experimental.workspaceSidebarStateByWorkspace).toBeTruthy(); + expect(typeof experimental.workspaceSidebarStateByWorkspace).toBe('object'); + expect(experimental.workspaceTabState).toBeTruthy(); + expect(Array.isArray(experimental.workspaceTabState.openWorkspaceIds)).toBe(true); + expect(experimental.workspaceTabState.activeWorkspaceId).toBe(''); + }); + test('includes ui.worktrees auto-create defaults', () => { const defaults = UserSettingsService.prototype.getDefaultSettings.call({}); expect(defaults?.global?.ui?.worktrees).toBeTruthy(); @@ -137,6 +149,19 @@ describe('UserSettingsService defaults', () => { simpleMode: { startupOpen: true }, + experimental: { + workspaceSidebarStateByWorkspace: { + alpha: { + viewMode: 'claude', + tierFilter: '3', + hiddenWorktreeKeys: ['repo-work4'] + } + }, + workspaceTabState: { + openWorkspaceIds: ['alpha', 'beta'], + activeWorkspaceId: 'beta' + } + }, workflow: { mode: 'focus' }, @@ -193,6 +218,18 @@ describe('UserSettingsService defaults', () => { expect(merged.global.ui.simpleMode.startupOpen).toBe(true); expect(merged.global.ui.simpleMode.enabled).toBe(false); expect(merged.global.ui.simpleMode.hotkeys).toBe(true); + // Keeps experimental defaults while allowing workspace-scoped sidebar state overrides. + expect(merged.global.ui.experimental).toBeTruthy(); + expect(merged.global.ui.experimental.persistWorkspaceSidebarState).toBe(false); + expect(merged.global.ui.experimental.workspaceSidebarStateByWorkspace.alpha).toEqual({ + viewMode: 'claude', + tierFilter: '3', + hiddenWorktreeKeys: ['repo-work4'] + }); + expect(merged.global.ui.experimental.workspaceTabState).toEqual({ + openWorkspaceIds: ['alpha', 'beta'], + activeWorkspaceId: 'beta' + }); // Does not drop process.status defaults when only one cap is provided. expect(merged.global.process.status.lookbackHours).toBeTruthy(); diff --git a/user-settings.default.json b/user-settings.default.json index 438f1435..8b16d9f8 100644 --- a/user-settings.default.json +++ b/user-settings.default.json @@ -226,6 +226,10 @@ "viewMode": "all", "tierFilter": "all" }, + "experimental": { + "persistWorkspaceSidebarState": false, + "workspaceSidebarStateByWorkspace": {} + }, "worktrees": { "autoCreateExtraWhenBusy": true, "autoCreateMinNumber": 9,