OAuth enabled but no valid token found. Starting authentication...

Found expired OAuth token, attempting refresh...
Token refresh successful
POST "https://api.anthropic.com/v1/messages": 429 Too Many Requests {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CWJa3o6GdjJKgdNY7G8EN"}
This commit is contained in:
Pedram Amini
2025-12-20 14:28:42 -06:00
parent f0bf158c8f
commit 53acecf0f2
31 changed files with 407 additions and 2010 deletions

View File

@@ -41,11 +41,16 @@ jobs:
cache: 'npm'
# Linux: Install build dependencies for native modules and electron-builder
# Includes ARM64 cross-compilation support for multi-arch builds
- name: Install Linux build dependencies
if: matrix.platform == 'linux'
run: |
sudo apt-get update
sudo apt-get install -y libarchive-tools rpm
# Add ARM64 architecture for cross-compilation
sudo dpkg --add-architecture arm64
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
# Windows: Setup for native module compilation
- name: Setup Windows build tools

View File

@@ -413,16 +413,14 @@ Manages sessions and groups with CRUD operations.
- `createNewGroup(name, emoji, moveSession, activeSessionId)`
- Drag and drop handlers
#### useFileExplorer (`src/renderer/hooks/useFileExplorer.ts`)
#### useFileTreeManagement (`src/renderer/hooks/useFileTreeManagement.ts`)
Manages file tree state and navigation.
Manages file tree refresh/filter state and git-related file metadata.
**Key methods:**
- `handleFileClick(node, path, activeSession)` - Open file or external app
- `loadFileTree(dirPath, maxDepth?)` - Load directory tree
- `toggleFolder(path, activeSessionId, setSessions)` - Toggle folder expansion
- `expandAllFolders()` / `collapseAllFolders()`
- `updateSessionWorkingDirectory()` - Change session CWD
- `refreshFileTree(sessionId)` - Reload directory tree and return change stats
- `refreshGitFileState(sessionId)` - Refresh tree + git repo metadata
- `filteredFileTree` - Derived tree based on filter string
#### useBatchProcessor (`src/renderer/hooks/useBatchProcessor.ts`)

View File

@@ -116,9 +116,18 @@
},
"linux": {
"target": [
"AppImage",
"deb",
"rpm"
{
"target": "AppImage",
"arch": ["x64", "arm64"]
},
{
"target": "deb",
"arch": ["x64", "arm64"]
},
{
"target": "rpm",
"arch": ["x64", "arm64"]
}
],
"category": "Development",
"icon": "build/icon.png",

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAtMentionCompletion, type AtMentionSuggestion, type UseAtMentionCompletionReturn } from '../../../renderer/hooks/useAtMentionCompletion';
import type { Session } from '../../../renderer/types';
import type { FileNode } from '../../../renderer/hooks/useFileExplorer';
import type { FileNode } from '../../../renderer/types/fileTree';
// =============================================================================
// TEST HELPERS

View File

@@ -10,7 +10,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import type { Session, Group, HistoryEntry, UsageStats, BatchRunConfig } from '../../../renderer/types';
import type { Session, Group, HistoryEntry, UsageStats, BatchRunConfig, AgentError } from '../../../renderer/types';
// Import the exported functions directly
import { countUnfinishedTasks, uncheckAllTasks, useBatchProcessor } from '../../../renderer/hooks/useBatchProcessor';
@@ -2235,6 +2235,79 @@ describe('useBatchProcessor hook', () => {
});
});
describe('error pause handling', () => {
it('should pause processing until resumeAfterError is called', async () => {
const sessions = [createMockSession()];
const groups = [createMockGroup()];
const contentInitial = '- [ ] Task 1\n- [ ] Task 2';
const contentAfterFirst = '- [x] Task 1\n- [ ] Task 2';
const contentAfterSecond = '- [x] Task 1\n- [x] Task 2';
const docStates = [
contentInitial,
contentInitial,
contentInitial,
contentAfterFirst,
contentAfterFirst,
contentAfterSecond
];
mockReadDoc.mockImplementation(async () => ({
success: true,
content: docStates.shift() ?? contentAfterSecond
}));
let pauseHandler: ((sessionId: string, error: AgentError, documentIndex: number, taskDescription?: string) => void) | null = null;
mockOnSpawnAgent.mockImplementation(async () => {
if (pauseHandler) {
pauseHandler('test-session-id', {
type: 'auth',
message: 'Auth error',
recoverable: true,
timestamp: Date.now()
}, 0, 'Task 1');
pauseHandler = null;
}
return { success: true, agentSessionId: 'session-1' };
});
const { result } = renderHook(() =>
useBatchProcessor({
sessions,
groups,
onUpdateSession: mockOnUpdateSession,
onSpawnAgent: mockOnSpawnAgent,
onSpawnSynopsis: mockOnSpawnSynopsis,
onAddHistoryEntry: mockOnAddHistoryEntry,
onComplete: mockOnComplete
})
);
pauseHandler = result.current.pauseBatchOnError;
let startPromise: Promise<void>;
act(() => {
startPromise = result.current.startBatchRun('test-session-id', {
documents: [{ filename: 'tasks', resetOnCompletion: false }],
prompt: 'Test',
loopEnabled: false
}, '/test/folder');
});
await waitFor(() => expect(mockOnSpawnAgent).toHaveBeenCalledTimes(1));
await waitFor(() => expect(result.current.getBatchState('test-session-id').errorPaused).toBe(true));
expect(mockOnSpawnAgent).toHaveBeenCalledTimes(1);
act(() => {
result.current.resumeAfterError('test-session-id');
});
await startPromise;
expect(mockOnSpawnAgent).toHaveBeenCalledTimes(2);
});
});
describe('session claude ID tracking', () => {
it('should collect claude session IDs from successful spawns', async () => {
const sessions = [createMockSession()];

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useTabCompletion, TabCompletionSuggestion, TabCompletionFilter } from '../../../renderer/hooks/useTabCompletion';
import type { Session } from '../../../renderer/types';
import type { FileNode } from '../../../renderer/hooks/useFileExplorer';
import type { FileNode } from '../../../renderer/types/fileTree';
// Helper to create a minimal session for testing
const createMockSession = (overrides: Partial<Session> = {}): Session => ({

View File

@@ -3,7 +3,7 @@ import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkStringify from 'remark-stringify';
import { remarkFileLinks } from '../../../renderer/utils/remarkFileLinks';
import type { FileNode } from '../../../renderer/hooks/useFileExplorer';
import type { FileNode } from '../../../renderer/types/fileTree';
// Helper to process markdown and return the result
async function processMarkdown(content: string, fileTree: FileNode[], cwd: string, projectRoot?: string): Promise<string> {

View File

@@ -495,6 +495,26 @@ export async function routeModeratorResponse(
groupChatEmitters.emitMessage?.(groupChatId, moderatorMessage);
console.log(`[GroupChat:Debug] Emitted moderator message to renderer`);
// Add history entry for moderator response
try {
const summary = extractFirstSentence(message);
const historyEntry = await addGroupChatHistoryEntry(groupChatId, {
timestamp: Date.now(),
summary,
participantName: 'Moderator',
participantColor: '#808080', // Gray for moderator
type: 'response',
fullResponse: message,
});
// Emit history entry event to renderer
groupChatEmitters.emitHistoryEntry?.(groupChatId, historyEntry);
console.log(`[GroupChatRouter] Added history entry for Moderator: ${summary.substring(0, 50)}...`);
} catch (error) {
console.error(`[GroupChatRouter] Failed to add history entry for Moderator:`, error);
// Don't throw - history logging failure shouldn't break the message flow
}
// Extract ALL mentions from the message
const allMentions = extractAllMentions(message);
console.log(`[GroupChat:Debug] Extracted @mentions: ${allMentions.join(', ') || '(none)'}`);

View File

@@ -12,7 +12,7 @@ import { tunnelManager } from './tunnel-manager';
import { getThemeById } from './themes';
import Store from 'electron-store';
import { getHistoryManager } from './history-manager';
import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, registerClaudeHandlers, registerAgentSessionsHandlers, registerGroupChatHandlers, setupLoggerEventForwarding } from './ipc/handlers';
import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, registerClaudeHandlers, registerAgentSessionsHandlers, registerGroupChatHandlers, registerDebugHandlers, setupLoggerEventForwarding } from './ipc/handlers';
import { groupChatEmitters } from './ipc/handlers/groupChat';
import { routeModeratorResponse, routeAgentResponse, setGetSessionsCallback, setGetCustomEnvVarsCallback, setGetAgentConfigCallback, markParticipantResponded, spawnModeratorSynthesis, getGroupChatReadOnlyState } from './group-chat/group-chat-router';
import { updateParticipant, loadGroupChat, updateGroupChat } from './group-chat/group-chat-storage';
@@ -921,6 +921,18 @@ function setupIpcHandlers() {
getAgentConfig: getAgentConfigForAgent,
});
// Register Debug Package handlers
registerDebugHandlers({
getMainWindow: () => mainWindow,
getAgentDetector: () => agentDetector,
getProcessManager: () => processManager,
getWebServer: () => webServer,
settingsStore: store,
sessionsStore,
groupsStore,
bootstrapStore,
});
// Set up callback for group chat router to lookup sessions for auto-add @mentions
setGetSessionsCallback(() => {
const sessions = sessionsStore.get('sessions', []);

View File

@@ -379,4 +379,67 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void {
return models;
})
);
// Discover available slash commands for an agent by spawning it briefly
// This allows the UI to show available commands before the user sends their first message
ipcMain.handle(
'agents:discoverSlashCommands',
withIpcErrorLogging(handlerOpts('discoverSlashCommands'), async (agentId: string, cwd: string, customPath?: string) => {
const agentDetector = requireDependency(getAgentDetector, 'Agent detector');
logger.info(`Discovering slash commands for agent: ${agentId} in ${cwd}`, LOG_CONTEXT);
const agent = await agentDetector.getAgent(agentId);
if (!agent?.available) {
logger.warn(`Agent ${agentId} not available for slash command discovery`, LOG_CONTEXT);
return null;
}
// Only Claude Code supports slash command discovery via init message
if (agentId !== 'claude-code') {
logger.debug(`Agent ${agentId} does not support slash command discovery`, LOG_CONTEXT);
return null;
}
try {
// Use custom path if provided, otherwise use detected path
const commandPath = customPath || agent.path || agent.command;
// Spawn Claude with /help which immediately exits and costs no tokens
// The init message contains all available slash commands
const args = ['--print', '--verbose', '--output-format', 'stream-json', '--dangerously-skip-permissions', '--', '/help'];
logger.debug(`Spawning for slash command discovery: ${commandPath} ${args.join(' ')}`, LOG_CONTEXT);
const result = await execFileNoThrow(commandPath, args, cwd);
if (result.exitCode !== 0 && !result.stdout) {
logger.warn(`Slash command discovery failed with exit code ${result.exitCode}`, LOG_CONTEXT, {
stderr: result.stderr?.substring(0, 500)
});
return null;
}
// Parse the first JSON line to get the init message
const lines = result.stdout.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.type === 'system' && msg.subtype === 'init' && msg.slash_commands) {
logger.info(`Discovered ${msg.slash_commands.length} slash commands for ${agentId}`, LOG_CONTEXT);
return msg.slash_commands as string[];
}
} catch {
// Not valid JSON, skip
}
}
logger.warn(`No init message found in slash command discovery output`, LOG_CONTEXT);
return null;
} catch (error) {
logger.error(`Error discovering slash commands for ${agentId}`, LOG_CONTEXT, { error: String(error) });
return null;
}
})
);
}

View File

@@ -417,6 +417,10 @@ contextBridge.exposeInMainWorld('maestro', {
// Discover available models for agents that support model selection (e.g., OpenCode with Ollama)
getModels: (agentId: string, forceRefresh?: boolean) =>
ipcRenderer.invoke('agents:getModels', agentId, forceRefresh) as Promise<string[]>,
// Discover available slash commands for an agent by spawning it briefly
// Returns array of command names (e.g., ['compact', 'help', 'my-custom-command'])
discoverSlashCommands: (agentId: string, cwd: string, customPath?: string) =>
ipcRenderer.invoke('agents:discoverSlashCommands', agentId, cwd, customPath) as Promise<string[] | null>,
},
// Dialog API
@@ -1256,6 +1260,7 @@ export interface MaestroAPI {
getCustomEnvVars: (agentId: string) => Promise<Record<string, string> | null>;
getAllCustomEnvVars: () => Promise<Record<string, Record<string, string>>>;
getModels: (agentId: string, forceRefresh?: boolean) => Promise<string[]>;
discoverSlashCommands: (agentId: string, cwd: string, customPath?: string) => Promise<string[] | null>;
};
dialog: {
selectFolder: () => Promise<string | null>;

View File

@@ -1826,6 +1826,57 @@ export default function MaestroConsole() {
[sessions, activeSessionId]
);
// Discover slash commands when a session becomes active and doesn't have them yet
// This spawns Claude briefly to get the init message with available commands
useEffect(() => {
if (!activeSession) return;
if (activeSession.toolType !== 'claude-code') return;
// Skip if we already have commands
if (activeSession.agentCommands && activeSession.agentCommands.length > 0) return;
// Capture session ID to prevent race conditions when switching sessions
const sessionId = activeSession.id;
let cancelled = false;
// Discover slash commands in background
const discoverCommands = async () => {
try {
const commands = await window.maestro.agents.discoverSlashCommands(
activeSession.toolType,
activeSession.cwd,
activeSession.customPath
);
// Don't update if effect was cancelled (session switched)
if (cancelled) return;
if (commands && commands.length > 0) {
// Convert to command objects and store on session
const commandObjects = commands.map(cmd => ({
command: cmd.startsWith('/') ? cmd : `/${cmd}`,
description: getSlashCommandDescription(cmd),
}));
setSessions(prev => prev.map(s =>
s.id === sessionId
? { ...s, agentCommands: commandObjects }
: s
));
}
} catch (error) {
if (!cancelled) {
console.error('[SlashCommandDiscovery] Failed to discover commands:', error);
}
}
};
discoverCommands();
return () => {
cancelled = true;
};
}, [activeSession?.id, activeSession?.toolType, activeSession?.cwd, activeSession?.customPath, activeSession?.agentCommands]);
// File preview navigation history - derived from active session (per-agent history)
const filePreviewHistory = useMemo(() =>
activeSession?.filePreviewHistory ?? [],
@@ -3921,11 +3972,6 @@ export default function MaestroConsole() {
}
};
const handleCreateDebugPackage = useCallback(() => {
setDebugPackageModalOpen(true);
}, []);
// startRenamingSession now accepts a unique key (e.g., 'bookmark-id', 'group-gid-id', 'ungrouped-id')
// to support renaming the same session from different UI locations (bookmarks vs groups)
const startRenamingSession = (editKey: string) => {
@@ -5299,7 +5345,7 @@ export default function MaestroConsole() {
setQuickActionOpen, cycleSession, toggleInputMode, setShortcutsHelpOpen, setSettingsModalOpen,
setSettingsTab, setActiveRightTab, handleSetActiveRightTab, setActiveFocus, setBookmarksCollapsed, setGroups,
setSelectedSidebarIndex, setActiveSessionId, handleViewGitDiff, setGitLogOpen, setActiveAgentSessionId,
setAgentSessionsOpen, setLogViewerOpen, setProcessMonitorOpen, handleCreateDebugPackage, logsEndRef, inputRef, terminalOutputRef, sidebarContainerRef,
setAgentSessionsOpen, setLogViewerOpen, setProcessMonitorOpen, logsEndRef, inputRef, terminalOutputRef, sidebarContainerRef,
setSessions, createTab, closeTab, reopenClosedTab, getActiveTab, setRenameTabId, setRenameTabInitialName,
setRenameTabModalOpen, navigateToNextTab, navigateToPrevTab, navigateToTabByIndex, navigateToLastTab,
setFileTreeFilterOpen, isShortcut, isTabShortcut, handleNavBack, handleNavForward, toggleUnreadFilter,

View File

@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, EyeOff } from 'lucide-react';
import type { Session, Theme } from '../types';
import type { FileNode } from '../types/fileTree';
import type { FileTreeChanges } from '../utils/fileExplorer';
import { getFileIcon } from '../utils/theme';
import { useLayerStack } from '../contexts/LayerStackContext';
@@ -16,12 +17,6 @@ const AUTO_REFRESH_OPTIONS = [
{ label: 'Every 3 minutes', value: 180 },
];
interface FileNode {
name: string;
type: 'file' | 'folder';
children?: FileNode[];
}
// Flattened node for virtualization
interface FlattenedNode {
node: FileNode;

View File

@@ -15,7 +15,7 @@ import { formatShortcutKeys } from '../utils/shortcutFormatter';
import { remarkFileLinks } from '../utils/remarkFileLinks';
import remarkFrontmatter from 'remark-frontmatter';
import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';
import type { FileNode } from '../hooks/useFileExplorer';
import type { FileNode } from '../types/fileTree';
interface FileStats {
size: number;

View File

@@ -1,17 +1,12 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Search, File, FileImage, FileText } from 'lucide-react';
import type { Theme, Shortcut } from '../types';
import type { FileNode } from '../types/fileTree';
import { fuzzyMatchWithScore } from '../utils/search';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { formatShortcutKeys } from '../utils/shortcutFormatter';
interface FileNode {
name: string;
type: 'file' | 'folder';
children?: FileNode[];
}
/** Flattened file item for the search list */
export interface FlatFileItem {
name: string;

View File

@@ -164,7 +164,7 @@ interface MainPanelProps {
// Replay a user message (AI mode)
onReplayMessage?: (text: string, images?: string[]) => void;
// File tree for linking file references in AI responses
fileTree?: import('../hooks/useFileExplorer').FileNode[];
fileTree?: import('../types/fileTree').FileNode[];
// Callback when a file link is clicked in AI response
onFileClick?: (relativePath: string) => void;
// File preview navigation
@@ -220,6 +220,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
// 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 [configuredContextWindow, setConfiguredContextWindow] = useState(0);
// Extract tab handlers from props
const { onTabSelect, onTabClose, onNewTab, onTabRename, onRequestTabRename, onTabReorder, onCloseOtherTabs, onTabStar, onTabMarkUnread, showUnreadOnly, onToggleUnreadFilter, onOpenTabSearch } = props;
@@ -231,14 +232,51 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
?? activeSession?.aiTabs?.[0]
?? null;
// 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]);
const activeTabContextWindow = useMemo(() => {
const configured = configuredContextWindow;
const reported = activeTab?.usageStats?.contextWindow ?? 0;
return configured > 0 ? configured : reported;
}, [configuredContextWindow, activeTab?.usageStats?.contextWindow]);
// Compute context usage percentage from active tab's usage stats
const activeTabContextUsage = useMemo(() => {
if (!activeTab?.usageStats) return 0;
const { inputTokens, outputTokens, contextWindow } = activeTab.usageStats;
if (!contextWindow || contextWindow === 0) return 0;
const { inputTokens, outputTokens } = activeTab.usageStats;
if (!activeTabContextWindow || activeTabContextWindow === 0) return 0;
const contextTokens = inputTokens + outputTokens;
return Math.min(Math.round((contextTokens / contextWindow) * 100), 100);
}, [activeTab?.usageStats]);
return Math.min(Math.round((contextTokens / activeTabContextWindow) * 100), 100);
}, [activeTab?.usageStats, activeTabContextWindow]);
// PERF: Track panel width for responsive widget hiding with throttled updates
useEffect(() => {
@@ -644,7 +682,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
)}
{/* 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') && (activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
{activeSession.inputMode === 'ai' && (activeTab?.agentSessionId || activeTab?.usageStats) && hasCapability('supportsUsageStats') && activeTabContextWindow > 0 && (
<div
className="flex flex-col items-end mr-2 relative cursor-pointer"
onMouseEnter={() => {
@@ -748,7 +786,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
</div>
{/* Context usage section - only shown when contextWindow is configured */}
{(activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
{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>
@@ -762,7 +800,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
<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 }}>
{activeTab.usageStats.contextWindow.toLocaleString()}
{activeTabContextWindow.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center mt-1">

View File

@@ -5,7 +5,7 @@ import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Clipboard, Loader2, ImageOff } from 'lucide-react';
import type { Theme } from '../types';
import type { FileNode } from '../hooks/useFileExplorer';
import type { FileNode } from '../types/fileTree';
import { remarkFileLinks } from '../utils/remarkFileLinks';
import remarkFrontmatter from 'remark-frontmatter';
import { remarkFrontmatterTable } from '../utils/remarkFrontmatterTable';

View File

@@ -302,7 +302,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
{ id: 'website', label: 'Maestro Website', subtext: 'Open the Maestro website', action: () => { window.maestro.shell.openExternal('https://runmaestro.ai/'); setQuickActionOpen(false); } },
{ id: 'discord', label: 'Join Discord', subtext: 'Join the Maestro community', action: () => { window.maestro.shell.openExternal('https://discord.gg/86crXbGb'); setQuickActionOpen(false); } },
...(setUpdateCheckModalOpen ? [{ id: 'updateCheck', label: 'Check for Updates', action: () => { setUpdateCheckModalOpen(true); setQuickActionOpen(false); } }] : []),
{ id: 'createDebugPackage', label: 'Create Debug Package', shortcut: shortcuts.createDebugPackage, subtext: 'Generate a support bundle for bug reporting', action: () => {
{ id: 'createDebugPackage', label: 'Create Debug Package', subtext: 'Generate a support bundle for bug reporting', action: () => {
setQuickActionOpen(false);
if (setDebugPackageModalOpen) {
setDebugPackageModalOpen(true);

View File

@@ -1,7 +1,7 @@
import React, { useRef, useEffect, useMemo, forwardRef, useState, useCallback, memo } from 'react';
import { Activity, X, ChevronDown, ChevronUp, Trash2, Copy, Volume2, Square, Check, ArrowDown, Eye, FileText, RotateCcw, AlertCircle } from 'lucide-react';
import type { Session, Theme, LogEntry } from '../types';
import type { FileNode } from '../hooks/useFileExplorer';
import type { FileNode } from '../types/fileTree';
import Convert from 'ansi-to-html';
import DOMPurify from 'dompurify';
import { useLayerStack } from '../contexts/LayerStackContext';

View File

@@ -36,7 +36,6 @@ export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
openPromptComposer: { id: 'openPromptComposer', label: 'Open Prompt Composer', keys: ['Meta', 'Shift', 'p'] },
openWizard: { id: 'openWizard', label: 'New Agent Wizard', keys: ['Meta', 'Shift', 'n'] },
fuzzyFileSearch: { id: 'fuzzyFileSearch', label: 'Fuzzy File Search', keys: ['Meta', 'g'] },
createDebugPackage: { id: 'createDebugPackage', label: 'Create Debug Package', keys: ['Alt', 'Meta', 'd'] },
};
// Non-editable shortcuts (displayed in help but not configurable)

View File

@@ -1,5 +1,4 @@
export { useSettings } from './useSettings';
export { useFileExplorer } from './useFileExplorer';
export { useActivityTracker } from './useActivityTracker';
export { useMobileLandscape } from './useMobileLandscape';
export { useNavigationHistory } from './useNavigationHistory';
@@ -42,7 +41,6 @@ export { useAgentCapabilities, clearCapabilitiesCache, setCapabilitiesCache, DEF
export { useAgentErrorRecovery } from './useAgentErrorRecovery';
export type { UseSettingsReturn } from './useSettings';
export type { UseFileExplorerReturn } from './useFileExplorer';
export type { UseActivityTrackerReturn } from './useActivityTracker';
export type { NavHistoryEntry } from './useNavigationHistory';
export type { UseAutoRunHandlersReturn, UseAutoRunHandlersDeps, AutoRunTreeNode } from './useAutoRunHandlers';

View File

@@ -48,7 +48,7 @@ export interface UseAgentExecutionReturn {
toolType?: ToolType
) => Promise<AgentSpawnResult>;
/** Ref to spawnBackgroundSynopsis for use in callbacks that need latest version */
spawnBackgroundSynopsisRef: React.MutableRefObject<typeof useAgentExecution extends (...args: infer _) => { spawnBackgroundSynopsis: infer R } ? R : never>;
spawnBackgroundSynopsisRef: React.MutableRefObject<UseAgentExecutionReturn['spawnBackgroundSynopsis'] | null>;
/** Ref to spawnAgentWithPrompt for use in callbacks that need latest version */
spawnAgentWithPromptRef: React.MutableRefObject<((prompt: string) => Promise<AgentSpawnResult>) | null>;
/** Show flash notification (auto-dismisses after 2 seconds) */
@@ -84,6 +84,20 @@ export function useAgentExecution(
// Refs for functions that need to be accessed from other callbacks
const spawnBackgroundSynopsisRef = useRef<UseAgentExecutionReturn['spawnBackgroundSynopsis'] | null>(null);
const spawnAgentWithPromptRef = useRef<((prompt: string) => Promise<AgentSpawnResult>) | null>(null);
const accumulateUsageStats = useCallback(
(current: UsageStats | undefined, usageStats: UsageStats): UsageStats => ({
...usageStats,
inputTokens: (current?.inputTokens || 0) + usageStats.inputTokens,
outputTokens: (current?.outputTokens || 0) + usageStats.outputTokens,
cacheReadInputTokens: (current?.cacheReadInputTokens || 0) + usageStats.cacheReadInputTokens,
cacheCreationInputTokens: (current?.cacheCreationInputTokens || 0) + usageStats.cacheCreationInputTokens,
totalCostUsd: (current?.totalCostUsd || 0) + usageStats.totalCostUsd,
reasoningTokens: current?.reasoningTokens || usageStats.reasoningTokens
? (current?.reasoningTokens || 0) + (usageStats.reasoningTokens || 0)
: undefined,
}),
[]
);
/**
* Spawn a Claude agent for a specific session and wait for completion.
@@ -158,19 +172,7 @@ export function useAgentExecution(
cleanupUsage = window.maestro.process.onUsage((sid: string, usageStats) => {
if (sid === targetSessionId) {
// Accumulate usage stats for this task (there may be multiple usage events per task)
if (!taskUsageStats) {
taskUsageStats = { ...usageStats };
} else {
// Accumulate tokens and cost
taskUsageStats = {
...usageStats,
inputTokens: taskUsageStats.inputTokens + usageStats.inputTokens,
outputTokens: taskUsageStats.outputTokens + usageStats.outputTokens,
cacheReadInputTokens: taskUsageStats.cacheReadInputTokens + usageStats.cacheReadInputTokens,
cacheCreationInputTokens: taskUsageStats.cacheCreationInputTokens + usageStats.cacheCreationInputTokens,
totalCostUsd: taskUsageStats.totalCostUsd + usageStats.totalCostUsd,
};
}
taskUsageStats = accumulateUsageStats(taskUsageStats, usageStats);
}
});
@@ -324,7 +326,7 @@ export function useAgentExecution(
console.error('Error spawning agent:', error);
return { success: false };
}
}, [sessionsRef, setSessions, processQueuedItemRef]); // Uses sessionsRef for latest sessions
}, [accumulateUsageStats, processQueuedItemRef, sessionsRef, setSessions]); // Uses sessionsRef for latest sessions
/**
* Wrapper for slash commands that need to spawn an agent with just a prompt.
@@ -397,18 +399,7 @@ export function useAgentExecution(
cleanupUsage = window.maestro.process.onUsage((sid: string, usageStats) => {
if (sid === targetSessionId) {
// Accumulate usage stats (there may be multiple events)
if (!synopsisUsageStats) {
synopsisUsageStats = { ...usageStats };
} else {
synopsisUsageStats = {
...usageStats,
inputTokens: synopsisUsageStats.inputTokens + usageStats.inputTokens,
outputTokens: synopsisUsageStats.outputTokens + usageStats.outputTokens,
cacheReadInputTokens: synopsisUsageStats.cacheReadInputTokens + usageStats.cacheReadInputTokens,
cacheCreationInputTokens: synopsisUsageStats.cacheCreationInputTokens + usageStats.cacheCreationInputTokens,
totalCostUsd: synopsisUsageStats.totalCostUsd + usageStats.totalCostUsd,
};
}
synopsisUsageStats = accumulateUsageStats(synopsisUsageStats, usageStats);
}
});
@@ -439,7 +430,7 @@ export function useAgentExecution(
console.error('Error spawning background synopsis:', error);
return { success: false };
}
}, []);
}, [accumulateUsageStats]);
/**
* Show flash notification (bottom-right, auto-dismisses after 2 seconds).

View File

@@ -1,5 +1,5 @@
import { useCallback, useRef } from 'react';
import type { Session, SessionState, LogEntry, UsageStats } from '../types';
import type { Session, LogEntry, UsageStats } from '../types';
import { createTab, getActiveTab } from '../utils/tabHelpers';
import { generateId } from '../utils/ids';
import type { RightPanelHandle } from '../components/RightPanel';
@@ -33,11 +33,6 @@ export interface UseAgentSessionManagementDeps {
setActiveAgentSessionId: (id: string | null) => void;
/** Agent sessions browser open state setter */
setAgentSessionsOpen: (open: boolean) => void;
/** Helper to add a log entry to the active tab */
addLogToActiveTab: (
sessionId: string,
logEntry: Omit<LogEntry, 'id' | 'timestamp'> & { id?: string; timestamp?: number }
) => void;
/** Ref to the right panel for refreshing history */
rightPanelRef: React.RefObject<RightPanelHandle | null>;
/** Default value for saveToHistory on new tabs */
@@ -52,10 +47,6 @@ export interface UseAgentSessionManagementReturn {
addHistoryEntry: (entry: HistoryEntryInput) => Promise<void>;
/** Ref to addHistoryEntry for use in callbacks that need latest version */
addHistoryEntryRef: React.MutableRefObject<((entry: HistoryEntryInput) => Promise<void>) | null>;
/** Clear Agent session and start fresh */
startNewAgentSession: () => void;
/** Ref to startNewAgentSession for use in callbacks that need latest version */
startNewAgentSessionRef: React.MutableRefObject<(() => void) | null>;
/** Jump to a specific agent session in the browser */
handleJumpToAgentSession: (agentSessionId: string) => void;
/** Resume a Agent session, opening as a new tab or switching to existing */
@@ -73,7 +64,6 @@ export interface UseAgentSessionManagementReturn {
*
* Handles:
* - Adding history entries with session metadata
* - Starting new Agent sessions (clearing context)
* - Jumping to Agent sessions in the browser
* - Resuming saved Agent sessions as tabs
*
@@ -88,14 +78,12 @@ export function useAgentSessionManagement(
setSessions,
setActiveAgentSessionId,
setAgentSessionsOpen,
addLogToActiveTab,
rightPanelRef,
defaultSaveToHistory,
} = deps;
// Refs for functions that need to be accessed from other callbacks
const addHistoryEntryRef = useRef<((entry: HistoryEntryInput) => Promise<void>) | null>(null);
const startNewAgentSessionRef = useRef<(() => void) | null>(null);
/**
* Add a history entry for a session.
@@ -115,6 +103,8 @@ export function useAgentSessionManagement(
sessionName = activeTab?.name;
}
const shouldIncludeContextUsage = !entry.sessionId || entry.sessionId === activeSession?.id;
await window.maestro.history.add({
id: generateId(),
type: entry.type,
@@ -125,7 +115,7 @@ export function useAgentSessionManagement(
sessionId: targetSessionId,
sessionName: sessionName,
projectPath: targetProjectPath,
contextUsage: activeSession?.contextUsage,
...(shouldIncludeContextUsage ? { contextUsage: activeSession?.contextUsage } : {}),
// Only include usageStats if explicitly provided (per-task tracking)
// Never use cumulative session stats - they're lifetime totals
usageStats: entry.usageStats
@@ -135,43 +125,6 @@ export function useAgentSessionManagement(
rightPanelRef.current?.refreshHistoryPanel();
}, [activeSession, rightPanelRef]);
/**
* Start a new Agent session by clearing the current context.
* Blocks if there are queued items.
*/
const startNewAgentSession = useCallback(() => {
if (!activeSession) return;
// Block clearing when there are queued items
if (activeSession.executionQueue.length > 0) {
addLogToActiveTab(activeSession.id, {
source: 'system',
text: 'Cannot clear session while items are queued. Remove queued items first.'
});
return;
}
setSessions(prev => prev.map(s => {
if (s.id !== activeSession.id) return s;
// Reset active tab's state to 'idle' for write-mode tracking
const updatedAiTabs = s.aiTabs?.length > 0
? s.aiTabs.map(tab =>
tab.id === s.activeTabId ? { ...tab, state: 'idle' as const, thinkingStartTime: undefined } : tab
)
: s.aiTabs;
return {
...s,
agentSessionId: undefined,
aiLogs: [],
state: 'idle' as SessionState,
busySource: undefined,
thinkingStartTime: undefined,
aiTabs: updatedAiTabs
};
}));
setActiveAgentSessionId(null);
}, [activeSession, addLogToActiveTab, setSessions, setActiveAgentSessionId]);
/**
* Jump to a specific agent session in the agent sessions browser.
*/
@@ -240,7 +193,10 @@ export function useAgentSessionManagement(
let isStarred = starred ?? false;
let name = sessionName ?? null;
if (!starred && !sessionName && activeSession.toolType === 'claude-code') {
const shouldLookupOrigins = activeSession.toolType === 'claude-code'
&& (starred === undefined || sessionName === undefined);
if (shouldLookupOrigins) {
try {
// Look up session metadata from session origins (name and starred)
// Note: getSessionOrigins is still Claude-specific until we add generic origin tracking
@@ -248,10 +204,10 @@ export function useAgentSessionManagement(
const origins = await window.maestro.claude.getSessionOrigins(activeSession.projectRoot);
const originData = origins[agentSessionId];
if (originData && typeof originData === 'object') {
if (originData.sessionName) {
if (sessionName === undefined && originData.sessionName) {
name = originData.sessionName;
}
if (originData.starred !== undefined) {
if (starred === undefined && originData.starred !== undefined) {
isStarred = originData.starred;
}
}
@@ -286,13 +242,10 @@ export function useAgentSessionManagement(
// Update refs for slash command functions (so other handlers can access latest versions)
addHistoryEntryRef.current = addHistoryEntry;
startNewAgentSessionRef.current = startNewAgentSession;
return {
addHistoryEntry,
addHistoryEntryRef,
startNewAgentSession,
startNewAgentSessionRef,
handleJumpToAgentSession,
handleResumeSession,
};

View File

@@ -1,6 +1,6 @@
import { useMemo, useCallback } from 'react';
import type { Session } from '../types';
import type { FileNode } from './useFileExplorer';
import type { FileNode } from '../types/fileTree';
import { fuzzyMatchWithScore } from '../utils/search';
export interface AtMentionSuggestion {

View File

@@ -109,6 +109,13 @@ interface UseBatchProcessorReturn {
abortBatchOnError: (sessionId: string) => void;
}
type ErrorResolutionAction = 'resume' | 'skip-document' | 'abort';
interface ErrorResolutionEntry {
promise: Promise<ErrorResolutionAction>;
resolve: (action: ErrorResolutionAction) => void;
}
/**
* Format duration in human-readable format for loop summaries
*/
@@ -253,6 +260,9 @@ export function useBatchProcessor({
const accumulatedTimeRefs = useRef<Record<string, number>>({});
const lastActiveTimestampRefs = useRef<Record<string, number | null>>({});
// Error resolution promises to pause batch processing until user action (per session)
const errorResolutionRefs = useRef<Record<string, ErrorResolutionEntry>>({});
// Helper to get batch state for a session
const getBatchState = useCallback((sessionId: string): BatchRunState => {
return batchRunStates[sessionId] || DEFAULT_BATCH_STATE;
@@ -316,17 +326,6 @@ export function useBatchProcessor({
broadcastAutoRunState(sessionId, newStateForSession);
}, [broadcastAutoRunState]);
// Helper to get current accumulated elapsed time for a session (visibility-aware)
const getAccumulatedElapsedMs = useCallback((sessionId: string): number => {
const accumulated = accumulatedTimeRefs.current[sessionId] || 0;
const lastActive = lastActiveTimestampRefs.current[sessionId];
if (lastActive !== null && lastActive !== undefined && !document.hidden) {
// Add time since last active timestamp
return accumulated + (Date.now() - lastActive);
}
return accumulated;
}, []);
// Visibility change handler to pause/resume time tracking
useEffect(() => {
const handleVisibilityChange = () => {
@@ -430,6 +429,7 @@ ${docList}
// Reset stop flag for this session
stopRequestedRefs.current[sessionId] = false;
delete errorResolutionRefs.current[sessionId];
// Set up worktree if enabled
let effectiveCwd = session.cwd; // Default to session's cwd
@@ -640,7 +640,6 @@ ${docList}
// Per-loop tracking for loop summary
let loopStartTime = Date.now();
let loopTasksCompleted = 0;
let loopTasksDiscovered = 0;
let loopTotalInputTokens = 0;
let loopTotalOutputTokens = 0;
let loopTotalCost = 0;
@@ -659,9 +658,6 @@ ${docList}
// Track stalled documents (document filename -> stall reason)
const stalledDocuments: Map<string, string> = new Map();
// Legacy flag for backwards compatibility - true if ANY document stalled
let stalledDueToNoProgress = false;
// Helper to add final loop summary (defined here so it has access to tracking vars)
const addFinalLoopSummary = (exitReason: string) => {
// AUTORUN LOG: Exit
@@ -775,6 +771,7 @@ ${docList}
}));
let docTasksCompleted = 0;
let skipCurrentDocumentAfterError = false;
// Process tasks in this document until none remain
while (remainingTasks > 0) {
@@ -784,6 +781,23 @@ ${docList}
break;
}
// Pause processing until the user resolves the error state
const errorResolution = errorResolutionRefs.current[sessionId];
if (errorResolution) {
const action = await errorResolution.promise;
delete errorResolutionRefs.current[sessionId];
if (action === 'abort') {
stopRequestedRefs.current[sessionId] = true;
break;
}
if (action === 'skip-document') {
skipCurrentDocumentAfterError = true;
break;
}
}
// Build template context for this task
const templateContext: TemplateContext = {
session,
@@ -955,7 +969,6 @@ ${docList}
// Track this document as stalled
stalledDocuments.set(docEntry.filename, stallReason);
stalledDueToNoProgress = true;
// AUTORUN LOG: Document stalled
window.maestro.logger.autorun(
@@ -1028,6 +1041,10 @@ ${docList}
continue;
}
if (skipCurrentDocumentAfterError) {
continue;
}
// Document complete - handle reset-on-completion if enabled
console.log(`[BatchProcessor] Document ${docEntry.filename} complete. resetOnCompletion=${docEntry.resetOnCompletion}, docTasksCompleted=${docTasksCompleted}`);
if (docEntry.resetOnCompletion && docTasksCompleted > 0) {
@@ -1193,7 +1210,6 @@ ${docList}
// Reset per-loop tracking for next iteration
loopStartTime = Date.now();
loopTasksCompleted = 0;
loopTasksDiscovered = newTotalTasks;
loopTotalInputTokens = 0;
loopTotalOutputTokens = 0;
loopTotalCost = 0;
@@ -1445,6 +1461,7 @@ ${docList}
// Clean up time tracking refs
delete accumulatedTimeRefs.current[sessionId];
delete lastActiveTimestampRefs.current[sessionId];
delete errorResolutionRefs.current[sessionId];
}, [onUpdateSession, onSpawnAgent, onSpawnSynopsis, onAddHistoryEntry, onComplete, onPRResult, audioFeedbackEnabled, audioFeedbackCommand, updateBatchStateAndBroadcast]);
/**
@@ -1452,6 +1469,11 @@ ${docList}
*/
const stopBatchRun = useCallback((sessionId: string) => {
stopRequestedRefs.current[sessionId] = true;
const errorResolution = errorResolutionRefs.current[sessionId];
if (errorResolution) {
errorResolution.resolve('abort');
delete errorResolutionRefs.current[sessionId];
}
updateBatchStateAndBroadcast(sessionId, prev => ({
...prev,
[sessionId]: {
@@ -1494,6 +1516,17 @@ ${docList}
}
};
});
if (!errorResolutionRefs.current[sessionId]) {
let resolvePromise: ((action: ErrorResolutionAction) => void) | undefined;
const promise = new Promise<ErrorResolutionAction>(resolve => {
resolvePromise = resolve;
});
errorResolutionRefs.current[sessionId] = {
promise,
resolve: resolvePromise as (action: ErrorResolutionAction) => void
};
}
}, [updateBatchStateAndBroadcast]);
/**
@@ -1527,9 +1560,13 @@ ${docList}
};
});
// Signal to skip current document in the processing loop
// The stopRequestedRefs is reused with a special marker
// Note: This relies on the processing loop checking for error state changes
const errorResolution = errorResolutionRefs.current[sessionId];
if (errorResolution) {
errorResolution.resolve('skip-document');
delete errorResolutionRefs.current[sessionId];
}
// Signal to skip the current document in the processing loop
}, [updateBatchStateAndBroadcast]);
/**
@@ -1560,6 +1597,12 @@ ${docList}
}
};
});
const errorResolution = errorResolutionRefs.current[sessionId];
if (errorResolution) {
errorResolution.resolve('resume');
delete errorResolutionRefs.current[sessionId];
}
}, [updateBatchStateAndBroadcast]);
/**
@@ -1575,6 +1618,11 @@ ${docList}
// Request stop and clear error state
stopRequestedRefs.current[sessionId] = true;
const errorResolution = errorResolutionRefs.current[sessionId];
if (errorResolution) {
errorResolution.resolve('abort');
delete errorResolutionRefs.current[sessionId];
}
updateBatchStateAndBroadcast(sessionId, prev => ({
...prev,
[sessionId]: {

View File

@@ -1,243 +0,0 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import type { Session } from '../types';
import { fuzzyMatch } from '../utils/search';
import {
shouldOpenExternally,
flattenTree as flattenTreeUtil,
getAllFolderPaths as getAllFolderPathsUtil,
type FileTreeNode,
} from '../utils/fileExplorer';
export interface FileNode {
name: string;
type: 'file' | 'folder';
children?: FileNode[];
fullPath?: string;
isFolder?: boolean;
}
export interface UseFileExplorerReturn {
// State
previewFile: {name: string; content: string; path: string} | null;
setPreviewFile: (file: {name: string; content: string; path: string} | null) => void;
selectedFileIndex: number;
setSelectedFileIndex: (index: number) => void;
flatFileList: any[];
fileTreeFilter: string;
setFileTreeFilter: (filter: string) => void;
fileTreeFilterOpen: boolean;
setFileTreeFilterOpen: (open: boolean) => void;
fileTreeContainerRef: React.RefObject<HTMLDivElement>;
// Operations
handleFileClick: (node: any, path: string, activeSession: Session) => Promise<void>;
loadFileTree: (dirPath: string, maxDepth?: number, currentDepth?: number) => Promise<any[]>;
updateSessionWorkingDirectory: (activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => Promise<void>;
toggleFolder: (path: string, activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
expandAllFolders: (activeSessionId: string, activeSession: Session, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
collapseAllFolders: (activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
flattenTree: (nodes: any[], expandedSet: Set<string>, currentPath?: string) => any[];
filteredFileTree: any[];
shouldOpenExternally: (filename: string) => boolean;
}
export function useFileExplorer(
activeSession: Session | null,
setActiveFocus: (focus: string) => void
): UseFileExplorerReturn {
const [previewFile, setPreviewFile] = useState<{name: string; content: string; path: string} | null>(null);
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
const [flatFileList, setFlatFileList] = useState<any[]>([]);
const [fileTreeFilter, setFileTreeFilter] = useState('');
const [fileTreeFilterOpen, setFileTreeFilterOpen] = useState(false);
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
const handleFileClick = async (node: any, path: string, activeSession: Session) => {
if (node.type === 'file') {
try {
// Construct full file path
const fullPath = `${activeSession.fullPath}/${path}`;
// Check if file should be opened externally
if (shouldOpenExternally(node.name)) {
await window.maestro.shell.openExternal(`file://${fullPath}`);
return;
}
const content = await window.maestro.fs.readFile(fullPath);
setPreviewFile({
name: node.name,
content: content,
path: fullPath
});
setActiveFocus('main');
} catch (error) {
console.error('Failed to read file:', error);
}
}
};
// Load file tree from directory
const loadFileTree = async (dirPath: string, maxDepth = 10, currentDepth = 0): Promise<any[]> => {
if (currentDepth >= maxDepth) return [];
try {
const entries = await window.maestro.fs.readDir(dirPath);
const tree: any[] = [];
for (const entry of entries) {
// Skip hidden files and common ignore patterns
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') {
continue;
}
if (entry.isDirectory) {
const children = await loadFileTree(`${dirPath}/${entry.name}`, maxDepth, currentDepth + 1);
tree.push({
name: entry.name,
type: 'folder',
children
});
} else if (entry.isFile) {
tree.push({
name: entry.name,
type: 'file'
});
}
}
return tree.sort((a, b) => {
// Folders first, then alphabetically
if (a.type === 'folder' && b.type !== 'folder') return -1;
if (a.type !== 'folder' && b.type === 'folder') return 1;
return a.name.localeCompare(b.name);
});
} catch (error) {
console.error('Error loading file tree:', error);
throw error;
}
};
const updateSessionWorkingDirectory = async (
activeSessionId: string,
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
) => {
const newPath = await window.maestro.dialog.selectFolder();
if (!newPath) return;
setSessions(prev => prev.map(s => {
if (s.id !== activeSessionId) return s;
return {
...s,
cwd: newPath,
fullPath: newPath,
fileTree: [],
fileTreeError: undefined
};
}));
};
const toggleFolder = (
path: string,
activeSessionId: string,
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
) => {
setSessions(prev => prev.map(s => {
if (s.id !== activeSessionId) return s;
if (!s.fileExplorerExpanded) return s;
const expanded = new Set(s.fileExplorerExpanded);
if (expanded.has(path)) {
expanded.delete(path);
} else {
expanded.add(path);
}
return { ...s, fileExplorerExpanded: Array.from(expanded) };
}));
};
const expandAllFolders = (
activeSessionId: string,
activeSession: Session,
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
) => {
setSessions(prev => prev.map(s => {
if (s.id !== activeSessionId) return s;
if (!s.fileTree) return s;
const allFolderPaths = getAllFolderPathsUtil(s.fileTree as FileTreeNode[]);
return { ...s, fileExplorerExpanded: allFolderPaths };
}));
};
const collapseAllFolders = (
activeSessionId: string,
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
) => {
setSessions(prev => prev.map(s => {
if (s.id !== activeSessionId) return s;
return { ...s, fileExplorerExpanded: [] };
}));
};
// Update flat file list when active session's tree or expanded folders change
useEffect(() => {
if (!activeSession || !activeSession.fileTree || !activeSession.fileExplorerExpanded) {
setFlatFileList([]);
return;
}
const expandedSet = new Set(activeSession.fileExplorerExpanded);
setFlatFileList(flattenTreeUtil(activeSession.fileTree as FileTreeNode[], expandedSet));
}, [activeSession?.fileTree, activeSession?.fileExplorerExpanded]);
// Filter file tree based on search query
const filteredFileTree = useMemo(() => {
if (!activeSession || !fileTreeFilter || !activeSession.fileTree) {
return activeSession?.fileTree || [];
}
const filterTree = (nodes: any[]): any[] => {
return nodes.reduce((acc: any[], node) => {
const matchesFilter = fuzzyMatch(node.name, fileTreeFilter);
if (node.type === 'folder' && node.children) {
const filteredChildren = filterTree(node.children);
// Include folder if it matches or has matching children
if (matchesFilter || filteredChildren.length > 0) {
acc.push({
...node,
children: filteredChildren
});
}
} else if (matchesFilter) {
// Include file if it matches
acc.push(node);
}
return acc;
}, []);
};
return filterTree(activeSession.fileTree);
}, [activeSession?.fileTree, fileTreeFilter]);
return {
previewFile,
setPreviewFile,
selectedFileIndex,
setSelectedFileIndex,
flatFileList,
fileTreeFilter,
setFileTreeFilter,
fileTreeFilterOpen,
setFileTreeFilterOpen,
fileTreeContainerRef,
handleFileClick,
loadFileTree,
updateSessionWorkingDirectory,
toggleFolder,
expandAllFolders,
collapseAllFolders,
flattenTree: flattenTreeUtil,
filteredFileTree,
shouldOpenExternally,
};
}

View File

@@ -95,7 +95,7 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
// Allow system utility shortcuts (Alt+Cmd+L for logs, Alt+Cmd+P for processes) even when modals are open
// NOTE: Must use e.code for Alt key combos on macOS because e.key produces special characters (e.g., Alt+P = π)
const codeKeyLower = e.code?.replace('Key', '').toLowerCase() || '';
const isSystemUtilShortcut = e.altKey && (e.metaKey || e.ctrlKey) && (codeKeyLower === 'l' || codeKeyLower === 'p' || codeKeyLower === 'd');
const isSystemUtilShortcut = e.altKey && (e.metaKey || e.ctrlKey) && (codeKeyLower === 'l' || codeKeyLower === 'p');
// Allow session jump shortcuts (Alt+Cmd+NUMBER) even when modals are open
// NOTE: Must use e.code for Alt key combos on macOS because e.key produces special characters
const isSessionJumpShortcut = e.altKey && (e.metaKey || e.ctrlKey) && /^Digit[0-9]$/.test(e.code || '');
@@ -292,10 +292,6 @@ export function useMainKeyboardHandler(): UseMainKeyboardHandlerReturn {
e.preventDefault();
ctx.setProcessMonitorOpen(true);
}
else if (ctx.isShortcut(e, 'createDebugPackage')) {
e.preventDefault();
ctx.handleCreateDebugPackage();
}
else if (ctx.isShortcut(e, 'jumpToBottom')) {
e.preventDefault();
// Jump to the bottom of the current main panel output (AI logs or terminal output)

View File

@@ -1,6 +1,6 @@
import { useMemo, useCallback } from 'react';
import type { Session } from '../types';
import type { FileNode } from './useFileExplorer';
import type { FileNode } from '../types/fileTree';
export interface TabCompletionSuggestion {
value: string;

View File

@@ -14,7 +14,7 @@
import { visit } from 'unist-util-visit';
import type { Root, Text, Link, Image } from 'mdast';
import type { FileNode } from '../hooks/useFileExplorer';
import type { FileNode } from '../types/fileTree';
import { buildFileIndex as buildFileIndexShared, type FilePathEntry } from '../../shared/treeUtils';
export interface RemarkFileLinksOptions {

View File

@@ -48,6 +48,7 @@ const defaultDarkTheme: Theme = {
accent: '#6366f1',
accentDim: 'rgba(99, 102, 241, 0.2)',
accentText: '#a5b4fc',
accentForeground: '#0b0b0d',
success: '#22c55e',
warning: '#eab308',
error: '#ef4444',
@@ -72,6 +73,7 @@ const defaultLightTheme: Theme = {
accent: '#0969da',
accentDim: 'rgba(9, 105, 218, 0.1)',
accentText: '#0969da',
accentForeground: '#ffffff',
success: '#1a7f37',
warning: '#9a6700',
error: '#cf222e',