MAESTRO: Add file tabs to TabSwitcherModal (Cmd+Shift+O)

Updated TabSwitcherModal to display both AI and file preview tabs:
- Added fileTabs, activeFileTabId, and onFileTabSelect props
- File tabs show in "Open Tabs" mode with AI tabs, sorted alphabetically
- Each file tab displays: filename, extension badge with type-specific
  coloring, file path, "File" indicator, active/unsaved indicators
- Search works across file names, extensions, and paths
- Updated AppModals and App.tsx to pass file tabs and handlers
- Added 9 new tests (total 85 tests pass)
This commit is contained in:
Pedram Amini
2026-02-02 05:37:11 -06:00
parent 711b952f7a
commit ff7e8d89dc
4 changed files with 477 additions and 25 deletions

View File

@@ -22,6 +22,7 @@ import type { Theme, AITab } from '../../../renderer/types';
vi.mock('lucide-react', () => ({
Search: () => <svg data-testid="search-icon" />,
Star: () => <svg data-testid="star-icon" />,
FileText: () => <svg data-testid="file-text-icon" />,
}));
// Create a test theme
@@ -2104,4 +2105,262 @@ describe('TabSwitcherModal', () => {
expect(screen.getByText('1 sessions')).toBeInTheDocument();
});
});
describe('file tab support', () => {
// Helper to create a test file tab
const createTestFileTab = (overrides: Partial<import('../../../renderer/types').FilePreviewTab> = {}) => ({
id: `file-tab-${Math.random().toString(36).substr(2, 9)}`,
path: '/test/project/src/example.ts',
name: 'example',
extension: '.ts',
content: 'export const example = 1;',
scrollTop: 0,
searchQuery: '',
editMode: false,
editContent: undefined,
createdAt: Date.now(),
lastModified: Date.now(),
...overrides,
});
it('includes file tabs in Open Tabs count', () => {
const aiTabs = [createTestTab({ name: 'AI Tab' })];
const fileTabs = [
createTestFileTab({ name: 'file1', extension: '.ts' }),
createTestFileTab({ name: 'file2', extension: '.md' }),
];
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={aiTabs}
fileTabs={fileTabs}
activeTabId={aiTabs[0].id}
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
// Should show 3 total tabs (1 AI + 2 file)
expect(screen.getByText('Open Tabs (3)')).toBeInTheDocument();
});
it('renders file tabs with extension badge', () => {
const fileTabs = [createTestFileTab({ name: 'example', extension: '.ts' })];
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={[]}
fileTabs={fileTabs}
activeTabId=""
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('example')).toBeInTheDocument();
expect(screen.getByText('.ts')).toBeInTheDocument();
expect(screen.getByText('File')).toBeInTheDocument();
});
it('calls onFileTabSelect when clicking a file tab', () => {
const fileTabs = [createTestFileTab({ name: 'myfile' })];
const onFileTabSelect = vi.fn();
const onClose = vi.fn();
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={[]}
fileTabs={fileTabs}
activeTabId=""
projectRoot="/test"
onTabSelect={vi.fn()}
onFileTabSelect={onFileTabSelect}
onNamedSessionSelect={vi.fn()}
onClose={onClose}
/>
);
fireEvent.click(screen.getByText('myfile'));
expect(onFileTabSelect).toHaveBeenCalledWith(fileTabs[0].id);
expect(onClose).toHaveBeenCalled();
});
it('filters file tabs by search query', () => {
const fileTabs = [
createTestFileTab({ name: 'component', extension: '.tsx' }),
createTestFileTab({ name: 'utils', extension: '.ts' }),
];
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={[]}
fileTabs={fileTabs}
activeTabId=""
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
const input = screen.getByPlaceholderText('Search open tabs...');
fireEvent.change(input, { target: { value: 'component' } });
expect(screen.getByText('component')).toBeInTheDocument();
expect(screen.queryByText('utils')).not.toBeInTheDocument();
});
it('shows unsaved indicator for file tabs with edits', () => {
const fileTabs = [
createTestFileTab({ name: 'unsaved', editContent: 'some edited content' }),
];
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={[]}
fileTabs={fileTabs}
activeTabId=""
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
// Unsaved indicator should be shown
expect(screen.getByText('●')).toBeInTheDocument();
});
it('shows active indicator for active file tab', () => {
const fileTabs = [createTestFileTab({ id: 'active-file-tab' })];
const { container } = renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={[]}
fileTabs={fileTabs}
activeTabId=""
activeFileTabId="active-file-tab"
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
// Active file tab should show a green dot instead of file icon
const dots = container.querySelectorAll('.w-2.h-2.rounded-full');
const greenDot = Array.from(dots).find((d) => {
const style = d.getAttribute('style') || '';
return style.includes('rgb(137, 209, 133)') || style.includes(theme.colors.success);
});
expect(greenDot).toBeTruthy();
});
it('sorts file tabs alphabetically with AI tabs', () => {
const aiTabs = [createTestTab({ name: 'Beta AI' })];
const fileTabs = [
createTestFileTab({ name: 'Zeta' }),
createTestFileTab({ name: 'Alpha' }),
];
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={aiTabs}
fileTabs={fileTabs}
activeTabId={aiTabs[0].id}
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
const buttons = screen
.getAllByRole('button')
.filter(
(b) =>
b.textContent?.includes('Alpha') ||
b.textContent?.includes('Beta AI') ||
b.textContent?.includes('Zeta')
);
// Should be sorted: Alpha (file), Beta AI (ai), Zeta (file)
expect(buttons[0]).toHaveTextContent('Alpha');
expect(buttons[1]).toHaveTextContent('Beta AI');
expect(buttons[2]).toHaveTextContent('Zeta');
});
it('shows file extension badge for non-selected file tabs', () => {
// Create two file tabs - the second one won't be selected by default
const fileTabs = [
createTestFileTab({ name: 'aaa', extension: '.ts' }),
createTestFileTab({ name: 'readme', extension: '.md' }),
];
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={[]}
fileTabs={fileTabs}
activeTabId=""
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
// The .md extension should be present
const mdBadge = screen.getByText('.md');
expect(mdBadge).toBeInTheDocument();
// The .ts extension should also be present
const tsBadge = screen.getByText('.ts');
expect(tsBadge).toBeInTheDocument();
// Check second file tab's (readme.md) extension has green-ish color
// (first item is selected, so it has a different color)
const mdStyle = mdBadge.getAttribute('style') || '';
expect(mdStyle).toContain('background-color');
// Green color for markdown files
expect(mdStyle).toMatch(/34,\s*197,\s*94/);
});
it('renders file path in file tab item', () => {
const fileTabs = [
createTestFileTab({
name: 'example',
extension: '.ts',
path: '/project/src/components/example.ts',
}),
];
renderWithLayerStack(
<TabSwitcherModal
theme={theme}
tabs={[]}
fileTabs={fileTabs}
activeTabId=""
projectRoot="/test"
onTabSelect={vi.fn()}
onNamedSessionSelect={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText('/project/src/components/example.ts')).toBeInTheDocument();
});
});
});

View File

@@ -12179,8 +12179,21 @@ You are taking over this conversation. Based on the context above, provide a bri
const handleUtilityTabSelect = useCallback(
(tabId: string) => {
if (!activeSession) return;
// Clear activeFileTabId when selecting an AI tab
setSessions((prev) =>
prev.map((s) => (s.id === activeSession.id ? { ...s, activeTabId: tabId } : s))
prev.map((s) =>
s.id === activeSession.id ? { ...s, activeTabId: tabId, activeFileTabId: null } : s
)
);
},
[activeSession]
);
const handleUtilityFileTabSelect = useCallback(
(tabId: string) => {
if (!activeSession) return;
// Set activeFileTabId, keep activeTabId as-is (for when returning to AI tabs)
setSessions((prev) =>
prev.map((s) => (s.id === activeSession.id ? { ...s, activeFileTabId: tabId } : s))
);
},
[activeSession]
@@ -13715,6 +13728,7 @@ You are taking over this conversation. Based on the context above, provide a bri
tabSwitcherOpen={tabSwitcherOpen}
onCloseTabSwitcher={handleCloseTabSwitcher}
onTabSelect={handleUtilityTabSelect}
onFileTabSelect={handleUtilityFileTabSelect}
onNamedSessionSelect={handleNamedSessionSelect}
fuzzyFileSearchOpen={fuzzyFileSearchOpen}
filteredFileTree={filteredFileTree}

View File

@@ -867,6 +867,7 @@ export interface AppUtilityModalsProps {
tabSwitcherOpen: boolean;
onCloseTabSwitcher: () => void;
onTabSelect: (tabId: string) => void;
onFileTabSelect?: (tabId: string) => void;
onNamedSessionSelect: (
agentSessionId: string,
projectPath: string,
@@ -1048,6 +1049,7 @@ export function AppUtilityModals({
tabSwitcherOpen,
onCloseTabSwitcher,
onTabSelect,
onFileTabSelect,
onNamedSessionSelect,
// FileSearchModal
fuzzyFileSearchOpen,
@@ -1243,11 +1245,14 @@ export function AppUtilityModals({
<TabSwitcherModal
theme={theme}
tabs={activeSession.aiTabs}
fileTabs={activeSession.filePreviewTabs}
activeTabId={activeSession.activeTabId}
activeFileTabId={activeSession.activeFileTabId}
projectRoot={activeSession.projectRoot}
agentId={activeSession.toolType}
shortcut={tabShortcuts.tabSwitcher}
onTabSelect={onTabSelect}
onFileTabSelect={onFileTabSelect}
onNamedSessionSelect={onNamedSessionSelect}
onClose={onCloseTabSwitcher}
/>
@@ -1928,6 +1933,7 @@ export interface AppModalsProps {
tabSwitcherOpen: boolean;
onCloseTabSwitcher: () => void;
onTabSelect: (tabId: string) => void;
onFileTabSelect?: (tabId: string) => void;
onNamedSessionSelect: (
agentSessionId: string,
projectPath: string,

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Search, Star } from 'lucide-react';
import type { AITab, Theme, Shortcut, ToolType } from '../types';
import { Search, Star, FileText } from 'lucide-react';
import type { AITab, FilePreviewTab, Theme, Shortcut, ToolType } from '../types';
import { fuzzyMatchWithScore } from '../utils/search';
import { useLayerStack } from '../contexts/LayerStackContext';
import { useListNavigation } from '../hooks';
@@ -21,16 +21,25 @@ interface NamedSession {
}
/** Union type for items in the list */
type ListItem = { type: 'open'; tab: AITab } | { type: 'named'; session: NamedSession };
type ListItem =
| { type: 'open'; tab: AITab }
| { type: 'file'; tab: FilePreviewTab }
| { type: 'named'; session: NamedSession };
interface TabSwitcherModalProps {
theme: Theme;
tabs: AITab[];
/** File preview tabs to include in "Open Tabs" view */
fileTabs?: FilePreviewTab[];
activeTabId: string;
/** Currently active file tab ID (if a file tab is active) */
activeFileTabId?: string | null;
projectRoot: string; // The initial project directory (used for Claude session storage)
agentId?: string;
shortcut?: Shortcut;
onTabSelect: (tabId: string) => void;
/** Handler to select a file tab */
onFileTabSelect?: (tabId: string) => void;
onNamedSessionSelect: (
agentSessionId: string,
projectPath: string,
@@ -96,6 +105,37 @@ function getUuidPill(agentSessionId: string | undefined | null): string | null {
return agentSessionId.split('-')[0].toUpperCase();
}
/**
* Get color for file extension badge.
* Returns a muted color based on file type for visual differentiation.
* (Copied from TabBar.tsx for consistency)
*/
function getExtensionColor(extension: string, theme: Theme): { bg: string; text: string } {
const ext = extension.toLowerCase();
// TypeScript/JavaScript - blue tones
if (['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'].includes(ext)) {
return { bg: 'rgba(59, 130, 246, 0.3)', text: 'rgba(147, 197, 253, 0.9)' };
}
// Markdown/Docs - green tones
if (['.md', '.mdx', '.txt', '.rst'].includes(ext)) {
return { bg: 'rgba(34, 197, 94, 0.3)', text: 'rgba(134, 239, 172, 0.9)' };
}
// JSON/Config - yellow tones
if (['.json', '.yaml', '.yml', '.toml', '.ini', '.env'].includes(ext)) {
return { bg: 'rgba(234, 179, 8, 0.3)', text: 'rgba(253, 224, 71, 0.9)' };
}
// CSS/Styles - purple tones
if (['.css', '.scss', '.sass', '.less', '.styl'].includes(ext)) {
return { bg: 'rgba(168, 85, 247, 0.3)', text: 'rgba(216, 180, 254, 0.9)' };
}
// HTML/Templates - orange tones
if (['.html', '.htm', '.xml', '.svg'].includes(ext)) {
return { bg: 'rgba(249, 115, 22, 0.3)', text: 'rgba(253, 186, 116, 0.9)' };
}
// Default - use theme's dim colors
return { bg: theme.colors.border, text: theme.colors.textDim };
}
/**
* Circular progress gauge component
*/
@@ -154,18 +194,22 @@ function ContextGauge({
type ViewMode = 'open' | 'all-named' | 'starred';
/**
* Tab Switcher Modal - Quick navigation between AI tabs with fuzzy search.
* Shows context window consumption, cost, custom name, and UUID pill for each tab.
* Supports switching between "Open Tabs" and "All Named" sessions.
* Tab Switcher Modal - Quick navigation between AI and file tabs with fuzzy search.
* Shows context window consumption, cost, custom name, and UUID pill for AI tabs.
* Shows filename, extension badge, and file icon for file tabs.
* Supports switching between "Open Tabs", "All Named" sessions, and "Starred".
*/
export function TabSwitcherModal({
theme,
tabs,
fileTabs = [],
activeTabId,
activeFileTabId,
projectRoot,
agentId = 'claude-code',
shortcut,
onTabSelect,
onFileTabSelect,
onNamedSessionSelect,
onClose,
}: TabSwitcherModalProps) {
@@ -270,13 +314,37 @@ export function TabSwitcherModal({
// Build the list items based on view mode
const listItems: ListItem[] = useMemo(() => {
if (viewMode === 'open') {
// Open tabs mode - show all currently open tabs
const sorted = [...tabs].sort((a, b) => {
const nameA = getTabDisplayName(a).toLowerCase();
const nameB = getTabDisplayName(b).toLowerCase();
// Open tabs mode - show all currently open tabs (AI and file tabs)
const items: ListItem[] = [];
// Add AI tabs
for (const tab of tabs) {
items.push({ type: 'open' as const, tab });
}
// Add file tabs
for (const tab of fileTabs) {
items.push({ type: 'file' as const, tab });
}
// Sort alphabetically by display name
items.sort((a, b) => {
const nameA =
a.type === 'open'
? getTabDisplayName(a.tab).toLowerCase()
: a.type === 'file'
? a.tab.name.toLowerCase()
: '';
const nameB =
b.type === 'open'
? getTabDisplayName(b.tab).toLowerCase()
: b.type === 'file'
? b.tab.name.toLowerCase()
: '';
return nameA.localeCompare(nameB);
});
return sorted.map((tab) => ({ type: 'open' as const, tab }));
return items;
} else if (viewMode === 'starred') {
// Starred mode - show all starred sessions (open or closed) for the current project
const items: ListItem[] = [];
@@ -304,11 +372,15 @@ export function TabSwitcherModal({
const nameA =
a.type === 'open'
? getTabDisplayName(a.tab).toLowerCase()
: a.session.sessionName.toLowerCase();
: a.type === 'named'
? a.session.sessionName.toLowerCase()
: '';
const nameB =
b.type === 'open'
? getTabDisplayName(b.tab).toLowerCase()
: b.session.sessionName.toLowerCase();
: b.type === 'named'
? b.session.sessionName.toLowerCase()
: '';
return nameA.localeCompare(nameB);
});
@@ -345,17 +417,21 @@ export function TabSwitcherModal({
const nameA =
a.type === 'open'
? getTabDisplayName(a.tab).toLowerCase()
: a.session.sessionName.toLowerCase();
: a.type === 'named'
? a.session.sessionName.toLowerCase()
: '';
const nameB =
b.type === 'open'
? getTabDisplayName(b.tab).toLowerCase()
: b.session.sessionName.toLowerCase();
: b.type === 'named'
? b.session.sessionName.toLowerCase()
: '';
return nameA.localeCompare(nameB);
});
return items;
}
}, [viewMode, tabs, namedSessions, openTabSessionIds, projectRoot]);
}, [viewMode, tabs, fileTabs, namedSessions, openTabSessionIds, projectRoot]);
// Filter items based on search query
const filteredItems = useMemo(() => {
@@ -366,21 +442,25 @@ export function TabSwitcherModal({
// Fuzzy search
const results = listItems.map((item) => {
let displayName: string;
let uuid: string;
let searchableId: string;
if (item.type === 'open') {
displayName = getTabDisplayName(item.tab);
uuid = item.tab.agentSessionId || '';
searchableId = item.tab.agentSessionId || '';
} else if (item.type === 'file') {
// For file tabs, search by name and extension
displayName = item.tab.name;
searchableId = item.tab.extension + ' ' + item.tab.path;
} else {
displayName = item.session.sessionName;
uuid = item.session.agentSessionId;
searchableId = item.session.agentSessionId;
}
const nameResult = fuzzyMatchWithScore(displayName, search);
const uuidResult = fuzzyMatchWithScore(uuid, search);
const idResult = fuzzyMatchWithScore(searchableId, search);
const bestScore = Math.max(nameResult.score, uuidResult.score);
const matches = nameResult.matches || uuidResult.matches;
const bestScore = Math.max(nameResult.score, idResult.score);
const matches = nameResult.matches || idResult.matches;
return { item, score: bestScore, matches };
});
@@ -398,6 +478,8 @@ export function TabSwitcherModal({
if (item) {
if (item.type === 'open') {
onTabSelect(item.tab.id);
} else if (item.type === 'file') {
onFileTabSelect?.(item.tab.id);
} else {
onNamedSessionSelect(
item.session.agentSessionId,
@@ -409,7 +491,7 @@ export function TabSwitcherModal({
onClose();
}
},
[filteredItems, onTabSelect, onNamedSessionSelect, onClose]
[filteredItems, onTabSelect, onFileTabSelect, onNamedSessionSelect, onClose]
);
// Use the list navigation hook for keyboard navigation
@@ -528,7 +610,7 @@ export function TabSwitcherModal({
color: viewMode === 'open' ? theme.colors.accentForeground : theme.colors.textDim,
}}
>
Open Tabs ({tabs.length})
Open Tabs ({tabs.length + fileTabs.length})
</button>
<button
onClick={() => setViewMode('all-named')}
@@ -690,6 +772,97 @@ export function TabSwitcherModal({
)}
</button>
);
} else if (item.type === 'file') {
// File preview tab
const { tab } = item;
const isActive = tab.id === activeFileTabId;
const extColors = getExtensionColor(tab.extension, theme);
const hasUnsavedEdits = !!tab.editContent;
return (
<button
key={tab.id}
ref={isSelected ? selectedItemRef : null}
onClick={() => handleSelectByIndex(i)}
className="w-full text-left px-4 py-3 flex items-center gap-3 hover:bg-opacity-10"
style={{
backgroundColor: isSelected ? theme.colors.accent : 'transparent',
color: isSelected ? theme.colors.accentForeground : theme.colors.textMain,
}}
>
{/* Number Badge */}
{showNumber ? (
<div
className="flex-shrink-0 w-5 h-5 rounded flex items-center justify-center text-xs font-bold"
style={{ backgroundColor: theme.colors.bgMain, color: theme.colors.textDim }}
>
{numberBadge}
</div>
) : (
<div className="flex-shrink-0 w-5 h-5" />
)}
{/* File Icon - shows active indicator or file icon */}
<div className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
{isActive ? (
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: theme.colors.success }}
/>
) : (
<FileText
className="w-3.5 h-3.5"
style={{ color: theme.colors.textDim }}
/>
)}
</div>
{/* File Info */}
<div className="flex flex-col flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">{tab.name}</span>
{/* Extension badge */}
<span
className="text-[10px] px-1.5 py-0.5 rounded font-mono flex-shrink-0"
style={{
backgroundColor: isSelected
? 'rgba(255,255,255,0.2)'
: extColors.bg,
color: isSelected
? theme.colors.accentForeground
: extColors.text,
}}
>
{tab.extension}
</span>
{/* Unsaved indicator */}
{hasUnsavedEdits && (
<span
className="text-[10px] opacity-80"
style={{ color: theme.colors.warning }}
>
</span>
)}
</div>
{/* File path (truncated) */}
<div className="flex items-center gap-3 text-[10px] opacity-60 truncate">
<span className="truncate">{tab.path}</span>
</div>
</div>
{/* File indicator instead of gauge */}
<div
className="flex-shrink-0 text-[10px] px-2 py-1 rounded"
style={{
backgroundColor: isSelected ? 'rgba(255,255,255,0.2)' : theme.colors.bgMain,
color: isSelected ? theme.colors.accentForeground : theme.colors.textDim,
}}
>
File
</div>
</button>
);
} else {
// Named session (not open)
const { session } = item;