Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions src/__tests__/renderer/utils/tabHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
165 changes: 91 additions & 74 deletions src/renderer/components/MainPanel/MainPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 && (
<TabBar
tabs={activeSession.aiTabs}
activeTabId={activeSession.activeTabId}
theme={theme}
sessionId={activeSession.id}
sessionAgentSessionId={activeSession.agentSessionId}
onTabSelect={onTabSelect}
onTabClose={onTabClose}
onNewTab={onNewTab}
onRequestRename={onRequestTabRename}
onTabReorder={onTabReorder}
onUnifiedTabReorder={onUnifiedTabReorder}
onTabStar={onTabStar}
onTabMarkUnread={onTabMarkUnread}
onMergeWith={onMergeWith}
onSendToAgent={onSendToAgent}
onSummarizeAndContinue={onSummarizeAndContinue}
onCopyContext={onCopyContext}
onExportHtml={onExportHtml}
onSnooze={handleOpenSnooze}
onPublishGist={props.onPublishTabGist}
ghCliAvailable={props.ghCliAvailable}
showUnreadOnly={showUnreadOnly}
onToggleUnreadFilter={onToggleUnreadFilter}
onOpenTabSearch={onOpenTabSearch}
onOpenOutputSearch={onOpenOutputSearch}
onOpenCrossTabSearch={onOpenCrossTabSearch}
onCloseAllTabs={onCloseAllTabs}
onCloseOtherTabs={onCloseOtherTabs}
onCloseTabsLeft={onCloseTabsLeft}
onCloseTabsRight={onCloseTabsRight}
// Unified tab system props (Phase 4)
unifiedTabs={unifiedTabs}
activeFileTabId={activeFileTabId}
activeBrowserTabId={activeBrowserTabId}
onFileTabSelect={onFileTabSelect}
onFileTabClose={onFileTabClose}
onNewFileTab={onNewFileTab}
onNewBrowserTab={onNewBrowserTab}
onBrowserTabSelect={onBrowserTabSelect}
onBrowserTabClose={onBrowserTabClose}
onBrowserTabRename={onBrowserTabRename}
onBrowserTabResetName={onBrowserTabResetName}
// Terminal tab props (Phase 8)
onNewTerminalTab={onNewTerminalTab}
activeTerminalTabId={activeSession.activeTerminalTabId}
inputMode={activeSession.inputMode}
onTerminalTabSelect={onTerminalTabSelect}
onTerminalTabClose={onTerminalTabClose}
onTerminalTabRename={onTerminalTabRename}
onTerminalTabConfigureStartupCommand={onTerminalTabConfigureStartupCommand}
onCopyTerminalBuffer={props.onCopyText ? handleCopyTerminalBuffer : undefined}
onPublishTerminalBufferGist={
props.onPublishTextAsGist ? handlePublishTerminalBufferGist : undefined
}
onSendTerminalBufferToAgent={
props.onSendTextToAgent ? handleSendTerminalBufferToAgent : undefined
}
onCopyBrowserContent={props.onCopyText ? handleCopyBrowserContent : undefined}
onSendBrowserContentToAgent={
props.onSendTextToAgent ? handleSendBrowserContentToAgent : undefined
}
// Accessibility
colorBlindMode={colorBlindMode}
// Hide local-only OS actions (Reveal in Finder) when the agent runs over SSH
sshRemote={Boolean(filePreviewSshRemoteId)}
/>
)}
{/* 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 && (
<TabBar
tabs={activeSession.aiTabs}
activeTabId={activeSession.activeTabId}
theme={theme}
sessionId={activeSession.id}
sessionAgentSessionId={activeSession.agentSessionId}
onTabSelect={onTabSelect}
onTabClose={onTabClose}
onNewTab={onNewTab}
onRequestRename={onRequestTabRename}
onTabReorder={onTabReorder}
onUnifiedTabReorder={onUnifiedTabReorder}
onTabStar={onTabStar}
onTabMarkUnread={onTabMarkUnread}
onMergeWith={onMergeWith}
onSendToAgent={onSendToAgent}
onSummarizeAndContinue={onSummarizeAndContinue}
onCopyContext={onCopyContext}
onExportHtml={onExportHtml}
onSnooze={handleOpenSnooze}
onPublishGist={props.onPublishTabGist}
ghCliAvailable={props.ghCliAvailable}
showUnreadOnly={showUnreadOnly}
onToggleUnreadFilter={onToggleUnreadFilter}
onOpenTabSearch={onOpenTabSearch}
onOpenOutputSearch={onOpenOutputSearch}
onOpenCrossTabSearch={onOpenCrossTabSearch}
onCloseAllTabs={onCloseAllTabs}
onCloseOtherTabs={onCloseOtherTabs}
onCloseTabsLeft={onCloseTabsLeft}
onCloseTabsRight={onCloseTabsRight}
// Unified tab system props (Phase 4)
unifiedTabs={unifiedTabs}
activeFileTabId={activeFileTabId}
activeBrowserTabId={activeBrowserTabId}
onFileTabSelect={onFileTabSelect}
onFileTabClose={onFileTabClose}
onNewFileTab={onNewFileTab}
onNewBrowserTab={onNewBrowserTab}
onBrowserTabSelect={onBrowserTabSelect}
onBrowserTabClose={onBrowserTabClose}
onBrowserTabRename={onBrowserTabRename}
onBrowserTabResetName={onBrowserTabResetName}
// Terminal tab props (Phase 8)
onNewTerminalTab={onNewTerminalTab}
activeTerminalTabId={activeSession.activeTerminalTabId}
inputMode={activeSession.inputMode}
onTerminalTabSelect={onTerminalTabSelect}
onTerminalTabClose={onTerminalTabClose}
onTerminalTabRename={onTerminalTabRename}
onTerminalTabConfigureStartupCommand={onTerminalTabConfigureStartupCommand}
onCopyTerminalBuffer={props.onCopyText ? handleCopyTerminalBuffer : undefined}
onPublishTerminalBufferGist={
props.onPublishTextAsGist ? handlePublishTerminalBufferGist : undefined
}
onSendTerminalBufferToAgent={
props.onSendTextToAgent ? handleSendTerminalBufferToAgent : undefined
}
onCopyBrowserContent={props.onCopyText ? handleCopyBrowserContent : undefined}
onSendBrowserContentToAgent={
props.onSendTextToAgent ? handleSendBrowserContentToAgent : undefined
}
// Accessibility
colorBlindMode={colorBlindMode}
// Hide local-only OS actions (Reveal in Finder) when the agent runs over SSH
sshRemote={Boolean(filePreviewSshRemoteId)}
/>
)}

{/* Agent Error Banner */}
{activeTabError && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/hooks/remote/useRemoteIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions src/renderer/hooks/session/useSessionRestoration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +210 to +214

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Recovery Counts Disposable Terminal Tabs

When the last AI tab is closed beside an ordinary terminal and Maestro restarts, this check counts that terminal and skips recovery before the terminal is later discarded for lacking a startup command, causing the session to open with no usable tabs and a blank AI workspace.

if (restoredTabCount === 0) {
Comment on lines +206 to +215

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Count only terminal tabs that survive restoration.

A session with zero AI tabs and one terminal tab without startupCommand passes this check. Lines 415-425 then discard that terminal tab. The restored session has no tabs and does not receive the default AI tab.

Count only terminal tabs with a startup command here, or repeat the empty-session recovery check after transient terminal tabs are removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/session/useSessionRestoration.ts` around lines 206 - 215,
Update the restoredTabCount calculation in the session restoration flow to count
only terminal tabs that will survive restoration, excluding terminal tabs
without a startupCommand. Alternatively, rerun the empty-session recovery check
after transient terminal tabs are discarded so sessions left with no tabs still
receive the default AI tab.

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
);
Expand Down Expand Up @@ -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: [] };
}
Comment on lines +259 to +263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear stale AI selection during zero-AI restoration.

This normalization accepts an empty aiTabs array, but the later fallback keeps correctedSession.activeTabId when resetAiTabs is empty. A persisted session with non-AI tabs can therefore restore an ID for an AI tab that does not exist.

Use an empty string when no reset AI tab exists.

Proposed fix
 const restoredActiveTabId = validAiTabIds.has(correctedSession.activeTabId)
 	? correctedSession.activeTabId
-	: resetAiTabs[0]?.id || correctedSession.activeTabId;
+	: resetAiTabs[0]?.id || '';
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderer/hooks/session/useSessionRestoration.ts` around lines 259 - 263,
Update the session restoration fallback near the aiTabs normalization so that
when resetAiTabs contains no AI tab, correctedSession.activeTabId is set to an
empty string instead of preserving the stale ID. Keep the existing selection
behavior when a reset AI tab is available.


// 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).
Expand Down
Loading