diff --git a/electron.vite.config.ts b/electron.vite.config.ts index fb7a0dc0..485af723 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -51,7 +51,8 @@ export default defineConfig({ outDir: 'dist-electron/main', rollupOptions: { input: { - index: resolve(__dirname, 'src/main/index.ts') + index: resolve(__dirname, 'src/main/index.ts'), + 'team-fs-worker': resolve(__dirname, 'src/main/workers/team-fs-worker.ts') }, output: { // CJS format so bundled deps can use __dirname/require. diff --git a/resources/pricing.json b/resources/pricing.json index 85868da3..61373380 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -2702,6 +2702,30 @@ "supports_vision": true, "tool_use_system_prompt_tokens": 159 }, + "openrouter/anthropic/claude-sonnet-4.6": { + "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 6e-7, + "input_cost_per_token": 0.000003, + "input_cost_per_token_above_200k_tokens": 0.000006, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 0.000015, + "output_cost_per_token_above_200k_tokens": 0.0000225, + "source": "https://openrouter.ai/anthropic/claude-sonnet-4.6", + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 0.00000625, "cache_read_input_token_cost": 5e-7, diff --git a/src/main/index.ts b/src/main/index.ts index 1075e7d4..e73c6e97 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -42,6 +42,7 @@ import { join } from 'path'; import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { showTeamNativeNotification } from './ipc/teams'; +import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor'; import { HttpServer } from './services/infrastructure/HttpServer'; import { TeamInboxReader } from './services/team/TeamInboxReader'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; @@ -69,6 +70,7 @@ import type { FileChangeEvent } from '@main/types'; import type { TeamChangeEvent } from '@shared/types'; const logger = createLogger('App'); +startEventLoopLagMonitor(); // --- Team message notification tracking --- const teamInboxReader = new TeamInboxReader(); @@ -792,6 +794,7 @@ function syncTrafficLightPosition(win: BrowserWindow): void { */ function createWindow(): void { const isMac = process.platform === 'darwin'; + const isDev = process.env.NODE_ENV === 'development'; const iconPath = isMac ? undefined : getAppIconPath(); const useNativeTitleBar = !isMac && configManager.getConfig().general.useNativeTitleBar; mainWindow = new BrowserWindow({ @@ -802,6 +805,10 @@ function createWindow(): void { preload: join(__dirname, '../preload/index.js'), nodeIntegration: false, contextIsolation: true, + // In development, avoid persistent Chromium storage locks (IndexedDB/Quota) + // when multiple dev instances are started or ports rotate (5173 -> 5174, etc). + // This keeps startup resilient and prevents "LOCK" contention. + ...(isDev ? { partition: `temp:dev-${process.pid}` } : {}), }, backgroundColor: '#1a1a1a', ...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }), @@ -809,9 +816,53 @@ function createWindow(): void { title: 'Claude Agent Teams UI', }); + // In dev, forward selected renderer console warnings/errors to the main terminal. + // Use the new single-argument event payload to avoid Electron deprecation warnings. + if (isDev) { + mainWindow.webContents.on('console-message', (details: unknown) => { + if (!details || typeof details !== 'object') return; + const d = details as { + level?: unknown; + message?: unknown; + lineNumber?: unknown; + sourceId?: unknown; + }; + const level = typeof d.level === 'string' ? d.level : 'info'; + if (level !== 'warning' && level !== 'error') return; + const message = typeof d.message === 'string' ? d.message.trim() : ''; + if (!message) return; + const isNamespaced = + message.startsWith('[Store:') || + message.startsWith('[Component:') || + message.startsWith('[IPC:') || + message.startsWith('[Service:') || + message.startsWith('[Perf:') || + message.startsWith('[startup]'); + if (!isNamespaced) return; + const sourceId = typeof d.sourceId === 'string' ? d.sourceId : 'unknown'; + const line = typeof d.lineNumber === 'number' ? d.lineNumber : -1; + logger.warn(`RendererConsole: ${message} (${sourceId}:${line})`); + }); + } + // Load the renderer - if (process.env.NODE_ENV === 'development') { - void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`); + if (isDev) { + // electron-vite may move the dev server off 5173 if it's already taken. + // Always prefer the URL it provides via env; fallback to the default port. + const envUrl = + process.env.ELECTRON_RENDERER_URL || + process.env.VITE_DEV_SERVER_URL || + process.env.ELECTRON_VITE_DEV_SERVER_URL; + const devUrl = envUrl?.trim() || `http://localhost:${DEV_SERVER_PORT}`; + if (!envUrl) { + logger.warn( + `[dev] renderer dev server URL env not set; falling back to ${devUrl}. ` + + `If you see "Port 5173 is in use" in the terminal, the UI may appear stuck until this is fixed.` + ); + } else { + logger.warn(`[dev] loading renderer from ${devUrl}`); + } + void mainWindow.loadURL(devUrl); mainWindow.webContents.openDevTools(); } else { void mainWindow.loadFile(getRendererIndexPath()).catch((error: unknown) => { @@ -834,6 +885,7 @@ function createWindow(): void { // Set traffic light position + notify renderer on first load, and auto-check for updates mainWindow.webContents.on('did-finish-load', () => { if (mainWindow && !mainWindow.isDestroyed()) { + logger.warn('[startup] renderer did-finish-load'); syncTrafficLightPosition(mainWindow); setTimeout(() => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -865,6 +917,10 @@ function createWindow(): void { } }); + mainWindow.webContents.on('dom-ready', () => { + logger.warn('[startup] renderer dom-ready'); + }); + // Log top-level renderer load failures (helps diagnose blank/black window issues in packaged apps) mainWindow.webContents.on( 'did-fail-load', @@ -986,10 +1042,13 @@ void app.whenReady().then(() => { // Apply configuration settings const config = configManager.getConfig(); - // Apply launch at login setting - app.setLoginItemSettings({ - openAtLogin: config.general.launchAtLogin, - }); + // Apply launch-at-login setting only in packaged builds. + // In dev, macOS may deny this (and Electron logs a noisy error to stderr). + if (app.isPackaged) { + app.setLoginItemSettings({ + openAtLogin: config.general.launchAtLogin, + }); + } // Apply dock visibility and icon (macOS) if (process.platform === 'darwin') { diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 5db69900..6f77cadc 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -22,6 +22,9 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron'; const logger = createLogger('IPC:cliInstaller'); let service: CliInstallerService; +let statusInFlight: Promise | null = null; +let cachedStatus: { value: CliInstallationStatus; at: number } | null = null; +const STATUS_CACHE_TTL_MS = 5_000; /** * Initializes CLI installer handlers with the service instance. @@ -58,7 +61,28 @@ async function handleGetStatus( _event: IpcMainInvokeEvent ): Promise> { try { - const status = await service.getStatus(); + if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { + return { success: true, data: cachedStatus.value }; + } + + if (!statusInFlight) { + const startedAt = Date.now(); + statusInFlight = service + .getStatus() + .then((status) => { + cachedStatus = { value: status, at: Date.now() }; + return status; + }) + .finally(() => { + const ms = Date.now() - startedAt; + if (ms >= 2000) { + logger.warn(`cliInstaller:getStatus slow ms=${ms}`); + } + statusInFlight = null; + }); + } + + const status = await statusInFlight; return { success: true, data: status }; } catch (error) { const msg = getErrorMessage(error); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 95df0efb..3344f8b7 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -69,6 +69,7 @@ import { import { registerUtilityHandlers, removeUtilityHandlers } from './utility'; import { registerValidationHandlers, removeValidationHandlers } from './validation'; import { registerWindowHandlers, removeWindowHandlers } from './window'; +import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs'; import type { ChangeExtractorService, @@ -171,6 +172,7 @@ export function initializeIpcHandlers( registerReviewHandlers(ipcMain); registerEditorHandlers(ipcMain); registerWindowHandlers(ipcMain); + registerRendererLogHandlers(ipcMain); if (cliInstaller) { registerCliInstallerHandlers(ipcMain); } @@ -204,6 +206,7 @@ export function removeIpcHandlers(): void { removeReviewHandlers(ipcMain); removeEditorHandlers(ipcMain); removeWindowHandlers(ipcMain); + removeRendererLogHandlers(ipcMain); removeCliInstallerHandlers(ipcMain); removeTerminalHandlers(ipcMain); removeHttpServerHandlers(ipcMain); diff --git a/src/main/ipc/projects.ts b/src/main/ipc/projects.ts index 0ae3922f..5b36753b 100644 --- a/src/main/ipc/projects.ts +++ b/src/main/ipc/projects.ts @@ -10,6 +10,7 @@ import { createLogger } from '@shared/utils/logger'; import { type IpcMain, type IpcMainInvokeEvent } from 'electron'; +import { setCurrentMainOp } from '../services/infrastructure/EventLoopLagMonitor'; import { type Project, type RepositoryGroup, type Session } from '../types'; import { validateProjectId } from './guards'; @@ -59,6 +60,12 @@ export function removeProjectHandlers(ipcMain: IpcMain): void { * Lists all projects from ~/.claude/projects/ */ async function handleGetProjects(_event: IpcMainInvokeEvent): Promise { + setCurrentMainOp('projects:getProjects'); + const startedAt = Date.now(); + const watchdogMs = 10_000; + const watchdog = setTimeout(() => { + logger.warn(`get-projects still running after ${watchdogMs}ms`); + }, watchdogMs); try { const { projectScanner } = registry.getActive(); const projects = await projectScanner.scan(); @@ -66,6 +73,13 @@ async function handleGetProjects(_event: IpcMainInvokeEvent): Promise } catch (error) { logger.error('Error in get-projects:', error); return []; + } finally { + clearTimeout(watchdog); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`get-projects slow ms=${ms}`); + } + setCurrentMainOp(null); } } @@ -75,6 +89,12 @@ async function handleGetProjects(_event: IpcMainInvokeEvent): Promise * Worktrees of the same repo are grouped together. */ async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise { + setCurrentMainOp('projects:getRepositoryGroups'); + const startedAt = Date.now(); + const watchdogMs = 10_000; + const watchdog = setTimeout(() => { + logger.warn(`get-repository-groups still running after ${watchdogMs}ms`); + }, watchdogMs); try { const { projectScanner } = registry.getActive(); const groups = await projectScanner.scanWithWorktreeGrouping(); @@ -82,6 +102,13 @@ async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise= 2000) { + logger.warn(`get-repository-groups slow ms=${ms}`); + } + setCurrentMainOp(null); } } diff --git a/src/main/ipc/rendererLogs.ts b/src/main/ipc/rendererLogs.ts new file mode 100644 index 00000000..5dbb3710 --- /dev/null +++ b/src/main/ipc/rendererLogs.ts @@ -0,0 +1,94 @@ +import { RENDERER_BOOT, RENDERER_HEARTBEAT, RENDERER_LOG } from '@preload/constants/ipcChannels'; +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain } from 'electron'; + +const logger = createLogger('IPC:rendererLogs'); + +type RendererLogLevel = 'warn' | 'error'; + +function truncate(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}…(truncated)`; +} + +function isRendererLogPayload( + payload: unknown +): payload is { level: RendererLogLevel; message: string } { + if (!payload || typeof payload !== 'object') return false; + const p = payload as { level?: unknown; message?: unknown }; + return (p.level === 'warn' || p.level === 'error') && typeof p.message === 'string'; +} + +const lastHeartbeatByWebContentsId = new Map(); +const lastHeartbeatWarnedAtByWebContentsId = new Map(); +const hasReceivedHeartbeatByWebContentsId = new Set(); +let heartbeatMonitorStarted = false; + +function startHeartbeatMonitor(): void { + if (heartbeatMonitorStarted) return; + heartbeatMonitorStarted = true; + + const CHECK_EVERY_MS = 1500; + const STALE_AFTER_MS = 5000; + const WARN_THROTTLE_MS = 10_000; + + setInterval(() => { + const now = Date.now(); + for (const [id, last] of lastHeartbeatByWebContentsId.entries()) { + if (!hasReceivedHeartbeatByWebContentsId.has(id)) { + // Don't warn "stale" if we never saw a heartbeat — that likely indicates the + // heartbeat channel isn't wired (or the window reloaded) rather than a stall. + continue; + } + const age = now - last; + if (age < STALE_AFTER_MS) continue; + const lastWarnedAt = lastHeartbeatWarnedAtByWebContentsId.get(id) ?? 0; + if (now - lastWarnedAt < WARN_THROTTLE_MS) continue; + lastHeartbeatWarnedAtByWebContentsId.set(id, now); + logger.warn(`Renderer heartbeat stale webContentsId=${id} ageMs=${age}`); + } + }, CHECK_EVERY_MS); +} + +export function registerRendererLogHandlers(ipcMain: IpcMain): void { + startHeartbeatMonitor(); + + ipcMain.on(RENDERER_LOG, (_event, payload: unknown) => { + if (!isRendererLogPayload(payload)) return; + const msg = truncate(payload.message, 4000); + if (payload.level === 'error') { + logger.error(`Renderer: ${msg}`); + } else { + logger.warn(`Renderer: ${msg}`); + } + }); + + ipcMain.on(RENDERER_BOOT, (event) => { + const id = event.sender.id; + lastHeartbeatByWebContentsId.set(id, Date.now()); + lastHeartbeatWarnedAtByWebContentsId.delete(id); + hasReceivedHeartbeatByWebContentsId.delete(id); + logger.warn(`Renderer boot webContentsId=${id}`); + event.sender.once('destroyed', () => { + lastHeartbeatByWebContentsId.delete(id); + lastHeartbeatWarnedAtByWebContentsId.delete(id); + hasReceivedHeartbeatByWebContentsId.delete(id); + }); + }); + + ipcMain.on(RENDERER_HEARTBEAT, (event) => { + const id = event.sender.id; + const isFirst = !hasReceivedHeartbeatByWebContentsId.has(id); + hasReceivedHeartbeatByWebContentsId.add(id); + lastHeartbeatByWebContentsId.set(id, Date.now()); + if (isFirst) { + logger.warn(`Renderer heartbeat started webContentsId=${id}`); + } + }); +} + +export function removeRendererLogHandlers(ipcMain: IpcMain): void { + ipcMain.removeAllListeners(RENDERER_LOG); + ipcMain.removeAllListeners(RENDERER_BOOT); + ipcMain.removeAllListeners(RENDERER_HEARTBEAT); +} diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 7de99ccb..c38828f9 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; +import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMonitor'; import { getAppIconPath } from '@main/utils/appIcon'; import { TEAM_ADD_MEMBER, @@ -319,7 +320,17 @@ async function handleGetProjectBranch( } async function handleListTeams(_event: IpcMainInvokeEvent): Promise> { - return wrapTeamHandler('list', () => getTeamDataService().listTeams()); + setCurrentMainOp('team:list'); + const startedAt = Date.now(); + try { + return await wrapTeamHandler('list', () => getTeamDataService().listTeams()); + } finally { + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`[teams:list] slow ms=${ms}`); + } + setCurrentMainOp(null); + } } async function handleGetData( @@ -332,7 +343,6 @@ async function handleGetData( } const tn = validated.value!; const startedAt = Date.now(); - logger.info(`[teams:getData] start team=${tn}`); let data: TeamData; try { data = await getTeamDataService().getTeamData(tn); @@ -348,12 +358,8 @@ async function handleGetData( return { success: false, error: message }; } const getDataMs = Date.now() - startedAt; - if (getDataMs >= 1000) { - logger.warn( - `[teams:getData] slow team=${tn} ms=${getDataMs} tasks=${data.tasks.length} members=${data.members.length} messages=${data.messages.length}` - ); - } else { - logger.info(`[teams:getData] done team=${tn} ms=${getDataMs}`); + if (getDataMs >= 1500) { + logger.warn(`[teams:getData] slow team=${tn} ms=${getDataMs}`); } const provisioning = getTeamProvisioningService(); const isAlive = provisioning.isTeamAlive(tn); @@ -1517,7 +1523,17 @@ async function handleStartTask( } async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise> { - return wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks()); + setCurrentMainOp('team:getAllTasks'); + const startedAt = Date.now(); + try { + return await wrapTeamHandler('getAllTasks', () => getTeamDataService().getAllTasks()); + } finally { + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`[teams:getAllTasks] slow ms=${ms}`); + } + setCurrentMainOp(null); + } } async function handleAddMember( diff --git a/src/main/services/discovery/ProjectPathResolver.ts b/src/main/services/discovery/ProjectPathResolver.ts index 396a8415..e8ba6801 100644 --- a/src/main/services/discovery/ProjectPathResolver.ts +++ b/src/main/services/discovery/ProjectPathResolver.ts @@ -72,7 +72,11 @@ export class ProjectPathResolver { // In SSH mode, avoid scanning every remote session file just to resolve display path. // One successful cwd extraction is sufficient. - const maxPathsToInspect = this.fsProvider.type === 'ssh' ? 1 : sessionPaths.length; + const MAX_LOCAL_PATHS_TO_INSPECT = 5; + const maxPathsToInspect = + this.fsProvider.type === 'ssh' + ? 1 + : Math.min(sessionPaths.length, MAX_LOCAL_PATHS_TO_INSPECT); for (const sessionPath of sessionPaths.slice(0, maxPathsToInspect)) { try { const cwd = await extractCwd(sessionPath, this.fsProvider); diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index 4d2e9109..dee6e7dd 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -58,6 +58,12 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP const logger = createLogger('Discovery:ProjectScanner'); +// IPC payload safety: session ID arrays can be extremely large for long-lived projects. +// Keep counts accurate via totalSessions, but truncate ID lists to keep renderer responsive. +// We no longer need session IDs in project/repository listings (session lists are fetched separately). +// Keeping this at 0 avoids huge IPC payloads that can stall the renderer thread. +const MAX_SESSION_IDS_EXPORTED = 0; + export class ProjectScanner { private readonly projectsDir: string; private readonly todosDir: string; @@ -84,8 +90,8 @@ export class ProjectScanner { private static readonly SCAN_CACHE_TTL_MS = 2000; // Platform-aware batch sizes to avoid UV thread pool saturation on Windows - private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 32 : 128; - private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 8 : 24; + private static readonly LOCAL_SESSION_BATCH = process.platform === 'win32' ? 16 : 64; + private static readonly LOCAL_PROJECT_BATCH = process.platform === 'win32' ? 4 : 12; // Delegated services private readonly fsProvider: FileSystemProvider; @@ -127,7 +133,15 @@ export class ProjectScanner { } const startedAt = Date.now(); + let stage = 'start'; + const slowWarnAfterMs = 10_000; + const slowWarnTimer = setTimeout(() => { + logger.warn( + `[scan] still running after ${slowWarnAfterMs}ms stage=${stage} projectsDir=${this.projectsDir}` + ); + }, slowWarnAfterMs); try { + stage = 'exists'; if (!(await this.fsProvider.exists(this.projectsDir))) { logger.warn(`Projects directory does not exist: ${this.projectsDir}`); return []; @@ -136,7 +150,13 @@ export class ProjectScanner { // Clear the subproject registry on full re-scan subprojectRegistry.clear(); + stage = 'readdirProjectsDir'; + const readdirStartedAt = Date.now(); const entries = await this.fsProvider.readdir(this.projectsDir); + const readdirMs = Date.now() - readdirStartedAt; + if (readdirMs >= 2000) { + logger.warn(`[scan] readdir slow ms=${readdirMs} entries=${entries.length}`); + } // Filter to only directories with valid encoding pattern const projectDirs = entries.filter( @@ -144,6 +164,7 @@ export class ProjectScanner { ); // Process each project directory (may return multiple projects per dir) + stage = 'scanProjects'; const projectArrays = await this.collectFulfilledInBatches( projectDirs, this.fsProvider.type === 'ssh' ? 8 : ProjectScanner.LOCAL_PROJECT_BATCH, @@ -160,11 +181,19 @@ export class ProjectScanner { ); } + const ms = Date.now() - startedAt; + if (ms >= 2000) { + logger.warn( + `[scan] completed slow ms=${ms} projectDirs=${projectDirs.length} projects=${validProjects.length}` + ); + } this.scanCache = { projects: validProjects, timestamp: Date.now() }; return validProjects; } catch (error) { logger.error('Error scanning projects directory:', error); return []; + } finally { + clearTimeout(slowWarnTimer); } } @@ -199,25 +228,29 @@ export class ProjectScanner { // 2. Convert each project to a simple RepositoryGroup (git resolution disabled) // Git identity resolution is bypassed to avoid blocking I/O on startup. // Each project becomes a single-worktree group. - const groups: RepositoryGroup[] = projects.map((project) => ({ - id: project.id, - identity: null, - worktrees: [ - { - id: project.id, - path: project.path, - name: project.name, - isMainWorktree: true, - source: 'unknown' as const, - sessions: project.sessions, - createdAt: project.createdAt, - mostRecentSession: project.mostRecentSession, - }, - ], - name: project.name, - mostRecentSession: project.mostRecentSession, - totalSessions: project.sessions.length, - })); + const groups: RepositoryGroup[] = projects.map((project) => { + const totalSessions = project.totalSessions ?? project.sessions.length; + return { + id: project.id, + identity: null, + worktrees: [ + { + id: project.id, + path: project.path, + name: project.name, + isMainWorktree: true, + source: 'unknown' as const, + sessions: project.sessions, + totalSessions, + createdAt: project.createdAt, + mostRecentSession: project.mostRecentSession, + }, + ], + name: project.name, + mostRecentSession: project.mostRecentSession, + totalSessions, + }; + }); // 3. Merge custom project paths from config (persisted "Select Folder" picks) const { configManager } = await import('../infrastructure/ConfigManager'); @@ -244,6 +277,7 @@ export class ProjectScanner { isMainWorktree: true, source: 'unknown' as const, sessions: [], + totalSessions: 0, createdAt: now, }, ], @@ -305,7 +339,13 @@ export class ProjectScanner { cwd: string | null; } - const shouldSplitByCwd = this.fsProvider.type !== 'ssh'; + // Reading JSONL heads for cwd across hundreds/thousands of sessions can saturate I/O and + // make the renderer appear frozen while waiting for repository groups. + // Prefer correctness for small projects; for large ones, skip cwd splitting and fall back + // to encoded-path decoding / limited path probing. + const MAX_CWD_SPLIT_FILES = 80; + const shouldSplitByCwd = + this.fsProvider.type !== 'ssh' && sessionFiles.length <= MAX_CWD_SPLIT_FILES; const sessionInfos = await this.collectFulfilledInBatches( sessionFiles, this.fsProvider.type === 'ssh' ? 32 : ProjectScanner.LOCAL_SESSION_BATCH, @@ -356,6 +396,7 @@ export class ProjectScanner { const realCwdKeys = [...cwdGroups.keys()].filter((k) => !k.startsWith('__decoded__')); if (realCwdKeys.length <= 1) { const allSessionIds = sessionInfos.map((s) => s.sessionId); + const exportedSessionIds = allSessionIds.slice(0, MAX_SESSION_IDS_EXPORTED); let mostRecentSession: number | undefined; let createdAt = Date.now(); for (const info of sessionInfos) { @@ -369,6 +410,7 @@ export class ProjectScanner { const sessionPaths = sessionInfos.map((s) => s.filePath); const actualPath = await this.projectPathResolver.resolveProjectPath(encodedName, { + cwdHint: firstCwd ?? undefined, sessionPaths, }); @@ -381,7 +423,8 @@ export class ProjectScanner { id: encodedName, path: actualPath, name: resolvedName, - sessions: allSessionIds, + sessions: exportedSessionIds, + totalSessions: allSessionIds.length, createdAt: Math.floor(createdAt), mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined, }, @@ -411,6 +454,7 @@ export class ProjectScanner { actualCwd ?? decodedFallback, sessionIds ); + const exportedSessionIds = sessionIds.slice(0, MAX_SESSION_IDS_EXPORTED); // Compute timestamps let mostRecentSession: number | undefined; @@ -438,7 +482,8 @@ export class ProjectScanner { id: compositeId, path: actualCwd ?? decodedFallback, name: displayName, - sessions: sessionIds, + sessions: exportedSessionIds, + totalSessions: sessionIds.length, createdAt: Math.floor(createdAt), mostRecentSession: mostRecentSession ? Math.floor(mostRecentSession) : undefined, }); @@ -504,8 +549,10 @@ export class ProjectScanner { const sessionPaths = sessionFiles.map((file) => path.join(projectPath, file.name)); const decodedPath = await this.resolveProjectPathForId(projectId, sessionPaths); - const sessions = await Promise.all( - sessionFiles.map(async (file) => { + const sessions = await this.collectFulfilledInBatches( + sessionFiles, + this.fsProvider.type === 'ssh' ? 8 : 16, + async (file) => { const sessionId = extractSessionId(file.name); const filePath = path.join(projectPath, file.name); const fileDetails = await this.resolveFileDetails(file, filePath); @@ -535,7 +582,7 @@ export class ProjectScanner { prefetchedSize, prefetchedBirthtimeMs ); - }) + } ); // Filter out null results (noise-only sessions) diff --git a/src/main/services/discovery/SessionContentFilter.ts b/src/main/services/discovery/SessionContentFilter.ts index b57c7f5b..64181188 100644 --- a/src/main/services/discovery/SessionContentFilter.ts +++ b/src/main/services/discovery/SessionContentFilter.ts @@ -32,6 +32,14 @@ const logger = createLogger('Service:SessionContentFilter'); const defaultProvider = new LocalFileSystemProvider(); +const SESSION_SCAN_TIMEOUT_MS = 2500; +const SESSION_SCAN_MAX_BYTES = 2 * 1024 * 1024; +const SESSION_SCAN_MAX_LINES = 2000; + +function byteLen(chunk: string): number { + return Buffer.byteLength(chunk, 'utf8'); +} + /** * Hard noise tags - user messages with ONLY these tags are filtered out. */ @@ -66,14 +74,39 @@ export class SessionContentFilter { return false; } + try { + const stat = await fsProvider.stat(filePath); + if (!stat.isFile()) { + return false; + } + } catch { + return false; + } + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); + let bytes = 0; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + fileStream.destroy(); + }, SESSION_SCAN_TIMEOUT_MS); + fileStream.on('data', (chunk: string) => { + bytes += byteLen(chunk); + if (bytes > SESSION_SCAN_MAX_BYTES) { + fileStream.destroy(); + } + }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); try { + let lines = 0; for await (const line of rl) { + if (++lines > SESSION_SCAN_MAX_LINES) { + break; + } if (!line.trim()) continue; try { @@ -87,6 +120,7 @@ export class SessionContentFilter { // Check if this entry would create a displayable chunk // This aligns with ChunkBuilder.categorizeMessage() logic if (SessionContentFilter.isDisplayableEntry(entry)) { + rl.close(); fileStream.destroy(); return true; } @@ -96,10 +130,21 @@ export class SessionContentFilter { } } } catch (error) { - logger.error(`Error checking displayable messages in ${filePath}:`, error); + if (!timedOut) { + logger.debug(`Error checking displayable messages in ${filePath}:`, error); + } + } finally { + clearTimeout(timer); + rl.close(); + fileStream.destroy(); + } + + // If we hit limits/timeouts, be conservative: treat as having content so we + // don't accidentally hide sessions due to partial reads. + if (timedOut || bytes > SESSION_SCAN_MAX_BYTES) { + return true; } - // No displayable messages found return false; } diff --git a/src/main/services/infrastructure/EventLoopLagMonitor.ts b/src/main/services/infrastructure/EventLoopLagMonitor.ts new file mode 100644 index 00000000..7af62b24 --- /dev/null +++ b/src/main/services/infrastructure/EventLoopLagMonitor.ts @@ -0,0 +1,37 @@ +import { monitorEventLoopDelay } from 'node:perf_hooks'; + +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('Perf:EventLoop'); + +let started = false; +let currentOp: string | null = null; + +export function setCurrentMainOp(op: string | null): void { + currentOp = op; +} + +export function startEventLoopLagMonitor(): void { + if (started) return; + started = true; + + const h = monitorEventLoopDelay({ resolution: 20 }); + h.enable(); + + const interval = setInterval(() => { + const maxMs = Number(h.max) / 1e6; + const p95Ms = Number(h.percentile(95)) / 1e6; + // Reset first so next window is clean even if logging throws + h.reset(); + + // Only report meaningful stalls + if (maxMs < 250) return; + + logger.warn( + `Event loop stall detected: p95=${p95Ms.toFixed(1)}ms max=${maxMs.toFixed(1)}ms` + + (currentOp ? ` op=${currentOp}` : '') + ); + }, 5000); + + interval.unref(); +} diff --git a/src/main/services/infrastructure/LocalFileSystemProvider.ts b/src/main/services/infrastructure/LocalFileSystemProvider.ts index de104982..1d87dbd1 100644 --- a/src/main/services/infrastructure/LocalFileSystemProvider.ts +++ b/src/main/services/infrastructure/LocalFileSystemProvider.ts @@ -14,6 +14,32 @@ import type { ReadStreamOptions, } from './FileSystemProvider'; +const STAT_CONCURRENCY = process.platform === 'win32' ? 32 : 128; +const STAT_TIMEOUT_MS = 2000; +// If a directory is huge, pre-statting every entry can take seconds+ and +// saturate the thread pool. In those cases, prefer returning bare dirents and +// let callers stat only the files they actually need. +const STAT_PREFETCH_LIMIT = 1500; + +async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let index = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = new Array(workerCount).fill(0).map(async () => { + while (true) { + const i = index++; + if (i >= items.length) return; + results[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return results; +} + export class LocalFileSystemProvider implements FileSystemProvider { readonly type = 'local' as const; @@ -31,7 +57,12 @@ export class LocalFileSystemProvider implements FileSystemProvider { } async stat(filePath: string): Promise { - const stats = await fs.promises.stat(filePath); + const stats = await Promise.race([ + fs.promises.stat(filePath), + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS) + ), + ]); return { size: stats.size, mtimeMs: stats.mtimeMs, @@ -43,32 +74,44 @@ export class LocalFileSystemProvider implements FileSystemProvider { async readdir(dirPath: string): Promise { const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - // Stat all entries concurrently to populate mtimeMs/birthtimeMs/size. - // Populating all three avoids a second stat() call in resolveFileDetails(). - // Failures are silently ignored (fields stay undefined). - return Promise.all( - entries.map(async (entry) => { - let mtimeMs: number | undefined; - let birthtimeMs: number | undefined; - let size: number | undefined; - try { - const stat = await fs.promises.stat(`${dirPath}/${entry.name}`); - mtimeMs = stat.mtimeMs; - birthtimeMs = stat.birthtimeMs; - size = stat.size; - } catch { - // ignore - } - return { - name: entry.name, - mtimeMs, - birthtimeMs, - size, - isFile: () => entry.isFile(), - isDirectory: () => entry.isDirectory(), - }; - }) - ); + if (entries.length > STAT_PREFETCH_LIMIT) { + return entries.map((entry) => ({ + name: entry.name, + isFile: () => entry.isFile(), + isDirectory: () => entry.isDirectory(), + })); + } + // Stat entries with bounded concurrency. + // Unbounded Promise.all(stat(...)) can saturate the UV thread pool (even with + // increased UV_THREADPOOL_SIZE) when directories contain thousands of files, + // causing unrelated operations (teams/tasks/CLI checks) to time out. + return mapLimit(entries, STAT_CONCURRENCY, async (entry) => { + let mtimeMs: number | undefined; + let birthtimeMs: number | undefined; + let size: number | undefined; + try { + const fullPath = `${dirPath}/${entry.name}`; + const stat = await Promise.race([ + fs.promises.stat(fullPath), + new Promise((_resolve, reject) => + setTimeout(() => reject(new Error('stat timeout')), STAT_TIMEOUT_MS) + ), + ]); + mtimeMs = stat.mtimeMs; + birthtimeMs = stat.birthtimeMs; + size = stat.size; + } catch { + // ignore + } + return { + name: entry.name, + mtimeMs, + birthtimeMs, + size, + isFile: () => entry.isFile(), + isDirectory: () => entry.isDirectory(), + }; + }); } createReadStream(filePath: string, opts?: ReadStreamOptions): fs.ReadStream { diff --git a/src/main/services/team/FileContentResolver.ts b/src/main/services/team/FileContentResolver.ts index 0cfb6dfa..be991093 100644 --- a/src/main/services/team/FileContentResolver.ts +++ b/src/main/services/team/FileContentResolver.ts @@ -374,7 +374,9 @@ export class FileContentResolver { currentContent: string | null, snippets: SnippetDiff[] ): string | null { - if (!currentContent) return null; + // `readFile()` can legitimately return an empty string for empty files. + // Only treat `null` as "missing on disk". + if (currentContent === null) return null; if (snippets.length === 0) return null; // Filter out errored snippets diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 5abe2db4..083da584 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -4,6 +4,7 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; +import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types'; @@ -79,6 +80,43 @@ export class TeamConfigReader { ) {} async listTeams(): Promise { + const worker = getTeamFsWorkerClient(); + if (worker.isAvailable()) { + const startedAt = Date.now(); + try { + const { teams, diag } = await worker.listTeams({ + largeConfigBytes: LARGE_CONFIG_BYTES, + configHeadBytes: CONFIG_HEAD_BYTES, + maxConfigBytes: MAX_CONFIG_READ_BYTES, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: MAX_SESSION_HISTORY_IN_SUMMARY, + maxProjectPathHistoryInSummary: MAX_PROJECT_PATH_HISTORY_IN_SUMMARY, + concurrency: TEAM_LIST_CONCURRENCY, + maxConfigReadMs: PER_TEAM_READ_TIMEOUT_MS, + }); + const ms = Date.now() - startedAt; + const skipReasons = + diag && typeof diag === 'object' ? (diag as Record).skipReasons : null; + if (skipReasons && typeof skipReasons === 'object') { + const bad = + Number((skipReasons as Record).config_parse_failed ?? 0) + + Number((skipReasons as Record).config_read_timeout ?? 0); + if (bad > 0) { + logger.warn(`[listTeams] worker skipped broken team configs count=${bad}`); + } + } + if (ms >= 1500) { + logger.warn(`[listTeams] worker slow ms=${ms} diag=${JSON.stringify(diag)}`); + } + return teams; + } catch (error) { + logger.warn( + `[listTeams] worker failed: ${error instanceof Error ? error.message : String(error)}` + ); + // Fall through to in-process implementation. + } + } + const teamsDir = getTeamsBasePath(); let entries: fs.Dirent[]; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 95ca45e3..f6c61577 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '@main/utils/asyncYield'; import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { encodePath, @@ -59,6 +60,7 @@ const MIN_TEXT_LENGTH = 30; const MAX_LEAD_TEXTS = 50; const PROCESS_HEALTH_INTERVAL_MS = 2_000; const MAX_PROCESSES_FILE_BYTES = 2 * 1024 * 1024; +const TASK_MAP_YIELD_EVERY = 250; export class TeamDataService { private processHealthTimer: ReturnType | null = null; @@ -82,10 +84,8 @@ export class TeamDataService { } async getAllTasks(): Promise { - const [rawTasks, teams] = await Promise.all([ - this.taskReader.getAllTasks(), - this.configReader.listTeams(), - ]); + const rawTasks = await this.taskReader.getAllTasks(); + const teams = await this.configReader.listTeams(); const teamInfoMap = new Map< string, @@ -116,24 +116,62 @@ export class TeamDataService { }) ); - return rawTasks - .filter((task) => teamInfoMap.has(task.teamName)) - .map((task) => { - const info = teamInfoMap.get(task.teamName)!; - const kanban = kanbanByTeam.get(task.teamName); - const kanbanEntry = kanban?.tasks[task.id]; - const kanbanColumn = - kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved' - ? kanbanEntry.column - : undefined; - return { - ...task, - teamDisplayName: info.displayName, - projectPath: task.projectPath ?? info.projectPath, - kanbanColumn, - teamDeleted: deletedTeams.has(task.teamName) || undefined, - }; + const out: GlobalTask[] = []; + let processed = 0; + for (const task of rawTasks) { + if (!teamInfoMap.has(task.teamName)) { + continue; + } + const info = teamInfoMap.get(task.teamName)!; + const kanban = kanbanByTeam.get(task.teamName); + const kanbanEntry = kanban?.tasks[task.id]; + const kanbanColumn = + kanbanEntry?.column === 'review' || kanbanEntry?.column === 'approved' + ? kanbanEntry.column + : undefined; + + // IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields). + // Return a "light" task object and defer heavy details to team/task detail views. + const projectPath = task.projectPath ?? info.projectPath; + const subject = + typeof task.subject === 'string' + ? task.subject.slice(0, 300) + : String(task.subject).slice(0, 300); + out.push({ + id: task.id, + subject, + owner: task.owner, + status: task.status, + createdAt: task.createdAt, + updatedAt: task.updatedAt, + projectPath, + needsClarification: task.needsClarification, + deletedAt: task.deletedAt, + // Intentionally omit description/comments/activeForm/workIntervals/links to keep payload small + kanbanColumn, + teamName: task.teamName, + teamDisplayName: info.displayName, + teamDeleted: deletedTeams.has(task.teamName) || undefined, }); + processed++; + if (processed % TASK_MAP_YIELD_EVERY === 0) { + await yieldToEventLoop(); + } + } + + // Hard cap: keep renderer responsive even with huge task sets. + const MAX_GLOBAL_TASKS_EXPORTED = 500; + if (out.length > MAX_GLOBAL_TASKS_EXPORTED) { + // Prefer newest first if timestamps exist. + out.sort((a, b) => { + const at = Date.parse(a.updatedAt ?? a.createdAt ?? '') || 0; + const bt = Date.parse(b.updatedAt ?? b.createdAt ?? '') || 0; + return bt - at; + }); + return out.slice(0, MAX_GLOBAL_TASKS_EXPORTED); + } + + return out; } async updateConfig( @@ -320,7 +358,7 @@ export class TeamDataService { const totalMs = Date.now() - startedAt; if (totalMs >= 1500) { logger.warn( - `[getTeamData] slow team=${teamName} total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( + `getTeamData team=${teamName} slow total=${totalMs}ms config=${msSince('config')} tasks=${msSince('tasks')} inboxNames=${msSince( 'inboxNames' )} messages=${msSince('messages')} leadTexts=${msSince('leadTexts')} sent=${msSince( 'sentMessages' diff --git a/src/main/services/team/TeamFsWorkerClient.ts b/src/main/services/team/TeamFsWorkerClient.ts new file mode 100644 index 00000000..e83755c8 --- /dev/null +++ b/src/main/services/team/TeamFsWorkerClient.ts @@ -0,0 +1,231 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Worker } from 'node:worker_threads'; + +import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; + +import type { TeamSummary, TeamTask } from '@shared/types'; + +const logger = createLogger('Service:TeamFsWorkerClient'); + +const DEFAULT_CONCURRENCY = process.platform === 'win32' ? 4 : 12; +const DEFAULT_READ_TIMEOUT_MS = 5_000; +const WORKER_CALL_TIMEOUT_MS = 20_000; + +type WorkerDiag = Record; + +interface ListTeamsPayload { + teamsDir: string; + largeConfigBytes: number; + configHeadBytes: number; + maxConfigBytes: number; + maxConfigReadMs: number; + maxMembersMetaBytes: number; + maxSessionHistoryInSummary: number; + maxProjectPathHistoryInSummary: number; + concurrency: number; +} + +interface GetAllTasksPayload { + tasksBase: string; + maxTaskBytes: number; + maxTaskReadMs: number; + concurrency: number; +} + +type WorkerRequest = + | { id: string; op: 'listTeams'; payload: ListTeamsPayload } + | { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload }; + +type WorkerResponse = + | { id: string; ok: true; result: unknown; diag?: WorkerDiag } + | { id: string; ok: false; error: string }; + +function makeId(): string { + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +function resolveWorkerPath(): string | null { + // We try multiple locations because dev/prod/test environments differ. + // Priority: co-located with bundled main output, then workspace dist folder. + const baseDir = + typeof __dirname === 'string' && __dirname.length > 0 + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + + const candidates = [ + path.join(baseDir, 'team-fs-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.js'), + ]; + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + return candidate; + } + } catch { + // ignore + } + } + + return null; +} + +export class TeamFsWorkerClient { + private worker: Worker | null = null; + private readonly workerPath: string | null = resolveWorkerPath(); + private warnedUnavailable = false; + private pending = new Map< + string, + { resolve: (v: { result: unknown; diag?: WorkerDiag }) => void; reject: (e: Error) => void } + >(); + + isAvailable(): boolean { + if (!this.workerPath && !this.warnedUnavailable) { + this.warnedUnavailable = true; + const baseDir = + typeof __dirname === 'string' && __dirname.length > 0 + ? __dirname + : path.dirname(fileURLToPath(import.meta.url)); + const expected = [ + path.join(baseDir, 'team-fs-worker.cjs'), + path.join(process.cwd(), 'dist-electron', 'main', 'team-fs-worker.cjs'), + ]; + logger.warn( + `team-fs-worker not found; falling back to main-thread scanning. expectedOneOf=${expected.join(',')}` + ); + } + return this.workerPath !== null; + } + + private ensureWorker(): Worker { + if (!this.workerPath) { + throw new Error('Worker is not available in this environment'); + } + if (this.worker) { + return this.worker; + } + + this.worker = new Worker(this.workerPath); + this.worker.on('message', (msg: WorkerResponse) => { + const entry = this.pending.get(msg.id); + if (!entry) return; + this.pending.delete(msg.id); + if (msg.ok) { + entry.resolve({ result: msg.result, diag: msg.diag }); + } else { + entry.reject(new Error(msg.error)); + } + }); + this.worker.on('error', (err) => { + logger.error('Worker error', err); + for (const [, entry] of this.pending) { + entry.reject(err instanceof Error ? err : new Error(String(err))); + } + this.pending.clear(); + this.worker = null; + }); + this.worker.on('exit', (code) => { + if (code !== 0) { + logger.warn(`Worker exited with code ${code}`); + } + for (const [, entry] of this.pending) { + entry.reject(new Error(`Worker exited with code ${code}`)); + } + this.pending.clear(); + this.worker = null; + }); + + return this.worker; + } + + private call( + op: WorkerRequest['op'], + payload: WorkerRequest['payload'] + ): Promise<{ result: unknown; diag?: WorkerDiag }> { + const worker = this.ensureWorker(); + const id = makeId(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + try { + // Terminate and recreate on next call — worker may be stuck in native IO. + this.worker?.terminate().catch(() => undefined); + } catch { + // ignore + } finally { + this.worker = null; + } + reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`)); + }, WORKER_CALL_TIMEOUT_MS); + + this.pending.set(id, { + resolve: (value) => { + clearTimeout(timeout); + resolve(value); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + const msg: WorkerRequest = + op === 'listTeams' + ? ({ id, op, payload } as WorkerRequest) + : ({ id, op, payload } as WorkerRequest); + worker.postMessage(msg); + }); + } + + async listTeams(options: { + largeConfigBytes: number; + configHeadBytes: number; + maxConfigBytes: number; + maxMembersMetaBytes: number; + maxSessionHistoryInSummary: number; + maxProjectPathHistoryInSummary: number; + concurrency?: number; + maxConfigReadMs?: number; + }): Promise<{ teams: TeamSummary[]; diag?: WorkerDiag }> { + const payload: ListTeamsPayload = { + teamsDir: getTeamsBasePath(), + largeConfigBytes: options.largeConfigBytes, + configHeadBytes: options.configHeadBytes, + maxConfigBytes: options.maxConfigBytes, + maxConfigReadMs: options.maxConfigReadMs ?? DEFAULT_READ_TIMEOUT_MS, + maxMembersMetaBytes: options.maxMembersMetaBytes, + maxSessionHistoryInSummary: options.maxSessionHistoryInSummary, + maxProjectPathHistoryInSummary: options.maxProjectPathHistoryInSummary, + concurrency: options.concurrency ?? DEFAULT_CONCURRENCY, + }; + const { result, diag } = await this.call('listTeams', payload); + return { teams: result as TeamSummary[], diag }; + } + + async getAllTasks(options: { + maxTaskBytes: number; + concurrency?: number; + maxTaskReadMs?: number; + }): Promise<{ tasks: (TeamTask & { teamName: string })[]; diag?: WorkerDiag }> { + const payload: GetAllTasksPayload = { + tasksBase: getTasksBasePath(), + maxTaskBytes: options.maxTaskBytes, + maxTaskReadMs: options.maxTaskReadMs ?? DEFAULT_READ_TIMEOUT_MS, + concurrency: options.concurrency ?? DEFAULT_CONCURRENCY, + }; + const { result, diag } = await this.call('getAllTasks', payload); + return { tasks: result as (TeamTask & { teamName: string })[], diag }; + } +} + +let singleton: TeamFsWorkerClient | null = null; + +export function getTeamFsWorkerClient(): TeamFsWorkerClient { + if (!singleton) { + singleton = new TeamFsWorkerClient(); + } + return singleton; +} diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 17edf43b..f463885d 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -1,9 +1,12 @@ +import { yieldToEventLoop } from '@main/utils/asyncYield'; import { readFileUtf8WithTimeout } from '@main/utils/fsRead'; import { getTasksBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; +import { getTeamFsWorkerClient } from './TeamFsWorkerClient'; + import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types'; const logger = createLogger('Service:TeamTaskReader'); @@ -53,6 +56,7 @@ export class TeamTaskReader { } const tasks: TeamTask[] = []; + let processed = 0; for (const file of entries) { if ( !file.endsWith('.json') || @@ -172,6 +176,10 @@ export class TeamTaskReader { } catch { logger.debug(`Skipping invalid task file: ${taskPath}`); } + processed++; + if (processed % 50 === 0) { + await yieldToEventLoop(); + } } return tasks; @@ -191,6 +199,7 @@ export class TeamTaskReader { } const tasks: TeamTask[] = []; + let processed = 0; for (const file of entries) { if ( !file.endsWith('.json') || @@ -241,12 +250,46 @@ export class TeamTaskReader { } catch { logger.debug(`Skipping invalid task file: ${taskPath}`); } + processed++; + if (processed % 50 === 0) { + await yieldToEventLoop(); + } } return tasks; } async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> { + const worker = getTeamFsWorkerClient(); + if (worker.isAvailable()) { + const startedAt = Date.now(); + try { + const { tasks, diag } = await worker.getAllTasks({ + maxTaskBytes: MAX_TASK_FILE_BYTES, + }); + const ms = Date.now() - startedAt; + const skipReasons = + diag && typeof diag === 'object' ? (diag as Record).skipReasons : null; + if (skipReasons && typeof skipReasons === 'object') { + const bad = + Number((skipReasons as Record).task_parse_failed ?? 0) + + Number((skipReasons as Record).task_read_timeout ?? 0); + if (bad > 0) { + logger.warn(`[getAllTasks] worker skipped broken task files count=${bad}`); + } + } + if (ms >= 2000) { + logger.warn(`[getAllTasks] worker slow ms=${ms} diag=${JSON.stringify(diag)}`); + } + return tasks; + } catch (error) { + logger.warn( + `[getAllTasks] worker failed: ${error instanceof Error ? error.message : String(error)}` + ); + // fall back + } + } + const tasksBase = getTasksBasePath(); let entries: fs.Dirent[]; @@ -260,6 +303,7 @@ export class TeamTaskReader { } const result: (TeamTask & { teamName: string })[] = []; + let dirCount = 0; for (const entry of entries) { if (!entry.isDirectory()) continue; try { @@ -270,6 +314,11 @@ export class TeamTaskReader { } catch { logger.debug(`Skipping tasks dir: ${entry.name}`); } + dirCount++; + if (dirCount % 2 === 0) { + // Yield periodically to keep the main process responsive in worst-case directories. + await yieldToEventLoop(); + } } return result; diff --git a/src/main/types/domain.ts b/src/main/types/domain.ts index 6f7342b7..b9600e24 100644 --- a/src/main/types/domain.ts +++ b/src/main/types/domain.ts @@ -51,8 +51,13 @@ export interface Project { path: string; /** Display name (last path segment) */ name: string; - /** List of session IDs (JSONL filenames without extension) */ + /** + * List of session IDs (JSONL filenames without extension). + * Note: this list may be truncated for performance; use totalSessions for counts. + */ sessions: string[]; + /** Total session count (may exceed sessions.length if sessions list is truncated) */ + totalSessions?: number; /** Unix timestamp when project directory was created */ createdAt: number; /** Unix timestamp of most recent session activity */ @@ -184,8 +189,13 @@ export interface Worktree { isMainWorktree: boolean; /** Worktree source for badge display (vibe-kanban, conductor, etc.) */ source: WorktreeSource; - /** List of session IDs */ + /** + * List of session IDs. + * Note: this list may be truncated for performance; use totalSessions for counts. + */ sessions: string[]; + /** Total session count (may exceed sessions.length if sessions list is truncated) */ + totalSessions?: number; /** Unix timestamp when first session was created */ createdAt: number; /** Unix timestamp of most recent session activity */ diff --git a/src/main/utils/asyncYield.ts b/src/main/utils/asyncYield.ts new file mode 100644 index 00000000..c7d8492e --- /dev/null +++ b/src/main/utils/asyncYield.ts @@ -0,0 +1,3 @@ +export function yieldToEventLoop(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 60297c7f..653f7755 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -27,6 +27,8 @@ import { type ToolCall, } from '../types'; +import { yieldToEventLoop } from './asyncYield'; +import { extractFirstUserMessagePreview } from './metadataExtraction'; // Import from extracted modules import { extractToolCalls, extractToolResults } from './toolExtraction'; @@ -65,6 +67,7 @@ export async function parseJsonlFile( crlfDelay: Infinity, }); + let lineCount = 0; for await (const line of rl) { if (!line.trim()) continue; @@ -76,6 +79,11 @@ export async function parseJsonlFile( } catch (error) { logger.error(`Error parsing line in ${filePath}:`, error); } + + lineCount++; + if (lineCount % 250 === 0) { + await yieldToEventLoop(); + } } return messages; @@ -344,6 +352,41 @@ export async function analyzeSessionFileMetadata( }; } + const MAX_DEEP_SCAN_BYTES = 50 * 1024 * 1024; // 50MB + try { + const stat = await fsProvider.stat(filePath); + if (!stat.isFile()) { + return { + firstUserMessage: null, + messageCount: 0, + isOngoing: false, + gitBranch: null, + }; + } + if (stat.size > MAX_DEEP_SCAN_BYTES) { + // Too large for deep scan — avoid blocking main/renderer. + // Prefer a best-effort preview from the head (already size/time bounded). + try { + const preview = await extractFirstUserMessagePreview(filePath, fsProvider); + return { + firstUserMessage: preview, + messageCount: 0, + isOngoing: false, + gitBranch: null, + }; + } catch { + return { + firstUserMessage: null, + messageCount: 0, + isOngoing: false, + gitBranch: null, + }; + } + } + } catch { + // best effort — proceed to scan + } + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: fileStream, @@ -371,11 +414,16 @@ export async function analyzeSessionFileMetadata( let awaitingPostCompaction = false; + let lineCount = 0; for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) { continue; } + lineCount++; + if (lineCount % 250 === 0) { + await yieldToEventLoop(); + } let entry: ChatHistoryEntry; try { diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index 8281d9bc..5b717093 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -15,12 +15,20 @@ const logger = createLogger('Util:metadataExtraction'); const defaultProvider = new LocalFileSystemProvider(); +const JSONL_HEAD_TIMEOUT_MS = 2000; +const JSONL_HEAD_MAX_BYTES = 256 * 1024; +const JSONL_HEAD_MAX_LINES = 400; + interface MessagePreview { text: string; timestamp: string; isCommand: boolean; } +function byteLen(chunk: string): number { + return Buffer.byteLength(chunk, 'utf8'); +} + /** * Extract CWD (current working directory) from the first entry. * Used to get the actual project path from encoded directory names. @@ -33,17 +41,47 @@ export async function extractCwd( return null; } + try { + const stat = await fsProvider.stat(filePath); + if (!stat.isFile()) { + return null; + } + } catch { + return null; + } + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); + let bytes = 0; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + fileStream.destroy(); + }, JSONL_HEAD_TIMEOUT_MS); + fileStream.on('data', (chunk: string) => { + bytes += byteLen(chunk); + if (bytes > JSONL_HEAD_MAX_BYTES) { + fileStream.destroy(); + } + }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, }); try { + let lines = 0; for await (const line of rl) { + if (++lines > JSONL_HEAD_MAX_LINES) { + break; + } if (!line.trim()) continue; - const entry = JSON.parse(line) as ChatHistoryEntry; + let entry: ChatHistoryEntry; + try { + entry = JSON.parse(line) as ChatHistoryEntry; + } catch { + continue; + } // Only conversational entries have cwd if ('cwd' in entry && entry.cwd) { rl.close(); @@ -52,8 +90,11 @@ export async function extractCwd( } } } catch (error) { - logger.error(`Error extracting cwd from ${filePath}:`, error); + if (!timedOut) { + logger.debug(`Error extracting cwd from ${filePath}:`, error); + } } finally { + clearTimeout(timer); rl.close(); fileStream.destroy(); } @@ -71,7 +112,28 @@ export async function extractFirstUserMessagePreview( maxLines: number = 200 ): Promise<{ text: string; timestamp: string } | null> { const safeMaxLines = Math.max(1, maxLines); + try { + const stat = await fsProvider.stat(filePath); + if (!stat.isFile()) { + return null; + } + } catch { + return null; + } + const fileStream = fsProvider.createReadStream(filePath, { encoding: 'utf8' }); + let bytes = 0; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + fileStream.destroy(); + }, JSONL_HEAD_TIMEOUT_MS); + fileStream.on('data', (chunk: string) => { + bytes += byteLen(chunk); + if (bytes > JSONL_HEAD_MAX_BYTES) { + fileStream.destroy(); + } + }); const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity, @@ -114,9 +176,12 @@ export async function extractFirstUserMessagePreview( } } } catch (error) { - logger.debug(`Error extracting first user preview from ${filePath}:`, error); - throw error; + if (!timedOut) { + logger.debug(`Error extracting first user preview from ${filePath}:`, error); + } + return commandFallback; } finally { + clearTimeout(timer); rl.close(); fileStream.destroy(); } @@ -192,5 +257,5 @@ function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null { function extractCommandName(content: string): string { const commandMatch = /\/([^<]+)<\/command-name>/.exec(content); - return commandMatch ? `/${commandMatch[1]}` : '/command'; + return commandMatch?.[1] ? `/${commandMatch[1]}` : '/command'; } diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts new file mode 100644 index 00000000..68069a50 --- /dev/null +++ b/src/main/workers/team-fs-worker.ts @@ -0,0 +1,514 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parentPort } from 'node:worker_threads'; + +interface ListTeamsPayload { + teamsDir: string; + largeConfigBytes: number; + configHeadBytes: number; + maxConfigBytes: number; + maxConfigReadMs: number; + maxMembersMetaBytes: number; + maxSessionHistoryInSummary: number; + maxProjectPathHistoryInSummary: number; + concurrency: number; +} + +interface GetAllTasksPayload { + tasksBase: string; + maxTaskBytes: number; + maxTaskReadMs: number; + concurrency: number; +} + +type WorkerRequest = + | { id: string; op: 'listTeams'; payload: ListTeamsPayload } + | { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload }; + +type WorkerResponse = + | { id: string; ok: true; result: unknown; diag?: unknown } + | { id: string; ok: false; error: string }; + +function isAbortError(error: unknown): boolean { + return ( + !!error && + typeof error === 'object' && + 'name' in error && + (error as { name?: unknown }).name === 'AbortError' + ); +} + +async function readFileUtf8WithTimeout(filePath: string, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fs.promises.readFile(filePath, { encoding: 'utf8', signal: controller.signal }); + } catch (error) { + if (isAbortError(error)) { + const err = new Error('READ_TIMEOUT'); + (err as NodeJS.ErrnoException).code = 'READ_TIMEOUT'; + throw err; + } + throw error; + } finally { + clearTimeout(timeoutId); + } +} + +async function readFileHeadUtf8(filePath: string, maxBytes: number): Promise { + const handle = await fs.promises.open(filePath, 'r'); + try { + const stat = await handle.stat(); + const bytesToRead = Math.max(0, Math.min(stat.size, maxBytes)); + if (bytesToRead === 0) return ''; + const buffer = Buffer.alloc(bytesToRead); + await handle.read(buffer, 0, bytesToRead, 0); + return buffer.toString('utf8'); + } finally { + await handle.close(); + } +} + +function extractQuotedString(head: string, key: string): string | null { + const re = new RegExp(`"${key}"\\s*:\\s*("(?:\\\\.|[^"\\\\])*")`); + const match = head.match(re); + if (!match?.[1]) return null; + try { + const value = JSON.parse(match[1]) as unknown; + return typeof value === 'string' ? value : null; + } catch { + return null; + } +} + +async function mapLimit( + items: readonly T[], + limit: number, + fn: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let index = 0; + const workerCount = Math.max(1, Math.min(limit, items.length)); + const workers = new Array(workerCount).fill(0).map(async () => { + while (true) { + const i = index++; + if (i >= items.length) return; + results[i] = await fn(items[i]); + } + }); + await Promise.all(workers); + return results; +} + +function nowMs(): number { + return Date.now(); +} + +async function listTeams(payload: ListTeamsPayload): Promise<{ teams: unknown[]; diag: unknown }> { + const startedAt = nowMs(); + const diag: any = { + op: 'listTeams', + startedAt, + teamsDir: payload.teamsDir, + totalDirs: 0, + returned: 0, + skipped: 0, + skipReasons: {}, + slowest: [], + totalMs: 0, + }; + + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(payload.teamsDir, { withFileTypes: true }); + } catch { + diag.totalMs = nowMs() - startedAt; + return { teams: [], diag }; + } + + const teamDirs = entries.filter((e) => e.isDirectory()); + diag.totalDirs = teamDirs.length; + + const perTeam = await mapLimit(teamDirs, payload.concurrency, async (entry) => { + const teamName = entry.name; + const t0 = nowMs(); + const configPath = path.join(payload.teamsDir, teamName, 'config.json'); + + const skip = (reason: string): null => { + diag.skipped++; + diag.skipReasons[reason] = (diag.skipReasons[reason] || 0) + 1; + return null; + }; + + let stat: fs.Stats; + try { + stat = await fs.promises.stat(configPath); + } catch { + return skip('config_stat_failed'); + } + if (!stat.isFile()) return skip('config_not_file'); + if (stat.size > payload.maxConfigBytes) return skip('config_too_large'); + + let config: any = null; + let displayName: string | null = null; + let description = ''; + let color: string | undefined; + let projectPath: string | undefined; + let leadSessionId: string | undefined; + let deletedAt: string | undefined; + let projectPathHistory: string[] | undefined; + let sessionHistory: string[] | undefined; + + try { + if (stat.size > payload.largeConfigBytes) { + const head = await readFileHeadUtf8(configPath, payload.configHeadBytes); + displayName = extractQuotedString(head, 'name'); + const desc = extractQuotedString(head, 'description'); + description = typeof desc === 'string' ? desc : ''; + const c = extractQuotedString(head, 'color'); + color = typeof c === 'string' && c.trim().length > 0 ? c : undefined; + const pp = extractQuotedString(head, 'projectPath'); + projectPath = typeof pp === 'string' && pp.trim().length > 0 ? pp : undefined; + const lead = extractQuotedString(head, 'leadSessionId'); + leadSessionId = typeof lead === 'string' && lead.trim().length > 0 ? lead : undefined; + const del = extractQuotedString(head, 'deletedAt'); + deletedAt = typeof del === 'string' ? del : undefined; + } else { + const raw = await readFileUtf8WithTimeout(configPath, payload.maxConfigReadMs); + config = JSON.parse(raw); + displayName = typeof config.name === 'string' ? config.name : null; + description = typeof config.description === 'string' ? config.description : ''; + color = + typeof config.color === 'string' && config.color.trim().length > 0 + ? config.color + : undefined; + projectPath = + typeof config.projectPath === 'string' && config.projectPath.trim().length > 0 + ? config.projectPath + : undefined; + leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId + : undefined; + projectPathHistory = Array.isArray(config.projectPathHistory) + ? config.projectPathHistory.slice(-payload.maxProjectPathHistoryInSummary) + : undefined; + sessionHistory = Array.isArray(config.sessionHistory) + ? config.sessionHistory.slice(-payload.maxSessionHistoryInSummary) + : undefined; + deletedAt = typeof config.deletedAt === 'string' ? config.deletedAt : undefined; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'READ_TIMEOUT') return skip('config_read_timeout'); + return skip('config_parse_failed'); + } + + if (typeof displayName !== 'string' || displayName.trim() === '') { + return skip('invalid_display_name'); + } + + const memberMap = new Map(); + const mergeMember = (m: any): void => { + const name = typeof m?.name === 'string' ? m.name.trim() : ''; + if (!name) return; + const key = name.toLowerCase(); + const existing = memberMap.get(key); + memberMap.set(key, { + name: existing?.name ?? name, + role: (typeof m.role === 'string' && m.role.trim()) || existing?.role, + color: (typeof m.color === 'string' && m.color.trim()) || existing?.color, + }); + }; + + if (config && Array.isArray(config.members)) { + for (const member of config.members) { + mergeMember(member); + } + } + + try { + const metaPath = path.join(payload.teamsDir, teamName, 'members.meta.json'); + const metaStat = await fs.promises.stat(metaPath); + if (metaStat.isFile() && metaStat.size <= payload.maxMembersMetaBytes) { + const raw = await readFileUtf8WithTimeout(metaPath, payload.maxConfigReadMs); + const parsed = JSON.parse(raw); + const members: any[] = Array.isArray(parsed?.members) ? parsed.members : []; + for (const member of members) { + if (member && typeof member === 'object' && !member.removedAt) { + mergeMember(member); + } + } + } + } catch { + // ignore + } + + const members = Array.from(memberMap.values()); + const summary = { + teamName, + displayName, + description, + memberCount: memberMap.size, + taskCount: 0, + lastActivity: null, + ...(members.length > 0 ? { members } : {}), + ...(color ? { color } : {}), + ...(projectPath ? { projectPath } : {}), + ...(leadSessionId ? { leadSessionId } : {}), + ...(projectPathHistory ? { projectPathHistory } : {}), + ...(sessionHistory ? { sessionHistory } : {}), + ...(deletedAt ? { deletedAt } : {}), + }; + + const ms = nowMs() - t0; + if (ms >= 250) { + diag.slowest.push({ teamName, ms }); + diag.slowest.sort((a: any, b: any) => b.ms - a.ms); + if (diag.slowest.length > 10) diag.slowest.length = 10; + } + return summary; + }); + + const teams = perTeam.filter((t): t is NonNullable => t !== null); + diag.returned = teams.length; + diag.totalMs = nowMs() - startedAt; + return { teams, diag }; +} + +function normalizeWorkIntervals( + parsed: any +): { startedAt: string; completedAt?: string }[] | undefined { + if (!Array.isArray(parsed?.workIntervals)) return undefined; + return (parsed.workIntervals as unknown[]) + .filter( + (i): i is { startedAt: string; completedAt?: string } => + Boolean(i) && + typeof i === 'object' && + typeof (i as any).startedAt === 'string' && + ((i as any).completedAt === undefined || typeof (i as any).completedAt === 'string') + ) + .map((i) => ({ startedAt: i.startedAt, completedAt: i.completedAt })); +} + +function normalizeComments(parsed: any): unknown[] | undefined { + if (!Array.isArray(parsed?.comments)) return undefined; + return (parsed.comments as unknown[]) + .filter( + (c) => + c && + typeof c === 'object' && + typeof (c as any).id === 'string' && + typeof (c as any).author === 'string' && + typeof (c as any).text === 'string' && + typeof (c as any).createdAt === 'string' + ) + .map((c) => ({ + id: (c as any).id, + author: (c as any).author, + text: (c as any).text, + createdAt: (c as any).createdAt, + type: + (c as any).type === 'regular' || + (c as any).type === 'review_request' || + (c as any).type === 'review_approved' + ? (c as any).type + : 'regular', + })); +} + +async function readTasksDirForTeam( + tasksDir: string, + teamName: string, + payload: GetAllTasksPayload, + diag: any +): Promise { + let entries: string[]; + try { + entries = await fs.promises.readdir(tasksDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return []; + } + throw error; + } + + const tasks: unknown[] = []; + for (const file of entries) { + if ( + !file.endsWith('.json') || + file.startsWith('.') || + file === '.lock' || + file === '.highwatermark' + ) { + continue; + } + + const taskPath = path.join(tasksDir, file); + try { + const stat = await fs.promises.stat(taskPath); + if (!stat.isFile() || stat.size > payload.maxTaskBytes) { + diag.skipped++; + diag.skipReasons.task_not_file_or_large = + (diag.skipReasons.task_not_file_or_large || 0) + 1; + continue; + } + + const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs); + const parsed = JSON.parse(raw); + const metadata = parsed?.metadata; + if (metadata?._internal === true) { + diag.skipped++; + diag.skipReasons.task_internal = (diag.skipReasons.task_internal || 0) + 1; + continue; + } + if (parsed?.status === 'deleted') { + diag.skipped++; + diag.skipReasons.task_deleted = (diag.skipReasons.task_deleted || 0) + 1; + continue; + } + + const subject = + typeof parsed.subject === 'string' + ? parsed.subject + : typeof parsed.title === 'string' + ? parsed.title + : ''; + + let createdAt: string | undefined = + typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined; + let updatedAt: string | undefined; + try { + if (!createdAt) { + const bt = stat.birthtime.getTime(); + createdAt = (bt > 0 ? stat.birthtime : stat.mtime).toISOString(); + } + updatedAt = stat.mtime.toISOString(); + } catch { + /* ignore */ + } + + const needsClarification = + parsed.needsClarification === 'lead' || parsed.needsClarification === 'user' + ? parsed.needsClarification + : undefined; + + tasks.push({ + id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', + subject, + description: typeof parsed.description === 'string' ? parsed.description : undefined, + activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, + owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, + createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, + status: + parsed.status === 'pending' || + parsed.status === 'in_progress' || + parsed.status === 'completed' || + parsed.status === 'deleted' + ? parsed.status + : 'pending', + workIntervals: normalizeWorkIntervals(parsed), + blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined, + blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined, + related: Array.isArray(parsed.related) + ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, + createdAt, + updatedAt, + projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined, + comments: normalizeComments(parsed), + needsClarification, + deletedAt: undefined, + teamName, + }); + } catch (error) { + diag.skipped++; + const code = (error as NodeJS.ErrnoException).code; + if (code === 'READ_TIMEOUT') { + diag.skipReasons.task_read_timeout = (diag.skipReasons.task_read_timeout || 0) + 1; + } else { + diag.skipReasons.task_parse_failed = (diag.skipReasons.task_parse_failed || 0) + 1; + } + } + } + return tasks; +} + +async function getAllTasks( + payload: GetAllTasksPayload +): Promise<{ tasks: unknown[]; diag: unknown }> { + const startedAt = nowMs(); + const diag: any = { + op: 'getAllTasks', + startedAt, + tasksBase: payload.tasksBase, + teamDirs: 0, + returned: 0, + skipped: 0, + skipReasons: {}, + slowestTeams: [], + totalMs: 0, + }; + + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(payload.tasksBase, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + diag.totalMs = nowMs() - startedAt; + return { tasks: [], diag }; + } + throw error; + } + + const dirs = entries.filter((e) => e.isDirectory()); + diag.teamDirs = dirs.length; + + const chunks = await mapLimit(dirs, payload.concurrency, async (entry) => { + const teamName = entry.name; + const t0 = nowMs(); + try { + const tasksDir = path.join(payload.tasksBase, teamName); + const tasks = await readTasksDirForTeam(tasksDir, teamName, payload, diag); + const ms = nowMs() - t0; + if (ms >= 250) { + diag.slowestTeams.push({ teamName, ms }); + diag.slowestTeams.sort((a: any, b: any) => b.ms - a.ms); + if (diag.slowestTeams.length > 10) diag.slowestTeams.length = 10; + } + return tasks; + } catch { + diag.skipped++; + diag.skipReasons.team_dir_failed = (diag.skipReasons.team_dir_failed || 0) + 1; + return []; + } + }); + + const tasks = chunks.flat(); + diag.returned = tasks.length; + diag.totalMs = nowMs() - startedAt; + return { tasks, diag }; +} + +function post(msg: WorkerResponse): void { + parentPort?.postMessage(msg); +} + +parentPort?.on('message', async (msg: WorkerRequest) => { + const { id, op } = msg; + try { + if (op === 'listTeams') { + const { teams, diag } = await listTeams(msg.payload); + post({ id, ok: true, result: teams, diag }); + return; + } + if (op === 'getAllTasks') { + const { tasks, diag } = await getAllTasks(msg.payload); + post({ id, ok: true, result: tasks, diag }); + return; + } + post({ id, ok: false, error: `Unknown op: ${String((msg as any).op)}` }); + } catch (error) { + post({ id, ok: false, error: error instanceof Error ? error.message : String(error) }); + } +}); diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index fc26df0b..0b823901 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -4,6 +4,19 @@ * Centralized IPC channel names to avoid string duplication in preload bridge. */ +// ============================================================================= +// Diagnostics / Logging Channels +// ============================================================================= + +/** Renderer -> main log forwarding (filtered in preload) */ +export const RENDERER_LOG = 'renderer:log'; + +/** Renderer -> main lifecycle signal (preload executed) */ +export const RENDERER_BOOT = 'renderer:boot'; + +/** Renderer -> main heartbeat (detect renderer stalls) */ +export const RENDERER_HEARTBEAT = 'renderer:heartbeat'; + // ============================================================================= // Config API Channels // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index 0bc646dc..069fc45a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -32,6 +32,9 @@ import { HTTP_SERVER_START, HTTP_SERVER_STOP, PROJECT_LIST_FILES, + RENDERER_BOOT, + RENDERER_HEARTBEAT, + RENDERER_LOG, REVIEW_APPLY_DECISIONS, REVIEW_CHECK_CONFLICT, REVIEW_CLEAR_DECISIONS, @@ -246,6 +249,63 @@ async function invokeIpcWithResult(channel: string, ...args: unknown[]): Prom return result.data as T; } +function formatConsoleArg(arg: unknown): string { + if (typeof arg === 'string') return arg; + if (arg instanceof Error) return arg.stack ?? arg.message; + try { + return JSON.stringify(arg); + } catch { + return String(arg); + } +} + +function shouldForwardConsoleText(text: string): boolean { + return ( + text.startsWith('[Store:') || + text.startsWith('[Component:') || + text.startsWith('[IPC:') || + text.startsWith('[Service:') || + text.startsWith('[Perf:') + ); +} + +function installRendererLogForwarding(): void { + const originalWarn = console.warn.bind(console); + const originalError = console.error.bind(console); + + console.warn = (...args: unknown[]): void => { + originalWarn(...args); + try { + const text = args.map(formatConsoleArg).join(' ').trim(); + if (!text || !shouldForwardConsoleText(text)) return; + ipcRenderer.send(RENDERER_LOG, { level: 'warn', message: text }); + } catch { + // ignore + } + }; + + console.error = (...args: unknown[]): void => { + originalError(...args); + try { + const text = args.map(formatConsoleArg).join(' ').trim(); + if (!text || !shouldForwardConsoleText(text)) return; + ipcRenderer.send(RENDERER_LOG, { level: 'error', message: text }); + } catch { + // ignore + } + }; +} + +installRendererLogForwarding(); + +// Signal that preload executed (helps diagnose "UI stuck" with no logs). +ipcRenderer.send(RENDERER_BOOT); + +// Heartbeat to detect renderer thread stalls. +setInterval(() => { + ipcRenderer.send(RENDERER_HEARTBEAT, Date.now()); +}, 1000); + // Keep latest zoom factor cached even before renderer UI subscribes. let currentZoomFactor = 1; ipcRenderer.on( diff --git a/src/renderer/components/chat/ChatHistoryItem.tsx b/src/renderer/components/chat/ChatHistoryItem.tsx index 60238de8..03342f50 100644 --- a/src/renderer/components/chat/ChatHistoryItem.tsx +++ b/src/renderer/components/chat/ChatHistoryItem.tsx @@ -62,6 +62,7 @@ const ChatHistoryItemInner = ({ registerToolRef, }: ChatHistoryItemProps): JSX.Element | null => { const enterClass = isNew ? 'chat-message-enter-animate' : ''; + const transitionStyle: React.CSSProperties = { transitionDuration: '3000ms' }; switch (item.type) { case 'user': { @@ -75,8 +76,8 @@ const ChatHistoryItemInner = ({ return (
@@ -93,8 +94,8 @@ const ChatHistoryItemInner = ({ return (
@@ -115,8 +116,8 @@ const ChatHistoryItemInner = ({ return (
{ useEffect(() => { if (!isElectron) return; - - void fetchCliStatus(); + // IMPORTANT: do NOT auto-fetch on mount. + // Store initialization already schedules a deferred CLI status check to avoid + // competing with initial teams/tasks/project scans. + // Keep a low-frequency refresh, but only after we've successfully loaded a status. + if (!cliStatus) { + return; + } const interval = setInterval( () => { @@ -142,7 +147,7 @@ export const CliStatusBanner = (): React.JSX.Element | null => { ); return () => clearInterval(interval); - }, [isElectron, fetchCliStatus]); + }, [isElectron, cliStatus, fetchCliStatus]); const handleInstall = useCallback(() => { installCli(); @@ -200,7 +205,31 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
); } - // Still loading or initial render + + // If we aren't currently loading, avoid showing a "stuck" spinner. + // The initial CLI status check is deferred; allow user to trigger manually. + if (!cliStatusLoading) { + return ( +
+ + Claude CLI status will be checked in the background. + + +
+ ); + } + + // Loading state: show spinner only while an actual request is in-flight. return (
{ isMainWorktree: true, source: 'unknown', sessions: [], + totalSessions: 0, createdAt: now, }, ], @@ -481,6 +482,7 @@ const ProjectsGrid = ({ const { repositoryGroups, repositoryGroupsLoading, + repositoryGroupsError, fetchRepositoryGroups, selectRepository, globalTasks, @@ -491,6 +493,7 @@ const ProjectsGrid = ({ useShallow((s) => ({ repositoryGroups: s.repositoryGroups, repositoryGroupsLoading: s.repositoryGroupsLoading, + repositoryGroupsError: s.repositoryGroupsError, fetchRepositoryGroups: s.fetchRepositoryGroups, selectRepository: s.selectRepository, globalTasks: s.globalTasks, @@ -503,14 +506,17 @@ const ProjectsGrid = ({ const hasFetchedTasksRef = React.useRef(false); useEffect(() => { - if (repositoryGroups.length === 0) { + if (repositoryGroups.length === 0 && !repositoryGroupsLoading) { void fetchRepositoryGroups(); } - if (!hasFetchedTasksRef.current) { + }, [repositoryGroups.length, repositoryGroupsLoading, fetchRepositoryGroups]); + + useEffect(() => { + if (repositoryGroups.length > 0 && !hasFetchedTasksRef.current && !repositoryGroupsLoading) { hasFetchedTasksRef.current = true; void fetchAllTasks(); } - }, [repositoryGroups.length, fetchRepositoryGroups, fetchAllTasks]); + }, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]); const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]); @@ -587,6 +593,26 @@ const ProjectsGrid = ({ ); } + if (repositoryGroupsError && repositoryGroups.length === 0) { + return ( +
+
+ +
+
+

Failed to load projects

+

{repositoryGroupsError}

+
+ +
+ ); + } + if (filteredRepos.length === 0 && searchQuery.trim()) { return (
diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 6d09260b..06afc588 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -129,7 +129,7 @@ const WorktreeItem = ({ {truncateMiddle(worktree.name, 28)} - {worktree.sessions.length} + {worktree.totalSessions ?? worktree.sessions.length} {isSelected && } @@ -263,12 +263,13 @@ export const DateGroupedSessions = (): React.JSX.Element => { const items = viewMode === 'grouped' ? repositoryGroups.filter((r) => r.totalSessions > 0) - : projects.filter((p) => p.sessions.length > 0); + : projects.filter((p) => (p.totalSessions ?? p.sessions.length) > 0); return items.map((item) => { const sessionCount = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).totalSessions - : (item as (typeof projects)[0]).sessions.length; + : ((item as (typeof projects)[0]).totalSessions ?? + (item as (typeof projects)[0]).sessions.length); const path = viewMode === 'grouped' ? (item as (typeof repositoryGroups)[0]).worktrees[0]?.path @@ -292,7 +293,9 @@ export const DateGroupedSessions = (): React.JSX.Element => { // Worktree state const activeRepo = repositoryGroups.find((r) => r.id === selectedRepositoryId); const activeWorktree = activeRepo?.worktrees.find((w) => w.id === selectedWorktreeId); - const worktrees = (activeRepo?.worktrees ?? []).filter((w) => w.sessions.length > 0); + const worktrees = (activeRepo?.worktrees ?? []).filter( + (w) => (w.totalSessions ?? w.sessions.length) > 0 + ); const hasMultipleWorktrees = worktrees.length > 1; const worktreeGroupingResult = useMemo(() => groupWorktreesBySource(worktrees), [worktrees]); const mainWorktree = worktreeGroupingResult.mainWorktree; diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 3dd7d3b5..be34b50e 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -151,7 +151,7 @@ export const GlobalTaskList = ({ path: r.worktrees[0]?.path, })) : projects - .filter((p) => p.sessions.length > 0) + .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) .map((p) => ({ value: p.path, label: p.name, diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 3bfdd653..c2199a12 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -174,6 +174,7 @@ export const LaunchTeamDialog = ({ path: wt.path, name: wt.name, sessions: [], + totalSessions: 0, createdAt: wt.createdAt ?? Date.now(), }); } diff --git a/src/renderer/components/team/review/ChangeReviewDialog.tsx b/src/renderer/components/team/review/ChangeReviewDialog.tsx index 7757a8d7..2e6ada0a 100644 --- a/src/renderer/components/team/review/ChangeReviewDialog.tsx +++ b/src/renderer/components/team/review/ChangeReviewDialog.tsx @@ -200,7 +200,20 @@ export const ChangeReviewDialog = ({ rejectAllChunks(view); } }); - }, [activeChangeSet, rejectAllFile, pushReviewUndoSnapshot]); + if (REVIEW_INSTANT_APPLY) { + // In instant-apply mode we don't show an "Apply" button, so bulk reject must + // be applied immediately to match Cursor-like UX (including deleting new files). + void applyReview(teamName, taskId, memberName); + } + }, [ + activeChangeSet, + rejectAllFile, + pushReviewUndoSnapshot, + applyReview, + teamName, + taskId, + memberName, + ]); // Per-file callbacks for ContinuousScrollView const handleHunkAccepted = useCallback( diff --git a/src/renderer/main.tsx b/src/renderer/main.tsx index 63e6504d..fa7a74d0 100644 --- a/src/renderer/main.tsx +++ b/src/renderer/main.tsx @@ -17,6 +17,10 @@ declare global { // module-level side effect guarded by a global flag. if (!window.__claudeTeamsUiDidInit) { window.__claudeTeamsUiDidInit = true; + if (import.meta.env.DEV) { + // Intentionally console.warn so it shows up in main terminal via preload forwarding. + console.warn('[Perf:Renderer] boot renderer/main.tsx'); + } initializeNotificationListeners(); } diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index b300c95a..29a02126 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -81,20 +81,43 @@ export function initializeNotificationListeners(): () => void { // Components also fire these from useEffect — loading guards in each action // prevent duplicate IPC calls (whichever caller starts first wins). void (async () => { + const isDev = import.meta.env.DEV; + const log = (msg: string): void => { + if (!isDev) return; + console.warn(`[Perf:Renderer] init ${msg}`); + }; + const startedAt = Date.now(); + // Config: fast (in-memory read) — needed for theme before first paint. + log('fetchConfig:start'); + const configStartedAt = Date.now(); await useStore.getState().fetchConfig(); + log(`fetchConfig:done ms=${Date.now() - configStartedAt}`); + // Remaining fetches have no data dependency on each other — run in parallel // to avoid blocking teams/notifications behind a slow repository scan. + const run = async (label: string, fn: () => Promise): Promise => { + log(`${label}:start`); + const s = Date.now(); + try { + await fn(); + log(`${label}:done ms=${Date.now() - s}`); + } catch (e) { + log( + `${label}:error ms=${Date.now() - s} msg=${e instanceof Error ? e.message : String(e)}` + ); + throw e; + } + }; + await Promise.all([ - // Repository groups: heavy — full project directory scan for sidebar. - useStore.getState().fetchRepositoryGroups(), - // Global tasks: moderate — reads team task files for sidebar. - useStore.getState().fetchAllTasks(), - // Team summaries: moderate — reads team config files. - useStore.getState().fetchTeams(), - // Notification count: light — reads from in-memory store in main process. - useStore.getState().fetchNotifications(), + run('fetchRepositoryGroups', () => useStore.getState().fetchRepositoryGroups()), + run('fetchAllTasks', () => useStore.getState().fetchAllTasks()), + run('fetchTeams', () => useStore.getState().fetchTeams()), + run('fetchNotifications', () => useStore.getState().fetchNotifications()), ]); + + log(`init:done ms=${Date.now() - startedAt}`); })(); // CLI status check is non-critical for initial render (spawns child processes diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index e66a8294..92b25fdb 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -45,6 +45,8 @@ export interface CliInstallerSlice { installCli: () => void; } +let cliStatusInFlight: Promise | null = null; + // ============================================================================= // Slice Creator // ============================================================================= @@ -67,17 +69,25 @@ export const createCliInstallerSlice: StateCreator { - set({ cliStatusLoading: true, cliStatusError: null }); - try { - const status = await api.cliInstaller.getStatus(); - set({ cliStatus: status }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to check CLI status'; - logger.error('Failed to fetch CLI status:', error); - set({ cliStatusError: message }); - } finally { - set({ cliStatusLoading: false }); - } + if (!api.cliInstaller) return; + if (cliStatusInFlight) return cliStatusInFlight; + + cliStatusInFlight = (async () => { + set({ cliStatusLoading: true, cliStatusError: null }); + try { + const status = await api.cliInstaller.getStatus(); + set({ cliStatus: status }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to check CLI status'; + logger.error('Failed to fetch CLI status:', error); + set({ cliStatusError: message }); + } finally { + set({ cliStatusLoading: false }); + cliStatusInFlight = null; + } + })(); + + return cliStatusInFlight; }, installCli: () => { diff --git a/src/renderer/store/slices/repositorySlice.ts b/src/renderer/store/slices/repositorySlice.ts index d133928a..c27e7e7c 100644 --- a/src/renderer/store/slices/repositorySlice.ts +++ b/src/renderer/store/slices/repositorySlice.ts @@ -12,6 +12,22 @@ import type { RepositoryGroup } from '@renderer/types/data'; import type { StateCreator } from 'zustand'; const logger = createLogger('Store:repository'); +const FETCH_REPOSITORY_GROUPS_TIMEOUT_MS = 30_000; + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} // ============================================================================= // Slice Interface @@ -53,12 +69,25 @@ export const createRepositorySlice: StateCreator { // Guard: prevent concurrent fetches (component mount + centralized init chain) if (get().repositoryGroupsLoading) return; + const startedAt = Date.now(); set({ repositoryGroupsLoading: true, repositoryGroupsError: null }); try { - const groups = await api.getRepositoryGroups(); + const groups = await withTimeout( + api.getRepositoryGroups(), + FETCH_REPOSITORY_GROUPS_TIMEOUT_MS, + 'get-repository-groups' + ); // Already sorted by most recent session in the scanner set({ repositoryGroups: groups, repositoryGroupsLoading: false }); + const ms = Date.now() - startedAt; + if (ms >= 2000) { + logger.warn(`fetchRepositoryGroups slow ms=${ms} count=${groups.length}`); + } } catch (error) { + const ms = Date.now() - startedAt; + logger.warn( + `fetchRepositoryGroups failed ms=${ms}: ${error instanceof Error ? error.message : String(error)}` + ); set({ repositoryGroupsError: error instanceof Error ? error.message : 'Failed to fetch repository groups', diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index dd041845..923dec27 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -361,6 +361,9 @@ export const createTeamSlice: StateCreator = (set, fetchAllTasks: async () => { // Guard: prevent concurrent fetches (component mount + centralized init chain) if (get().globalTasksLoading) return; + if (import.meta.env.DEV) { + console.warn('[Perf:Renderer] fetchAllTasks:enter'); + } // Show skeleton only on the very first fetch — not on subsequent refreshes // even when the task list is empty (avoids flickering skeleton on every watcher event). const isInitialLoad = !get().globalTasksInitialized; @@ -371,11 +374,17 @@ export const createTeamSlice: StateCreator = (set, const wasFirst = isFirstFetchAllTasks; isFirstFetchAllTasks = false; try { + if (import.meta.env.DEV) { + console.warn('[Perf:Renderer] fetchAllTasks:invoke'); + } const tasks = await withTimeout( unwrapIpc('team:getAllTasks', () => api.teams.getAllTasks()), TEAM_FETCH_TIMEOUT_MS, 'fetchAllTasks' ); + if (import.meta.env.DEV) { + console.warn(`[Perf:Renderer] fetchAllTasks:received count=${tasks.length}`); + } if (!wasFirst) { const notifyOnClarifications = @@ -396,6 +405,9 @@ export const createTeamSlice: StateCreator = (set, globalTasksInitialized: true, globalTasksError: null, }); + if (import.meta.env.DEV) { + console.warn('[Perf:Renderer] fetchAllTasks:setState:done'); + } } catch (error) { set({ globalTasksLoading: false, diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index f886fb5d..de937fa9 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -1,4 +1,4 @@ -import { getMemberColor } from '@shared/constants/memberColors'; +import { getMemberColor, MEMBER_COLOR_PALETTE } from '@shared/constants/memberColors'; import type { LeadActivityState, @@ -87,12 +87,20 @@ export function buildMemberColorMap(members: MemberColorInput[]): Map m.removedAt); const usedColors = new Set(); + const paletteSize = MEMBER_COLOR_PALETTE.length; let nextFallback = 0; for (const member of active) { let color = member.color; if (!color || usedColors.has(color)) { + // Search for an unused palette color, but cap iterations to avoid + // an infinite loop when there are more members than palette colors. + const searchStart = nextFallback; while (usedColors.has(getMemberColor(nextFallback))) { nextFallback++; + if (nextFallback - searchStart >= paletteSize) { + // All palette colors exhausted — reuse by cycling + break; + } } color = getMemberColor(nextFallback); nextFallback++; diff --git a/src/shared/constants/memberColors.ts b/src/shared/constants/memberColors.ts index 9830e2ce..79079fef 100644 --- a/src/shared/constants/memberColors.ts +++ b/src/shared/constants/memberColors.ts @@ -1,9 +1,91 @@ /** - * Default color palette for team members. - * Used during team creation and for preview in the UI. + * Default color palette for team members — 64 contrasting colors. + * Designed for high contrast on dark backgrounds. * Colors cycle by index: member[i] gets MEMBER_COLOR_PALETTE[i % length]. */ -export const MEMBER_COLOR_PALETTE = ['blue', 'green', 'yellow', 'cyan', 'purple', 'red'] as const; +export const MEMBER_COLOR_PALETTE = [ + // ── Primary & classic ── + 'blue', + 'green', + 'yellow', + 'cyan', + 'purple', + 'red', + 'orange', + 'pink', + + // ── Red family ── + 'rose', + 'coral', + 'crimson', + 'scarlet', + 'tomato', + 'salmon', + 'brick', + 'ruby', + + // ── Orange / warm family ── + 'amber', + 'tangerine', + 'peach', + 'rust', + 'copper', + 'apricot', + 'bronze', + 'sienna', + + // ── Yellow / gold family ── + 'gold', + 'lemon', + 'mustard', + 'honey', + 'saffron', + 'marigold', + 'canary', + 'sunflower', + + // ── Green family ── + 'emerald', + 'lime', + 'mint', + 'forest', + 'olive', + 'jade', + 'sage', + 'chartreuse', + + // ── Cyan / teal family ── + 'teal', + 'aqua', + 'turquoise', + 'sky', + 'azure', + 'cerulean', + 'seafoam', + 'arctic', + + // ── Blue / indigo family ── + 'cobalt', + 'indigo', + 'sapphire', + 'periwinkle', + 'denim', + 'steel', + 'royal', + 'cornflower', + + // ── Purple / pink family ── + 'violet', + 'plum', + 'amethyst', + 'lavender', + 'orchid', + 'magenta', + 'fuchsia', + 'berry', +] as const; + +export type MemberColorName = (typeof MEMBER_COLOR_PALETTE)[number]; export function getMemberColor(index: number): string { return MEMBER_COLOR_PALETTE[index % MEMBER_COLOR_PALETTE.length]; diff --git a/test/main/services/team/FileContentResolver.test.ts b/test/main/services/team/FileContentResolver.test.ts new file mode 100644 index 00000000..fb4ef25c --- /dev/null +++ b/test/main/services/team/FileContentResolver.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { SnippetDiff } from '@shared/types'; + +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + const access = vi.fn(); + const readFile = vi.fn(); + return { + ...actual, + access, + readFile, + // ESM interop: some code paths expect a default export + default: { ...actual, access, readFile }, + }; +}); + +describe('FileContentResolver', () => { + it('treats empty on-disk content as valid for write-new reconstruction', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + readFile.mockResolvedValue(''); + + const { FileContentResolver } = await import('@main/services/team/FileContentResolver'); + + const logsFinder = { + findMemberLogPaths: vi.fn().mockResolvedValue([]), + }; + + const resolver = new FileContentResolver(logsFinder as any); + + const snippets: SnippetDiff[] = [ + { + toolUseId: 't1', + filePath: '/tmp/empty-new.txt', + toolName: 'Write', + type: 'write-new', + oldString: '', + newString: '', + replaceAll: false, + timestamp: new Date().toISOString(), + isError: false, + }, + ]; + + const content = await resolver.getFileContent('team', 'member', '/tmp/empty-new.txt', snippets); + expect(content.isNewFile).toBe(true); + expect(content.originalFullContent).toBe(''); + expect(content.modifiedFullContent).toBe(''); + expect(content.contentSource).toBe('snippet-reconstruction'); + }); +}); diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index 21dba9bd..113f89b3 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -4,6 +4,21 @@ import { structuredPatch } from 'diff'; import type { SnippetDiff } from '@shared/types'; +vi.mock('fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + const readFile = vi.fn(); + const writeFile = vi.fn(); + const unlink = vi.fn(); + return { + ...actual, + readFile, + writeFile, + unlink, + // ESM interop: some code paths expect a default export + default: { ...actual, readFile, writeFile, unlink }, + }; +}); + describe('ReviewApplierService', () => { it('previewReject avoids write-update snippet-level replacement', async () => { const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); @@ -35,4 +50,65 @@ describe('ReviewApplierService', () => { expect(preview.hasConflicts).toBe(false); expect(preview.preview).toBe(original); }); + + it('deletes a newly created file when fully rejected', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + const writeFile = fsPromises.writeFile as unknown as ReturnType; + + readFile.mockResolvedValue('content\n'); + unlink.mockResolvedValue(undefined); + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + + const filePath = '/tmp/new-file.txt'; + const snippets: SnippetDiff[] = [ + { + toolUseId: 't1', + filePath, + toolName: 'Write', + type: 'write-new', + oldString: '', + newString: 'content\n', + replaceAll: false, + timestamp: new Date().toISOString(), + isError: false, + }, + ]; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [ + { + filePath, + fileDecision: 'rejected', + hunkDecisions: { 0: 'rejected' }, + }, + ], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'new-file.txt', + snippets, + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + originalFullContent: '', + modifiedFullContent: 'content\n', + contentSource: 'snippet-reconstruction', + }, + ], + ]) + ); + + expect(res.applied).toBe(1); + expect(unlink).toHaveBeenCalledWith(filePath); + expect(writeFile).not.toHaveBeenCalled(); + }); });