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,