mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
OAuth enabled but no valid token found. Starting authentication...
Found expired OAuth token, attempting refresh... Token refresh successful POST "https://api.anthropic.com/v1/messages": 429 Too Many Requests {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account's rate limit. Please try again later."},"request_id":"req_011CWJa3o6GdjJKgdNY7G8EN"}
This commit is contained in:
5
.github/workflows/release.yml
vendored
5
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
|
||||
15
package.json
15
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import type { Session, Group, HistoryEntry, UsageStats, BatchRunConfig } from '../../../renderer/types';
|
||||
import type { Session, Group, HistoryEntry, UsageStats, BatchRunConfig, AgentError } from '../../../renderer/types';
|
||||
|
||||
// Import the exported functions directly
|
||||
import { countUnfinishedTasks, uncheckAllTasks, useBatchProcessor } from '../../../renderer/hooks/useBatchProcessor';
|
||||
@@ -2235,6 +2235,79 @@ describe('useBatchProcessor hook', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('error pause handling', () => {
|
||||
it('should pause processing until resumeAfterError is called', async () => {
|
||||
const sessions = [createMockSession()];
|
||||
const groups = [createMockGroup()];
|
||||
|
||||
const contentInitial = '- [ ] Task 1\n- [ ] Task 2';
|
||||
const contentAfterFirst = '- [x] Task 1\n- [ ] Task 2';
|
||||
const contentAfterSecond = '- [x] Task 1\n- [x] Task 2';
|
||||
const docStates = [
|
||||
contentInitial,
|
||||
contentInitial,
|
||||
contentInitial,
|
||||
contentAfterFirst,
|
||||
contentAfterFirst,
|
||||
contentAfterSecond
|
||||
];
|
||||
|
||||
mockReadDoc.mockImplementation(async () => ({
|
||||
success: true,
|
||||
content: docStates.shift() ?? contentAfterSecond
|
||||
}));
|
||||
|
||||
let pauseHandler: ((sessionId: string, error: AgentError, documentIndex: number, taskDescription?: string) => void) | null = null;
|
||||
|
||||
mockOnSpawnAgent.mockImplementation(async () => {
|
||||
if (pauseHandler) {
|
||||
pauseHandler('test-session-id', {
|
||||
type: 'auth',
|
||||
message: 'Auth error',
|
||||
recoverable: true,
|
||||
timestamp: Date.now()
|
||||
}, 0, 'Task 1');
|
||||
pauseHandler = null;
|
||||
}
|
||||
return { success: true, agentSessionId: 'session-1' };
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useBatchProcessor({
|
||||
sessions,
|
||||
groups,
|
||||
onUpdateSession: mockOnUpdateSession,
|
||||
onSpawnAgent: mockOnSpawnAgent,
|
||||
onSpawnSynopsis: mockOnSpawnSynopsis,
|
||||
onAddHistoryEntry: mockOnAddHistoryEntry,
|
||||
onComplete: mockOnComplete
|
||||
})
|
||||
);
|
||||
|
||||
pauseHandler = result.current.pauseBatchOnError;
|
||||
|
||||
let startPromise: Promise<void>;
|
||||
act(() => {
|
||||
startPromise = result.current.startBatchRun('test-session-id', {
|
||||
documents: [{ filename: 'tasks', resetOnCompletion: false }],
|
||||
prompt: 'Test',
|
||||
loopEnabled: false
|
||||
}, '/test/folder');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockOnSpawnAgent).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.getBatchState('test-session-id').errorPaused).toBe(true));
|
||||
expect(mockOnSpawnAgent).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => {
|
||||
result.current.resumeAfterError('test-session-id');
|
||||
});
|
||||
|
||||
await startPromise;
|
||||
expect(mockOnSpawnAgent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('session claude ID tracking', () => {
|
||||
it('should collect claude session IDs from successful spawns', async () => {
|
||||
const sessions = [createMockSession()];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { useTabCompletion, TabCompletionSuggestion, TabCompletionFilter } from '../../../renderer/hooks/useTabCompletion';
|
||||
import type { Session } from '../../../renderer/types';
|
||||
import type { FileNode } from '../../../renderer/hooks/useFileExplorer';
|
||||
import type { FileNode } from '../../../renderer/types/fileTree';
|
||||
|
||||
// Helper to create a minimal session for testing
|
||||
const createMockSession = (overrides: Partial<Session> = {}): Session => ({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { unified } from 'unified';
|
||||
import remarkParse from 'remark-parse';
|
||||
import remarkStringify from 'remark-stringify';
|
||||
import { remarkFileLinks } from '../../../renderer/utils/remarkFileLinks';
|
||||
import type { FileNode } from '../../../renderer/hooks/useFileExplorer';
|
||||
import type { FileNode } from '../../../renderer/types/fileTree';
|
||||
|
||||
// Helper to process markdown and return the result
|
||||
async function processMarkdown(content: string, fileTree: FileNode[], cwd: string, projectRoot?: string): Promise<string> {
|
||||
|
||||
@@ -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)'}`);
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -417,6 +417,10 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
// Discover available models for agents that support model selection (e.g., OpenCode with Ollama)
|
||||
getModels: (agentId: string, forceRefresh?: boolean) =>
|
||||
ipcRenderer.invoke('agents:getModels', agentId, forceRefresh) as Promise<string[]>,
|
||||
// Discover available slash commands for an agent by spawning it briefly
|
||||
// Returns array of command names (e.g., ['compact', 'help', 'my-custom-command'])
|
||||
discoverSlashCommands: (agentId: string, cwd: string, customPath?: string) =>
|
||||
ipcRenderer.invoke('agents:discoverSlashCommands', agentId, cwd, customPath) as Promise<string[] | null>,
|
||||
},
|
||||
|
||||
// Dialog API
|
||||
@@ -1256,6 +1260,7 @@ export interface MaestroAPI {
|
||||
getCustomEnvVars: (agentId: string) => Promise<Record<string, string> | null>;
|
||||
getAllCustomEnvVars: () => Promise<Record<string, Record<string, string>>>;
|
||||
getModels: (agentId: string, forceRefresh?: boolean) => Promise<string[]>;
|
||||
discoverSlashCommands: (agentId: string, cwd: string, customPath?: string) => Promise<string[] | null>;
|
||||
};
|
||||
dialog: {
|
||||
selectFolder: () => Promise<string | null>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -164,7 +164,7 @@ interface MainPanelProps {
|
||||
// Replay a user message (AI mode)
|
||||
onReplayMessage?: (text: string, images?: string[]) => void;
|
||||
// File tree for linking file references in AI responses
|
||||
fileTree?: import('../hooks/useFileExplorer').FileNode[];
|
||||
fileTree?: import('../types/fileTree').FileNode[];
|
||||
// Callback when a file link is clicked in AI response
|
||||
onFileClick?: (relativePath: string) => void;
|
||||
// File preview navigation
|
||||
@@ -220,6 +220,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
|
||||
// Panel width for responsive hiding of widgets
|
||||
const [panelWidth, setPanelWidth] = useState(Infinity); // Start with Infinity so widgets show by default
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const [configuredContextWindow, setConfiguredContextWindow] = useState(0);
|
||||
|
||||
// Extract tab handlers from props
|
||||
const { onTabSelect, onTabClose, onNewTab, onTabRename, onRequestTabRename, onTabReorder, onCloseOtherTabs, onTabStar, onTabMarkUnread, showUnreadOnly, onToggleUnreadFilter, onOpenTabSearch } = props;
|
||||
@@ -231,14 +232,51 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
|
||||
?? activeSession?.aiTabs?.[0]
|
||||
?? null;
|
||||
|
||||
// Resolve the configured context window from session override or agent settings.
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const loadContextWindow = async () => {
|
||||
if (!activeSession) {
|
||||
if (isActive) setConfiguredContextWindow(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof activeSession.customContextWindow === 'number' && activeSession.customContextWindow > 0) {
|
||||
if (isActive) setConfiguredContextWindow(activeSession.customContextWindow);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await window.maestro.agents.getConfig(activeSession.toolType);
|
||||
const value = typeof config?.contextWindow === 'number' ? config.contextWindow : 0;
|
||||
if (isActive) setConfiguredContextWindow(value);
|
||||
} catch (error) {
|
||||
console.error('Failed to load agent context window setting', error);
|
||||
if (isActive) setConfiguredContextWindow(0);
|
||||
}
|
||||
};
|
||||
|
||||
loadContextWindow();
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [activeSession?.toolType, activeSession?.customContextWindow]);
|
||||
|
||||
const activeTabContextWindow = useMemo(() => {
|
||||
const configured = configuredContextWindow;
|
||||
const reported = activeTab?.usageStats?.contextWindow ?? 0;
|
||||
return configured > 0 ? configured : reported;
|
||||
}, [configuredContextWindow, activeTab?.usageStats?.contextWindow]);
|
||||
|
||||
// Compute context usage percentage from active tab's usage stats
|
||||
const activeTabContextUsage = useMemo(() => {
|
||||
if (!activeTab?.usageStats) return 0;
|
||||
const { inputTokens, outputTokens, contextWindow } = activeTab.usageStats;
|
||||
if (!contextWindow || contextWindow === 0) return 0;
|
||||
const { inputTokens, outputTokens } = activeTab.usageStats;
|
||||
if (!activeTabContextWindow || activeTabContextWindow === 0) return 0;
|
||||
const contextTokens = inputTokens + outputTokens;
|
||||
return Math.min(Math.round((contextTokens / contextWindow) * 100), 100);
|
||||
}, [activeTab?.usageStats]);
|
||||
return Math.min(Math.round((contextTokens / activeTabContextWindow) * 100), 100);
|
||||
}, [activeTab?.usageStats, activeTabContextWindow]);
|
||||
|
||||
// PERF: Track panel width for responsive widget hiding with throttled updates
|
||||
useEffect(() => {
|
||||
@@ -644,7 +682,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
|
||||
)}
|
||||
|
||||
{/* Context Window Widget with Tooltip - only show when context window is configured and agent supports usage stats */}
|
||||
{activeSession.inputMode === 'ai' && (activeTab?.agentSessionId || activeTab?.usageStats) && hasCapability('supportsUsageStats') && (activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
|
||||
{activeSession.inputMode === 'ai' && (activeTab?.agentSessionId || activeTab?.usageStats) && hasCapability('supportsUsageStats') && activeTabContextWindow > 0 && (
|
||||
<div
|
||||
className="flex flex-col items-end mr-2 relative cursor-pointer"
|
||||
onMouseEnter={() => {
|
||||
@@ -748,7 +786,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
|
||||
</div>
|
||||
|
||||
{/* Context usage section - only shown when contextWindow is configured */}
|
||||
{(activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
|
||||
{activeTabContextWindow > 0 && (
|
||||
<div className="border-t pt-2 mt-2" style={{ borderColor: theme.colors.border }}>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Tokens</span>
|
||||
@@ -762,7 +800,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Size</span>
|
||||
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.textMain }}>
|
||||
{activeTab.usageStats.contextWindow.toLocaleString()}
|
||||
{activeTabContextWindow.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-1">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -36,7 +36,6 @@ export const DEFAULT_SHORTCUTS: Record<string, Shortcut> = {
|
||||
openPromptComposer: { id: 'openPromptComposer', label: 'Open Prompt Composer', keys: ['Meta', 'Shift', 'p'] },
|
||||
openWizard: { id: 'openWizard', label: 'New Agent Wizard', keys: ['Meta', 'Shift', 'n'] },
|
||||
fuzzyFileSearch: { id: 'fuzzyFileSearch', label: 'Fuzzy File Search', keys: ['Meta', 'g'] },
|
||||
createDebugPackage: { id: 'createDebugPackage', label: 'Create Debug Package', keys: ['Alt', 'Meta', 'd'] },
|
||||
};
|
||||
|
||||
// Non-editable shortcuts (displayed in help but not configurable)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface UseAgentExecutionReturn {
|
||||
toolType?: ToolType
|
||||
) => Promise<AgentSpawnResult>;
|
||||
/** Ref to spawnBackgroundSynopsis for use in callbacks that need latest version */
|
||||
spawnBackgroundSynopsisRef: React.MutableRefObject<typeof useAgentExecution extends (...args: infer _) => { spawnBackgroundSynopsis: infer R } ? R : never>;
|
||||
spawnBackgroundSynopsisRef: React.MutableRefObject<UseAgentExecutionReturn['spawnBackgroundSynopsis'] | null>;
|
||||
/** Ref to spawnAgentWithPrompt for use in callbacks that need latest version */
|
||||
spawnAgentWithPromptRef: React.MutableRefObject<((prompt: string) => Promise<AgentSpawnResult>) | null>;
|
||||
/** Show flash notification (auto-dismisses after 2 seconds) */
|
||||
@@ -84,6 +84,20 @@ export function useAgentExecution(
|
||||
// Refs for functions that need to be accessed from other callbacks
|
||||
const spawnBackgroundSynopsisRef = useRef<UseAgentExecutionReturn['spawnBackgroundSynopsis'] | null>(null);
|
||||
const spawnAgentWithPromptRef = useRef<((prompt: string) => Promise<AgentSpawnResult>) | null>(null);
|
||||
const accumulateUsageStats = useCallback(
|
||||
(current: UsageStats | undefined, usageStats: UsageStats): UsageStats => ({
|
||||
...usageStats,
|
||||
inputTokens: (current?.inputTokens || 0) + usageStats.inputTokens,
|
||||
outputTokens: (current?.outputTokens || 0) + usageStats.outputTokens,
|
||||
cacheReadInputTokens: (current?.cacheReadInputTokens || 0) + usageStats.cacheReadInputTokens,
|
||||
cacheCreationInputTokens: (current?.cacheCreationInputTokens || 0) + usageStats.cacheCreationInputTokens,
|
||||
totalCostUsd: (current?.totalCostUsd || 0) + usageStats.totalCostUsd,
|
||||
reasoningTokens: current?.reasoningTokens || usageStats.reasoningTokens
|
||||
? (current?.reasoningTokens || 0) + (usageStats.reasoningTokens || 0)
|
||||
: undefined,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* Spawn a Claude agent for a specific session and wait for completion.
|
||||
@@ -158,19 +172,7 @@ export function useAgentExecution(
|
||||
cleanupUsage = window.maestro.process.onUsage((sid: string, usageStats) => {
|
||||
if (sid === targetSessionId) {
|
||||
// Accumulate usage stats for this task (there may be multiple usage events per task)
|
||||
if (!taskUsageStats) {
|
||||
taskUsageStats = { ...usageStats };
|
||||
} else {
|
||||
// Accumulate tokens and cost
|
||||
taskUsageStats = {
|
||||
...usageStats,
|
||||
inputTokens: taskUsageStats.inputTokens + usageStats.inputTokens,
|
||||
outputTokens: taskUsageStats.outputTokens + usageStats.outputTokens,
|
||||
cacheReadInputTokens: taskUsageStats.cacheReadInputTokens + usageStats.cacheReadInputTokens,
|
||||
cacheCreationInputTokens: taskUsageStats.cacheCreationInputTokens + usageStats.cacheCreationInputTokens,
|
||||
totalCostUsd: taskUsageStats.totalCostUsd + usageStats.totalCostUsd,
|
||||
};
|
||||
}
|
||||
taskUsageStats = accumulateUsageStats(taskUsageStats, usageStats);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -324,7 +326,7 @@ export function useAgentExecution(
|
||||
console.error('Error spawning agent:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}, [sessionsRef, setSessions, processQueuedItemRef]); // Uses sessionsRef for latest sessions
|
||||
}, [accumulateUsageStats, processQueuedItemRef, sessionsRef, setSessions]); // Uses sessionsRef for latest sessions
|
||||
|
||||
/**
|
||||
* Wrapper for slash commands that need to spawn an agent with just a prompt.
|
||||
@@ -397,18 +399,7 @@ export function useAgentExecution(
|
||||
cleanupUsage = window.maestro.process.onUsage((sid: string, usageStats) => {
|
||||
if (sid === targetSessionId) {
|
||||
// Accumulate usage stats (there may be multiple events)
|
||||
if (!synopsisUsageStats) {
|
||||
synopsisUsageStats = { ...usageStats };
|
||||
} else {
|
||||
synopsisUsageStats = {
|
||||
...usageStats,
|
||||
inputTokens: synopsisUsageStats.inputTokens + usageStats.inputTokens,
|
||||
outputTokens: synopsisUsageStats.outputTokens + usageStats.outputTokens,
|
||||
cacheReadInputTokens: synopsisUsageStats.cacheReadInputTokens + usageStats.cacheReadInputTokens,
|
||||
cacheCreationInputTokens: synopsisUsageStats.cacheCreationInputTokens + usageStats.cacheCreationInputTokens,
|
||||
totalCostUsd: synopsisUsageStats.totalCostUsd + usageStats.totalCostUsd,
|
||||
};
|
||||
}
|
||||
synopsisUsageStats = accumulateUsageStats(synopsisUsageStats, usageStats);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -439,7 +430,7 @@ export function useAgentExecution(
|
||||
console.error('Error spawning background synopsis:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}, []);
|
||||
}, [accumulateUsageStats]);
|
||||
|
||||
/**
|
||||
* Show flash notification (bottom-right, auto-dismisses after 2 seconds).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Session, SessionState, LogEntry, UsageStats } from '../types';
|
||||
import type { Session, LogEntry, UsageStats } from '../types';
|
||||
import { createTab, getActiveTab } from '../utils/tabHelpers';
|
||||
import { generateId } from '../utils/ids';
|
||||
import type { RightPanelHandle } from '../components/RightPanel';
|
||||
@@ -33,11 +33,6 @@ export interface UseAgentSessionManagementDeps {
|
||||
setActiveAgentSessionId: (id: string | null) => void;
|
||||
/** Agent sessions browser open state setter */
|
||||
setAgentSessionsOpen: (open: boolean) => void;
|
||||
/** Helper to add a log entry to the active tab */
|
||||
addLogToActiveTab: (
|
||||
sessionId: string,
|
||||
logEntry: Omit<LogEntry, 'id' | 'timestamp'> & { id?: string; timestamp?: number }
|
||||
) => void;
|
||||
/** Ref to the right panel for refreshing history */
|
||||
rightPanelRef: React.RefObject<RightPanelHandle | null>;
|
||||
/** Default value for saveToHistory on new tabs */
|
||||
@@ -52,10 +47,6 @@ export interface UseAgentSessionManagementReturn {
|
||||
addHistoryEntry: (entry: HistoryEntryInput) => Promise<void>;
|
||||
/** Ref to addHistoryEntry for use in callbacks that need latest version */
|
||||
addHistoryEntryRef: React.MutableRefObject<((entry: HistoryEntryInput) => Promise<void>) | null>;
|
||||
/** Clear Agent session and start fresh */
|
||||
startNewAgentSession: () => void;
|
||||
/** Ref to startNewAgentSession for use in callbacks that need latest version */
|
||||
startNewAgentSessionRef: React.MutableRefObject<(() => void) | null>;
|
||||
/** Jump to a specific agent session in the browser */
|
||||
handleJumpToAgentSession: (agentSessionId: string) => void;
|
||||
/** Resume a Agent session, opening as a new tab or switching to existing */
|
||||
@@ -73,7 +64,6 @@ export interface UseAgentSessionManagementReturn {
|
||||
*
|
||||
* Handles:
|
||||
* - Adding history entries with session metadata
|
||||
* - Starting new Agent sessions (clearing context)
|
||||
* - Jumping to Agent sessions in the browser
|
||||
* - Resuming saved Agent sessions as tabs
|
||||
*
|
||||
@@ -88,14 +78,12 @@ export function useAgentSessionManagement(
|
||||
setSessions,
|
||||
setActiveAgentSessionId,
|
||||
setAgentSessionsOpen,
|
||||
addLogToActiveTab,
|
||||
rightPanelRef,
|
||||
defaultSaveToHistory,
|
||||
} = deps;
|
||||
|
||||
// Refs for functions that need to be accessed from other callbacks
|
||||
const addHistoryEntryRef = useRef<((entry: HistoryEntryInput) => Promise<void>) | null>(null);
|
||||
const startNewAgentSessionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
/**
|
||||
* Add a history entry for a session.
|
||||
@@ -115,6 +103,8 @@ export function useAgentSessionManagement(
|
||||
sessionName = activeTab?.name;
|
||||
}
|
||||
|
||||
const shouldIncludeContextUsage = !entry.sessionId || entry.sessionId === activeSession?.id;
|
||||
|
||||
await window.maestro.history.add({
|
||||
id: generateId(),
|
||||
type: entry.type,
|
||||
@@ -125,7 +115,7 @@ export function useAgentSessionManagement(
|
||||
sessionId: targetSessionId,
|
||||
sessionName: sessionName,
|
||||
projectPath: targetProjectPath,
|
||||
contextUsage: activeSession?.contextUsage,
|
||||
...(shouldIncludeContextUsage ? { contextUsage: activeSession?.contextUsage } : {}),
|
||||
// Only include usageStats if explicitly provided (per-task tracking)
|
||||
// Never use cumulative session stats - they're lifetime totals
|
||||
usageStats: entry.usageStats
|
||||
@@ -135,43 +125,6 @@ export function useAgentSessionManagement(
|
||||
rightPanelRef.current?.refreshHistoryPanel();
|
||||
}, [activeSession, rightPanelRef]);
|
||||
|
||||
/**
|
||||
* Start a new Agent session by clearing the current context.
|
||||
* Blocks if there are queued items.
|
||||
*/
|
||||
const startNewAgentSession = useCallback(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
// Block clearing when there are queued items
|
||||
if (activeSession.executionQueue.length > 0) {
|
||||
addLogToActiveTab(activeSession.id, {
|
||||
source: 'system',
|
||||
text: 'Cannot clear session while items are queued. Remove queued items first.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
// Reset active tab's state to 'idle' for write-mode tracking
|
||||
const updatedAiTabs = s.aiTabs?.length > 0
|
||||
? s.aiTabs.map(tab =>
|
||||
tab.id === s.activeTabId ? { ...tab, state: 'idle' as const, thinkingStartTime: undefined } : tab
|
||||
)
|
||||
: s.aiTabs;
|
||||
return {
|
||||
...s,
|
||||
agentSessionId: undefined,
|
||||
aiLogs: [],
|
||||
state: 'idle' as SessionState,
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined,
|
||||
aiTabs: updatedAiTabs
|
||||
};
|
||||
}));
|
||||
setActiveAgentSessionId(null);
|
||||
}, [activeSession, addLogToActiveTab, setSessions, setActiveAgentSessionId]);
|
||||
|
||||
/**
|
||||
* Jump to a specific agent session in the agent sessions browser.
|
||||
*/
|
||||
@@ -240,7 +193,10 @@ export function useAgentSessionManagement(
|
||||
let isStarred = starred ?? false;
|
||||
let name = sessionName ?? null;
|
||||
|
||||
if (!starred && !sessionName && activeSession.toolType === 'claude-code') {
|
||||
const shouldLookupOrigins = activeSession.toolType === 'claude-code'
|
||||
&& (starred === undefined || sessionName === undefined);
|
||||
|
||||
if (shouldLookupOrigins) {
|
||||
try {
|
||||
// Look up session metadata from session origins (name and starred)
|
||||
// Note: getSessionOrigins is still Claude-specific until we add generic origin tracking
|
||||
@@ -248,10 +204,10 @@ export function useAgentSessionManagement(
|
||||
const origins = await window.maestro.claude.getSessionOrigins(activeSession.projectRoot);
|
||||
const originData = origins[agentSessionId];
|
||||
if (originData && typeof originData === 'object') {
|
||||
if (originData.sessionName) {
|
||||
if (sessionName === undefined && originData.sessionName) {
|
||||
name = originData.sessionName;
|
||||
}
|
||||
if (originData.starred !== undefined) {
|
||||
if (starred === undefined && originData.starred !== undefined) {
|
||||
isStarred = originData.starred;
|
||||
}
|
||||
}
|
||||
@@ -286,13 +242,10 @@ export function useAgentSessionManagement(
|
||||
|
||||
// Update refs for slash command functions (so other handlers can access latest versions)
|
||||
addHistoryEntryRef.current = addHistoryEntry;
|
||||
startNewAgentSessionRef.current = startNewAgentSession;
|
||||
|
||||
return {
|
||||
addHistoryEntry,
|
||||
addHistoryEntryRef,
|
||||
startNewAgentSession,
|
||||
startNewAgentSessionRef,
|
||||
handleJumpToAgentSession,
|
||||
handleResumeSession,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -109,6 +109,13 @@ interface UseBatchProcessorReturn {
|
||||
abortBatchOnError: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
type ErrorResolutionAction = 'resume' | 'skip-document' | 'abort';
|
||||
|
||||
interface ErrorResolutionEntry {
|
||||
promise: Promise<ErrorResolutionAction>;
|
||||
resolve: (action: ErrorResolutionAction) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in human-readable format for loop summaries
|
||||
*/
|
||||
@@ -253,6 +260,9 @@ export function useBatchProcessor({
|
||||
const accumulatedTimeRefs = useRef<Record<string, number>>({});
|
||||
const lastActiveTimestampRefs = useRef<Record<string, number | null>>({});
|
||||
|
||||
// Error resolution promises to pause batch processing until user action (per session)
|
||||
const errorResolutionRefs = useRef<Record<string, ErrorResolutionEntry>>({});
|
||||
|
||||
// Helper to get batch state for a session
|
||||
const getBatchState = useCallback((sessionId: string): BatchRunState => {
|
||||
return batchRunStates[sessionId] || DEFAULT_BATCH_STATE;
|
||||
@@ -316,17 +326,6 @@ export function useBatchProcessor({
|
||||
broadcastAutoRunState(sessionId, newStateForSession);
|
||||
}, [broadcastAutoRunState]);
|
||||
|
||||
// Helper to get current accumulated elapsed time for a session (visibility-aware)
|
||||
const getAccumulatedElapsedMs = useCallback((sessionId: string): number => {
|
||||
const accumulated = accumulatedTimeRefs.current[sessionId] || 0;
|
||||
const lastActive = lastActiveTimestampRefs.current[sessionId];
|
||||
if (lastActive !== null && lastActive !== undefined && !document.hidden) {
|
||||
// Add time since last active timestamp
|
||||
return accumulated + (Date.now() - lastActive);
|
||||
}
|
||||
return accumulated;
|
||||
}, []);
|
||||
|
||||
// Visibility change handler to pause/resume time tracking
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -430,6 +429,7 @@ ${docList}
|
||||
|
||||
// Reset stop flag for this session
|
||||
stopRequestedRefs.current[sessionId] = false;
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
|
||||
// Set up worktree if enabled
|
||||
let effectiveCwd = session.cwd; // Default to session's cwd
|
||||
@@ -640,7 +640,6 @@ ${docList}
|
||||
// Per-loop tracking for loop summary
|
||||
let loopStartTime = Date.now();
|
||||
let loopTasksCompleted = 0;
|
||||
let loopTasksDiscovered = 0;
|
||||
let loopTotalInputTokens = 0;
|
||||
let loopTotalOutputTokens = 0;
|
||||
let loopTotalCost = 0;
|
||||
@@ -659,9 +658,6 @@ ${docList}
|
||||
// Track stalled documents (document filename -> stall reason)
|
||||
const stalledDocuments: Map<string, string> = new Map();
|
||||
|
||||
// Legacy flag for backwards compatibility - true if ANY document stalled
|
||||
let stalledDueToNoProgress = false;
|
||||
|
||||
// Helper to add final loop summary (defined here so it has access to tracking vars)
|
||||
const addFinalLoopSummary = (exitReason: string) => {
|
||||
// AUTORUN LOG: Exit
|
||||
@@ -775,6 +771,7 @@ ${docList}
|
||||
}));
|
||||
|
||||
let docTasksCompleted = 0;
|
||||
let skipCurrentDocumentAfterError = false;
|
||||
|
||||
// Process tasks in this document until none remain
|
||||
while (remainingTasks > 0) {
|
||||
@@ -784,6 +781,23 @@ ${docList}
|
||||
break;
|
||||
}
|
||||
|
||||
// Pause processing until the user resolves the error state
|
||||
const errorResolution = errorResolutionRefs.current[sessionId];
|
||||
if (errorResolution) {
|
||||
const action = await errorResolution.promise;
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
|
||||
if (action === 'abort') {
|
||||
stopRequestedRefs.current[sessionId] = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (action === 'skip-document') {
|
||||
skipCurrentDocumentAfterError = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build template context for this task
|
||||
const templateContext: TemplateContext = {
|
||||
session,
|
||||
@@ -955,7 +969,6 @@ ${docList}
|
||||
|
||||
// Track this document as stalled
|
||||
stalledDocuments.set(docEntry.filename, stallReason);
|
||||
stalledDueToNoProgress = true;
|
||||
|
||||
// AUTORUN LOG: Document stalled
|
||||
window.maestro.logger.autorun(
|
||||
@@ -1028,6 +1041,10 @@ ${docList}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipCurrentDocumentAfterError) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Document complete - handle reset-on-completion if enabled
|
||||
console.log(`[BatchProcessor] Document ${docEntry.filename} complete. resetOnCompletion=${docEntry.resetOnCompletion}, docTasksCompleted=${docTasksCompleted}`);
|
||||
if (docEntry.resetOnCompletion && docTasksCompleted > 0) {
|
||||
@@ -1193,7 +1210,6 @@ ${docList}
|
||||
// Reset per-loop tracking for next iteration
|
||||
loopStartTime = Date.now();
|
||||
loopTasksCompleted = 0;
|
||||
loopTasksDiscovered = newTotalTasks;
|
||||
loopTotalInputTokens = 0;
|
||||
loopTotalOutputTokens = 0;
|
||||
loopTotalCost = 0;
|
||||
@@ -1445,6 +1461,7 @@ ${docList}
|
||||
// Clean up time tracking refs
|
||||
delete accumulatedTimeRefs.current[sessionId];
|
||||
delete lastActiveTimestampRefs.current[sessionId];
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
}, [onUpdateSession, onSpawnAgent, onSpawnSynopsis, onAddHistoryEntry, onComplete, onPRResult, audioFeedbackEnabled, audioFeedbackCommand, updateBatchStateAndBroadcast]);
|
||||
|
||||
/**
|
||||
@@ -1452,6 +1469,11 @@ ${docList}
|
||||
*/
|
||||
const stopBatchRun = useCallback((sessionId: string) => {
|
||||
stopRequestedRefs.current[sessionId] = true;
|
||||
const errorResolution = errorResolutionRefs.current[sessionId];
|
||||
if (errorResolution) {
|
||||
errorResolution.resolve('abort');
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
}
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
@@ -1494,6 +1516,17 @@ ${docList}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
if (!errorResolutionRefs.current[sessionId]) {
|
||||
let resolvePromise: ((action: ErrorResolutionAction) => void) | undefined;
|
||||
const promise = new Promise<ErrorResolutionAction>(resolve => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
errorResolutionRefs.current[sessionId] = {
|
||||
promise,
|
||||
resolve: resolvePromise as (action: ErrorResolutionAction) => void
|
||||
};
|
||||
}
|
||||
}, [updateBatchStateAndBroadcast]);
|
||||
|
||||
/**
|
||||
@@ -1527,9 +1560,13 @@ ${docList}
|
||||
};
|
||||
});
|
||||
|
||||
// Signal to skip current document in the processing loop
|
||||
// The stopRequestedRefs is reused with a special marker
|
||||
// Note: This relies on the processing loop checking for error state changes
|
||||
const errorResolution = errorResolutionRefs.current[sessionId];
|
||||
if (errorResolution) {
|
||||
errorResolution.resolve('skip-document');
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
}
|
||||
|
||||
// Signal to skip the current document in the processing loop
|
||||
}, [updateBatchStateAndBroadcast]);
|
||||
|
||||
/**
|
||||
@@ -1560,6 +1597,12 @@ ${docList}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const errorResolution = errorResolutionRefs.current[sessionId];
|
||||
if (errorResolution) {
|
||||
errorResolution.resolve('resume');
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
}
|
||||
}, [updateBatchStateAndBroadcast]);
|
||||
|
||||
/**
|
||||
@@ -1575,6 +1618,11 @@ ${docList}
|
||||
|
||||
// Request stop and clear error state
|
||||
stopRequestedRefs.current[sessionId] = true;
|
||||
const errorResolution = errorResolutionRefs.current[sessionId];
|
||||
if (errorResolution) {
|
||||
errorResolution.resolve('abort');
|
||||
delete errorResolutionRefs.current[sessionId];
|
||||
}
|
||||
updateBatchStateAndBroadcast(sessionId, prev => ({
|
||||
...prev,
|
||||
[sessionId]: {
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import type { Session } from '../types';
|
||||
import { fuzzyMatch } from '../utils/search';
|
||||
import {
|
||||
shouldOpenExternally,
|
||||
flattenTree as flattenTreeUtil,
|
||||
getAllFolderPaths as getAllFolderPathsUtil,
|
||||
type FileTreeNode,
|
||||
} from '../utils/fileExplorer';
|
||||
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
type: 'file' | 'folder';
|
||||
children?: FileNode[];
|
||||
fullPath?: string;
|
||||
isFolder?: boolean;
|
||||
}
|
||||
|
||||
export interface UseFileExplorerReturn {
|
||||
// State
|
||||
previewFile: {name: string; content: string; path: string} | null;
|
||||
setPreviewFile: (file: {name: string; content: string; path: string} | null) => void;
|
||||
selectedFileIndex: number;
|
||||
setSelectedFileIndex: (index: number) => void;
|
||||
flatFileList: any[];
|
||||
fileTreeFilter: string;
|
||||
setFileTreeFilter: (filter: string) => void;
|
||||
fileTreeFilterOpen: boolean;
|
||||
setFileTreeFilterOpen: (open: boolean) => void;
|
||||
fileTreeContainerRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
// Operations
|
||||
handleFileClick: (node: any, path: string, activeSession: Session) => Promise<void>;
|
||||
loadFileTree: (dirPath: string, maxDepth?: number, currentDepth?: number) => Promise<any[]>;
|
||||
updateSessionWorkingDirectory: (activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => Promise<void>;
|
||||
toggleFolder: (path: string, activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
|
||||
expandAllFolders: (activeSessionId: string, activeSession: Session, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
|
||||
collapseAllFolders: (activeSessionId: string, setSessions: React.Dispatch<React.SetStateAction<Session[]>>) => void;
|
||||
flattenTree: (nodes: any[], expandedSet: Set<string>, currentPath?: string) => any[];
|
||||
filteredFileTree: any[];
|
||||
shouldOpenExternally: (filename: string) => boolean;
|
||||
}
|
||||
|
||||
export function useFileExplorer(
|
||||
activeSession: Session | null,
|
||||
setActiveFocus: (focus: string) => void
|
||||
): UseFileExplorerReturn {
|
||||
const [previewFile, setPreviewFile] = useState<{name: string; content: string; path: string} | null>(null);
|
||||
const [selectedFileIndex, setSelectedFileIndex] = useState(0);
|
||||
const [flatFileList, setFlatFileList] = useState<any[]>([]);
|
||||
const [fileTreeFilter, setFileTreeFilter] = useState('');
|
||||
const [fileTreeFilterOpen, setFileTreeFilterOpen] = useState(false);
|
||||
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleFileClick = async (node: any, path: string, activeSession: Session) => {
|
||||
if (node.type === 'file') {
|
||||
try {
|
||||
// Construct full file path
|
||||
const fullPath = `${activeSession.fullPath}/${path}`;
|
||||
|
||||
// Check if file should be opened externally
|
||||
if (shouldOpenExternally(node.name)) {
|
||||
await window.maestro.shell.openExternal(`file://${fullPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = await window.maestro.fs.readFile(fullPath);
|
||||
setPreviewFile({
|
||||
name: node.name,
|
||||
content: content,
|
||||
path: fullPath
|
||||
});
|
||||
setActiveFocus('main');
|
||||
} catch (error) {
|
||||
console.error('Failed to read file:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Load file tree from directory
|
||||
const loadFileTree = async (dirPath: string, maxDepth = 10, currentDepth = 0): Promise<any[]> => {
|
||||
if (currentDepth >= maxDepth) return [];
|
||||
|
||||
try {
|
||||
const entries = await window.maestro.fs.readDir(dirPath);
|
||||
const tree: any[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files and common ignore patterns
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules' || entry.name === '__pycache__') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
const children = await loadFileTree(`${dirPath}/${entry.name}`, maxDepth, currentDepth + 1);
|
||||
tree.push({
|
||||
name: entry.name,
|
||||
type: 'folder',
|
||||
children
|
||||
});
|
||||
} else if (entry.isFile) {
|
||||
tree.push({
|
||||
name: entry.name,
|
||||
type: 'file'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tree.sort((a, b) => {
|
||||
// Folders first, then alphabetically
|
||||
if (a.type === 'folder' && b.type !== 'folder') return -1;
|
||||
if (a.type !== 'folder' && b.type === 'folder') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading file tree:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const updateSessionWorkingDirectory = async (
|
||||
activeSessionId: string,
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
|
||||
) => {
|
||||
const newPath = await window.maestro.dialog.selectFolder();
|
||||
if (!newPath) return;
|
||||
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionId) return s;
|
||||
return {
|
||||
...s,
|
||||
cwd: newPath,
|
||||
fullPath: newPath,
|
||||
fileTree: [],
|
||||
fileTreeError: undefined
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleFolder = (
|
||||
path: string,
|
||||
activeSessionId: string,
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
|
||||
) => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionId) return s;
|
||||
if (!s.fileExplorerExpanded) return s;
|
||||
const expanded = new Set(s.fileExplorerExpanded);
|
||||
if (expanded.has(path)) {
|
||||
expanded.delete(path);
|
||||
} else {
|
||||
expanded.add(path);
|
||||
}
|
||||
return { ...s, fileExplorerExpanded: Array.from(expanded) };
|
||||
}));
|
||||
};
|
||||
|
||||
const expandAllFolders = (
|
||||
activeSessionId: string,
|
||||
activeSession: Session,
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
|
||||
) => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionId) return s;
|
||||
if (!s.fileTree) return s;
|
||||
const allFolderPaths = getAllFolderPathsUtil(s.fileTree as FileTreeNode[]);
|
||||
return { ...s, fileExplorerExpanded: allFolderPaths };
|
||||
}));
|
||||
};
|
||||
|
||||
const collapseAllFolders = (
|
||||
activeSessionId: string,
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>
|
||||
) => {
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSessionId) return s;
|
||||
return { ...s, fileExplorerExpanded: [] };
|
||||
}));
|
||||
};
|
||||
|
||||
// Update flat file list when active session's tree or expanded folders change
|
||||
useEffect(() => {
|
||||
if (!activeSession || !activeSession.fileTree || !activeSession.fileExplorerExpanded) {
|
||||
setFlatFileList([]);
|
||||
return;
|
||||
}
|
||||
const expandedSet = new Set(activeSession.fileExplorerExpanded);
|
||||
setFlatFileList(flattenTreeUtil(activeSession.fileTree as FileTreeNode[], expandedSet));
|
||||
}, [activeSession?.fileTree, activeSession?.fileExplorerExpanded]);
|
||||
|
||||
// Filter file tree based on search query
|
||||
const filteredFileTree = useMemo(() => {
|
||||
if (!activeSession || !fileTreeFilter || !activeSession.fileTree) {
|
||||
return activeSession?.fileTree || [];
|
||||
}
|
||||
|
||||
const filterTree = (nodes: any[]): any[] => {
|
||||
return nodes.reduce((acc: any[], node) => {
|
||||
const matchesFilter = fuzzyMatch(node.name, fileTreeFilter);
|
||||
|
||||
if (node.type === 'folder' && node.children) {
|
||||
const filteredChildren = filterTree(node.children);
|
||||
// Include folder if it matches or has matching children
|
||||
if (matchesFilter || filteredChildren.length > 0) {
|
||||
acc.push({
|
||||
...node,
|
||||
children: filteredChildren
|
||||
});
|
||||
}
|
||||
} else if (matchesFilter) {
|
||||
// Include file if it matches
|
||||
acc.push(node);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
return filterTree(activeSession.fileTree);
|
||||
}, [activeSession?.fileTree, fileTreeFilter]);
|
||||
|
||||
return {
|
||||
previewFile,
|
||||
setPreviewFile,
|
||||
selectedFileIndex,
|
||||
setSelectedFileIndex,
|
||||
flatFileList,
|
||||
fileTreeFilter,
|
||||
setFileTreeFilter,
|
||||
fileTreeFilterOpen,
|
||||
setFileTreeFilterOpen,
|
||||
fileTreeContainerRef,
|
||||
handleFileClick,
|
||||
loadFileTree,
|
||||
updateSessionWorkingDirectory,
|
||||
toggleFolder,
|
||||
expandAllFolders,
|
||||
collapseAllFolders,
|
||||
flattenTree: flattenTreeUtil,
|
||||
filteredFileTree,
|
||||
shouldOpenExternally,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user