feat: enhance team file handling and logging improvements

- Added support for a new worker in the team file system to list teams, improving performance and reliability in team management.
- Introduced event loop lag monitoring in various IPC handlers to track and log slow operations, enhancing observability.
- Implemented caching for CLI installation status to reduce redundant calls and improve responsiveness.
- Updated project and team data services to include total session counts, optimizing data handling and performance.
- Enhanced error handling and logging across multiple services to provide clearer insights into performance issues and failures.

Made-with: Cursor
This commit is contained in:
iliya 2026-03-03 22:00:11 +02:00
parent 43b18d4920
commit a30727d3b0
41 changed files with 2011 additions and 147 deletions

View file

@ -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.

View file

@ -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,

View file

@ -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') {

View file

@ -22,6 +22,9 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:cliInstaller');
let service: CliInstallerService;
let statusInFlight: Promise<CliInstallationStatus> | 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<IpcResult<CliInstallationStatus>> {
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);

View file

@ -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);

View file

@ -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<Project[]> {
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<Project[]>
} 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<Project[]>
* Worktrees of the same repo are grouped together.
*/
async function handleGetRepositoryGroups(_event: IpcMainInvokeEvent): Promise<RepositoryGroup[]> {
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<Re
} catch (error) {
logger.error('Error in get-repository-groups:', error);
return [];
} finally {
clearTimeout(watchdog);
const ms = Date.now() - startedAt;
if (ms >= 2000) {
logger.warn(`get-repository-groups slow ms=${ms}`);
}
setCurrentMainOp(null);
}
}

View file

@ -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<number, number>();
const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
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);
}

View file

@ -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<IpcResult<TeamSummary[]>> {
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<IpcResult<GlobalTask[]>> {
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(

View file

@ -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);

View file

@ -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)

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(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<FsStatResult> {
const stats = await fs.promises.stat(filePath);
const stats = await Promise.race([
fs.promises.stat(filePath),
new Promise<fs.Stats>((_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<FsDirent[]> {
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<fs.Stats>((_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 {

View file

@ -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

View file

@ -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<TeamSummary[]> {
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<string, unknown>).skipReasons : null;
if (skipReasons && typeof skipReasons === 'object') {
const bad =
Number((skipReasons as Record<string, unknown>).config_parse_failed ?? 0) +
Number((skipReasons as Record<string, unknown>).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[];

View file

@ -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<typeof setInterval> | null = null;
@ -82,10 +84,8 @@ export class TeamDataService {
}
async getAllTasks(): Promise<GlobalTask[]> {
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'

View file

@ -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<string, unknown>;
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;
}

View file

@ -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<string, unknown>).skipReasons : null;
if (skipReasons && typeof skipReasons === 'object') {
const bad =
Number((skipReasons as Record<string, unknown>).task_parse_failed ?? 0) +
Number((skipReasons as Record<string, unknown>).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;

View file

@ -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 */

View file

@ -0,0 +1,3 @@
export function yieldToEventLoop(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}

View file

@ -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 {

View file

@ -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>\/([^<]+)<\/command-name>/.exec(content);
return commandMatch ? `/${commandMatch[1]}` : '/command';
return commandMatch?.[1] ? `/${commandMatch[1]}` : '/command';
}

View file

@ -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<string> {
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<string> {
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<T, R>(
items: readonly T[],
limit: number,
fn: (item: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(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<string, { name: string; role?: string; color?: string }>();
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<typeof t> => 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<unknown[]> {
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) });
}
});

View file

@ -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
// =============================================================================

View file

@ -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<T>(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(

View file

@ -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 (
<div
ref={registerChatItemRef(item.group.id)}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={hl.style}
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
>
<UserChatGroup userGroup={item.group} />
</div>
@ -93,8 +94,8 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerChatItemRef(item.group.id)}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={hl.style}
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
>
<SystemChatGroup systemGroup={item.group} />
</div>
@ -115,8 +116,8 @@ const ChatHistoryItemInner = ({
return (
<div
ref={registerAIGroupRef(item.group.id)}
className={`duration-[3000ms] rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={hl.style}
className={`rounded-lg transition-all ease-out ${hl.className} ${enterClass}`}
style={{ ...transitionStyle, ...(hl.style ?? {}) }}
>
<AIChatGroup
aiGroup={item.group}

View file

@ -131,8 +131,13 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
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 => {
</div>
);
}
// 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 (
<div
className="mb-6 flex items-center justify-between gap-3 rounded-lg border-l-4 px-4 py-3"
style={{ borderColor: styles.border, backgroundColor: styles.bg }}
>
<span className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Claude CLI status will be checked in the background.
</span>
<button
onClick={handleRefresh}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<RefreshCw className="size-3.5" />
Check now
</button>
</div>
);
}
// Loading state: show spinner only while an actual request is in-flight.
return (
<div
className="mb-6 flex items-center gap-3 rounded-lg border-l-4 px-4 py-3"

View file

@ -431,6 +431,7 @@ const NewProjectCard = (): React.JSX.Element => {
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 (
<div className="flex flex-col items-center justify-center gap-3 rounded-sm border border-dashed border-border px-8 py-16">
<div className="mb-1 flex size-12 items-center justify-center rounded-sm border border-border bg-surface-raised">
<FolderGit2 className="size-6 text-text-muted" />
</div>
<div className="text-center">
<p className="mb-1 text-sm text-text-secondary">Failed to load projects</p>
<p className="max-w-xl text-xs text-text-muted">{repositoryGroupsError}</p>
</div>
<button
onClick={() => void fetchRepositoryGroups()}
className="rounded-sm border border-border bg-surface-raised px-3 py-1.5 text-xs text-text-secondary transition-colors hover:border-border-emphasis hover:text-text"
>
Retry
</button>
</div>
);
}
if (filteredRepos.length === 0 && searchQuery.trim()) {
return (
<div className="flex flex-col items-center justify-center rounded-sm border border-dashed border-border px-8 py-16">

View file

@ -129,7 +129,7 @@ const WorktreeItem = ({
{truncateMiddle(worktree.name, 28)}
</span>
<span className="shrink-0 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
{worktree.sessions.length}
{worktree.totalSessions ?? worktree.sessions.length}
</span>
{isSelected && <Check className="size-3.5 shrink-0 text-indigo-400" />}
</button>
@ -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;

View file

@ -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,

View file

@ -174,6 +174,7 @@ export const LaunchTeamDialog = ({
path: wt.path,
name: wt.name,
sessions: [],
totalSessions: 0,
createdAt: wt.createdAt ?? Date.now(),
});
}

View file

@ -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(

View file

@ -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();
}

View file

@ -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<void>): Promise<void> => {
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

View file

@ -45,6 +45,8 @@ export interface CliInstallerSlice {
installCli: () => void;
}
let cliStatusInFlight: Promise<void> | null = null;
// =============================================================================
// Slice Creator
// =============================================================================
@ -67,17 +69,25 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
cliCompletedVersion: null,
fetchCliStatus: 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 });
}
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: () => {

View file

@ -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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeoutPromise = new Promise<T>((_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<AppState, [], [], RepositorySli
fetchRepositoryGroups: async () => {
// 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',

View file

@ -361,6 +361,9 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (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<AppState, [], [], TeamSlice> = (set,
globalTasksInitialized: true,
globalTasksError: null,
});
if (import.meta.env.DEV) {
console.warn('[Perf:Renderer] fetchAllTasks:setState:done');
}
} catch (error) {
set({
globalTasksLoading: false,

View file

@ -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<string, st
const removed = members.filter((m) => m.removedAt);
const usedColors = new Set<string>();
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++;

View file

@ -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];

View file

@ -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<typeof import('fs/promises')>();
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<typeof vi.fn>;
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');
});
});

View file

@ -4,6 +4,21 @@ import { structuredPatch } from 'diff';
import type { SnippetDiff } from '@shared/types';
vi.mock('fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
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<typeof vi.fn>;
const unlink = fsPromises.unlink as unknown as ReturnType<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
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();
});
});