Files
Maestro/src/renderer/components/MainPanel.tsx
Pedram Amini 2aa3cc701e MAESTRO: Implement file link navigation behavior
Regular click on file links replaces current tab content, while
Cmd/Ctrl+Click opens a new tab adjacent to the current tab.
2026-02-02 17:13:37 -06:00

1843 lines
69 KiB
TypeScript

import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from 'react';
import {
Wand2,
ExternalLink,
Columns,
Copy,
Loader2,
GitBranch,
ArrowUp,
ArrowDown,
FileEdit,
List,
AlertCircle,
X,
GitPullRequest,
Settings2,
Server,
} from 'lucide-react';
import { LogViewer } from './LogViewer';
import { TerminalOutput } from './TerminalOutput';
import { InputArea } from './InputArea';
import { FilePreview, FilePreviewHandle } from './FilePreview';
import { ErrorBoundary } from './ErrorBoundary';
import { GitStatusWidget } from './GitStatusWidget';
import { AgentSessionsBrowser } from './AgentSessionsBrowser';
import { TabBar } from './TabBar';
import { WizardConversationView, DocumentGenerationView } from './InlineWizard';
import { gitService } from '../services/git';
import { remoteUrlToBrowserUrl } from '../../shared/gitUtils';
import { useGitBranch, useGitDetail, useGitFileStatus } from '../contexts/GitStatusContext';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
import { calculateContextTokens } from '../utils/contextUsage';
import { useAgentCapabilities, useHoverTooltip } from '../hooks';
import type { Session, Theme, Shortcut, FocusArea, BatchRunState, UnifiedTab, FilePreviewTab } from '../types';
interface SlashCommand {
command: string;
description: string;
}
/**
* Handle for MainPanel component to expose methods to parent.
*/
export interface MainPanelHandle {
/** Refresh git info (branch, ahead/behind, uncommitted changes) */
refreshGitInfo: () => Promise<void>;
/** Focus the file preview container (if open) */
focusFilePreview: () => void;
}
interface MainPanelProps {
// State
logViewerOpen: boolean;
agentSessionsOpen: boolean;
activeAgentSessionId: string | null;
activeSession: Session | null;
// PERF: Receive pre-filtered thinkingSessions instead of full sessions array.
// This prevents cascade re-renders when unrelated session updates occur.
thinkingSessions: Session[];
theme: Theme;
fontFamily: string;
isMobileLandscape?: boolean;
activeFocus: FocusArea;
outputSearchOpen: boolean;
outputSearchQuery: string;
inputValue: string;
enterToSendAI: boolean;
enterToSendTerminal: boolean;
stagedImages: string[];
commandHistoryOpen: boolean;
commandHistoryFilter: string;
commandHistorySelectedIndex: number;
slashCommandOpen: boolean;
slashCommands: SlashCommand[];
selectedSlashCommandIndex: number;
// Tab completion props
tabCompletionOpen?: boolean;
tabCompletionSuggestions?: import('../hooks').TabCompletionSuggestion[];
selectedTabCompletionIndex?: number;
tabCompletionFilter?: import('../hooks').TabCompletionFilter;
// @ mention completion props (AI mode)
atMentionOpen?: boolean;
atMentionFilter?: string;
atMentionStartIndex?: number;
atMentionSuggestions?: Array<{
value: string;
type: 'file' | 'folder';
displayText: string;
fullPath: string;
}>;
selectedAtMentionIndex?: number;
filePreviewLoading?: { name: string; path: string } | null;
markdownEditMode: boolean; // FilePreview: whether editing file content
chatRawTextMode: boolean; // TerminalOutput: whether to show raw text in AI responses
shortcuts: Record<string, Shortcut>;
rightPanelOpen: boolean;
maxOutputLines: number;
gitDiffPreview: string | null;
fileTreeFilterOpen: boolean;
logLevel?: string; // Current log level setting for LogViewer
logViewerSelectedLevels: string[]; // Persisted filter selections for LogViewer
setLogViewerSelectedLevels: (levels: string[]) => void;
// Setters
setGitDiffPreview: (preview: string | null) => void;
setLogViewerOpen: (open: boolean) => void;
setAgentSessionsOpen: (open: boolean) => void;
setActiveAgentSessionId: (id: string | null) => void;
onResumeAgentSession: (
agentSessionId: string,
messages: import('../types').LogEntry[],
sessionName?: string,
starred?: boolean,
usageStats?: import('../types').UsageStats
) => void;
onNewAgentSession: () => void;
setActiveFocus: (focus: FocusArea) => void;
setOutputSearchOpen: (open: boolean) => void;
setOutputSearchQuery: (query: string) => void;
setInputValue: (value: string) => void;
setEnterToSendAI: (value: boolean) => void;
setEnterToSendTerminal: (value: boolean) => void;
setStagedImages: React.Dispatch<React.SetStateAction<string[]>>;
setLightboxImage: (
image: string | null,
contextImages?: string[],
source?: 'staged' | 'history'
) => void;
setCommandHistoryOpen: (open: boolean) => void;
setCommandHistoryFilter: (filter: string) => void;
setCommandHistorySelectedIndex: (index: number) => void;
setSlashCommandOpen: (open: boolean) => void;
setSelectedSlashCommandIndex: (index: number) => void;
// Tab completion setters
setTabCompletionOpen?: (open: boolean) => void;
setSelectedTabCompletionIndex?: (index: number) => void;
setTabCompletionFilter?: (filter: import('../hooks').TabCompletionFilter) => void;
// @ mention completion setters
setAtMentionOpen?: (open: boolean) => void;
setAtMentionFilter?: (filter: string) => void;
setAtMentionStartIndex?: (index: number) => void;
setSelectedAtMentionIndex?: (index: number) => void;
setMarkdownEditMode: (mode: boolean) => void;
setChatRawTextMode: (mode: boolean) => void;
setAboutModalOpen: (open: boolean) => void;
setRightPanelOpen: (open: boolean) => void;
setGitLogOpen: (open: boolean) => void;
// Refs
inputRef: React.RefObject<HTMLTextAreaElement>;
logsEndRef: React.RefObject<HTMLDivElement>;
terminalOutputRef: React.RefObject<HTMLDivElement>;
fileTreeContainerRef: React.RefObject<HTMLDivElement>;
fileTreeFilterInputRef: React.RefObject<HTMLInputElement>;
// Functions
toggleInputMode: () => void;
processInput: () => void;
handleInterrupt: () => void;
handleInputKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
handlePaste: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
handleDrop: (e: React.DragEvent<HTMLElement>) => void;
getContextColor: (usage: number, theme: Theme) => string;
setActiveSessionId: (id: string) => void;
onDeleteLog?: (logId: string) => number | null;
onRemoveQueuedItem?: (itemId: string) => void;
onOpenQueueBrowser?: () => void;
// Auto mode props
batchRunState?: BatchRunState; // For display (may be from any session with active batch)
currentSessionBatchState?: BatchRunState | null; // For current session only (input highlighting)
onStopBatchRun?: (sessionId?: string) => void;
showConfirmation?: (message: string, onConfirm: () => void) => void;
// Tab management for AI sessions
onTabSelect?: (tabId: string) => void;
onTabClose?: (tabId: string) => void;
onNewTab?: () => void;
onRequestTabRename?: (tabId: string) => void;
onTabReorder?: (fromIndex: number, toIndex: number) => void;
onUnifiedTabReorder?: (fromIndex: number, toIndex: number) => void;
onTabStar?: (tabId: string, starred: boolean) => void;
onTabMarkUnread?: (tabId: string) => void;
onUpdateTabByClaudeSessionId?: (
agentSessionId: string,
updates: { name?: string | null; starred?: boolean }
) => void;
onToggleTabReadOnlyMode?: () => void;
onToggleTabSaveToHistory?: () => void;
onToggleTabShowThinking?: () => void;
showUnreadOnly?: boolean;
/** Whether colorblind-friendly colors should be used for extension badges */
colorBlindMode?: boolean;
onToggleUnreadFilter?: () => void;
onOpenTabSearch?: () => void;
// Bulk tab close operations
onCloseAllTabs?: () => void;
onCloseOtherTabs?: () => void;
onCloseTabsLeft?: () => void;
onCloseTabsRight?: () => void;
// Unified tab system (Phase 4) - file preview tabs integrated with AI tabs
unifiedTabs?: UnifiedTab[];
activeFileTabId?: string | null;
activeFileTab?: FilePreviewTab | null;
onFileTabSelect?: (tabId: string) => void;
onFileTabClose?: (tabId: string) => void;
onOpenFileTab?: (filePath: string) => void;
/** Handler to update file tab editMode when toggled in FilePreview */
onFileTabEditModeChange?: (tabId: string, editMode: boolean) => void;
/** Handler to update file tab editContent when changed in FilePreview */
onFileTabEditContentChange?: (tabId: string, editContent: string | undefined) => void;
/** Handler to update file tab scrollTop when scrolling in FilePreview */
onFileTabScrollPositionChange?: (tabId: string, scrollTop: number) => void;
/** Handler to update file tab searchQuery when searching in FilePreview */
onFileTabSearchQueryChange?: (tabId: string, searchQuery: string) => void;
// Scroll position persistence
onScrollPositionChange?: (scrollTop: number) => void;
// Scroll bottom state change handler (for hasUnread logic)
onAtBottomChange?: (isAtBottom: boolean) => void;
// Input blur handler for persisting AI input state
onInputBlur?: () => void;
// Prompt composer modal
onOpenPromptComposer?: () => void;
// Replay a user message (AI mode)
onReplayMessage?: (text: string, images?: string[]) => void;
// File tree for linking file references in AI responses
fileTree?: import('../types/fileTree').FileNode[];
// Callback when a file link is clicked in AI response
// options.openInNewTab: true = open in new tab adjacent to current, false = replace current tab content
onFileClick?: (relativePath: string, options?: { openInNewTab?: boolean }) => void;
// File preview navigation
canGoBack?: boolean;
canGoForward?: boolean;
onNavigateBack?: () => void;
onNavigateForward?: () => void;
backHistory?: { name: string; content: string; path: string }[];
forwardHistory?: { name: string; content: string; path: string }[];
currentHistoryIndex?: number;
onNavigateToIndex?: (index: number) => void;
onClearFilePreviewHistory?: () => void;
// Agent error handling
onClearAgentError?: () => void;
onShowAgentErrorModal?: () => void;
// Flash notification callback
showFlashNotification?: (message: string) => void;
// Fuzzy file search callback (for FilePreview in preview mode)
onOpenFuzzySearch?: () => void;
// Worktree configuration
onOpenWorktreeConfig?: () => void;
onOpenCreatePR?: () => void;
/** True if this session is a worktree child (has parentSessionId) */
isWorktreeChild?: boolean;
// Context management
onSummarizeAndContinue?: (tabId: string) => void;
onMergeWith?: (tabId: string) => void;
onSendToAgent?: (tabId: string) => void;
onCopyContext?: (tabId: string) => void;
onExportHtml?: (tabId: string) => void;
onPublishTabGist?: (tabId: string) => void;
// Context warning sash settings (Phase 6)
contextWarningsEnabled?: boolean;
contextWarningYellowThreshold?: number;
contextWarningRedThreshold?: number;
// Summarization progress props (non-blocking, per-tab)
summarizeProgress?: import('../types/contextMerge').SummarizeProgress | null;
summarizeResult?: import('../types/contextMerge').SummarizeResult | null;
summarizeStartTime?: number;
isSummarizing?: boolean;
onCancelSummarize?: () => void;
// Merge progress props (non-blocking, per-tab)
mergeProgress?: import('../types/contextMerge').GroomingProgress | null;
mergeResult?: import('../types/contextMerge').MergeResult | null;
mergeStartTime?: number;
isMerging?: boolean;
mergeSourceName?: string;
mergeTargetName?: string;
onCancelMerge?: () => void;
// Keyboard mastery tracking
onShortcutUsed?: (shortcutId: string) => void;
// Gist publishing
ghCliAvailable?: boolean;
onPublishGist?: () => void;
/** Whether the current preview file has been published as a gist */
hasGist?: boolean;
// Document Graph
onOpenInGraph?: () => void;
// Wizard document generation callbacks
/** Called when wizard document generation completes and user clicks Done */
onWizardComplete?: () => void;
/** Called when user selects a different document in the wizard */
onWizardDocumentSelect?: (index: number) => void;
/** Called when user edits document content in the wizard */
onWizardContentChange?: (content: string, docIndex: number) => void;
/** Called when user clicks "Let's Go" in wizard to start document generation */
onWizardLetsGo?: () => void;
/** Called when user clicks "Retry" in wizard after an error */
onWizardRetry?: () => void;
/** Called when user dismisses an error in the wizard */
onWizardClearError?: () => void;
/** Called when user exits inline wizard mode (Escape or clicks pill) */
onExitWizard?: () => void;
/** Toggle showing wizard thinking instead of filler phrases */
onToggleWizardShowThinking?: () => void;
/** Called when user cancels document generation */
onWizardCancelGeneration?: () => void;
}
// PERFORMANCE: Wrap with React.memo to prevent re-renders when parent (App.tsx) re-renders
// due to input value changes. The component will only re-render when its props actually change.
export const MainPanel = React.memo(
forwardRef<MainPanelHandle, MainPanelProps>(function MainPanel(props, ref) {
const {
logViewerOpen,
agentSessionsOpen,
activeAgentSessionId,
activeSession,
thinkingSessions,
theme,
activeFocus,
outputSearchOpen,
outputSearchQuery,
inputValue,
enterToSendAI,
enterToSendTerminal,
stagedImages,
commandHistoryOpen,
commandHistoryFilter,
commandHistorySelectedIndex,
slashCommandOpen,
slashCommands,
selectedSlashCommandIndex,
tabCompletionOpen,
tabCompletionSuggestions,
selectedTabCompletionIndex,
tabCompletionFilter,
setTabCompletionOpen,
setSelectedTabCompletionIndex,
setTabCompletionFilter,
atMentionOpen,
atMentionFilter,
atMentionStartIndex,
atMentionSuggestions,
selectedAtMentionIndex,
setAtMentionOpen,
setAtMentionFilter,
setAtMentionStartIndex,
setSelectedAtMentionIndex,
filePreviewLoading,
markdownEditMode,
chatRawTextMode,
shortcuts,
rightPanelOpen,
maxOutputLines,
gitDiffPreview: _gitDiffPreview,
fileTreeFilterOpen,
logLevel,
setGitDiffPreview,
setLogViewerOpen,
setAgentSessionsOpen,
setActiveAgentSessionId,
onResumeAgentSession,
onNewAgentSession,
setActiveFocus,
setOutputSearchOpen,
setOutputSearchQuery,
setInputValue,
setEnterToSendAI,
setEnterToSendTerminal,
setStagedImages,
setLightboxImage,
setCommandHistoryOpen,
setCommandHistoryFilter,
setCommandHistorySelectedIndex,
setSlashCommandOpen,
setSelectedSlashCommandIndex,
setMarkdownEditMode,
setChatRawTextMode,
setAboutModalOpen: _setAboutModalOpen,
setRightPanelOpen,
setGitLogOpen,
inputRef,
logsEndRef,
terminalOutputRef,
fileTreeContainerRef,
fileTreeFilterInputRef,
toggleInputMode,
processInput,
handleInterrupt,
handleInputKeyDown,
handlePaste,
handleDrop,
getContextColor,
setActiveSessionId,
batchRunState: _batchRunState,
currentSessionBatchState,
onStopBatchRun,
showConfirmation: _showConfirmation,
onRemoveQueuedItem,
onOpenQueueBrowser,
isMobileLandscape = false,
showFlashNotification,
onOpenWorktreeConfig,
onOpenCreatePR,
isWorktreeChild,
onSummarizeAndContinue,
onMergeWith,
onSendToAgent,
onCopyContext,
onExportHtml,
// Context warning sash settings (Phase 6)
contextWarningsEnabled = false,
contextWarningYellowThreshold = 60,
contextWarningRedThreshold = 80,
// Summarization progress props
summarizeProgress,
summarizeResult,
summarizeStartTime = 0,
isSummarizing = false,
onCancelSummarize,
// Merge progress props
mergeProgress,
mergeResult,
mergeStartTime = 0,
isMerging = false,
mergeSourceName,
mergeTargetName,
onCancelMerge,
// Inline wizard exit handler
onExitWizard,
} = props;
// isCurrentSessionAutoMode: THIS session has active batch run (for all UI indicators)
const isCurrentSessionAutoMode = currentSessionBatchState?.isRunning || false;
const isCurrentSessionStopping = currentSessionBatchState?.isStopping || false;
// Hover tooltip state using reusable hook
const gitTooltip = useHoverTooltip(150);
const contextTooltip = useHoverTooltip(150);
// Panel width for responsive hiding of widgets
const [panelWidth, setPanelWidth] = useState(Infinity); // Start with Infinity so widgets show by default
const headerRef = useRef<HTMLDivElement>(null);
const filePreviewContainerRef = useRef<HTMLDivElement>(null);
const filePreviewRef = useRef<FilePreviewHandle>(null);
const [configuredContextWindow, setConfiguredContextWindow] = useState(0);
// Extract tab handlers from props
const {
onTabSelect,
onTabClose,
onNewTab,
onRequestTabRename,
onTabReorder,
onUnifiedTabReorder,
onTabStar,
onTabMarkUnread,
showUnreadOnly,
colorBlindMode,
onToggleUnreadFilter,
onOpenTabSearch,
onCloseAllTabs,
onCloseOtherTabs,
onCloseTabsLeft,
onCloseTabsRight,
// Unified tab system props (Phase 4)
unifiedTabs,
activeFileTabId,
activeFileTab,
onFileTabSelect,
onFileTabClose,
onFileTabEditModeChange,
onFileTabEditContentChange,
onFileTabScrollPositionChange,
onFileTabSearchQueryChange,
} = props;
// Get the active tab for header display
// The header should show the active tab's data (UUID, name, cost, context), not session-level data
// PERF: Memoize the lookup to avoid O(n) search on every render - will still update when
// aiTabs array or activeTabId changes (which happens when tabs change, not on every keystroke)
const activeTab = useMemo(
() =>
activeSession?.aiTabs?.find((tab) => tab.id === activeSession.activeTabId) ??
activeSession?.aiTabs?.[0] ??
null,
[activeSession?.aiTabs, activeSession?.activeTabId]
);
const activeTabError = activeTab?.agentError;
// Resolve the configured context window from session override or agent settings.
useEffect(() => {
let isActive = true;
const loadContextWindow = async () => {
if (!activeSession) {
if (isActive) setConfiguredContextWindow(0);
return;
}
if (
typeof activeSession.customContextWindow === 'number' &&
activeSession.customContextWindow > 0
) {
if (isActive) setConfiguredContextWindow(activeSession.customContextWindow);
return;
}
try {
const config = await window.maestro.agents.getConfig(activeSession.toolType);
const value = typeof config?.contextWindow === 'number' ? config.contextWindow : 0;
if (isActive) setConfiguredContextWindow(value);
} catch (error) {
console.error('Failed to load agent context window setting', error);
if (isActive) setConfiguredContextWindow(0);
}
};
loadContextWindow();
return () => {
isActive = false;
};
}, [activeSession?.toolType, activeSession?.customContextWindow]);
// Resolve SSH remote name for header display when session has SSH configured
const [sshRemoteName, setSshRemoteName] = useState<string | null>(null);
useEffect(() => {
if (
!activeSession?.sessionSshRemoteConfig?.enabled ||
!activeSession.sessionSshRemoteConfig.remoteId
) {
setSshRemoteName(null);
return;
}
const remoteId = activeSession.sessionSshRemoteConfig.remoteId;
window.maestro.sshRemote
.getConfigs()
.then((result) => {
if (result.success && result.configs) {
const remote = result.configs.find((r: { id: string }) => r.id === remoteId);
setSshRemoteName(remote?.name || null);
} else {
setSshRemoteName(null);
}
})
.catch(() => setSshRemoteName(null));
}, [
activeSession?.sessionSshRemoteConfig?.enabled,
activeSession?.sessionSshRemoteConfig?.remoteId,
]);
const activeTabContextWindow = useMemo(() => {
const configured = configuredContextWindow;
const reported = activeTab?.usageStats?.contextWindow ?? 0;
return configured > 0 ? configured : reported;
}, [configuredContextWindow, activeTab?.usageStats?.contextWindow]);
// Compute context tokens using agent-specific calculation
// SYNC: Uses calculateContextTokens() from shared/contextUsage.ts
// See that file for the canonical formula and all locations that must stay in sync.
const activeTabContextTokens = useMemo(() => {
if (!activeTab?.usageStats) return 0;
return calculateContextTokens(
{
inputTokens: activeTab.usageStats.inputTokens,
outputTokens: activeTab.usageStats.outputTokens,
cacheCreationInputTokens: activeTab.usageStats.cacheCreationInputTokens ?? 0,
cacheReadInputTokens: activeTab.usageStats.cacheReadInputTokens ?? 0,
},
activeSession?.toolType
);
}, [activeTab?.usageStats, activeSession?.toolType]);
// Compute context usage percentage from context tokens and window size
const activeTabContextUsage = useMemo(() => {
if (!activeTabContextWindow || activeTabContextWindow === 0) return 0;
if (activeTabContextTokens === 0) return 0;
const percentage = Math.min(
Math.round((activeTabContextTokens / activeTabContextWindow) * 100),
100
);
// DEBUG: Log MainPanel context display calculation
console.log('[MainPanel] Context display calculation', {
sessionId: activeSession?.id,
tabId: activeTab?.id,
usageStats: activeTab?.usageStats
? {
inputTokens: activeTab.usageStats.inputTokens,
outputTokens: activeTab.usageStats.outputTokens,
cacheReadInputTokens: activeTab.usageStats.cacheReadInputTokens,
cacheCreationInputTokens: activeTab.usageStats.cacheCreationInputTokens,
contextWindow: activeTab.usageStats.contextWindow,
}
: null,
activeTabContextTokens,
activeTabContextWindow,
displayedPercentage: percentage,
});
return percentage;
}, [
activeTabContextTokens,
activeTabContextWindow,
activeSession?.id,
activeTab?.id,
activeTab?.usageStats,
]);
// PERF: Track panel width for responsive widget hiding with threshold-based updates
// Only update state when width crosses a meaningful threshold (20px) to prevent
// unnecessary re-renders during window resize animations
useEffect(() => {
const header = headerRef.current;
if (!header) return;
// Get initial width immediately, but only if it's a reasonable value
// (protects against measuring during layout/animation when width might be 0)
const initialWidth = header.offsetWidth;
let lastSetWidth = 0;
if (initialWidth > 100) {
setPanelWidth(initialWidth);
lastSetWidth = initialWidth;
}
// Threshold for triggering state updates - prevents flicker during resize
const WIDTH_THRESHOLD = 20;
let rafId: number | null = null;
let pendingWidth: number | null = null;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
// Only accept reasonable width values (protects against mid-animation measurements)
if (entry.contentRect.width > 100) {
pendingWidth = entry.contentRect.width;
}
}
// Use requestAnimationFrame to batch updates, but only if change exceeds threshold
if (rafId === null && pendingWidth !== null) {
rafId = requestAnimationFrame(() => {
if (pendingWidth !== null && Math.abs(pendingWidth - lastSetWidth) >= WIDTH_THRESHOLD) {
setPanelWidth(pendingWidth);
lastSetWidth = pendingWidth;
}
pendingWidth = null;
rafId = null;
});
}
});
resizeObserver.observe(header);
return () => {
resizeObserver.disconnect();
if (rafId !== null) {
cancelAnimationFrame(rafId);
}
};
}, []);
// Responsive breakpoints for hiding/simplifying widgets (progressive reduction as space shrinks)
// When AUTO mode is active, the center button takes ~120px, so we shift thresholds higher
// At widest: full display with "CONTEXT WINDOW" label and wide gauge (w-24)
// Below 700px: "CONTEXT" label + narrow gauge (w-16) together
// Below 550px: compact git widget (file count only)
// Below 500px: git branch shows icon only (no text)
// Below 400px: hide UUID pill
// Below 350px: hide cost widget
// Below 300px: hide session name (shown in menu bar anyway)
const autoModeOffset = isCurrentSessionAutoMode ? 150 : 0; // Extra space needed when AUTO button is visible
const showSessionName = panelWidth > 300 + autoModeOffset;
const showCostWidget = panelWidth > 350 + autoModeOffset;
const showUuidPill = panelWidth > 400 + autoModeOffset;
const useIconOnlyGitBranch = panelWidth < 500 + autoModeOffset;
const useCompactGitWidget = panelWidth < 550 + autoModeOffset;
const useCompactContext = panelWidth < 700 + autoModeOffset; // Both label and gauge shrink together
// Git status from focused contexts (reduces cascade re-renders)
// Branch info: branch name, remote, ahead/behind - rarely changes
const { getBranchInfo } = useGitBranch();
// File counts: file count per session - changes on file operations
const { getFileCount } = useGitFileStatus();
// Detail info: detailed file changes, refreshGitStatus - only for active session
const { refreshGitStatus } = useGitDetail();
// Derive gitInfo format from focused context data for backward compatibility
const branchInfo = activeSession ? getBranchInfo(activeSession.id) : undefined;
const gitInfo =
branchInfo && activeSession?.isGitRepo
? {
branch: branchInfo.branch || '',
remote: branchInfo.remote || '',
behind: branchInfo.behind,
ahead: branchInfo.ahead,
uncommittedChanges: getFileCount(activeSession.id),
}
: null;
// Copy notification state (centered flash notice)
const [copyNotification, setCopyNotification] = useState<string | null>(null);
// Get agent capabilities for conditional feature rendering
const { hasCapability } = useAgentCapabilities(activeSession?.toolType);
// Expose methods to parent via ref
useImperativeHandle(
ref,
() => ({
refreshGitInfo: refreshGitStatus,
focusFilePreview: () => {
// Use the FilePreview's focus method if available, otherwise fallback to container
if (filePreviewRef.current) {
filePreviewRef.current.focus();
} else {
filePreviewContainerRef.current?.focus();
}
},
}),
[refreshGitStatus]
);
// Handler for input focus - select session in sidebar
// Memoized to avoid recreating on every render
const handleInputFocus = useCallback(() => {
if (activeSession) {
setActiveSessionId(activeSession.id);
setActiveFocus('main');
}
}, [activeSession, setActiveSessionId, setActiveFocus]);
// Memoized session click handler for InputArea's ThinkingStatusPill
// Avoids creating new function reference on every render
const handleSessionClick = useCallback(
(sessionId: string, tabId?: string) => {
setActiveSessionId(sessionId);
if (tabId && onTabSelect) {
onTabSelect(tabId);
}
},
[setActiveSessionId, onTabSelect]
);
// Handler to view git diff
const handleViewGitDiff = async () => {
if (!activeSession || !activeSession.isGitRepo) return;
const cwd =
activeSession.inputMode === 'terminal'
? activeSession.shellCwd || activeSession.cwd
: activeSession.cwd;
const diff = await gitService.getDiff(cwd);
if (diff.diff) {
setGitDiffPreview(diff.diff);
}
};
// Copy to clipboard handler with flash notification
const copyToClipboard = async (text: string, message?: string) => {
try {
await navigator.clipboard.writeText(text);
// Show centered flash notification
setCopyNotification(message || 'Copied to Clipboard');
setTimeout(() => setCopyNotification(null), 2000);
} catch (err) {
console.error('Failed to copy to clipboard:', err);
}
};
// Show log viewer
if (logViewerOpen) {
return (
<div
className="flex-1 flex flex-col min-w-0 relative"
style={{ backgroundColor: theme.colors.bgMain }}
>
<LogViewer
theme={theme}
onClose={() => setLogViewerOpen(false)}
logLevel={logLevel}
savedSelectedLevels={props.logViewerSelectedLevels}
onSelectedLevelsChange={props.setLogViewerSelectedLevels}
onShortcutUsed={props.onShortcutUsed}
/>
</div>
);
}
// Show agent sessions browser (only if agent supports session storage)
if (agentSessionsOpen && hasCapability('supportsSessionStorage')) {
return (
<div
className="flex-1 flex flex-col min-w-0 relative"
style={{ backgroundColor: theme.colors.bgMain }}
>
<AgentSessionsBrowser
theme={theme}
activeSession={activeSession || undefined}
activeAgentSessionId={activeAgentSessionId}
onClose={() => setAgentSessionsOpen(false)}
onResumeSession={onResumeAgentSession}
onNewSession={onNewAgentSession}
onUpdateTab={props.onUpdateTabByClaudeSessionId}
/>
</div>
);
}
// Show empty state when no active session
if (!activeSession) {
return (
<div
className="flex-1 flex flex-col items-center justify-center min-w-0 relative opacity-30"
style={{ backgroundColor: theme.colors.bgMain }}
>
<Wand2 className="w-16 h-16 mb-4" style={{ color: theme.colors.textDim }} />
<p className="text-sm" style={{ color: theme.colors.textDim }}>
No agents. Create one to get started.
</p>
</div>
);
}
// Show normal session view
return (
<>
<ErrorBoundary>
<div
className={`flex-1 flex flex-col min-w-0 relative ${activeFocus === 'main' ? 'ring-1 ring-inset z-10' : ''}`}
style={
{
backgroundColor: theme.colors.bgMain,
'--tw-ring-color': theme.colors.accent,
} as React.CSSProperties
}
onClick={() => setActiveFocus('main')}
>
{/* Top Bar (hidden in mobile landscape for focused reading) */}
{!isMobileLandscape && (
<div
ref={headerRef}
className="h-16 border-b flex items-center justify-between px-6 shrink-0"
style={{
borderColor: theme.colors.border,
backgroundColor: theme.colors.bgSidebar,
}}
data-tour="header-controls"
>
<div className="flex items-center gap-4 min-w-0 overflow-hidden">
<div className="flex items-center gap-2 text-sm font-medium min-w-0 overflow-hidden">
{/* Session name - hidden at narrow widths (also shown in menu bar) */}
{showSessionName && (
<span className="shrink-0">{activeSession.name}</span>
)}
<div
className="relative"
onMouseEnter={
activeSession.isGitRepo
? gitTooltip.triggerHandlers.onMouseEnter
: undefined
}
onMouseLeave={gitTooltip.triggerHandlers.onMouseLeave}
>
{/* SSH Host Pill - show SSH remote name when running remotely (replaces GIT/LOCAL badge) */}
{activeSession.sessionSshRemoteConfig?.enabled && sshRemoteName ? (
<span
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full border border-purple-500/30 text-purple-500 bg-purple-500/10 max-w-[120px]"
title={`SSH Remote: ${sshRemoteName}`}
>
<Server className="w-3 h-3 shrink-0" />
<span className="truncate uppercase">{sshRemoteName}</span>
</span>
) : (
<span
className={`flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full border cursor-pointer ${
activeSession.isGitRepo
? 'border-orange-500/30 text-orange-500 bg-orange-500/10 hover:bg-orange-500/20'
: 'border-blue-500/30 text-blue-500 bg-blue-500/10'
}`}
onClick={(e) => {
e.stopPropagation();
if (activeSession.isGitRepo) {
refreshGitStatus(); // Refresh git info immediately on click
setGitLogOpen?.(true);
}
}}
title={
activeSession.isGitRepo && gitInfo?.branch ? gitInfo.branch : undefined
}
>
{activeSession.isGitRepo ? (
<>
<GitBranch className="w-3 h-3 shrink-0" />
{/* Hide branch name text at narrow widths, show on hover via title */}
{!useIconOnlyGitBranch && (
<span className="truncate max-w-[120px]">
{gitInfo?.branch || 'GIT'}
</span>
)}
</>
) : (
'LOCAL'
)}
</span>
)}
{activeSession.isGitRepo && gitTooltip.isOpen && gitInfo && (
<>
{/* Invisible bridge to prevent hover gap */}
<div
className="absolute left-0 right-0 h-3 pointer-events-auto"
style={{ top: '100%' }}
{...gitTooltip.contentHandlers}
/>
<div
className="absolute top-full left-0 pt-2 w-96 z-50 pointer-events-auto"
{...gitTooltip.contentHandlers}
>
<div
className="rounded shadow-xl"
style={{
backgroundColor: theme.colors.bgSidebar,
border: `1px solid ${theme.colors.border}`,
}}
>
{/* Branch / Origin / Status */}
<div
className="p-3 space-y-2 border-b"
style={{ borderColor: theme.colors.border }}
>
{/* Branch row */}
<div className="flex items-center gap-2">
<span
className="text-[10px] uppercase font-bold w-14 shrink-0"
style={{ color: theme.colors.textDim }}
>
Branch
</span>
<GitBranch className="w-3.5 h-3.5 text-orange-500 shrink-0" />
<span
className="text-xs font-mono font-medium truncate"
style={{ color: theme.colors.textMain }}
>
{gitInfo.branch}
</span>
<div className="flex items-center gap-1.5 ml-auto shrink-0">
{gitInfo.ahead > 0 && (
<span className="flex items-center gap-0.5 text-xs text-green-500">
<ArrowUp className="w-3 h-3" />
{gitInfo.ahead}
</span>
)}
{gitInfo.behind > 0 && (
<span className="flex items-center gap-0.5 text-xs text-red-500">
<ArrowDown className="w-3 h-3" />
{gitInfo.behind}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
gitInfo.branch,
`"${gitInfo.branch}" copied to clipboard`
);
}}
className="p-1 rounded hover:bg-white/10 transition-colors"
title="Copy branch name"
>
<Copy
className="w-3 h-3"
style={{ color: theme.colors.textDim }}
/>
</button>
</div>
</div>
{/* Origin row */}
{gitInfo.remote && (
<div className="flex items-center gap-2">
<span
className="text-[10px] uppercase font-bold w-14 shrink-0"
style={{ color: theme.colors.textDim }}
>
Origin
</span>
<ExternalLink
className="w-3 h-3 shrink-0"
style={{ color: theme.colors.textDim }}
/>
<button
onClick={(e) => {
e.stopPropagation();
const url = remoteUrlToBrowserUrl(gitInfo.remote);
if (url) window.maestro.shell.openExternal(url);
}}
className="text-xs font-mono truncate hover:underline text-left"
style={{ color: theme.colors.textMain }}
title={`Open ${gitInfo.remote}`}
>
{gitInfo.remote
.replace(/^https?:\/\//, '')
.replace(/\.git$/, '')}
</button>
<button
onClick={(e) => {
e.stopPropagation();
copyToClipboard(gitInfo.remote);
}}
className="p-1 rounded hover:bg-white/10 transition-colors ml-auto shrink-0"
title="Copy remote URL"
>
<Copy
className="w-3 h-3"
style={{ color: theme.colors.textDim }}
/>
</button>
</div>
)}
{/* Status row */}
<div className="flex items-center gap-2">
<span
className="text-[10px] uppercase font-bold w-14 shrink-0"
style={{ color: theme.colors.textDim }}
>
Status
</span>
{gitInfo.uncommittedChanges > 0 ? (
<span
className="flex items-center gap-1.5 text-xs"
style={{ color: theme.colors.textMain }}
>
<FileEdit className="w-3 h-3 text-orange-500" />
{gitInfo.uncommittedChanges} uncommitted{' '}
{gitInfo.uncommittedChanges === 1 ? 'change' : 'changes'}
</span>
) : (
<span className="flex items-center gap-1.5 text-xs text-green-500">
Working tree clean
</span>
)}
</div>
</div>
{/* Worktree Actions */}
<div className="p-2 space-y-1">
{/* Configure Worktrees - only for parent sessions (not worktree children) */}
{!isWorktreeChild && onOpenWorktreeConfig && (
<button
onClick={(e) => {
e.stopPropagation();
onOpenWorktreeConfig();
gitTooltip.close();
}}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
>
<Settings2
className="w-3.5 h-3.5"
style={{ color: theme.colors.textDim }}
/>
Configure Worktrees
</button>
)}
{/* Create PR - only for worktree children */}
{isWorktreeChild && onOpenCreatePR && (
<button
onClick={(e) => {
e.stopPropagation();
onOpenCreatePR();
gitTooltip.close();
}}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textDim }}
>
<GitPullRequest
className="w-3.5 h-3.5"
style={{ color: theme.colors.textDim }}
/>
Create Pull Request
</button>
)}
</div>
</div>
</div>
</>
)}
</div>
</div>
{/* Git Status Widget */}
<GitStatusWidget
sessionId={activeSession.id}
isGitRepo={activeSession.isGitRepo}
theme={theme}
onViewDiff={handleViewGitDiff}
onViewLog={() => setGitLogOpen?.(true)}
compact={useCompactGitWidget}
/>
</div>
{/* Center: AUTO Mode Indicator - only show for current session */}
{isCurrentSessionAutoMode && (
<button
onClick={() => {
if (isCurrentSessionStopping) return;
// Call onStopBatchRun with the active session's ID to stop THIS session's batch
onStopBatchRun?.(activeSession.id);
}}
disabled={isCurrentSessionStopping}
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg font-bold text-xs transition-all ${isCurrentSessionStopping ? 'cursor-not-allowed' : 'hover:opacity-90 cursor-pointer'}`}
style={{
backgroundColor: isCurrentSessionStopping
? theme.colors.warning
: theme.colors.error,
color: isCurrentSessionStopping ? theme.colors.bgMain : 'white',
pointerEvents: isCurrentSessionStopping ? 'none' : 'auto',
}}
title={
isCurrentSessionStopping
? 'Stopping after current task...'
: 'Click to stop auto-run'
}
>
{isCurrentSessionStopping ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Wand2 className="w-4 h-4" />
)}
<span className="uppercase tracking-wider">
{isCurrentSessionStopping ? 'Stopping' : 'Auto'}
</span>
{/* Hide progress count when stopping - spinner is sufficient */}
{currentSessionBatchState && !isCurrentSessionStopping && (
<span className="text-[10px] opacity-80">
{currentSessionBatchState.completedTasks}/
{currentSessionBatchState.totalTasks}
</span>
)}
{currentSessionBatchState?.worktreeActive && (
<span
title={`Worktree: ${currentSessionBatchState.worktreeBranch || 'active'}`}
>
<GitBranch className="w-3.5 h-3.5 ml-0.5" />
</span>
)}
</button>
)}
<div className="flex items-center gap-3 justify-end shrink-0">
{/* Session UUID Pill - click to copy full UUID, left-most of session stats, hidden at narrow widths */}
{showUuidPill &&
activeSession.inputMode === 'ai' &&
activeTab?.agentSessionId &&
hasCapability('supportsSessionId') && (
<button
className="text-[10px] font-mono font-bold px-2 py-0.5 rounded-full border transition-colors hover:opacity-80"
style={{
backgroundColor: theme.colors.accent + '20',
color: theme.colors.accent,
borderColor: theme.colors.accent + '30',
}}
title={
activeTab.name
? `${activeTab.name}\nClick to copy: ${activeTab.agentSessionId}`
: `Click to copy: ${activeTab.agentSessionId}`
}
onClick={(e) => {
e.stopPropagation();
copyToClipboard(
activeTab.agentSessionId!,
'Session ID Copied to Clipboard'
);
}}
>
{activeTab.agentSessionId.split('-')[0].toUpperCase()}
</button>
)}
{/* Cost Tracker - styled as pill (hidden when panel is narrow or agent doesn't support cost tracking) - shows active tab's cost */}
{showCostWidget &&
activeSession.inputMode === 'ai' &&
(activeTab?.agentSessionId || activeTab?.usageStats) &&
hasCapability('supportsCostTracking') && (
<span className="text-xs font-mono font-bold px-2 py-0.5 rounded-full border border-green-500/30 text-green-500 bg-green-500/10">
${(activeTab?.usageStats?.totalCostUsd ?? 0).toFixed(2)}
</span>
)}
{/* Context Window Widget with Tooltip - only show when context window is configured and agent supports usage stats */}
{activeSession.inputMode === 'ai' &&
(activeTab?.agentSessionId || activeTab?.usageStats) &&
hasCapability('supportsUsageStats') &&
activeTabContextWindow > 0 && (
<div
className="flex flex-col items-end mr-2 relative cursor-pointer"
{...contextTooltip.triggerHandlers}
>
<span
className="text-[10px] font-bold uppercase"
style={{ color: theme.colors.textDim }}
>
{useCompactContext ? 'Context' : 'Context Window'}
</span>
{/* Gauge width: w-24 (96px) normally, w-16 (64px) when compact - both change together */}
<div
className={`${useCompactContext ? 'w-16' : 'w-24'} h-1.5 rounded-full mt-1 overflow-hidden`}
style={{ backgroundColor: theme.colors.border }}
>
<div
className="h-full transition-all duration-500 ease-out"
style={{
width: `${activeTabContextUsage}%`,
backgroundColor: getContextColor(activeTabContextUsage, theme),
}}
/>
</div>
{/* Context Window Tooltip */}
{contextTooltip.isOpen && activeSession.inputMode === 'ai' && (
<>
{/* Invisible bridge to prevent hover gap */}
<div
className="absolute left-0 right-0 h-3 pointer-events-auto"
style={{ top: '100%' }}
{...contextTooltip.contentHandlers}
/>
<div
className="absolute top-full right-0 pt-2 w-64 z-50 pointer-events-auto"
{...contextTooltip.contentHandlers}
>
<div
className="border rounded-lg p-3 shadow-xl"
style={{
backgroundColor: theme.colors.bgSidebar,
borderColor: theme.colors.border,
}}
>
<div
className="text-[10px] uppercase font-bold mb-3"
style={{ color: theme.colors.textDim }}
>
Context Details
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span
className="text-xs"
style={{ color: theme.colors.textDim }}
>
Input Tokens
</span>
<span
className="text-xs font-mono"
style={{ color: theme.colors.textMain }}
>
{(activeTab?.usageStats?.inputTokens ?? 0).toLocaleString(
'en-US'
)}
</span>
</div>
<div className="flex justify-between items-center">
<span
className="text-xs"
style={{ color: theme.colors.textDim }}
>
Output Tokens
</span>
<span
className="text-xs font-mono"
style={{ color: theme.colors.textMain }}
>
{(activeTab?.usageStats?.outputTokens ?? 0).toLocaleString(
'en-US'
)}
</span>
</div>
{/* Reasoning tokens - only shown for agents that report them (e.g., Codex o3/o4-mini) */}
{(activeTab?.usageStats?.reasoningTokens ?? 0) > 0 && (
<div className="flex justify-between items-center">
<span
className="text-xs"
style={{ color: theme.colors.textDim }}
>
Reasoning Tokens
<span className="ml-1 text-[10px] opacity-60">
(in output)
</span>
</span>
<span
className="text-xs font-mono"
style={{ color: theme.colors.textMain }}
>
{(
activeTab?.usageStats?.reasoningTokens ?? 0
).toLocaleString('en-US')}
</span>
</div>
)}
<div className="flex justify-between items-center">
<span
className="text-xs"
style={{ color: theme.colors.textDim }}
>
Cache Read
</span>
<span
className="text-xs font-mono"
style={{ color: theme.colors.textMain }}
>
{(
activeTab?.usageStats?.cacheReadInputTokens ?? 0
).toLocaleString('en-US')}
</span>
</div>
<div className="flex justify-between items-center">
<span
className="text-xs"
style={{ color: theme.colors.textDim }}
>
Cache Write
</span>
<span
className="text-xs font-mono"
style={{ color: theme.colors.textMain }}
>
{(
activeTab?.usageStats?.cacheCreationInputTokens ?? 0
).toLocaleString('en-US')}
</span>
</div>
{/* Context usage section - only shown when contextWindow is configured */}
{activeTabContextWindow > 0 && (
<div
className="border-t pt-2 mt-2"
style={{ borderColor: theme.colors.border }}
>
<div className="flex justify-between items-center">
<span
className="text-xs font-bold"
style={{ color: theme.colors.textDim }}
>
Context Tokens
</span>
<span
className="text-xs font-mono font-bold"
style={{ color: theme.colors.accent }}
>
{activeTabContextTokens.toLocaleString('en-US')}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span
className="text-xs font-bold"
style={{ color: theme.colors.textDim }}
>
Context Size
</span>
<span
className="text-xs font-mono font-bold"
style={{ color: theme.colors.textMain }}
>
{activeTabContextWindow.toLocaleString('en-US')}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span
className="text-xs font-bold"
style={{ color: theme.colors.textDim }}
>
Usage
</span>
<span
className="text-xs font-mono font-bold"
style={{
color: getContextColor(activeTabContextUsage, theme),
}}
>
{activeTabContextUsage}%
</span>
</div>
</div>
)}
</div>
</div>
</div>
</>
)}
</div>
)}
{/* Agent Sessions Button - only show if agent supports session storage */}
{hasCapability('supportsSessionStorage') && (
<button
onClick={() => {
setActiveAgentSessionId(null);
setAgentSessionsOpen(true);
}}
className="p-2 rounded hover:bg-white/5"
title={`Agent Sessions (${shortcuts.agentSessions?.keys?.join('+').replace('Meta', 'Cmd').replace('Shift', '⇧') || 'Cmd+⇧+L'})`}
data-tour="agent-sessions-button"
>
<List className="w-4 h-4" style={{ color: theme.colors.textDim }} />
</button>
)}
{!rightPanelOpen && (
<button
onClick={() => setRightPanelOpen(true)}
className="p-2 rounded hover:bg-white/5"
title={`Show right panel (${formatShortcutKeys(shortcuts.toggleRightPanel.keys)})`}
>
<Columns className="w-4 h-4" />
</button>
)}
</div>
</div>
)}
{/* Tab Bar - always shown in AI mode when we have tabs (includes both AI and file tabs) */}
{activeSession.inputMode === 'ai' &&
activeSession.aiTabs &&
activeSession.aiTabs.length > 0 &&
onTabSelect &&
onTabClose &&
onNewTab && (
<TabBar
tabs={activeSession.aiTabs}
activeTabId={activeSession.activeTabId}
theme={theme}
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}
onPublishGist={props.onPublishTabGist}
ghCliAvailable={props.ghCliAvailable}
showUnreadOnly={showUnreadOnly}
onToggleUnreadFilter={onToggleUnreadFilter}
onOpenTabSearch={onOpenTabSearch}
onCloseAllTabs={onCloseAllTabs}
onCloseOtherTabs={onCloseOtherTabs}
onCloseTabsLeft={onCloseTabsLeft}
onCloseTabsRight={onCloseTabsRight}
// Unified tab system props (Phase 4)
unifiedTabs={unifiedTabs}
activeFileTabId={activeFileTabId}
onFileTabSelect={onFileTabSelect}
onFileTabClose={onFileTabClose}
// Accessibility
colorBlindMode={colorBlindMode}
/>
)}
{/* Agent Error Banner */}
{activeTabError && (
<div
className="flex items-center gap-3 px-4 py-2 border-b shrink-0"
style={{
backgroundColor: theme.colors.error + '15',
borderColor: theme.colors.error + '40',
}}
>
<AlertCircle className="w-4 h-4 shrink-0" style={{ color: theme.colors.error }} />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium" style={{ color: theme.colors.error }}>
{activeTabError.message}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{props.onShowAgentErrorModal && (
<button
onClick={props.onShowAgentErrorModal}
className="px-2 py-1 text-xs font-medium rounded hover:opacity-80 transition-opacity"
style={{
backgroundColor: theme.colors.error,
color: '#ffffff',
}}
>
View Details
</button>
)}
{props.onClearAgentError && activeTabError.recoverable && (
<button
onClick={props.onClearAgentError}
className="p-1 rounded hover:bg-white/10 transition-colors"
title="Dismiss error"
>
<X className="w-4 h-4" style={{ color: theme.colors.error }} />
</button>
)}
</div>
</div>
)}
{/* Show loading state for file tabs (SSH remote file loading) */}
{/* Content area: Show FilePreview when file tab is active, otherwise show terminal output */}
{/* Skip rendering when loading remote file - loading state takes over entire main area */}
{(filePreviewLoading && !activeFileTabId) || activeFileTab?.isLoading ? (
<div
className="flex-1 flex items-center justify-center"
style={{ backgroundColor: theme.colors.bgMain }}
>
<div className="flex flex-col items-center gap-3">
<Loader2
className="w-8 h-8 animate-spin"
style={{ color: theme.colors.accent }}
/>
<div className="text-center">
<div className="text-sm font-medium" style={{ color: theme.colors.textMain }}>
Loading {activeFileTab ? `${activeFileTab.name}${activeFileTab.extension}` : filePreviewLoading?.name}
</div>
<div className="text-xs mt-1" style={{ color: theme.colors.textDim }}>
Fetching from remote server...
</div>
</div>
</div>
</div>
) : activeFileTabId && activeFileTab ? (
// New file tab system - FilePreview rendered as tab content (no close button, tab handles closing)
<div
ref={filePreviewContainerRef}
tabIndex={-1}
className="flex-1 overflow-hidden outline-none"
>
<FilePreview
ref={filePreviewRef}
file={{
name: activeFileTab.name + activeFileTab.extension,
// Always pass original content - editContent is passed separately for edit mode state
content: activeFileTab.content,
path: activeFileTab.path,
}}
onClose={() => {
// When rendered as tab, close via tab close handler
onFileTabClose?.(activeFileTabId);
}}
isTabMode={true}
theme={theme}
markdownEditMode={activeFileTab.editMode}
setMarkdownEditMode={(editMode) => {
// Update both the file tab's editMode and the legacy markdownEditMode setting
onFileTabEditModeChange?.(activeFileTabId, editMode);
}}
onSave={async (path, content) => {
await window.maestro.fs.writeFile(path, content);
// After save, clear the editContent to indicate no pending changes
onFileTabEditContentChange?.(activeFileTabId, undefined);
}}
shortcuts={shortcuts}
fileTree={props.fileTree}
cwd={(() => {
// Compute relative directory from file path for proximity matching
if (
!activeSession?.fullPath ||
!activeFileTab.path.startsWith(activeSession.fullPath)
) {
return '';
}
const relativePath = activeFileTab.path.slice(activeSession.fullPath.length + 1);
const lastSlash = relativePath.lastIndexOf('/');
return lastSlash > 0 ? relativePath.slice(0, lastSlash) : '';
})()}
onFileClick={props.onFileClick}
// File tabs don't use navigation history (each file is a separate tab)
canGoBack={false}
canGoForward={false}
onOpenFuzzySearch={props.onOpenFuzzySearch}
onShortcutUsed={props.onShortcutUsed}
ghCliAvailable={props.ghCliAvailable}
onPublishGist={props.onPublishGist}
hasGist={props.hasGist}
onOpenInGraph={props.onOpenInGraph}
sshRemoteId={
activeSession?.sshRemoteId ||
activeSession?.sessionSshRemoteConfig?.remoteId ||
undefined
}
// Pass external edit content for persistence across tab switches
externalEditContent={activeFileTab.editContent}
onEditContentChange={(content) => {
// Store edit content on the tab - undefined means no changes (content matches file)
const hasChanges = content !== activeFileTab.content;
onFileTabEditContentChange?.(activeFileTabId, hasChanges ? content : undefined);
}}
// Pass scroll position props for persistence across tab switches
initialScrollTop={activeFileTab.scrollTop}
onScrollPositionChange={(scrollTop) => {
onFileTabScrollPositionChange?.(activeFileTabId, scrollTop);
}}
// Pass search query props for persistence across tab switches
initialSearchQuery={activeFileTab.searchQuery}
onSearchQueryChange={(query) => {
onFileTabSearchQueryChange?.(activeFileTabId, query);
}}
/>
</div>
) : (
<>
{/* Logs Area - Show DocumentGenerationView while generating OR when docs exist (waiting for user to click Exit Wizard), WizardConversationView when wizard is active, otherwise show TerminalOutput */}
{/* Note: wizardState is per-tab (stored on activeTab), not per-session */}
{/* User clicks "Exit Wizard" button in DocumentGenerationView which calls onWizardComplete to convert tab to normal session */}
<div className="flex-1 overflow-hidden flex flex-col" data-tour="main-terminal">
{activeSession.inputMode === 'ai' &&
(activeTab?.wizardState?.isGeneratingDocs ||
(activeTab?.wizardState?.generatedDocuments?.length ?? 0) > 0) ? (
<DocumentGenerationView
key={`wizard-gen-${activeSession.id}-${activeSession.activeTabId}`}
theme={theme}
documents={activeTab?.wizardState?.generatedDocuments ?? []}
currentDocumentIndex={activeTab?.wizardState?.currentDocumentIndex ?? 0}
isGenerating={activeTab?.wizardState?.isGeneratingDocs ?? false}
streamingContent={activeTab?.wizardState?.streamingContent}
onComplete={props.onWizardComplete || (() => {})}
onDocumentSelect={props.onWizardDocumentSelect || (() => {})}
folderPath={
activeTab?.wizardState?.subfolderPath ??
activeTab?.wizardState?.autoRunFolderPath
}
onContentChange={props.onWizardContentChange}
progressMessage={activeTab?.wizardState?.progressMessage}
currentGeneratingIndex={activeTab?.wizardState?.currentGeneratingIndex}
totalDocuments={activeTab?.wizardState?.totalDocuments}
onCancel={props.onWizardCancelGeneration}
subfolderName={activeTab?.wizardState?.subfolderName}
/>
) : activeSession.inputMode === 'ai' && activeTab?.wizardState?.isActive ? (
<WizardConversationView
key={`wizard-${activeSession.id}-${activeSession.activeTabId}`}
theme={theme}
conversationHistory={activeTab.wizardState.conversationHistory}
isLoading={activeTab.wizardState.isWaiting ?? false}
agentName={activeSession.name}
confidence={activeTab.wizardState.confidence}
ready={activeTab.wizardState.ready}
onLetsGo={props.onWizardLetsGo}
error={activeTab.wizardState.error}
onRetry={props.onWizardRetry}
onClearError={props.onWizardClearError}
showThinking={activeTab.wizardState.showWizardThinking ?? false}
thinkingContent={activeTab.wizardState.thinkingContent ?? ''}
toolExecutions={activeTab.wizardState.toolExecutions ?? []}
hasStartedGenerating={
activeTab.wizardState.isGeneratingDocs ||
(activeTab.wizardState.generatedDocuments?.length ?? 0) > 0
}
/>
) : (
<TerminalOutput
key={`${activeSession.id}-${activeSession.activeTabId}`}
ref={terminalOutputRef}
session={activeSession}
theme={theme}
fontFamily={props.fontFamily}
activeFocus={activeFocus}
outputSearchOpen={outputSearchOpen}
outputSearchQuery={outputSearchQuery}
setOutputSearchOpen={setOutputSearchOpen}
setOutputSearchQuery={setOutputSearchQuery}
setActiveFocus={setActiveFocus}
setLightboxImage={setLightboxImage}
inputRef={inputRef}
logsEndRef={logsEndRef}
maxOutputLines={maxOutputLines}
onDeleteLog={props.onDeleteLog}
onRemoveQueuedItem={onRemoveQueuedItem}
onInterrupt={handleInterrupt}
onScrollPositionChange={props.onScrollPositionChange}
onAtBottomChange={props.onAtBottomChange}
initialScrollTop={
activeSession.inputMode === 'ai'
? activeTab?.scrollTop
: activeSession.terminalScrollTop
}
markdownEditMode={chatRawTextMode}
setMarkdownEditMode={setChatRawTextMode}
onReplayMessage={props.onReplayMessage}
fileTree={props.fileTree}
cwd={
activeSession.cwd.startsWith(activeSession.fullPath)
? activeSession.cwd.slice(activeSession.fullPath.length + 1)
: ''
}
projectRoot={activeSession.fullPath}
onFileClick={props.onFileClick}
onShowErrorDetails={props.onShowAgentErrorModal}
/>
)}
</div>
{/* Input Area (hidden in mobile landscape for focused reading, and during wizard doc generation) */}
{!isMobileLandscape && !activeTab?.wizardState?.isGeneratingDocs && (
<div data-tour="input-area">
<InputArea
session={activeSession}
theme={theme}
inputValue={inputValue}
setInputValue={setInputValue}
enterToSend={
activeSession.inputMode === 'terminal' ? enterToSendTerminal : enterToSendAI
}
setEnterToSend={
activeSession.inputMode === 'terminal'
? setEnterToSendTerminal
: setEnterToSendAI
}
stagedImages={stagedImages}
setStagedImages={setStagedImages}
setLightboxImage={setLightboxImage}
commandHistoryOpen={commandHistoryOpen}
setCommandHistoryOpen={setCommandHistoryOpen}
commandHistoryFilter={commandHistoryFilter}
setCommandHistoryFilter={setCommandHistoryFilter}
commandHistorySelectedIndex={commandHistorySelectedIndex}
setCommandHistorySelectedIndex={setCommandHistorySelectedIndex}
slashCommandOpen={slashCommandOpen}
setSlashCommandOpen={setSlashCommandOpen}
slashCommands={slashCommands}
selectedSlashCommandIndex={selectedSlashCommandIndex}
setSelectedSlashCommandIndex={setSelectedSlashCommandIndex}
tabCompletionOpen={tabCompletionOpen}
setTabCompletionOpen={setTabCompletionOpen}
tabCompletionSuggestions={tabCompletionSuggestions}
selectedTabCompletionIndex={selectedTabCompletionIndex}
setSelectedTabCompletionIndex={setSelectedTabCompletionIndex}
tabCompletionFilter={tabCompletionFilter}
setTabCompletionFilter={setTabCompletionFilter}
atMentionOpen={atMentionOpen}
setAtMentionOpen={setAtMentionOpen}
atMentionFilter={atMentionFilter}
setAtMentionFilter={setAtMentionFilter}
atMentionStartIndex={atMentionStartIndex}
setAtMentionStartIndex={setAtMentionStartIndex}
atMentionSuggestions={atMentionSuggestions}
selectedAtMentionIndex={selectedAtMentionIndex}
setSelectedAtMentionIndex={setSelectedAtMentionIndex}
inputRef={inputRef}
handleInputKeyDown={handleInputKeyDown}
handlePaste={handlePaste}
handleDrop={handleDrop}
toggleInputMode={toggleInputMode}
processInput={processInput}
handleInterrupt={handleInterrupt}
onInputFocus={handleInputFocus}
onInputBlur={props.onInputBlur}
isAutoModeActive={isCurrentSessionAutoMode}
thinkingSessions={thinkingSessions}
onSessionClick={handleSessionClick}
autoRunState={currentSessionBatchState || undefined}
onStopAutoRun={onStopBatchRun}
onOpenQueueBrowser={onOpenQueueBrowser}
tabReadOnlyMode={activeTab?.readOnlyMode ?? false}
onToggleTabReadOnlyMode={props.onToggleTabReadOnlyMode}
tabSaveToHistory={activeTab?.saveToHistory ?? false}
onToggleTabSaveToHistory={props.onToggleTabSaveToHistory}
tabShowThinking={activeTab?.showThinking ?? 'off'}
onToggleTabShowThinking={props.onToggleTabShowThinking}
supportsThinking={hasCapability('supportsThinkingDisplay')}
onOpenPromptComposer={props.onOpenPromptComposer}
shortcuts={shortcuts}
showFlashNotification={showFlashNotification}
// Context warning sash props (Phase 6) - use tab-level context usage
contextUsage={activeTabContextUsage}
contextWarningsEnabled={contextWarningsEnabled}
contextWarningYellowThreshold={contextWarningYellowThreshold}
contextWarningRedThreshold={contextWarningRedThreshold}
onSummarizeAndContinue={
onSummarizeAndContinue
? () => onSummarizeAndContinue(activeSession.activeTabId)
: undefined
}
// Summarization progress props
summarizeProgress={summarizeProgress}
summarizeResult={summarizeResult}
summarizeStartTime={summarizeStartTime}
isSummarizing={isSummarizing}
onCancelSummarize={onCancelSummarize}
// Merge progress props
mergeProgress={mergeProgress}
mergeResult={mergeResult}
mergeStartTime={mergeStartTime}
isMerging={isMerging}
mergeSourceName={mergeSourceName}
mergeTargetName={mergeTargetName}
onCancelMerge={onCancelMerge}
// Inline wizard mode
onExitWizard={onExitWizard}
wizardShowThinking={activeTab?.wizardState?.showWizardThinking ?? false}
onToggleWizardShowThinking={props.onToggleWizardShowThinking}
/>
</div>
)}
</>
)}
</div>
</ErrorBoundary>
{/* Copy Notification Toast - centered flash notice */}
{copyNotification && (
<div
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-6 py-4 rounded-lg shadow-2xl text-base font-bold animate-in fade-in zoom-in-95 duration-200 z-50"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.accentForeground,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.3)',
}}
>
{copyNotification}
</div>
)}
</>
);
})
);