From 6c1606f9b3e771b521dab640de7a1009c216e85c Mon Sep 17 00:00:00 2001 From: Test User Date: Tue, 4 Aug 2026 07:11:47 -0700 Subject: [PATCH] fix(tabs): allow an agent to have zero AI tabs (#1062) Closing the last AI tab always spawned a fresh replacement, so users were stuck with a dead chat tab they could not get rid of even when they had terminal/file/browser tabs open. Keep the replacement only when the agent would otherwise be left with no tabs at all. A brand new agent still always has a chat to type into (the original reason the tab was sticky), but once other tabs exist the last AI tab closes cleanly and the agent sits at zero AI tabs until the user picks something from the "+" menu. Supporting fixes for the newly reachable zero-AI-tab state: - MainPanel gated the whole tab strip on aiTabs.length > 0, which would have hidden the "+" button and stranded the user. Gate on any tab kind. - closeTab left activeTabId pointing at the removed tab; a later switch back to AI mode would render an input bound to nothing. Clear it. - The unread-filter and out-of-sync fallbacks indexed updatedTabs[i] and would throw on an empty array. Guard both. - Session restoration treated empty aiTabs as data corruption and rebuilt a default tab while wiping filePreviewTabs/browserTabs/unifiedTabOrder. Restrict that recovery to sessions with no tabs whatsoever. - Remote broadcast bailed out on empty aiTabs, leaving remote clients showing tabs the user had already closed. - Drop the stale "(creates new tab)" subtext from the Close All command. --- .../renderer/utils/tabHelpers.test.ts | 97 ++++++++++ .../components/MainPanel/MainPanel.tsx | 165 ++++++++++-------- .../QuickActionsModal/commands/tabCommands.ts | 5 +- .../hooks/remote/useRemoteIntegration.ts | 4 +- .../hooks/session/useSessionRestoration.ts | 21 ++- src/renderer/utils/tabHelpers.ts | 43 ++++- 6 files changed, 247 insertions(+), 88 deletions(-) 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,