diff --git a/src/__tests__/renderer/utils/tabHelpers.test.ts b/src/__tests__/renderer/utils/tabHelpers.test.ts index 951d799adb..70b19952e1 100644 --- a/src/__tests__/renderer/utils/tabHelpers.test.ts +++ b/src/__tests__/renderer/utils/tabHelpers.test.ts @@ -451,6 +451,103 @@ describe('tabHelpers', () => { expect(result!.session.activeTabId).toBe('mock-generated-id'); }); + it('leaves zero AI tabs when closing the only AI tab beside a terminal tab', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + activeTabId: 'tab-1', + terminalTabs: [{ id: 'term-1' }] as never, + unifiedTabOrder: [ + { type: 'terminal', id: 'term-1' }, + { type: 'ai', id: 'tab-1' }, + ], + }); + + const result = closeTab(session, 'tab-1'); + + expect(result!.session.aiTabs).toHaveLength(0); + // activeTabId must not keep pointing at the tab we just removed + expect(result!.session.activeTabId).toBe(''); + // the surviving terminal tab takes over the view + expect(result!.session.activeTerminalTabId).toBe('term-1'); + expect(result!.session.inputMode).toBe('terminal'); + // no phantom AI ref is left behind in the unified order + expect(result!.session.unifiedTabOrder).toEqual([{ type: 'terminal', id: 'term-1' }]); + }); + + it('leaves zero AI tabs when closing the only AI tab beside a browser tab', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + activeTabId: 'tab-1', + browserTabs: [createMockBrowserTab()] as never, + unifiedTabOrder: [ + { type: 'browser', id: 'browser-tab-1' }, + { type: 'ai', id: 'tab-1' }, + ], + }); + + const result = closeTab(session, 'tab-1'); + + expect(result!.session.aiTabs).toHaveLength(0); + expect(result!.session.activeTabId).toBe(''); + expect(result!.session.activeBrowserTabId).toBe('browser-tab-1'); + }); + + it('does not crash closing the only AI tab beside a terminal tab in unread-filter mode', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + activeTabId: 'tab-1', + terminalTabs: [{ id: 'term-1' }] as never, + unifiedTabOrder: [ + { type: 'terminal', id: 'term-1' }, + { type: 'ai', id: 'tab-1' }, + ], + }); + + const result = closeTab(session, 'tab-1', true); + + expect(result!.session.aiTabs).toHaveLength(0); + expect(result!.session.activeTabId).toBe(''); + }); + + it('clears activeTabId when the closed sole AI tab was not the active tab', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + // User is focused on the terminal, so activeTabId is not the tab being closed + activeTabId: 'tab-1-stale', + inputMode: 'terminal', + terminalTabs: [{ id: 'term-1' }] as never, + activeTerminalTabId: 'term-1', + unifiedTabOrder: [ + { type: 'terminal', id: 'term-1' }, + { type: 'ai', id: 'tab-1' }, + ], + }); + + const result = closeTab(session, 'tab-1'); + + expect(result!.session.aiTabs).toHaveLength(0); + expect(result!.session.activeTabId).toBe(''); + }); + + it('still creates a fresh tab when closing the only AI tab with no other tabs', () => { + const tab = createMockTab({ id: 'tab-1' }); + const session = createMockSession({ + aiTabs: [tab], + activeTabId: 'tab-1', + unifiedTabOrder: [{ type: 'ai', id: 'tab-1' }], + }); + + const result = closeTab(session, 'tab-1'); + + expect(result!.session.aiTabs).toHaveLength(1); + expect(result!.session.activeTabId).toBe('mock-generated-id'); + expect(result!.session.unifiedTabOrder).toEqual([{ type: 'ai', id: 'mock-generated-id' }]); + }); + it('maintains max 25 items in closed tab history', () => { const tab = createMockTab({ id: 'tab-1' }); const existingHistory: ClosedTab[] = Array.from({ length: 25 }, (_, i) => ({ diff --git a/src/renderer/components/MainPanel/MainPanel.tsx b/src/renderer/components/MainPanel/MainPanel.tsx index 617d1a4d6d..3af15ca525 100644 --- a/src/renderer/components/MainPanel/MainPanel.tsx +++ b/src/renderer/components/MainPanel/MainPanel.tsx @@ -223,6 +223,24 @@ export const MainPanel = React.memo( ); const activeTabError = activeTab?.agentError; + // Whether the agent has any tab at all. An agent is allowed to have zero AI + // tabs as long as some other tab kind is still open, so the tab strip has to + // key off the union rather than aiTabs alone. + const hasAnyTab = useMemo( + () => + (activeSession?.aiTabs?.length ?? 0) + + (activeSession?.filePreviewTabs?.length ?? 0) + + (activeSession?.terminalTabs?.length ?? 0) + + (activeSession?.browserTabs?.length ?? 0) > + 0, + [ + activeSession?.aiTabs, + activeSession?.filePreviewTabs, + activeSession?.terminalTabs, + activeSession?.browserTabs, + ] + ); + // SSH remote name for header display const sshRemoteName = useSshRemoteName( activeSession?.sessionSshRemoteConfig?.enabled, @@ -832,80 +850,79 @@ export const MainPanel = React.memo( /> )} - {/* Tab Bar - shown in AI and terminal modes when we have tabs (AI + file + terminal) */} - {activeSession.aiTabs && - activeSession.aiTabs.length > 0 && - onTabSelect && - onTabClose && - onNewTab && ( - - )} + {/* Tab Bar - shown in AI and terminal modes when we have tabs of any kind. + An agent can sit at zero AI tabs while terminal/file/browser tabs are + open, so gating this on aiTabs alone would hide the whole strip (and + the "+" button) and strand the user in whatever view was last active. */} + {hasAnyTab && onTabSelect && onTabClose && onNewTab && ( + + )} {/* Agent Error Banner */} {activeTabError && ( diff --git a/src/renderer/components/QuickActionsModal/commands/tabCommands.ts b/src/renderer/components/QuickActionsModal/commands/tabCommands.ts index f740fa12a7..bfa9e1c427 100644 --- a/src/renderer/components/QuickActionsModal/commands/tabCommands.ts +++ b/src/renderer/components/QuickActionsModal/commands/tabCommands.ts @@ -251,7 +251,10 @@ export function buildTabCommands({ id: 'closeAllTabs', label: 'Close All Tabs', shortcut: tabShortcuts?.closeAllTabs, - subtext: `Close all ${activeSession.aiTabs.length} tabs (creates new tab)`, + subtext: + activeSession.aiTabs.length === 1 + ? 'Close 1 tab' + : `Close all ${activeSession.aiTabs.length} tabs`, action: () => { onCloseAllTabs(); setQuickActionOpen(false); diff --git a/src/renderer/hooks/remote/useRemoteIntegration.ts b/src/renderer/hooks/remote/useRemoteIntegration.ts index 300afbf35b..4932c61ca3 100644 --- a/src/renderer/hooks/remote/useRemoteIntegration.ts +++ b/src/renderer/hooks/remote/useRemoteIntegration.ts @@ -1220,7 +1220,9 @@ export function useRemoteIntegration(deps: UseRemoteIntegrationDeps): UseRemoteI prevSessionStatesRef.current.set(session.id, session.state); } - if (!session.aiTabs || session.aiTabs.length === 0) return; + // An empty aiTabs array is a valid state and still has to be broadcast, + // otherwise remote clients keep rendering tabs the user already closed. + if (!session.aiTabs) return; // Create a hash of tab properties that should trigger a broadcast when changed const tabsHash = session.aiTabs diff --git a/src/renderer/hooks/session/useSessionRestoration.ts b/src/renderer/hooks/session/useSessionRestoration.ts index 97da4bce42..5c99f973b0 100644 --- a/src/renderer/hooks/session/useSessionRestoration.ts +++ b/src/renderer/hooks/session/useSessionRestoration.ts @@ -203,11 +203,18 @@ export function useSessionRestoration(): SessionRestorationReturn { session = { ...session, createdAt: backfill }; } - // Sessions must have aiTabs - if missing, this is a data corruption issue - // Create a default tab to prevent crashes when code calls .find() on aiTabs - if (!session.aiTabs || session.aiTabs.length === 0) { + // An agent may legitimately have zero AI tabs as long as some other tab kind + // is still open (the user closed the last chat but kept a terminal around). + // Only a session with no tabs whatsoever is treated as data corruption - + // recovering the zero-AI-tab case would wipe the tabs the user still has. + const restoredTabCount = + (session.aiTabs?.length ?? 0) + + (session.filePreviewTabs?.length ?? 0) + + (session.terminalTabs?.length ?? 0) + + (session.browserTabs?.length ?? 0); + if (restoredTabCount === 0) { logger.error( - '[restoreSession] Session has no aiTabs - data corruption, creating default tab:', + '[restoreSession] Session has no tabs of any kind - data corruption, creating default tab:', undefined, session.id ); @@ -249,6 +256,12 @@ export function useSessionRestoration(): SessionRestorationReturn { }; } + // Normalize a missing aiTabs array so the rest of the app can keep calling + // .find()/.map() on it. Zero AI tabs is a valid state; undefined is not. + if (!session.aiTabs) { + session = { ...session, aiTabs: [] }; + } + // Fix inconsistency: activeFileTabId should only be set in AI mode. // If inputMode is 'terminal' but a file tab is still active, clear it to prevent // rendering a file preview without a tab bar (orphaned file preview bug). diff --git a/src/renderer/utils/tabHelpers.ts b/src/renderer/utils/tabHelpers.ts index a024b15656..74a56d0ebe 100644 --- a/src/renderer/utils/tabHelpers.ts +++ b/src/renderer/utils/tabHelpers.ts @@ -664,7 +664,8 @@ export interface CloseTabResult { * unless skipHistory is true (e.g., for wizard tabs which should not be restorable). * If the closed tab was active, the next tab (or previous if at end) becomes active. * When showUnreadOnly is true, prioritizes switching to the next unread tab. - * If closing the last tab, a fresh new tab is created to replace it. + * Closing the last AI tab creates a fresh replacement only when the agent has no + * other tabs (terminal/file/browser) left, so an agent can sit at zero AI tabs. * * @param session - The Maestro session containing the tab * @param tabId - The ID of the tab to close @@ -711,11 +712,22 @@ export function closeTab( // Remove tab from aiTabs let updatedTabs = session.aiTabs.filter((tab) => tab.id !== tabId); - // If we just closed the last tab, create a fresh new tab to replace it + // Tabs of other kinds that survive this close. Closing the last AI tab only + // forces a fresh replacement when the agent would otherwise be left with no + // tabs at all, so a brand new agent still always has a chat to type into. + // Once the user has opened terminal/file/browser tabs, the agent is allowed to + // sit at zero AI tabs instead of keeping a dead one around - the "+" menu is + // still on screen to open whatever they want next. + const otherTabCount = + (session.filePreviewTabs?.length ?? 0) + + (session.terminalTabs?.length ?? 0) + + (session.browserTabs?.length ?? 0); + let newActiveTabId = session.activeTabId; // Fallback unified tab ref when the closed tab was active — may be terminal or file let fallbackRef: UnifiedTabRef | null = null; - if (updatedTabs.length === 0) { + let createdFreshTab = false; + if (updatedTabs.length === 0 && otherTabCount === 0) { const freshTab: AITab = { id: generateId(), agentSessionId: null, @@ -729,11 +741,12 @@ export function closeTab( }; updatedTabs = [freshTab]; newActiveTabId = freshTab.id; + createdFreshTab = true; } else if (session.activeTabId === tabId) { // If we closed the active tab, select the tab to the left (previous tab) // If closing the first tab, select the new first tab (was previously to the right) - if (showUnreadOnly) { + if (showUnreadOnly && updatedTabs.length > 0) { // When filtering unread tabs, find the previous unread tab to switch to // Build a temporary session with the updated tabs to use getNavigableTabs const tempSession = { ...session, aiTabs: updatedTabs }; @@ -767,7 +780,7 @@ export function closeTab( if (closedUnifiedIndex !== -1 && remainingUnified.length > 0) { const fallbackIndex = Math.max(0, closedUnifiedIndex - 1); fallbackRef = remainingUnified[Math.min(fallbackIndex, remainingUnified.length - 1)]; - } else { + } else if (updatedTabs.length > 0) { // unifiedTabOrder out of sync — fall back to aiTabs position const newIndex = Math.max(0, tabIndex - 1); newActiveTabId = updatedTabs[newIndex].id; @@ -775,6 +788,12 @@ export function closeTab( } } + // No AI tab survives, so there is nothing for activeTabId to point at. Covers + // every path above, including closing a non-active sole AI tab. + if (updatedTabs.length === 0) { + newActiveTabId = ''; + } + // Add to closed tab history unless skipHistory is set (e.g., for wizard tabs) // Wizard tabs should not be restorable via Cmd+Shift+T const updatedHistory = options.skipHistory @@ -788,12 +807,17 @@ export function closeTab( // If we created a fresh tab, add it to unifiedTabOrder at the end let finalUnifiedTabOrder = updatedUnifiedTabOrder; - if (session.aiTabs.length === 1 && updatedTabs.length === 1 && updatedTabs[0].id !== tabId) { - // A fresh tab was created to replace the closed one + if (createdFreshTab) { const freshTabRef: UnifiedTabRef = { type: 'ai', id: updatedTabs[0].id }; finalUnifiedTabOrder = [...updatedUnifiedTabOrder, freshTabRef]; } + // With no AI tabs left, activeTabId must stop pointing at the tab we just + // removed. A dangling id makes a later switch back to AI mode render an input + // area bound to a tab that no longer exists. Non-AI fallbacks otherwise keep + // activeTabId so returning to AI mode lands on the same tab as before. + const survivingActiveTabId = updatedTabs.length === 0 ? '' : session.activeTabId; + // Create updated session. // When the fallback is a non-AI tab (terminal or file), we must update the corresponding // active ID and inputMode so the UI switches to the correct view. @@ -802,7 +826,8 @@ export function closeTab( ? { ...session, aiTabs: updatedTabs, - // Keep activeTabId as-is; the terminal tab is now active + // Keep activeTabId unless no AI tab survives; the terminal tab is now active + activeTabId: survivingActiveTabId, activeTerminalTabId: fallbackRef.id, activeFileTabId: null, inputMode: 'terminal', @@ -813,6 +838,7 @@ export function closeTab( ? { ...session, aiTabs: updatedTabs, + activeTabId: survivingActiveTabId, activeFileTabId: fallbackRef.id, activeBrowserTabId: null, activeTerminalTabId: null, @@ -824,6 +850,7 @@ export function closeTab( ? { ...session, aiTabs: updatedTabs, + activeTabId: survivingActiveTabId, activeFileTabId: null, activeBrowserTabId: fallbackRef.id, activeTerminalTabId: null,