From 53acecf0f225e16d132d8658aafcc3f2802f2e6d Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Sat, 20 Dec 2025 14:28:42 -0600 Subject: [PATCH] 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"} --- .github/workflows/release.yml | 5 + ARCHITECTURE.md | 12 +- package.json | 15 +- .../hooks/useAtMentionCompletion.test.ts | 2 +- .../renderer/hooks/useBatchProcessor.test.ts | 75 +- .../renderer/hooks/useFileExplorer.test.ts | 1606 ----------------- .../renderer/hooks/useTabCompletion.test.ts | 2 +- .../renderer/utils/remarkFileLinks.test.ts | 2 +- src/main/group-chat/group-chat-router.ts | 20 + src/main/index.ts | 14 +- src/main/ipc/handlers/agents.ts | 63 + src/main/preload.ts | 5 + src/renderer/App.tsx | 58 +- src/renderer/components/FileExplorerPanel.tsx | 7 +- src/renderer/components/FilePreview.tsx | 2 +- src/renderer/components/FileSearchModal.tsx | 7 +- src/renderer/components/MainPanel.tsx | 54 +- src/renderer/components/MarkdownRenderer.tsx | 2 +- src/renderer/components/QuickActionsModal.tsx | 2 +- src/renderer/components/TerminalOutput.tsx | 2 +- src/renderer/constants/shortcuts.ts | 1 - src/renderer/hooks/index.ts | 2 - src/renderer/hooks/useAgentExecution.ts | 47 +- .../hooks/useAgentSessionManagement.ts | 67 +- src/renderer/hooks/useAtMentionCompletion.ts | 2 +- src/renderer/hooks/useBatchProcessor.ts | 88 +- src/renderer/hooks/useFileExplorer.ts | 243 --- src/renderer/hooks/useMainKeyboardHandler.ts | 6 +- src/renderer/hooks/useTabCompletion.ts | 2 +- src/renderer/utils/remarkFileLinks.ts | 2 +- src/web/components/ThemeProvider.tsx | 2 + 31 files changed, 407 insertions(+), 2010 deletions(-) delete mode 100644 src/__tests__/renderer/hooks/useFileExplorer.test.ts delete mode 100644 src/renderer/hooks/useFileExplorer.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14107741..5e9dc38a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cd717df9..26ef02df 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`) diff --git a/package.json b/package.json index cfd88fb7..56ae6892 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/renderer/hooks/useAtMentionCompletion.test.ts b/src/__tests__/renderer/hooks/useAtMentionCompletion.test.ts index 9b46ecff..774eeacb 100644 --- a/src/__tests__/renderer/hooks/useAtMentionCompletion.test.ts +++ b/src/__tests__/renderer/hooks/useAtMentionCompletion.test.ts @@ -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 diff --git a/src/__tests__/renderer/hooks/useBatchProcessor.test.ts b/src/__tests__/renderer/hooks/useBatchProcessor.test.ts index 59499dc6..0b062e75 100644 --- a/src/__tests__/renderer/hooks/useBatchProcessor.test.ts +++ b/src/__tests__/renderer/hooks/useBatchProcessor.test.ts @@ -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; + 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()]; diff --git a/src/__tests__/renderer/hooks/useFileExplorer.test.ts b/src/__tests__/renderer/hooks/useFileExplorer.test.ts deleted file mode 100644 index a57e46b9..00000000 --- a/src/__tests__/renderer/hooks/useFileExplorer.test.ts +++ /dev/null @@ -1,1606 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { renderHook, act, waitFor } from '@testing-library/react'; -import { useFileExplorer, FileNode } from '../../../renderer/hooks/useFileExplorer'; -import type { Session, AITab } from '../../../renderer/types'; - -// Helper to create a minimal valid session -const createMockSession = (overrides: Partial = {}): Session => ({ - id: `session-${Date.now()}-${Math.random()}`, - name: 'Test Session', - toolType: 'claude-code', - state: 'idle', - cwd: '/test/path', - fullPath: '/test/path', - projectRoot: '/test/path', - aiLogs: [], - shellLogs: [], - workLog: [], - contextUsage: 0, - inputMode: 'ai', - aiPid: 0, - terminalPid: 0, - port: 3000, - isLive: false, - changedFiles: [], - isGitRepo: true, - fileTree: [], - fileExplorerExpanded: [], - fileExplorerScrollPos: 0, - aiTabs: [], - activeTabId: '', - closedTabHistory: [], - executionQueue: [], - activeTimeMs: 0, - ...overrides, -}); - -// Store original maestro mock for restoration -const originalMaestro = { ...window.maestro }; - -// Add missing shell mock to window.maestro -const extendMaestro = () => { - Object.assign(window.maestro, { - shell: { - openExternal: vi.fn().mockResolvedValue(undefined), - }, - fs: { - readDir: vi.fn().mockResolvedValue([]), - readFile: vi.fn().mockResolvedValue(''), - }, - dialog: { - selectFolder: vi.fn().mockResolvedValue(null), - }, - }); -}; - -describe('useFileExplorer', () => { - let setActiveFocusMock: ReturnType; - - beforeEach(() => { - vi.clearAllMocks(); - setActiveFocusMock = vi.fn(); - extendMaestro(); - }); - - afterEach(() => { - // Restore original maestro - Object.assign(window.maestro, originalMaestro); - }); - - describe('shouldOpenExternally', () => { - // Get the function through the hook - const getShouldOpenExternally = () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - return result.current.shouldOpenExternally; - }; - - describe('document extensions', () => { - it('should return true for .pdf files', () => { - const fn = getShouldOpenExternally(); - expect(fn('document.pdf')).toBe(true); - }); - - it('should return true for .doc files', () => { - const fn = getShouldOpenExternally(); - expect(fn('document.doc')).toBe(true); - }); - - it('should return true for .docx files', () => { - const fn = getShouldOpenExternally(); - expect(fn('document.docx')).toBe(true); - }); - - it('should return true for .xls files', () => { - const fn = getShouldOpenExternally(); - expect(fn('spreadsheet.xls')).toBe(true); - }); - - it('should return true for .xlsx files', () => { - const fn = getShouldOpenExternally(); - expect(fn('spreadsheet.xlsx')).toBe(true); - }); - - it('should return true for .ppt files', () => { - const fn = getShouldOpenExternally(); - expect(fn('presentation.ppt')).toBe(true); - }); - - it('should return true for .pptx files', () => { - const fn = getShouldOpenExternally(); - expect(fn('presentation.pptx')).toBe(true); - }); - }); - - describe('image extensions', () => { - it('should return true for .png files', () => { - const fn = getShouldOpenExternally(); - expect(fn('image.png')).toBe(true); - }); - - it('should return true for .jpg files', () => { - const fn = getShouldOpenExternally(); - expect(fn('image.jpg')).toBe(true); - }); - - it('should return true for .jpeg files', () => { - const fn = getShouldOpenExternally(); - expect(fn('photo.jpeg')).toBe(true); - }); - - it('should return true for .gif files', () => { - const fn = getShouldOpenExternally(); - expect(fn('animation.gif')).toBe(true); - }); - - it('should return true for .bmp files', () => { - const fn = getShouldOpenExternally(); - expect(fn('bitmap.bmp')).toBe(true); - }); - - it('should return true for .svg files', () => { - const fn = getShouldOpenExternally(); - expect(fn('vector.svg')).toBe(true); - }); - - it('should return true for .webp files', () => { - const fn = getShouldOpenExternally(); - expect(fn('modern.webp')).toBe(true); - }); - }); - - describe('video extensions', () => { - it('should return true for .mp4 files', () => { - const fn = getShouldOpenExternally(); - expect(fn('video.mp4')).toBe(true); - }); - - it('should return true for .mov files', () => { - const fn = getShouldOpenExternally(); - expect(fn('movie.mov')).toBe(true); - }); - - it('should return true for .avi files', () => { - const fn = getShouldOpenExternally(); - expect(fn('clip.avi')).toBe(true); - }); - - it('should return true for .mkv files', () => { - const fn = getShouldOpenExternally(); - expect(fn('video.mkv')).toBe(true); - }); - - it('should return true for .webm files', () => { - const fn = getShouldOpenExternally(); - expect(fn('video.webm')).toBe(true); - }); - }); - - describe('audio extensions', () => { - it('should return true for .mp3 files', () => { - const fn = getShouldOpenExternally(); - expect(fn('song.mp3')).toBe(true); - }); - - it('should return true for .wav files', () => { - const fn = getShouldOpenExternally(); - expect(fn('audio.wav')).toBe(true); - }); - - it('should return true for .flac files', () => { - const fn = getShouldOpenExternally(); - expect(fn('lossless.flac')).toBe(true); - }); - - it('should return true for .aac files', () => { - const fn = getShouldOpenExternally(); - expect(fn('compressed.aac')).toBe(true); - }); - }); - - describe('archive extensions', () => { - it('should return true for .zip files', () => { - const fn = getShouldOpenExternally(); - expect(fn('archive.zip')).toBe(true); - }); - - it('should return true for .tar files', () => { - const fn = getShouldOpenExternally(); - expect(fn('archive.tar')).toBe(true); - }); - - it('should return true for .gz files', () => { - const fn = getShouldOpenExternally(); - expect(fn('compressed.gz')).toBe(true); - }); - - it('should return true for .7z files', () => { - const fn = getShouldOpenExternally(); - expect(fn('archive.7z')).toBe(true); - }); - - it('should return true for .rar files', () => { - const fn = getShouldOpenExternally(); - expect(fn('archive.rar')).toBe(true); - }); - }); - - describe('executable extensions', () => { - it('should return true for .exe files', () => { - const fn = getShouldOpenExternally(); - expect(fn('program.exe')).toBe(true); - }); - - it('should return true for .dmg files', () => { - const fn = getShouldOpenExternally(); - expect(fn('installer.dmg')).toBe(true); - }); - - it('should return true for .app files', () => { - const fn = getShouldOpenExternally(); - expect(fn('Application.app')).toBe(true); - }); - - it('should return true for .deb files', () => { - const fn = getShouldOpenExternally(); - expect(fn('package.deb')).toBe(true); - }); - - it('should return true for .rpm files', () => { - const fn = getShouldOpenExternally(); - expect(fn('package.rpm')).toBe(true); - }); - }); - - describe('code and text files (should NOT open externally)', () => { - it('should return false for .ts files', () => { - const fn = getShouldOpenExternally(); - expect(fn('code.ts')).toBe(false); - }); - - it('should return false for .tsx files', () => { - const fn = getShouldOpenExternally(); - expect(fn('component.tsx')).toBe(false); - }); - - it('should return false for .js files', () => { - const fn = getShouldOpenExternally(); - expect(fn('script.js')).toBe(false); - }); - - it('should return false for .jsx files', () => { - const fn = getShouldOpenExternally(); - expect(fn('component.jsx')).toBe(false); - }); - - it('should return false for .json files', () => { - const fn = getShouldOpenExternally(); - expect(fn('config.json')).toBe(false); - }); - - it('should return false for .md files', () => { - const fn = getShouldOpenExternally(); - expect(fn('README.md')).toBe(false); - }); - - it('should return false for .txt files', () => { - const fn = getShouldOpenExternally(); - expect(fn('notes.txt')).toBe(false); - }); - - it('should return false for .html files', () => { - const fn = getShouldOpenExternally(); - expect(fn('page.html')).toBe(false); - }); - - it('should return false for .css files', () => { - const fn = getShouldOpenExternally(); - expect(fn('styles.css')).toBe(false); - }); - - it('should return false for .py files', () => { - const fn = getShouldOpenExternally(); - expect(fn('script.py')).toBe(false); - }); - }); - - describe('edge cases', () => { - it('should return false for files with no extension', () => { - const fn = getShouldOpenExternally(); - expect(fn('Makefile')).toBe(false); - }); - - it('should handle uppercase extensions (case insensitive)', () => { - const fn = getShouldOpenExternally(); - expect(fn('IMAGE.PNG')).toBe(true); - expect(fn('document.PDF')).toBe(true); - }); - - it('should return false for empty filename', () => { - const fn = getShouldOpenExternally(); - expect(fn('')).toBe(false); - }); - - it('should handle multiple dots in filename', () => { - const fn = getShouldOpenExternally(); - expect(fn('file.name.with.dots.pdf')).toBe(true); - expect(fn('archive.tar.gz')).toBe(true); - }); - - it('should return false for hidden files without extension', () => { - const fn = getShouldOpenExternally(); - expect(fn('.gitignore')).toBe(false); - }); - - it('should handle hidden files with external extension', () => { - const fn = getShouldOpenExternally(); - expect(fn('.hidden.pdf')).toBe(true); - }); - }); - }); - - describe('flattenTree', () => { - const getFlattenTree = () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - return result.current.flattenTree; - }; - - it('should return empty array for empty tree', () => { - const flattenTree = getFlattenTree(); - expect(flattenTree([], new Set())).toEqual([]); - }); - - it('should flatten single file', () => { - const flattenTree = getFlattenTree(); - const tree = [{ name: 'file.ts', type: 'file' }]; - const result = flattenTree(tree, new Set()); - expect(result).toHaveLength(1); - expect(result[0].name).toBe('file.ts'); - expect(result[0].fullPath).toBe('file.ts'); - expect(result[0].isFolder).toBe(false); - }); - - it('should flatten single folder (collapsed)', () => { - const flattenTree = getFlattenTree(); - const tree = [{ - name: 'src', - type: 'folder', - children: [{ name: 'index.ts', type: 'file' }] - }]; - const result = flattenTree(tree, new Set()); // Not expanded - expect(result).toHaveLength(1); - expect(result[0].name).toBe('src'); - expect(result[0].isFolder).toBe(true); - }); - - it('should flatten folder with expanded children', () => { - const flattenTree = getFlattenTree(); - const tree = [{ - name: 'src', - type: 'folder', - children: [ - { name: 'index.ts', type: 'file' }, - { name: 'utils.ts', type: 'file' } - ] - }]; - const expandedSet = new Set(['src']); - const result = flattenTree(tree, expandedSet); - expect(result).toHaveLength(3); - expect(result[0].name).toBe('src'); - expect(result[1].name).toBe('index.ts'); - expect(result[1].fullPath).toBe('src/index.ts'); - expect(result[2].name).toBe('utils.ts'); - }); - - it('should not include children of collapsed folders', () => { - const flattenTree = getFlattenTree(); - const tree = [{ - name: 'src', - type: 'folder', - children: [ - { name: 'index.ts', type: 'file' }, - { - name: 'utils', - type: 'folder', - children: [{ name: 'helper.ts', type: 'file' }] - } - ] - }]; - const expandedSet = new Set(['src']); // Only src is expanded, not src/utils - const result = flattenTree(tree, expandedSet); - expect(result).toHaveLength(3); - expect(result.find(n => n.name === 'helper.ts')).toBeUndefined(); - }); - - it('should handle deeply nested expanded folders', () => { - const flattenTree = getFlattenTree(); - const tree = [{ - name: 'a', - type: 'folder', - children: [{ - name: 'b', - type: 'folder', - children: [{ - name: 'c', - type: 'folder', - children: [{ name: 'deep.ts', type: 'file' }] - }] - }] - }]; - const expandedSet = new Set(['a', 'a/b', 'a/b/c']); - const result = flattenTree(tree, expandedSet); - expect(result).toHaveLength(4); - expect(result[3].name).toBe('deep.ts'); - expect(result[3].fullPath).toBe('a/b/c/deep.ts'); - }); - - it('should set fullPath correctly for root items', () => { - const flattenTree = getFlattenTree(); - const tree = [ - { name: 'package.json', type: 'file' }, - { name: 'src', type: 'folder', children: [] } - ]; - const result = flattenTree(tree, new Set()); - expect(result[0].fullPath).toBe('package.json'); - expect(result[1].fullPath).toBe('src'); - }); - - it('should set fullPath correctly for nested items', () => { - const flattenTree = getFlattenTree(); - const tree = [{ - name: 'src', - type: 'folder', - children: [{ - name: 'components', - type: 'folder', - children: [{ name: 'Button.tsx', type: 'file' }] - }] - }]; - const expandedSet = new Set(['src', 'src/components']); - const result = flattenTree(tree, expandedSet); - expect(result[2].fullPath).toBe('src/components/Button.tsx'); - }); - - it('should set isFolder true for folders', () => { - const flattenTree = getFlattenTree(); - const tree = [{ name: 'src', type: 'folder', children: [] }]; - const result = flattenTree(tree, new Set()); - expect(result[0].isFolder).toBe(true); - }); - - it('should set isFolder false for files', () => { - const flattenTree = getFlattenTree(); - const tree = [{ name: 'index.ts', type: 'file' }]; - const result = flattenTree(tree, new Set()); - expect(result[0].isFolder).toBe(false); - }); - - it('should preserve node properties', () => { - const flattenTree = getFlattenTree(); - const tree = [{ - name: 'custom.ts', - type: 'file', - customProp: 'value' - }]; - const result = flattenTree(tree, new Set()); - expect(result[0].customProp).toBe('value'); - }); - - it('should handle mixed files and folders', () => { - const flattenTree = getFlattenTree(); - const tree = [ - { name: 'README.md', type: 'file' }, - { name: 'src', type: 'folder', children: [{ name: 'index.ts', type: 'file' }] }, - { name: 'package.json', type: 'file' } - ]; - const expandedSet = new Set(['src']); - const result = flattenTree(tree, expandedSet); - expect(result).toHaveLength(4); - expect(result[0].name).toBe('README.md'); - expect(result[1].name).toBe('src'); - expect(result[2].name).toBe('index.ts'); - expect(result[3].name).toBe('package.json'); - }); - - it('should maintain order from input tree', () => { - const flattenTree = getFlattenTree(); - const tree = [ - { name: 'z-last.ts', type: 'file' }, - { name: 'a-first.ts', type: 'file' }, - { name: 'm-middle.ts', type: 'file' } - ]; - const result = flattenTree(tree, new Set()); - expect(result[0].name).toBe('z-last.ts'); - expect(result[1].name).toBe('a-first.ts'); - expect(result[2].name).toBe('m-middle.ts'); - }); - - it('should handle multiple expanded folders', () => { - const flattenTree = getFlattenTree(); - const tree = [ - { - name: 'src', - type: 'folder', - children: [{ name: 'index.ts', type: 'file' }] - }, - { - name: 'tests', - type: 'folder', - children: [{ name: 'test.ts', type: 'file' }] - } - ]; - const expandedSet = new Set(['src', 'tests']); - const result = flattenTree(tree, expandedSet); - expect(result).toHaveLength(4); - expect(result.map(n => n.name)).toEqual(['src', 'index.ts', 'tests', 'test.ts']); - }); - - it('should handle partially expanded tree', () => { - const flattenTree = getFlattenTree(); - const tree = [ - { - name: 'expanded', - type: 'folder', - children: [{ name: 'visible.ts', type: 'file' }] - }, - { - name: 'collapsed', - type: 'folder', - children: [{ name: 'hidden.ts', type: 'file' }] - } - ]; - const expandedSet = new Set(['expanded']); - const result = flattenTree(tree, expandedSet); - expect(result).toHaveLength(3); - expect(result.map(n => n.name)).toEqual(['expanded', 'visible.ts', 'collapsed']); - }); - }); - - describe('handleFileClick', () => { - it('should read file content and set preview for text file', async () => { - const mockReadFile = vi.fn().mockResolvedValue('file content'); - (window.maestro.fs.readFile as ReturnType).mockImplementation(mockReadFile); - - const session = createMockSession({ fullPath: '/project' }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'index.ts', type: 'file' }; - await act(async () => { - await result.current.handleFileClick(node, 'src/index.ts', session); - }); - - expect(mockReadFile).toHaveBeenCalledWith('/project/src/index.ts'); - expect(result.current.previewFile).toEqual({ - name: 'index.ts', - content: 'file content', - path: '/project/src/index.ts' - }); - }); - - it('should call setActiveFocus with main after preview', async () => { - const mockReadFile = vi.fn().mockResolvedValue('content'); - (window.maestro.fs.readFile as ReturnType).mockImplementation(mockReadFile); - - const session = createMockSession({ fullPath: '/project' }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'test.ts', type: 'file' }; - await act(async () => { - await result.current.handleFileClick(node, 'test.ts', session); - }); - - expect(setActiveFocusMock).toHaveBeenCalledWith('main'); - }); - - it('should open externally for image file', async () => { - const mockOpenExternal = vi.fn().mockResolvedValue(undefined); - (window.maestro.shell.openExternal as ReturnType).mockImplementation(mockOpenExternal); - - const session = createMockSession({ fullPath: '/project' }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'photo.png', type: 'file' }; - await act(async () => { - await result.current.handleFileClick(node, 'images/photo.png', session); - }); - - expect(mockOpenExternal).toHaveBeenCalledWith('file:///project/images/photo.png'); - expect(result.current.previewFile).toBeNull(); - }); - - it('should open externally for pdf file', async () => { - const mockOpenExternal = vi.fn().mockResolvedValue(undefined); - (window.maestro.shell.openExternal as ReturnType).mockImplementation(mockOpenExternal); - - const session = createMockSession({ fullPath: '/project' }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'document.pdf', type: 'file' }; - await act(async () => { - await result.current.handleFileClick(node, 'docs/document.pdf', session); - }); - - expect(mockOpenExternal).toHaveBeenCalledWith('file:///project/docs/document.pdf'); - }); - - it('should do nothing for folder node', async () => { - const mockReadFile = vi.fn(); - const mockOpenExternal = vi.fn(); - (window.maestro.fs.readFile as ReturnType).mockImplementation(mockReadFile); - (window.maestro.shell.openExternal as ReturnType).mockImplementation(mockOpenExternal); - - const session = createMockSession({ fullPath: '/project' }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'src', type: 'folder', children: [] }; - await act(async () => { - await result.current.handleFileClick(node, 'src', session); - }); - - expect(mockReadFile).not.toHaveBeenCalled(); - expect(mockOpenExternal).not.toHaveBeenCalled(); - }); - - it('should construct correct full path', async () => { - const mockReadFile = vi.fn().mockResolvedValue('content'); - (window.maestro.fs.readFile as ReturnType).mockImplementation(mockReadFile); - - const session = createMockSession({ fullPath: '/Users/dev/myproject' }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'utils.ts', type: 'file' }; - await act(async () => { - await result.current.handleFileClick(node, 'src/lib/utils.ts', session); - }); - - expect(mockReadFile).toHaveBeenCalledWith('/Users/dev/myproject/src/lib/utils.ts'); - }); - - it('should handle file read error gracefully', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const mockReadFile = vi.fn().mockRejectedValue(new Error('Read failed')); - (window.maestro.fs.readFile as ReturnType).mockImplementation(mockReadFile); - - const session = createMockSession({ fullPath: '/project' }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'missing.ts', type: 'file' }; - await act(async () => { - await result.current.handleFileClick(node, 'missing.ts', session); - }); - - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to read file:', expect.any(Error)); - expect(result.current.previewFile).toBeNull(); - - consoleErrorSpy.mockRestore(); - }); - - it('should use activeSession.fullPath for path construction', async () => { - const mockOpenExternal = vi.fn().mockResolvedValue(undefined); - (window.maestro.shell.openExternal as ReturnType).mockImplementation(mockOpenExternal); - - const session = createMockSession({ - fullPath: '/different/path', - cwd: '/some/other/path' - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const node = { name: 'image.png', type: 'file' }; - await act(async () => { - await result.current.handleFileClick(node, 'image.png', session); - }); - - // Should use fullPath, not cwd - expect(mockOpenExternal).toHaveBeenCalledWith('file:///different/path/image.png'); - }); - }); - - describe('loadFileTree', () => { - it('should return empty array at max depth', async () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path', 2, 2); // currentDepth >= maxDepth - expect(tree).toEqual([]); - }); - - it('should load entries from directory', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: 'file1.ts', isFile: true, isDirectory: false }, - { name: 'file2.ts', isFile: true, isDirectory: false } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(mockReadDir).toHaveBeenCalledWith('/path'); - expect(tree).toHaveLength(2); - }); - - it('should skip hidden files (starting with .)', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: '.gitignore', isFile: true, isDirectory: false }, - { name: '.env', isFile: true, isDirectory: false }, - { name: 'visible.ts', isFile: true, isDirectory: false } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree).toHaveLength(1); - expect(tree[0].name).toBe('visible.ts'); - }); - - it('should skip node_modules', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: 'node_modules', isFile: false, isDirectory: true }, - { name: 'src', isFile: false, isDirectory: true } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree).toHaveLength(1); - expect(tree[0].name).toBe('src'); - }); - - it('should skip __pycache__', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: '__pycache__', isFile: false, isDirectory: true }, - { name: 'main.py', isFile: true, isDirectory: false } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree).toHaveLength(1); - expect(tree[0].name).toBe('main.py'); - }); - - it('should recursively load subdirectories', async () => { - const mockReadDir = vi.fn() - .mockResolvedValueOnce([ - { name: 'src', isFile: false, isDirectory: true } - ]) - .mockResolvedValueOnce([ - { name: 'index.ts', isFile: true, isDirectory: false } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(mockReadDir).toHaveBeenCalledTimes(2); - expect(mockReadDir).toHaveBeenCalledWith('/path'); - expect(mockReadDir).toHaveBeenCalledWith('/path/src'); - expect(tree[0].children).toHaveLength(1); - expect(tree[0].children[0].name).toBe('index.ts'); - }); - - it('should sort folders before files', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: 'zebra.ts', isFile: true, isDirectory: false }, - { name: 'apple', isFile: false, isDirectory: true }, - { name: 'banana.ts', isFile: true, isDirectory: false } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree[0].name).toBe('apple'); // Folder first - expect(tree[0].type).toBe('folder'); - expect(tree[1].type).toBe('file'); - expect(tree[2].type).toBe('file'); - }); - - it('should sort alphabetically within type', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: 'zebra.ts', isFile: true, isDirectory: false }, - { name: 'alpha.ts', isFile: true, isDirectory: false }, - { name: 'beta.ts', isFile: true, isDirectory: false } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree.map((n: FileNode) => n.name)).toEqual(['alpha.ts', 'beta.ts', 'zebra.ts']); - }); - - it('should set type folder for directories', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: 'folder', isFile: false, isDirectory: true } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree[0].type).toBe('folder'); - }); - - it('should set type file for files', async () => { - const mockReadDir = vi.fn().mockResolvedValue([ - { name: 'file.ts', isFile: true, isDirectory: false } - ]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree[0].type).toBe('file'); - }); - - it('should propagate errors', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const mockReadDir = vi.fn().mockRejectedValue(new Error('Permission denied')); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - await expect(result.current.loadFileTree('/path')).rejects.toThrow('Permission denied'); - consoleErrorSpy.mockRestore(); - }); - - it('should handle empty directory', async () => { - const mockReadDir = vi.fn().mockResolvedValue([]); - (window.maestro.fs.readDir as ReturnType).mockImplementation(mockReadDir); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const tree = await result.current.loadFileTree('/path'); - expect(tree).toEqual([]); - }); - }); - - describe('updateSessionWorkingDirectory', () => { - it('should update session cwd when folder selected', async () => { - const mockSelectFolder = vi.fn().mockResolvedValue('/new/path'); - (window.maestro.dialog.selectFolder as ReturnType).mockImplementation(mockSelectFolder); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - await act(async () => { - await result.current.updateSessionWorkingDirectory('session-1', setSessions); - }); - - expect(setSessions).toHaveBeenCalled(); - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ id: 'session-1', cwd: '/old/path' })]; - const updated = updateFn(sessions); - expect(updated[0].cwd).toBe('/new/path'); - expect(updated[0].fullPath).toBe('/new/path'); - }); - - it('should do nothing when dialog canceled', async () => { - const mockSelectFolder = vi.fn().mockResolvedValue(null); - (window.maestro.dialog.selectFolder as ReturnType).mockImplementation(mockSelectFolder); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - await act(async () => { - await result.current.updateSessionWorkingDirectory('session-1', setSessions); - }); - - expect(setSessions).not.toHaveBeenCalled(); - }); - - it('should only update matching session', async () => { - const mockSelectFolder = vi.fn().mockResolvedValue('/new/path'); - (window.maestro.dialog.selectFolder as ReturnType).mockImplementation(mockSelectFolder); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - await act(async () => { - await result.current.updateSessionWorkingDirectory('session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [ - createMockSession({ id: 'session-1', cwd: '/old/path' }), - createMockSession({ id: 'session-2', cwd: '/other/path' }) - ]; - const updated = updateFn(sessions); - expect(updated[0].cwd).toBe('/new/path'); - expect(updated[1].cwd).toBe('/other/path'); // Unchanged - }); - - it('should reset fileTree to empty array', async () => { - const mockSelectFolder = vi.fn().mockResolvedValue('/new/path'); - (window.maestro.dialog.selectFolder as ReturnType).mockImplementation(mockSelectFolder); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - await act(async () => { - await result.current.updateSessionWorkingDirectory('session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ id: 'session-1', fileTree: [{ name: 'old' }] })]; - const updated = updateFn(sessions); - expect(updated[0].fileTree).toEqual([]); - }); - - it('should clear fileTreeError', async () => { - const mockSelectFolder = vi.fn().mockResolvedValue('/new/path'); - (window.maestro.dialog.selectFolder as ReturnType).mockImplementation(mockSelectFolder); - - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - await act(async () => { - await result.current.updateSessionWorkingDirectory('session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ id: 'session-1', fileTreeError: 'Some error' })]; - const updated = updateFn(sessions); - expect(updated[0].fileTreeError).toBeUndefined(); - }); - }); - - describe('toggleFolder', () => { - it('should expand collapsed folder', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.toggleFolder('src', 'session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ - id: 'session-1', - fileExplorerExpanded: [] - })]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toContain('src'); - }); - - it('should collapse expanded folder', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.toggleFolder('src', 'session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ - id: 'session-1', - fileExplorerExpanded: ['src', 'tests'] - })]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).not.toContain('src'); - expect(updated[0].fileExplorerExpanded).toContain('tests'); - }); - - it('should not modify non-matching sessions', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.toggleFolder('src', 'session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [ - createMockSession({ id: 'session-1', fileExplorerExpanded: [] }), - createMockSession({ id: 'session-2', fileExplorerExpanded: ['other'] }) - ]; - const updated = updateFn(sessions); - expect(updated[1].fileExplorerExpanded).toEqual(['other']); // Unchanged - }); - - it('should return unchanged session if fileExplorerExpanded missing', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.toggleFolder('src', 'session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - // Create session without fileExplorerExpanded by explicitly setting to undefined - const sessionData = createMockSession({ id: 'session-1' }); - (sessionData as any).fileExplorerExpanded = undefined; - const sessions = [sessionData]; - const updated = updateFn(sessions); - expect(updated[0]).toBe(sessions[0]); // Same reference, unchanged - }); - - it('should preserve other expanded folders', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.toggleFolder('newFolder', 'session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ - id: 'session-1', - fileExplorerExpanded: ['a', 'b', 'c'] - })]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toContain('a'); - expect(updated[0].fileExplorerExpanded).toContain('b'); - expect(updated[0].fileExplorerExpanded).toContain('c'); - expect(updated[0].fileExplorerExpanded).toContain('newFolder'); - }); - - it('should handle path not in expanded set', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.toggleFolder('notExpanded', 'session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ - id: 'session-1', - fileExplorerExpanded: ['other'] - })]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toContain('notExpanded'); - }); - }); - - describe('expandAllFolders', () => { - it('should expand all folders in tree', () => { - const session = createMockSession({ - id: 'session-1', - fileTree: [ - { name: 'src', type: 'folder', children: [] }, - { name: 'tests', type: 'folder', children: [] } - ], - fileExplorerExpanded: [] - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.expandAllFolders('session-1', session, setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [session]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toContain('src'); - expect(updated[0].fileExplorerExpanded).toContain('tests'); - }); - - it('should handle nested folders', () => { - const session = createMockSession({ - id: 'session-1', - fileTree: [ - { - name: 'src', - type: 'folder', - children: [ - { - name: 'components', - type: 'folder', - children: [ - { name: 'ui', type: 'folder', children: [] } - ] - } - ] - } - ], - fileExplorerExpanded: [] - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.expandAllFolders('session-1', session, setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [session]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toContain('src'); - expect(updated[0].fileExplorerExpanded).toContain('src/components'); - expect(updated[0].fileExplorerExpanded).toContain('src/components/ui'); - }); - - it('should not modify non-matching sessions', () => { - const session = createMockSession({ - id: 'session-1', - fileTree: [{ name: 'src', type: 'folder', children: [] }], - fileExplorerExpanded: [] - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.expandAllFolders('session-1', session, setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [ - session, - createMockSession({ id: 'session-2', fileExplorerExpanded: ['other'] }) - ]; - const updated = updateFn(sessions); - expect(updated[1].fileExplorerExpanded).toEqual(['other']); // Unchanged - }); - - it('should return unchanged session if fileTree missing', () => { - const session = createMockSession({ - id: 'session-1', - fileExplorerExpanded: [] - }); - (session as any).fileTree = undefined; - - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.expandAllFolders('session-1', session, setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [session]; - const updated = updateFn(sessions); - expect(updated[0]).toBe(sessions[0]); // Same reference - }); - - it('should generate correct paths for nested folders', () => { - const session = createMockSession({ - id: 'session-1', - fileTree: [ - { - name: 'a', - type: 'folder', - children: [ - { - name: 'b', - type: 'folder', - children: [] - } - ] - } - ], - fileExplorerExpanded: [] - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.expandAllFolders('session-1', session, setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [session]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toEqual(['a', 'a/b']); - }); - }); - - describe('collapseAllFolders', () => { - it('should set fileExplorerExpanded to empty array', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.collapseAllFolders('session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ - id: 'session-1', - fileExplorerExpanded: ['src', 'tests', 'docs'] - })]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toEqual([]); - }); - - it('should not modify non-matching sessions', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.collapseAllFolders('session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [ - createMockSession({ id: 'session-1', fileExplorerExpanded: ['src'] }), - createMockSession({ id: 'session-2', fileExplorerExpanded: ['tests'] }) - ]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toEqual([]); - expect(updated[1].fileExplorerExpanded).toEqual(['tests']); // Unchanged - }); - - it('should clear all expanded paths', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.collapseAllFolders('session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ - id: 'session-1', - fileExplorerExpanded: ['a', 'a/b', 'a/b/c', 'x', 'y'] - })]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toHaveLength(0); - }); - - it('should handle already collapsed state', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - const setSessions = vi.fn(); - act(() => { - result.current.collapseAllFolders('session-1', setSessions); - }); - - const updateFn = setSessions.mock.calls[0][0]; - const sessions = [createMockSession({ - id: 'session-1', - fileExplorerExpanded: [] - })]; - const updated = updateFn(sessions); - expect(updated[0].fileExplorerExpanded).toEqual([]); - }); - }); - - describe('filteredFileTree', () => { - it('should return original tree when no filter', () => { - const fileTree = [ - { name: 'src', type: 'folder', children: [] }, - { name: 'README.md', type: 'file' } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - expect(result.current.filteredFileTree).toEqual(fileTree); - }); - - it('should return original tree when empty filter', () => { - const fileTree = [ - { name: 'src', type: 'folder', children: [] } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter(''); - }); - - expect(result.current.filteredFileTree).toEqual(fileTree); - }); - - it('should filter files by name', () => { - const fileTree = [ - { name: 'index.ts', type: 'file' }, - { name: 'App.tsx', type: 'file' }, - { name: 'utils.ts', type: 'file' } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('App'); - }); - - expect(result.current.filteredFileTree).toHaveLength(1); - expect(result.current.filteredFileTree[0].name).toBe('App.tsx'); - }); - - it('should include matching folders', () => { - const fileTree = [ - { name: 'src', type: 'folder', children: [] }, - { name: 'components', type: 'folder', children: [] }, - { name: 'utils', type: 'folder', children: [] } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('comp'); - }); - - expect(result.current.filteredFileTree).toHaveLength(1); - expect(result.current.filteredFileTree[0].name).toBe('components'); - }); - - it('should include folder if children match', () => { - const fileTree = [ - { - name: 'src', - type: 'folder', - children: [ - { name: 'matching.ts', type: 'file' } - ] - } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('matching'); - }); - - expect(result.current.filteredFileTree).toHaveLength(1); - expect(result.current.filteredFileTree[0].name).toBe('src'); - expect(result.current.filteredFileTree[0].children).toHaveLength(1); - }); - - it('should exclude non-matching files', () => { - const fileTree = [ - { name: 'visible.ts', type: 'file' }, - { name: 'hidden.ts', type: 'file' } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('visible'); - }); - - expect(result.current.filteredFileTree).toHaveLength(1); - expect(result.current.filteredFileTree[0].name).toBe('visible.ts'); - }); - - it('should handle nested filtering', () => { - const fileTree = [ - { - name: 'src', - type: 'folder', - children: [ - { - name: 'components', - type: 'folder', - children: [ - { name: 'Button.tsx', type: 'file' }, - { name: 'Input.tsx', type: 'file' } - ] - } - ] - } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('Button'); - }); - - expect(result.current.filteredFileTree).toHaveLength(1); - expect(result.current.filteredFileTree[0].name).toBe('src'); - expect(result.current.filteredFileTree[0].children[0].name).toBe('components'); - expect(result.current.filteredFileTree[0].children[0].children).toHaveLength(1); - expect(result.current.filteredFileTree[0].children[0].children[0].name).toBe('Button.tsx'); - }); - - it('should return empty array when no matches', () => { - const fileTree = [ - { name: 'index.ts', type: 'file' }, - { name: 'App.tsx', type: 'file' } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('nonexistent'); - }); - - expect(result.current.filteredFileTree).toEqual([]); - }); - - it('should return empty array when no activeSession', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('test'); - }); - - expect(result.current.filteredFileTree).toEqual([]); - }); - - it('should use fuzzy matching', () => { - const fileTree = [ - { name: 'ApplicationController.tsx', type: 'file' }, - { name: 'Button.tsx', type: 'file' } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree - }); - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - // Fuzzy match: 'appcont' should match 'ApplicationController' - act(() => { - result.current.setFileTreeFilter('appcont'); - }); - - expect(result.current.filteredFileTree).toHaveLength(1); - expect(result.current.filteredFileTree[0].name).toBe('ApplicationController.tsx'); - }); - }); - - describe('hook state management', () => { - it('should initialize previewFile as null', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - expect(result.current.previewFile).toBeNull(); - }); - - it('should initialize selectedFileIndex as 0', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - expect(result.current.selectedFileIndex).toBe(0); - }); - - it('should initialize flatFileList as empty array', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - expect(result.current.flatFileList).toEqual([]); - }); - - it('should initialize fileTreeFilter as empty string', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - expect(result.current.fileTreeFilter).toBe(''); - }); - - it('should initialize fileTreeFilterOpen as false', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - expect(result.current.fileTreeFilterOpen).toBe(false); - }); - - it('should update previewFile via setPreviewFile', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - act(() => { - result.current.setPreviewFile({ - name: 'test.ts', - content: 'content', - path: '/path/test.ts' - }); - }); - - expect(result.current.previewFile).toEqual({ - name: 'test.ts', - content: 'content', - path: '/path/test.ts' - }); - }); - - it('should update selectedFileIndex via setSelectedFileIndex', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - act(() => { - result.current.setSelectedFileIndex(5); - }); - - expect(result.current.selectedFileIndex).toBe(5); - }); - - it('should update fileTreeFilter via setFileTreeFilter', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilter('search query'); - }); - - expect(result.current.fileTreeFilter).toBe('search query'); - }); - - it('should update fileTreeFilterOpen via setFileTreeFilterOpen', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - - act(() => { - result.current.setFileTreeFilterOpen(true); - }); - - expect(result.current.fileTreeFilterOpen).toBe(true); - }); - - it('should update flatFileList when activeSession changes', async () => { - const fileTree = [ - { name: 'file1.ts', type: 'file' }, - { name: 'src', type: 'folder', children: [{ name: 'file2.ts', type: 'file' }] } - ]; - const session = createMockSession({ - id: 'session-1', - fileTree, - fileExplorerExpanded: ['src'] - }); - - const { result, rerender } = renderHook( - ({ session }) => useFileExplorer(session, setActiveFocusMock), - { initialProps: { session: null as Session | null } } - ); - - expect(result.current.flatFileList).toEqual([]); - - // Update with session that has file tree - rerender({ session }); - - await waitFor(() => { - expect(result.current.flatFileList).toHaveLength(3); - expect(result.current.flatFileList[0].name).toBe('file1.ts'); - expect(result.current.flatFileList[1].name).toBe('src'); - expect(result.current.flatFileList[2].name).toBe('file2.ts'); - }); - }); - - it('should provide fileTreeContainerRef', () => { - const { result } = renderHook(() => useFileExplorer(null, setActiveFocusMock)); - expect(result.current.fileTreeContainerRef).toBeDefined(); - expect(result.current.fileTreeContainerRef.current).toBeNull(); - }); - - it('should clear flatFileList when activeSession becomes null', async () => { - const session = createMockSession({ - id: 'session-1', - fileTree: [{ name: 'file.ts', type: 'file' }], - fileExplorerExpanded: [] - }); - - const { result, rerender } = renderHook( - ({ session }) => useFileExplorer(session, setActiveFocusMock), - { initialProps: { session } } - ); - - await waitFor(() => { - expect(result.current.flatFileList).toHaveLength(1); - }); - - rerender({ session: null }); - - await waitFor(() => { - expect(result.current.flatFileList).toEqual([]); - }); - }); - - it('should clear flatFileList when session has no fileExplorerExpanded', async () => { - const session = createMockSession({ - id: 'session-1', - fileTree: [{ name: 'file.ts', type: 'file' }] - }); - (session as any).fileExplorerExpanded = undefined; - - const { result } = renderHook(() => useFileExplorer(session, setActiveFocusMock)); - - await waitFor(() => { - expect(result.current.flatFileList).toEqual([]); - }); - }); - }); -}); diff --git a/src/__tests__/renderer/hooks/useTabCompletion.test.ts b/src/__tests__/renderer/hooks/useTabCompletion.test.ts index a688456f..776fd41f 100644 --- a/src/__tests__/renderer/hooks/useTabCompletion.test.ts +++ b/src/__tests__/renderer/hooks/useTabCompletion.test.ts @@ -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 => ({ diff --git a/src/__tests__/renderer/utils/remarkFileLinks.test.ts b/src/__tests__/renderer/utils/remarkFileLinks.test.ts index 9808d8c0..3297f7ba 100644 --- a/src/__tests__/renderer/utils/remarkFileLinks.test.ts +++ b/src/__tests__/renderer/utils/remarkFileLinks.test.ts @@ -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 { diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index f959522e..261814fc 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -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)'}`); diff --git a/src/main/index.ts b/src/main/index.ts index de26b381..fa9afb43 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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', []); diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index 0225b36a..acbb9912 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -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; + } + }) + ); } diff --git a/src/main/preload.ts b/src/main/preload.ts index f079c9e8..bdeb92a7 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -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, + // 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, }, // Dialog API @@ -1256,6 +1260,7 @@ export interface MaestroAPI { getCustomEnvVars: (agentId: string) => Promise | null>; getAllCustomEnvVars: () => Promise>>; getModels: (agentId: string, forceRefresh?: boolean) => Promise; + discoverSlashCommands: (agentId: string, cwd: string, customPath?: string) => Promise; }; dialog: { selectFolder: () => Promise; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 07c2e3f5..086f2dbd 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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, diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index 8e216989..25904a90 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -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; diff --git a/src/renderer/components/FilePreview.tsx b/src/renderer/components/FilePreview.tsx index c82d81a4..906c05e7 100644 --- a/src/renderer/components/FilePreview.tsx +++ b/src/renderer/components/FilePreview.tsx @@ -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; diff --git a/src/renderer/components/FileSearchModal.tsx b/src/renderer/components/FileSearchModal.tsx index 597d1ddc..252404d8 100644 --- a/src/renderer/components/FileSearchModal.tsx +++ b/src/renderer/components/FileSearchModal.tsx @@ -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; diff --git a/src/renderer/components/MainPanel.tsx b/src/renderer/components/MainPanel.tsx index 06ae45c0..deb57525 100644 --- a/src/renderer/components/MainPanel.tsx +++ b/src/renderer/components/MainPanel.tsx @@ -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(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(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(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(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 && (
{ @@ -748,7 +786,7 @@ export const MainPanel = forwardRef(function Ma
{/* Context usage section - only shown when contextWindow is configured */} - {(activeTab?.usageStats?.contextWindow ?? 0) > 0 && ( + {activeTabContextWindow > 0 && (
Context Tokens @@ -762,7 +800,7 @@ export const MainPanel = forwardRef(function Ma
Context Size - {activeTab.usageStats.contextWindow.toLocaleString()} + {activeTabContextWindow.toLocaleString()}
diff --git a/src/renderer/components/MarkdownRenderer.tsx b/src/renderer/components/MarkdownRenderer.tsx index 7851b591..bcfdd741 100644 --- a/src/renderer/components/MarkdownRenderer.tsx +++ b/src/renderer/components/MarkdownRenderer.tsx @@ -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'; diff --git a/src/renderer/components/QuickActionsModal.tsx b/src/renderer/components/QuickActionsModal.tsx index 3ea1aad1..7e8c6fb3 100644 --- a/src/renderer/components/QuickActionsModal.tsx +++ b/src/renderer/components/QuickActionsModal.tsx @@ -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); diff --git a/src/renderer/components/TerminalOutput.tsx b/src/renderer/components/TerminalOutput.tsx index cd051e4b..a205cfd5 100644 --- a/src/renderer/components/TerminalOutput.tsx +++ b/src/renderer/components/TerminalOutput.tsx @@ -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'; diff --git a/src/renderer/constants/shortcuts.ts b/src/renderer/constants/shortcuts.ts index 08a12bb5..c94dc9c7 100644 --- a/src/renderer/constants/shortcuts.ts +++ b/src/renderer/constants/shortcuts.ts @@ -36,7 +36,6 @@ export const DEFAULT_SHORTCUTS: Record = { 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) diff --git a/src/renderer/hooks/index.ts b/src/renderer/hooks/index.ts index b933f5b3..b96da69e 100644 --- a/src/renderer/hooks/index.ts +++ b/src/renderer/hooks/index.ts @@ -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'; diff --git a/src/renderer/hooks/useAgentExecution.ts b/src/renderer/hooks/useAgentExecution.ts index 3ebbcfff..08b1240a 100644 --- a/src/renderer/hooks/useAgentExecution.ts +++ b/src/renderer/hooks/useAgentExecution.ts @@ -48,7 +48,7 @@ export interface UseAgentExecutionReturn { toolType?: ToolType ) => Promise; /** Ref to spawnBackgroundSynopsis for use in callbacks that need latest version */ - spawnBackgroundSynopsisRef: React.MutableRefObject { spawnBackgroundSynopsis: infer R } ? R : never>; + spawnBackgroundSynopsisRef: React.MutableRefObject; /** Ref to spawnAgentWithPrompt for use in callbacks that need latest version */ spawnAgentWithPromptRef: React.MutableRefObject<((prompt: string) => Promise) | 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(null); const spawnAgentWithPromptRef = useRef<((prompt: string) => Promise) | 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). diff --git a/src/renderer/hooks/useAgentSessionManagement.ts b/src/renderer/hooks/useAgentSessionManagement.ts index 7999a7a9..5b76699c 100644 --- a/src/renderer/hooks/useAgentSessionManagement.ts +++ b/src/renderer/hooks/useAgentSessionManagement.ts @@ -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 & { id?: string; timestamp?: number } - ) => void; /** Ref to the right panel for refreshing history */ rightPanelRef: React.RefObject; /** Default value for saveToHistory on new tabs */ @@ -52,10 +47,6 @@ export interface UseAgentSessionManagementReturn { addHistoryEntry: (entry: HistoryEntryInput) => Promise; /** Ref to addHistoryEntry for use in callbacks that need latest version */ addHistoryEntryRef: React.MutableRefObject<((entry: HistoryEntryInput) => Promise) | 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) | 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, }; diff --git a/src/renderer/hooks/useAtMentionCompletion.ts b/src/renderer/hooks/useAtMentionCompletion.ts index 3801204d..0df932e9 100644 --- a/src/renderer/hooks/useAtMentionCompletion.ts +++ b/src/renderer/hooks/useAtMentionCompletion.ts @@ -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 { diff --git a/src/renderer/hooks/useBatchProcessor.ts b/src/renderer/hooks/useBatchProcessor.ts index ec1db0bd..48f218c4 100644 --- a/src/renderer/hooks/useBatchProcessor.ts +++ b/src/renderer/hooks/useBatchProcessor.ts @@ -109,6 +109,13 @@ interface UseBatchProcessorReturn { abortBatchOnError: (sessionId: string) => void; } +type ErrorResolutionAction = 'resume' | 'skip-document' | 'abort'; + +interface ErrorResolutionEntry { + promise: Promise; + resolve: (action: ErrorResolutionAction) => void; +} + /** * Format duration in human-readable format for loop summaries */ @@ -253,6 +260,9 @@ export function useBatchProcessor({ const accumulatedTimeRefs = useRef>({}); const lastActiveTimestampRefs = useRef>({}); + // Error resolution promises to pause batch processing until user action (per session) + const errorResolutionRefs = useRef>({}); + // 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 = 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(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]: { diff --git a/src/renderer/hooks/useFileExplorer.ts b/src/renderer/hooks/useFileExplorer.ts deleted file mode 100644 index 93c0dcfd..00000000 --- a/src/renderer/hooks/useFileExplorer.ts +++ /dev/null @@ -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; - - // Operations - handleFileClick: (node: any, path: string, activeSession: Session) => Promise; - loadFileTree: (dirPath: string, maxDepth?: number, currentDepth?: number) => Promise; - updateSessionWorkingDirectory: (activeSessionId: string, setSessions: React.Dispatch>) => Promise; - toggleFolder: (path: string, activeSessionId: string, setSessions: React.Dispatch>) => void; - expandAllFolders: (activeSessionId: string, activeSession: Session, setSessions: React.Dispatch>) => void; - collapseAllFolders: (activeSessionId: string, setSessions: React.Dispatch>) => void; - flattenTree: (nodes: any[], expandedSet: Set, 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([]); - const [fileTreeFilter, setFileTreeFilter] = useState(''); - const [fileTreeFilterOpen, setFileTreeFilterOpen] = useState(false); - const fileTreeContainerRef = useRef(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 => { - 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> - ) => { - 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> - ) => { - 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> - ) => { - 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> - ) => { - 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, - }; -} diff --git a/src/renderer/hooks/useMainKeyboardHandler.ts b/src/renderer/hooks/useMainKeyboardHandler.ts index 376bda54..1328582a 100644 --- a/src/renderer/hooks/useMainKeyboardHandler.ts +++ b/src/renderer/hooks/useMainKeyboardHandler.ts @@ -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) diff --git a/src/renderer/hooks/useTabCompletion.ts b/src/renderer/hooks/useTabCompletion.ts index b2753840..efbd52e7 100644 --- a/src/renderer/hooks/useTabCompletion.ts +++ b/src/renderer/hooks/useTabCompletion.ts @@ -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; diff --git a/src/renderer/utils/remarkFileLinks.ts b/src/renderer/utils/remarkFileLinks.ts index b0f1664a..54366bde 100644 --- a/src/renderer/utils/remarkFileLinks.ts +++ b/src/renderer/utils/remarkFileLinks.ts @@ -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 { diff --git a/src/web/components/ThemeProvider.tsx b/src/web/components/ThemeProvider.tsx index 00ee5808..2b12f73e 100644 --- a/src/web/components/ThemeProvider.tsx +++ b/src/web/components/ThemeProvider.tsx @@ -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',