From e96f97d83d1ff0a6ca488ddbdd3b57066db33357 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 11:50:07 +0300 Subject: [PATCH 01/51] fix: stabilize team launch runtime status --- .../agent-graph/src/canvas/draw-agents.ts | 2 + packages/agent-graph/src/ports/types.ts | 1 + src/main/index.ts | 10 + src/main/services/team/TeamBackupService.ts | 4 + src/main/services/team/TeamConfigReader.ts | 331 +++++++++++- src/main/services/team/TeamDataService.ts | 32 +- .../services/team/TeamDataWorkerClient.ts | 22 +- .../services/team/TeamProvisioningService.ts | 257 ++++++++- .../team/TeamTranscriptProjectResolver.ts | 5 +- .../OpenCodeRuntimeManifestEvidenceReader.ts | 5 + .../opencode/store/RuntimeStoreManifest.ts | 6 + .../team/opencode/store/VersionedJsonStore.ts | 67 ++- src/main/services/team/teamDataWorkerTypes.ts | 9 +- src/main/workers/team-data-worker.ts | 6 + .../components/team/members/MemberCard.tsx | 2 + src/renderer/utils/memberHelpers.ts | 40 ++ test/main/ipc/teams.test.ts | 2 + .../services/team/TeamConfigReader.test.ts | 489 +++++++++++++++++- .../services/team/TeamDataService.test.ts | 23 + .../team/TeamDataWorkerClient.test.ts | 143 +++++ .../team/TeamProvisioningService.test.ts | 335 +++++++++++- test/renderer/utils/memberHelpers.test.ts | 74 +++ 22 files changed, 1784 insertions(+), 81 deletions(-) create mode 100644 test/main/services/team/TeamDataWorkerClient.test.ts diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index a14dbcd2..9c2715cd 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -264,6 +264,7 @@ function drawLaunchStage( ctx.save(); switch (visualState) { + case 'queued': case 'waiting': { const ringR = r + 8 + Math.sin(time * 3.2) * 1.4; const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2)); @@ -778,6 +779,7 @@ function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: numbe function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string { switch (visualState) { + case 'queued': case 'waiting': return hexWithAlpha('#d4d4d8', 0.8); case 'spawning': diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index e0f566d0..8a990dbb 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -27,6 +27,7 @@ export type GraphLaunchVisualState = | 'registered_only' | 'stale_runtime' | 'settling' + | 'queued' | 'error' | 'skipped'; diff --git a/src/main/index.ts b/src/main/index.ts index a7ab29af..8fe3d823 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -136,6 +136,7 @@ import { writeTeamControlApiState, } from './services/team/TeamControlApiState'; import { TeamInboxReader } from './services/team/TeamInboxReader'; +import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient'; import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService'; import { createTeamReconcileDrainScheduler, @@ -807,6 +808,11 @@ function wireFileWatcherEvents(context: ServiceContext): void { const detail = typeof row.detail === 'string' ? row.detail : ''; memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); + if (row.type === 'config' && detail === 'config.json') { + TeamConfigReader.invalidateTeam(teamName); + getTeamDataWorkerClient().invalidateTeamConfig(teamName); + } + if ( teamDataService && (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') @@ -1197,6 +1203,10 @@ async function initializeServices(): Promise { }); const forwardTeamChange = (event: TeamChangeEvent): void => { + if (event.type === 'config' && event.detail === 'config.json') { + TeamConfigReader.invalidateTeam(event.teamName); + getTeamDataWorkerClient().invalidateTeamConfig(event.teamName); + } if ( teamDataService && (event.type === 'inbox' || event.type === 'lead-message' || event.type === 'config') diff --git a/src/main/services/team/TeamBackupService.ts b/src/main/services/team/TeamBackupService.ts index 8035e045..8d57d674 100644 --- a/src/main/services/team/TeamBackupService.ts +++ b/src/main/services/team/TeamBackupService.ts @@ -11,6 +11,8 @@ import { } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { TeamConfigReader } from './TeamConfigReader'; + const logger = createLogger('TeamBackupService'); // --------------------------------------------------------------------------- @@ -602,6 +604,7 @@ export class TeamBackupService { if (config._backupIdentityId === identityId) return; config._backupIdentityId = identityId; await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + TeamConfigReader.invalidateTeam(teamName); } catch { // best-effort — config may not exist yet } @@ -661,6 +664,7 @@ export class TeamBackupService { await fs.promises.mkdir(path.dirname(configDest), { recursive: true }); const content = await fs.promises.readFile(configBackup, 'utf8'); await atomicWriteAsync(configDest, content); + TeamConfigReader.invalidateTeam(teamName); count++; } catch (err: unknown) { logger.warn(`[Backup] Failed to restore config.json for ${teamName}: ${String(err)}`); diff --git a/src/main/services/team/TeamConfigReader.ts b/src/main/services/team/TeamConfigReader.ts index 3b1fbc38..b3060e73 100644 --- a/src/main/services/team/TeamConfigReader.ts +++ b/src/main/services/team/TeamConfigReader.ts @@ -37,16 +37,38 @@ const LARGE_CONFIG_BYTES = 512 * 1024; const CONFIG_HEAD_BYTES = 64 * 1024; const MAX_CONFIG_READ_BYTES = 10 * 1024 * 1024; // 10MB hard limit for full config reads const PER_TEAM_READ_TIMEOUT_MS = 5_000; -const GET_CONFIG_CACHE_TTL_MS = 750; const GET_CONFIG_SLOW_READ_WARN_MS = 500; +const CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS = 5_000; +const COARSE_FS_FULL_VERIFY_MS = 1_500; const MAX_SESSION_HISTORY_IN_SUMMARY = 2000; const MAX_PROJECT_PATH_HISTORY_IN_SUMMARY = 200; const MAX_LAUNCH_STATE_BYTES = 32 * 1024; const TEAM_LAUNCH_STATE_FILE = 'launch-state.json'; +export interface TeamConfigFingerprint { + size: string; + mode: string; + dev?: string; + ino?: string; + mtimeNs?: string; + ctimeNs?: string; + birthtimeNs?: string; + mtimeMs: number; + ctimeMs: number; + birthtimeMs: number; +} + +interface InternalTeamConfigFingerprint extends TeamConfigFingerprint { + isFile: boolean; + highResolution: boolean; + numericSize: number; +} + interface CachedTeamConfig { value: TeamConfig; - expiresAt: number; + fingerprint: InternalTeamConfigFingerprint | null; + verifiedAt: number; + fullVerifiedAt: number; } interface ConfigReadTiming { @@ -178,10 +200,65 @@ function cloneConfig(config: TeamConfig): TeamConfig { export class TeamConfigReader { private static readonly configCacheByPath = new Map(); private static readonly configReadInFlightByPath = new Map>(); + private static readonly configStatInFlightByPath = new Map< + string, + Promise + >(); + private static readonly configGenerationByPath = new Map(); static clearCacheForTests(): void { TeamConfigReader.configCacheByPath.clear(); TeamConfigReader.configReadInFlightByPath.clear(); + TeamConfigReader.configStatInFlightByPath.clear(); + TeamConfigReader.configGenerationByPath.clear(); + } + + static invalidateTeam(teamName: string): void { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + TeamConfigReader.invalidatePath(configPath); + } + + static invalidatePath(configPath: string): void { + TeamConfigReader.configCacheByPath.delete(configPath); + TeamConfigReader.configReadInFlightByPath.delete(configPath); + TeamConfigReader.configStatInFlightByPath.delete(configPath); + TeamConfigReader.bumpConfigGeneration(configPath); + } + + private static invalidatePathForGeneration( + configPath: string, + expectedGeneration?: number + ): void { + if ( + typeof expectedGeneration === 'number' && + TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration + ) { + return; + } + TeamConfigReader.invalidatePath(configPath); + } + + static async primeConfig( + teamName: string, + config: TeamConfig, + fingerprint?: TeamConfigFingerprint | null + ): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + const generation = TeamConfigReader.bumpConfigGeneration(configPath); + let internalFingerprint: InternalTeamConfigFingerprint | null = null; + if (fingerprint) { + internalFingerprint = { + ...fingerprint, + isFile: true, + highResolution: Boolean(fingerprint.mtimeNs || fingerprint.ctimeNs), + numericSize: Number(fingerprint.size), + }; + } else { + internalFingerprint = await TeamConfigReader.readConfigFingerprint(configPath).catch( + () => null + ); + } + TeamConfigReader.storeConfigCache(configPath, config, internalFingerprint, true, generation); } constructor( @@ -533,27 +610,18 @@ export class TeamConfigReader { } async getConfig(teamName: string): Promise { - const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); - const now = Date.now(); - const cached = TeamConfigReader.configCacheByPath.get(configPath); - if (cached && cached.expiresAt > now) { - return cloneConfig(cached.value); - } + return this.getConfigVerified(teamName); + } + async getConfigVerified(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath); if (existingRead) { return this.resolveConfigRead(teamName, configPath, existingRead); } - const readPromise = this.readConfigFromDisk(teamName, configPath).then((config) => { - if (config) { - TeamConfigReader.configCacheByPath.set(configPath, { - value: cloneConfig(config), - expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS, - }); - } - return config; - }); + const generation = TeamConfigReader.getConfigGeneration(configPath); + const readPromise = this.readConfigFromDisk(teamName, configPath, null, true, generation); TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); try { @@ -565,6 +633,88 @@ export class TeamConfigReader { } } + async getConfigSnapshot(teamName: string): Promise { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + + for (let attempt = 0; attempt < 3; attempt++) { + const generationAtStart = TeamConfigReader.getConfigGeneration(configPath); + let fingerprint: InternalTeamConfigFingerprint | null; + + try { + fingerprint = await TeamConfigReader.getConfigFingerprint(configPath); + } catch (error) { + if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) { + continue; + } + const cached = TeamConfigReader.configCacheByPath.get(configPath); + if ( + cached && + Date.now() - cached.verifiedAt <= CONFIG_SNAPSHOT_RECENT_STAT_FAILURE_FALLBACK_MS + ) { + logger.warn( + `[getConfigSnapshot] config_snapshot_stat_failed_using_recent_cache team=${teamName} error=${ + error instanceof Error ? error.message : String(error) + }` + ); + return cloneConfig(cached.value); + } + return null; + } + + if (TeamConfigReader.getConfigGeneration(configPath) !== generationAtStart) { + continue; + } + + if (!fingerprint?.isFile || fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { + TeamConfigReader.invalidatePathForGeneration(configPath, generationAtStart); + if (fingerprint && fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { + logger.warn( + `Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}` + ); + } + return null; + } + + const cached = TeamConfigReader.configCacheByPath.get(configPath); + if ( + cached?.fingerprint && + TeamConfigReader.fingerprintsEqual(cached.fingerprint, fingerprint) + ) { + const now = Date.now(); + const mustRevalidateCoarseFingerprint = + !fingerprint.highResolution && now - cached.fullVerifiedAt >= COARSE_FS_FULL_VERIFY_MS; + if (!mustRevalidateCoarseFingerprint) { + cached.verifiedAt = now; + return cloneConfig(cached.value); + } + } + + const existingRead = TeamConfigReader.configReadInFlightByPath.get(configPath); + if (existingRead) { + return this.resolveConfigRead(teamName, configPath, existingRead); + } + + const generation = TeamConfigReader.getConfigGeneration(configPath); + const readPromise = this.readConfigFromDisk( + teamName, + configPath, + fingerprint, + true, + generation + ); + TeamConfigReader.configReadInFlightByPath.set(configPath, readPromise); + try { + return await this.resolveConfigRead(teamName, configPath, readPromise); + } finally { + if (TeamConfigReader.configReadInFlightByPath.get(configPath) === readPromise) { + TeamConfigReader.configReadInFlightByPath.delete(configPath); + } + } + } + + return null; + } + private async resolveConfigRead( teamName: string, configPath: string, @@ -578,9 +728,121 @@ export class TeamConfigReader { } } + private static async getConfigFingerprint( + configPath: string + ): Promise { + const existing = TeamConfigReader.configStatInFlightByPath.get(configPath); + if (existing) return existing; + + const statPromise = TeamConfigReader.readConfigFingerprint(configPath).finally(() => { + if (TeamConfigReader.configStatInFlightByPath.get(configPath) === statPromise) { + TeamConfigReader.configStatInFlightByPath.delete(configPath); + } + }); + TeamConfigReader.configStatInFlightByPath.set(configPath, statPromise); + return statPromise; + } + + private static async readConfigFingerprint( + configPath: string + ): Promise { + let stat: fs.BigIntStats; + try { + stat = await withReadTimeout( + fs.promises.stat(configPath, { bigint: true }), + PER_TEAM_READ_TIMEOUT_MS + ); + } catch (error) { + const code = typeof error === 'object' && error ? (error as { code?: unknown }).code : null; + if (code === 'ENOENT') { + return null; + } + throw error; + } + + const highResStat = stat as fs.BigIntStats & { + mtimeNs?: bigint; + ctimeNs?: bigint; + birthtimeNs?: bigint; + }; + const mtimeNs = highResStat.mtimeNs; + const ctimeNs = highResStat.ctimeNs; + const birthtimeNs = highResStat.birthtimeNs; + + return { + size: stat.size.toString(), + mode: stat.mode.toString(), + dev: stat.dev.toString(), + ino: stat.ino.toString(), + mtimeNs: typeof mtimeNs === 'bigint' ? mtimeNs.toString() : undefined, + ctimeNs: typeof ctimeNs === 'bigint' ? ctimeNs.toString() : undefined, + birthtimeNs: typeof birthtimeNs === 'bigint' ? birthtimeNs.toString() : undefined, + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + birthtimeMs: Number(stat.birthtimeMs), + isFile: stat.isFile(), + highResolution: typeof mtimeNs === 'bigint' || typeof ctimeNs === 'bigint', + numericSize: Number(stat.size), + }; + } + + private static fingerprintsEqual( + a: InternalTeamConfigFingerprint, + b: InternalTeamConfigFingerprint + ): boolean { + return ( + a.size === b.size && + a.mode === b.mode && + a.dev === b.dev && + a.ino === b.ino && + a.mtimeNs === b.mtimeNs && + a.ctimeNs === b.ctimeNs && + a.birthtimeNs === b.birthtimeNs && + a.mtimeMs === b.mtimeMs && + a.ctimeMs === b.ctimeMs && + a.birthtimeMs === b.birthtimeMs + ); + } + + private static storeConfigCache( + configPath: string, + config: TeamConfig, + fingerprint: InternalTeamConfigFingerprint | null, + fullVerified: boolean, + expectedGeneration?: number + ): void { + if ( + typeof expectedGeneration === 'number' && + TeamConfigReader.getConfigGeneration(configPath) !== expectedGeneration + ) { + return; + } + const now = Date.now(); + const previous = TeamConfigReader.configCacheByPath.get(configPath); + TeamConfigReader.configCacheByPath.set(configPath, { + value: cloneConfig(config), + fingerprint, + verifiedAt: now, + fullVerifiedAt: fullVerified ? now : (previous?.fullVerifiedAt ?? now), + }); + } + + private static getConfigGeneration(configPath: string): number { + return TeamConfigReader.configGenerationByPath.get(configPath) ?? 0; + } + + private static bumpConfigGeneration(configPath: string): number { + const next = TeamConfigReader.getConfigGeneration(configPath) + 1; + TeamConfigReader.configGenerationByPath.set(configPath, next); + return next; + } + private async readConfigFromDisk( teamName: string, - configPath: string + configPath: string, + knownFingerprint: InternalTeamConfigFingerprint | null = null, + updateCache = false, + cacheGeneration?: number ): Promise { const startedAt = performance.now(); let size: number | null = null; @@ -599,17 +861,20 @@ export class TeamConfigReader { try { const statStartedAt = performance.now(); - const stat = await fs.promises.stat(configPath); + const fingerprint = + knownFingerprint ?? (await TeamConfigReader.getConfigFingerprint(configPath)); statMs = Math.round(performance.now() - statStartedAt); - size = stat.size; + size = fingerprint?.numericSize ?? null; // Safety: refuse special files and huge/binary configs - if (!stat.isFile()) { + if (!fingerprint?.isFile) { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); return null; } - if (stat.size > MAX_CONFIG_READ_BYTES) { + if (fingerprint.numericSize > MAX_CONFIG_READ_BYTES) { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); logger.warn( - `Refusing to load oversized config.json (${stat.size} bytes) for team: ${teamName}` + `Refusing to load oversized config.json (${fingerprint.numericSize} bytes) for team: ${teamName}` ); return null; } @@ -622,6 +887,7 @@ export class TeamConfigReader { const config = JSON.parse(raw) as TeamConfig; parseMs = Math.round(performance.now() - parseStartedAt); if (typeof config.name !== 'string' || config.name.trim() === '') { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); return null; } const resolvedProjectPath = resolveProjectPathFromConfig(config); @@ -633,10 +899,24 @@ export class TeamConfigReader { if (totalMs >= GET_CONFIG_SLOW_READ_WARN_MS) { logger.warn(`[getConfig] slow read diag=${JSON.stringify(buildTiming())}`); } + if (updateCache) { + TeamConfigReader.storeConfigCache( + configPath, + resolvedConfig, + fingerprint, + true, + cacheGeneration + ); + } return resolvedConfig; } catch (error) { + TeamConfigReader.invalidatePathForGeneration(configPath, cacheGeneration); if (error instanceof FileReadTimeoutError) { logger.warn(`[getConfig] ${error.message} diag=${JSON.stringify(buildTiming())}`); + } else if (error instanceof Error && error.message === 'Team config read timeout') { + logger.warn( + `[getConfig] Timed out after ${PER_TEAM_READ_TIMEOUT_MS}ms reading ${configPath} diag=${JSON.stringify(buildTiming())}` + ); } throw error; } @@ -664,10 +944,7 @@ export class TeamConfigReader { } const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); - TeamConfigReader.configCacheByPath.set(configPath, { - value: cloneConfig(config), - expiresAt: Date.now() + GET_CONFIG_CACHE_TTL_MS, - }); + await TeamConfigReader.primeConfig(teamName, config); return config; } } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 054550df..b4b09fa5 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -267,6 +267,25 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null { return body.length > 0 ? body : null; } +function readConfigForUiSnapshot( + configReader: TeamConfigReader & { + getConfigSnapshot?: (teamName: string) => Promise; + }, + teamName: string +): Promise { + return typeof configReader.getConfigSnapshot === 'function' + ? configReader.getConfigSnapshot(teamName) + : configReader.getConfig(teamName); +} + +function createUiSnapshotProjectResolver( + configReader: TeamConfigReader +): TeamTranscriptProjectResolver { + return new TeamTranscriptProjectResolver({ + getConfig: (teamName) => readConfigForUiSnapshot(configReader, teamName), + }); +} + function isExplicitLeadRole(role: string | undefined): boolean { const normalized = role?.trim().toLowerCase(); return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead'; @@ -385,13 +404,13 @@ export class TeamDataService { private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private memberRuntimeAdvisoryService: TeamMemberRuntimeAdvisoryService = new TeamMemberRuntimeAdvisoryService(), private readonly leadSessionParseCache: LeadSessionParseCache = new LeadSessionParseCache(), - private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver( + private readonly projectResolver: TeamTranscriptProjectResolver = createUiSnapshotProjectResolver( configReader ), private readonly launchStateStore: TeamLaunchStateStore = new TeamLaunchStateStore() ) { this.messageFeedService = new TeamMessageFeedService({ - getConfig: (teamName) => this.configReader.getConfig(teamName), + getConfig: (teamName) => this.readSnapshotConfig(teamName), getInboxMessages: (teamName) => this.inboxReader.getMessages(teamName), getLeadSessionMessages: (teamName, config) => this.extractLeadSessionTexts(teamName, config), getSentMessages: (teamName) => this.sentMessagesStore.readMessages(teamName), @@ -399,6 +418,10 @@ export class TeamDataService { this.memberActivityMetaService = new MemberActivityMetaService(this.messageFeedService); } + private readSnapshotConfig(teamName: string): Promise { + return readConfigForUiSnapshot(this.configReader, teamName); + } + private getController(teamName: string): AgentTeamsController { return this.controllerFactory(teamName); } @@ -1077,6 +1100,7 @@ export class TeamDataService { config.deletedAt = new Date().toISOString(); const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + await TeamConfigReader.primeConfig(teamName, config); } async restoreTeam(teamName: string): Promise { @@ -1087,11 +1111,13 @@ export class TeamDataService { delete config.deletedAt; const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); + await TeamConfigReader.primeConfig(teamName, config); } async permanentlyDeleteTeam(teamName: string): Promise { const teamsDir = path.join(getTeamsBasePath(), teamName); await fs.promises.rm(teamsDir, { recursive: true, force: true }); + TeamConfigReader.invalidateTeam(teamName); const tasksDir = path.join(getTasksBasePath(), teamName); await fs.promises.rm(tasksDir, { recursive: true, force: true }); @@ -1113,7 +1139,7 @@ export class TeamDataService { return typeof fromTs === 'number' && typeof toTs === 'number' ? toTs - fromTs : -1; }; - const config = await this.configReader.getConfig(teamName); + const config = await this.readSnapshotConfig(teamName); if (!config) { throw new Error(`Team not found: ${teamName}`); } diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index 9b69cc90..b628bd6c 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -68,6 +68,7 @@ export class TeamDataWorkerClient { private readonly workerPath: string | null = resolveWorkerPath(); private warnedUnavailable = false; private pending = new Map(); + private getTeamDataInFlight = new Map>(); private failWorker(worker: Worker, error: Error): void { if (this.worker !== worker) return; @@ -157,7 +158,25 @@ export class TeamDataWorkerClient { async getTeamData(teamName: string): Promise { if (!SAFE_NAME_RE.test(teamName)) throw new Error('Invalid teamName'); - return this.call('getTeamData', { teamName }) as Promise; + const existing = this.getTeamDataInFlight.get(teamName); + if (existing) return existing; + + const promise = (this.call('getTeamData', { teamName }) as Promise).finally( + () => { + if (this.getTeamDataInFlight.get(teamName) === promise) { + this.getTeamDataInFlight.delete(teamName); + } + } + ); + this.getTeamDataInFlight.set(teamName, promise); + return promise; + } + + invalidateTeamConfig(teamName: string): void { + if (!SAFE_NAME_RE.test(teamName)) return; + this.getTeamDataInFlight.delete(teamName); + if (!this.worker) return; + void this.call('invalidateTeamConfig', { teamName }).catch(() => undefined); } async getMessagesPage( @@ -193,6 +212,7 @@ export class TeamDataWorkerClient { dispose(): void { this.worker?.terminate().catch(() => undefined); this.worker = null; + this.getTeamDataInFlight.clear(); for (const [, entry] of this.pending) { entry.reject(new Error('Client disposed')); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 89429046..1188fa09 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1641,6 +1641,11 @@ interface PromptSizeSummary { const MEMBER_LAUNCH_GRACE_MS = 120_000; const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; +const OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS = { + acquireTimeoutMs: 45_000, + staleTimeoutMs: 60_000, + retryIntervalMs: 50, +} as const; export function shouldWarnOnUnreadableMemberAuditConfig(params: { nowMs: number; @@ -1957,10 +1962,16 @@ function hasStaleOpenCodeDiagnostics(values: readonly unknown[] | undefined): bo text.includes('registered runtime metadata without live process') || text.includes('member has persisted runtime metadata only') || text.includes('opencode bridge reported member launch failure') || + text.includes('file lock timeout') || text.includes(OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC.toLowerCase()) ); } +function isFileLockTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.toLowerCase().includes('file lock timeout'); +} + function hasRealOpenCodeFailureDiagnostic(text: string): boolean { return ( /\bauth(?:entication|orization)?\b/.test(text) || @@ -8487,13 +8498,32 @@ export class TeamProvisioningService { previousMember: idempotent.previousMember, }); if (idempotent.state === 'duplicate') { - await this.commitOpenCodeRuntimeBootstrapSessionEvidence({ + const committed = await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence({ teamName, runId, laneId, memberName, runtimeSessionId, + }); + if (!committed) { + await this.commitOpenCodeRuntimeBootstrapSessionEvidence({ + teamName, + runId, + laneId, + memberName, + runtimeSessionId, + observedAt, + }); + } + await this.updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName, + runtimeSessionId, observedAt, + diagnostics: payload.diagnostics, + metadata: parseRuntimeToolMetadata(payload.metadata), + reason: 'OpenCode runtime bootstrap check-in accepted', }); return { ok: true, @@ -8585,25 +8615,63 @@ export class TeamProvisioningService { const manifestStore = createRuntimeStoreManifestStore({ filePath: manifestPath, teamName: input.teamName, + lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS, }); const receiptStore = createRuntimeStoreReceiptStore({ filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'), + lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS, }); const writer = new RuntimeStoreBatchWriter(runtimeDirectory, manifestStore, receiptStore); - await writer.writeBatch({ + try { + await writer.writeBatch({ + teamName: input.teamName, + runId: input.runId, + capabilitySnapshotId: null, + behaviorFingerprint: null, + reason: 'launch_checkpoint', + writes: [ + { + descriptor, + data: { sessions }, + }, + ], + }); + } catch (error) { + if ( + isFileLockTimeoutError(error) && + (await this.hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input)) + ) { + return; + } + throw error; + } + } + + private async hasCommittedOpenCodeRuntimeBootstrapSessionEvidence(input: { + teamName: string; + runId: string; + laneId: string; + memberName: string; + runtimeSessionId: string; + }): Promise { + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), teamName: input.teamName, - runId: input.runId, - capabilitySnapshotId: null, - behaviorFingerprint: null, - reason: 'launch_checkpoint', - writes: [ - { - descriptor, - data: { sessions }, - }, - ], - }); + laneId: input.laneId, + }).catch(() => null); + if (!evidence?.committed) { + return false; + } + if (evidence.activeRunId && evidence.activeRunId.trim() !== input.runId) { + return false; + } + return evidence.sessions.some( + (session) => + session.id === input.runtimeSessionId && + session.runId === input.runId && + namesMatchCaseInsensitive(session.memberName, input.memberName) + ); } private async readOpenCodeRuntimeSessionStore( @@ -8918,6 +8986,25 @@ export class TeamProvisioningService { metadata?: RuntimeToolMetadata; reason: string; }): Promise { + const trackedUpdate = this.applyOpenCodeRuntimeBootstrapCheckinToTrackedRun(input); + if (trackedUpdate) { + await this.persistLaunchStateSnapshot( + trackedUpdate.run, + this.getMixedSecondaryLaunchPhase(trackedUpdate.run) + ); + this.agentRuntimeSnapshotCache.delete(input.teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); + if (trackedUpdate.changed) { + this.teamChangeEmitter?.({ + type: 'member-spawn', + teamName: input.teamName, + runId: input.runId, + detail: input.memberName, + }); + } + return; + } + const previous = await this.launchStateStore.read(input.teamName); const expectedMembers = previous ? this.getPersistedLaunchMemberNames(previous) @@ -9005,6 +9092,137 @@ export class TeamProvisioningService { } } + private applyOpenCodeRuntimeBootstrapCheckinToTrackedRun(input: { + teamName: string; + runId: string; + memberName: string; + runtimeSessionId: string; + observedAt: string; + diagnostics: unknown; + metadata?: RuntimeToolMetadata; + reason: string; + }): { run: ProvisioningRun; changed: boolean } | null { + const trackedRunId = this.getTrackedRunId(input.teamName); + const run = trackedRunId ? this.runs.get(trackedRunId) : undefined; + if (!run || run.processKilled || run.cancelRequested) { + return null; + } + + const lane = (run.mixedSecondaryLanes ?? []).find((candidate) => { + if (candidate.providerId !== 'opencode') { + return false; + } + if (!matchesTeamMemberIdentity(candidate.member.name, input.memberName)) { + return false; + } + return !candidate.runId || candidate.runId === input.runId; + }); + if (!lane) { + return null; + } + + const runtimePid = input.metadata?.runtimePid; + const runtimeDiagnostics = mergeRuntimeDiagnostics( + lane.result?.members[input.memberName]?.diagnostics ?? lane.diagnostics, + [ + ...normalizeRuntimeStringArray(input.diagnostics), + ...buildRuntimeToolMetadataDiagnostics(input.metadata), + 'opencode_bootstrap_evidence_committed', + ], + input.reason + ); + const evidence: TeamRuntimeMemberLaunchEvidence = { + memberName: input.memberName, + providerId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + sessionId: input.runtimeSessionId, + backendType: 'process', + ...(runtimePid ? { runtimePid, pidSource: 'runtime_bootstrap' as const } : {}), + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: input.reason, + runtimeDiagnosticSeverity: 'info', + diagnostics: runtimeDiagnostics ?? [input.reason], + }; + + const previousLaneState = lane.state; + const previousLaneRunId = lane.runId; + const previousLaneMember = lane.result?.members[input.memberName]; + lane.runId = input.runId; + lane.state = 'finished'; + lane.diagnostics = runtimeDiagnostics ?? lane.diagnostics; + lane.result = { + ...(lane.result ?? { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished' as const, + teamLaunchState: 'partial_pending' as const, + members: {}, + warnings: lane.warnings, + diagnostics: [], + }), + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished', + members: { + ...(lane.result?.members ?? {}), + [input.memberName]: evidence, + }, + warnings: lane.result?.warnings ?? lane.warnings, + diagnostics: runtimeDiagnostics ?? lane.result?.diagnostics ?? lane.diagnostics, + }; + lane.result.teamLaunchState = summarizeRuntimeLaunchResultMembers(lane.result.members); + + const previousStatus = + run.memberSpawnStatuses.get(input.memberName) ?? createInitialMemberSpawnStatusEntry(); + const nextStatus: MemberSpawnStatusEntry = { + ...previousStatus, + status: 'online', + launchState: 'confirmed_alive', + error: undefined, + hardFailureReason: undefined, + skippedForLaunch: undefined, + skipReason: undefined, + skippedAt: undefined, + livenessSource: 'heartbeat', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + pendingPermissionRequestIds: undefined, + firstSpawnAcceptedAt: previousStatus.firstSpawnAcceptedAt ?? input.observedAt, + lastHeartbeatAt: input.observedAt, + runtimeModel: lane.member.model, + livenessKind: 'confirmed_bootstrap', + runtimeDiagnostic: input.reason, + runtimeDiagnosticSeverity: 'info', + livenessLastCheckedAt: input.observedAt, + updatedAt: input.observedAt, + }; + run.memberSpawnStatuses.set(input.memberName, nextStatus); + run.pendingMemberRestarts?.delete(input.memberName); + this.syncMemberLaunchGraceCheck(run, input.memberName, nextStatus); + + const statusChanged = + previousStatus.status !== nextStatus.status || + previousStatus.launchState !== nextStatus.launchState || + previousStatus.bootstrapConfirmed !== nextStatus.bootstrapConfirmed || + previousStatus.runtimeAlive !== nextStatus.runtimeAlive || + previousStatus.hardFailure !== nextStatus.hardFailure || + previousStatus.livenessKind !== nextStatus.livenessKind; + const laneChanged = + previousLaneState !== lane.state || + previousLaneRunId !== lane.runId || + previousLaneMember?.sessionId !== evidence.sessionId || + previousLaneMember?.launchState !== evidence.launchState || + previousLaneMember?.bootstrapConfirmed !== evidence.bootstrapConfirmed; + + return { run, changed: statusChanged || laneChanged }; + } + private shouldEmitOpenCodeRuntimeLivenessMemberSpawnChange(input: { previousMember?: PersistedTeamLaunchMemberState; runtimeRunId: string; @@ -17663,6 +17881,7 @@ export class TeamProvisioningService { }).catch((error: unknown) => ({ state: 'invalid_store' as const, committed: false, + activeRunId: null, sessions: [], diagnostics: [ `OpenCode committed bootstrap evidence read failed: ${getErrorMessage(error)}`, @@ -17675,6 +17894,7 @@ export class TeamProvisioningService { previous, laneEntry, metaMembers, + activeRunId: evidence.activeRunId, sessions: evidence.committed ? evidence.sessions : [], diagnostics: evidence.diagnostics, }); @@ -17785,6 +18005,7 @@ export class TeamProvisioningService { previous: PersistedTeamLaunchMemberState | null; laneEntry: OpenCodeRuntimeLaneIndexEntry | null; metaMembers: Awaited>; + activeRunId: string | null; sessions: OpenCodeCommittedBootstrapSessionRecord[]; diagnostics: readonly string[]; }): Promise< @@ -17838,6 +18059,16 @@ export class TeamProvisioningService { ], }; } + if ( + params.activeRunId && + selected.runId && + params.activeRunId.trim() !== selected.runId.trim() + ) { + return { + kind: 'conflict', + diagnostics: ['opencode_overlay_session_run_mismatch'], + }; + } if (selected.runId) { const tombstoneStore = createRuntimeRunTombstoneStore({ diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index 63c1de58..cee44f65 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -186,7 +186,9 @@ export class TeamTranscriptProjectResolver { { value: TeamTranscriptProjectContext; expiresAt: number } >(); - constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + constructor( + private readonly configReader: Pick = new TeamConfigReader() + ) {} async getContext( teamName: string, @@ -605,6 +607,7 @@ export class TeamTranscriptProjectResolver { normalizedNextPath ); await atomicWriteAsync(configPath, JSON.stringify(parsed, null, 2)); + TeamConfigReader.invalidateTeam(teamName); logger.info( `[${teamName}] Repaired transcript projectPath via exact session match: ${normalizedNextPath}` ); diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index 4e0599e3..cbe3ebe7 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -71,6 +71,7 @@ export interface OpenCodeCommittedBootstrapSessionRecord { export interface OpenCodeCommittedBootstrapSessionEvidence { state: RuntimeStoreManifestEntryState | 'invalid_store' | 'descriptor_missing'; committed: boolean; + activeRunId: string | null; sessions: OpenCodeCommittedBootstrapSessionRecord[]; diagnostics: string[]; } @@ -497,6 +498,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: 'descriptor_missing', committed: false, + activeRunId: null, sessions: [], diagnostics: ['OpenCode session store descriptor is not registered.'], }; @@ -521,6 +523,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: 'invalid_store', committed: false, + activeRunId: null, sessions: [], diagnostics: ['OpenCode runtime manifest could not be read.'], }; @@ -539,6 +542,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: inspection.state, committed: false, + activeRunId: manifest.activeRunId, sessions: [], diagnostics, }; @@ -561,6 +565,7 @@ export async function readCommittedOpenCodeBootstrapSessionEvidence(params: { return { state: 'healthy', committed: true, + activeRunId: manifest.activeRunId, sessions, diagnostics, }; diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts index 99e6174e..be3b2e24 100644 --- a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -3,6 +3,8 @@ import { createHash, randomUUID } from 'crypto'; import { promises as fs } from 'fs'; import * as path from 'path'; +import type { FileLockOptions } from '../../fileLock'; + import { VersionedJsonStore, VersionedJsonStoreError } from './VersionedJsonStore'; export const OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION = 1; @@ -917,6 +919,7 @@ export function createRuntimeStoreManifestStore(options: { filePath: string; teamName: string; clock?: () => Date; + lockOptions?: FileLockOptions; }): RuntimeStoreManifestStore { const clock = options.clock ?? (() => new Date()); return new RuntimeStoreManifestStore( @@ -926,6 +929,7 @@ export function createRuntimeStoreManifestStore(options: { defaultData: () => createDefaultRuntimeStoreManifest(options.teamName, clock().toISOString()), validate: validateRuntimeStoreManifest, clock, + lockOptions: options.lockOptions, }), clock ); @@ -934,6 +938,7 @@ export function createRuntimeStoreManifestStore(options: { export function createRuntimeStoreReceiptStore(options: { filePath: string; clock?: () => Date; + lockOptions?: FileLockOptions; }): RuntimeStoreReceiptStore { const clock = options.clock ?? (() => new Date()); return new RuntimeStoreReceiptStore( @@ -943,6 +948,7 @@ export function createRuntimeStoreReceiptStore(options: { defaultData: () => [], validate: validateRuntimeStoreWriteBatches, clock, + lockOptions: options.lockOptions, }), clock ); diff --git a/src/main/services/team/opencode/store/VersionedJsonStore.ts b/src/main/services/team/opencode/store/VersionedJsonStore.ts index 9da4a5e1..afbf1912 100644 --- a/src/main/services/team/opencode/store/VersionedJsonStore.ts +++ b/src/main/services/team/opencode/store/VersionedJsonStore.ts @@ -2,7 +2,7 @@ import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { withFileLock } from '../../fileLock'; +import { type FileLockOptions, withFileLock } from '../../fileLock'; export interface VersionedJsonStoreEnvelope { schemaVersion: number; @@ -45,6 +45,7 @@ export interface VersionedJsonStoreOptions { validate: (value: unknown) => TData; clock?: () => Date; quarantineDir?: string; + lockOptions?: FileLockOptions; } export class VersionedJsonStoreError extends Error { @@ -65,6 +66,7 @@ export class VersionedJsonStore { private readonly validate: (value: unknown) => TData; private readonly clock: () => Date; private readonly quarantineDir: string | null; + private readonly lockOptions: FileLockOptions | undefined; constructor(options: VersionedJsonStoreOptions) { this.filePath = options.filePath; @@ -73,6 +75,7 @@ export class VersionedJsonStore { this.validate = options.validate; this.clock = options.clock ?? (() => new Date()); this.quarantineDir = options.quarantineDir ?? null; + this.lockOptions = options.lockOptions; } async read(): Promise> { @@ -82,36 +85,44 @@ export class VersionedJsonStore { async updateLocked( updater: (current: TData) => TData | Promise ): Promise> { - return withFileLock(this.filePath, async () => { - const current = await this.readUnlocked(); - if (!current.ok) { - throw new VersionedJsonStoreError(current.message, current.reason, current.quarantinePath); - } + return withFileLock( + this.filePath, + async () => { + const current = await this.readUnlocked(); + if (!current.ok) { + throw new VersionedJsonStoreError( + current.message, + current.reason, + current.quarantinePath + ); + } - const nextData = await updater(cloneJson(current.data)); - const validatedNextData = this.validate(nextData); - const currentJson = stableJsonStringify(current.data); - const nextJson = stableJsonStringify(validatedNextData); - const changed = current.status === 'missing' || currentJson !== nextJson; - const envelope: VersionedJsonStoreEnvelope = { - schemaVersion: this.schemaVersion, - updatedAt: changed - ? this.clock().toISOString() - : (current.envelope?.updatedAt ?? this.clock().toISOString()), - data: changed ? validatedNextData : current.data, - }; + const nextData = await updater(cloneJson(current.data)); + const validatedNextData = this.validate(nextData); + const currentJson = stableJsonStringify(current.data); + const nextJson = stableJsonStringify(validatedNextData); + const changed = current.status === 'missing' || currentJson !== nextJson; + const envelope: VersionedJsonStoreEnvelope = { + schemaVersion: this.schemaVersion, + updatedAt: changed + ? this.clock().toISOString() + : (current.envelope?.updatedAt ?? this.clock().toISOString()), + data: changed ? validatedNextData : current.data, + }; - if (changed) { - await fs.mkdir(path.dirname(this.filePath), { recursive: true }); - await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`); - } + if (changed) { + await fs.mkdir(path.dirname(this.filePath), { recursive: true }); + await atomicWriteAsync(this.filePath, `${JSON.stringify(envelope, null, 2)}\n`); + } - return { - changed, - data: envelope.data, - envelope, - }; - }); + return { + changed, + data: envelope.data, + envelope, + }; + }, + this.lockOptions + ); } private async readUnlocked(): Promise> { diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index ca798cc3..c130c0b8 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -38,18 +38,23 @@ export interface FindLogsForTaskPayload { }; } +export interface InvalidateTeamConfigPayload { + teamName: string; +} + // ── Request / Response ── export type TeamDataWorkerRequest = | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } - | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload }; + | { id: string; op: 'findLogsForTask'; payload: FindLogsForTaskPayload } + | { id: string; op: 'invalidateTeamConfig'; payload: InvalidateTeamConfigPayload }; export type TeamDataWorkerResponse = | { id: string; ok: true; - result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[]; + result: TeamViewSnapshot | MessagesPage | TeamMemberActivityMeta | MemberLogSummary[] | null; } | { id: string; ok: false; error: string }; diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 871eba11..55db4473 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -8,6 +8,7 @@ import { parentPort } from 'node:worker_threads'; +import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { TeamDataService } from '@main/services/team/TeamDataService'; import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import { createLogger } from '@shared/utils/logger'; @@ -55,6 +56,11 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { respond({ id: msg.id, ok: true, result }); break; } + case 'invalidateTeamConfig': { + TeamConfigReader.invalidateTeam(msg.payload.teamName); + respond({ id: msg.id, ok: true, result: null }); + break; + } case 'findLogsForTask': { const { teamName, taskId, options } = msg.payload; const intervalsKey = options?.intervals diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 4d26595b..6c9453e3 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -156,6 +156,7 @@ export const MemberCard = ({ const launchVisualState = launchPresentation.launchVisualState; const launchStatusLabel = launchPresentation.launchStatusLabel; const displayPresenceLabel = + launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || @@ -200,6 +201,7 @@ export const MemberCard = ({ !runtimeAdvisoryLabel && (presenceLabel === 'starting' || presenceLabel === 'connecting' || + launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index d95e0756..4ce026e0 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -546,6 +546,7 @@ export function getLaunchAwarePresenceLabel( } export type MemberLaunchVisualState = + | 'queued' | 'waiting' | 'spawning' | 'permission_pending' @@ -573,6 +574,8 @@ export interface MemberLaunchPresentation { export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState): string | null { switch (visualState) { + case 'queued': + return 'queued'; case 'waiting': return 'waiting to start'; case 'spawning': @@ -602,6 +605,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState) function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): string | null { switch (visualState) { + case 'queued': + return SPAWN_DOT_COLORS.waiting; case 'permission_pending': case 'runtime_pending': case 'runtime_candidate': @@ -617,6 +622,29 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str } } +function isQueuedOpenCodeLaunch( + member: ResolvedTeamMember, + spawnStatus: MemberSpawnStatus | undefined, + spawnLaunchState: MemberLaunchState | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined, + isLaunchSettling: boolean, + isTeamProvisioning: boolean | undefined +): boolean { + if (member.providerId !== 'opencode') { + return false; + } + if (isTeamProvisioning !== true && !isLaunchSettling) { + return false; + } + if (spawnStatus !== 'waiting' || spawnLaunchState !== 'starting') { + return false; + } + + // Only label lanes as queued before runtime evidence appears. Once the + // backend has any liveness signal, show the exact runtime state instead. + return runtimeEntry == null || runtimeEntry.livenessKind == null; +} + function hasElapsedSinceIso( value: string | undefined, thresholdMs: number, @@ -783,6 +811,17 @@ export function buildMemberLaunchPresentation({ runtimeEntry?.livenessKind === 'not_found' ) { launchVisualState = 'stale_runtime'; + } else if ( + isQueuedOpenCodeLaunch( + member, + spawnStatus, + spawnLaunchState, + runtimeEntry, + isLaunchSettling, + isTeamProvisioning + ) + ) { + launchVisualState = 'queued'; } else if ( isLaunchStillStarting( spawnStatus, @@ -810,6 +849,7 @@ export function buildMemberLaunchPresentation({ const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState); const shouldShowLaunchStatusAsPresence = + launchVisualState === 'queued' || launchVisualState === 'permission_pending' || launchVisualState === 'runtime_pending' || launchVisualState === 'shell_only' || diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 3070bdf9..565e1175 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -49,6 +49,7 @@ const { mockTeamDataWorkerClient } = vi.hoisted(() => ({ getMessagesPage: vi.fn(), getMemberActivityMeta: vi.fn(), findLogsForTask: vi.fn(), + invalidateTeamConfig: vi.fn(), }, })); vi.mock('@main/services/infrastructure/NotificationManager', () => ({ @@ -313,6 +314,7 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.getMessagesPage.mockReset(); mockTeamDataWorkerClient.getMemberActivityMeta.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset(); + mockTeamDataWorkerClient.invalidateTeamConfig.mockReset(); initializeTeamHandlers( service as never, provisioningService as never, diff --git a/test/main/services/team/TeamConfigReader.test.ts b/test/main/services/team/TeamConfigReader.test.ts index 88b3c47d..6171fc23 100644 --- a/test/main/services/team/TeamConfigReader.test.ts +++ b/test/main/services/team/TeamConfigReader.test.ts @@ -22,6 +22,20 @@ vi.mock('../../../../src/main/services/team/TeamFsWorkerClient', () => ({ import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { createPersistedLaunchSummaryProjection } from '../../../../src/main/services/team/TeamLaunchSummaryProjection'; +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + describe('TeamConfigReader', () => { let tempDir = ''; @@ -31,6 +45,9 @@ describe('TeamConfigReader', () => { }); afterEach(async () => { + vi.useRealTimers(); + vi.restoreAllMocks(); + TeamConfigReader.clearCacheForTests(); if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); } @@ -256,7 +273,7 @@ describe('TeamConfigReader', () => { }); }); - it('shares in-flight getConfig reads and returns cloned cached configs', async () => { + it('shares in-flight verified reads without reusing completed cache', async () => { const teamName = 'cached-config-team'; const teamDir = path.join(tempDir, teamName); await fs.mkdir(teamDir, { recursive: true }); @@ -273,17 +290,481 @@ describe('TeamConfigReader', () => { const reader = new TeamConfigReader(); const [first, second] = await Promise.all([ - reader.getConfig(teamName), - reader.getConfig(teamName), + reader.getConfigVerified(teamName), + reader.getConfigVerified(teamName), ]); if (!first) { throw new Error('Expected config to load.'); } first.name = 'Mutated In Caller'; - const third = await reader.getConfig(teamName); + const third = await reader.getConfigVerified(teamName); expect(second?.name).toBe('Cached Config Team'); expect(third?.name).toBe('Cached Config Team'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('uses fingerprint-validated snapshot cache without rereading unchanged config content', async () => { + const teamName = 'snapshot-cache-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Snapshot Cache Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + const statSpy = vi.spyOn(nodeFs.promises, 'stat'); + + const reader = new TeamConfigReader(); + const first = await reader.getConfigSnapshot(teamName); + if (!first) { + throw new Error('Expected config to load.'); + } + first.name = 'Mutated In Caller'; + const second = await reader.getConfigSnapshot(teamName); + + expect(second?.name).toBe('Snapshot Cache Team'); + expect(statSpy).toHaveBeenCalledTimes(2); expect(readFileSpy).toHaveBeenCalledTimes(1); }); + + it('shares in-flight snapshot stat and read work for concurrent calls', async () => { + const teamName = 'snapshot-inflight-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Snapshot Inflight Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + const statSpy = vi.spyOn(nodeFs.promises, 'stat'); + + const reader = new TeamConfigReader(); + const [first, second] = await Promise.all([ + reader.getConfigSnapshot(teamName), + reader.getConfigSnapshot(teamName), + ]); + + expect(first?.name).toBe('Snapshot Inflight Team'); + expect(second?.name).toBe('Snapshot Inflight Team'); + expect(statSpy).toHaveBeenCalledTimes(1); + expect(readFileSpy).toHaveBeenCalledTimes(1); + }); + + it('rereads snapshot when ctime changes even if mtime is unchanged', async () => { + const teamName = 'snapshot-ctime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Ctime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + let ctimeMs = 1000; + vi.spyOn(nodeFs.promises, 'stat').mockImplementation(async () => ({ + size: BigInt(4096), + mode: BigInt(33188), + dev: BigInt(1), + ino: BigInt(2), + mtimeMs: 1000, + ctimeMs, + birthtimeMs: 1000, + isFile: () => true, + }) as never); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Ctime'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After Ctime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + ctimeMs = 2000; + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Ctime'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('rereads snapshot when the config fingerprint changes', async () => { + const teamName = 'snapshot-reread-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('primeConfig updates snapshot cache immediately after app-owned writes', async () => { + const teamName = 'prime-cache-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Before Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Prime'); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'After Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'After Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + const snapshot = await reader.getConfigSnapshot(teamName); + expect(snapshot?.name).toBe('After Prime'); + }); + + it('does not let stale in-flight snapshot reads overwrite a primed config cache', async () => { + const teamName = 'stale-read-prime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + const staleRaw = JSON.stringify({ + name: 'Stale Read', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }); + await fs.writeFile(configPath, staleRaw, 'utf8'); + + const readDeferred = createDeferred(); + const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises); + let intercepted = false; + vi.spyOn(nodeFs.promises, 'readFile').mockImplementation( + ((file: unknown, ...args: unknown[]) => { + if (!intercepted && String(file) === configPath) { + intercepted = true; + return readDeferred.promise as never; + } + return realReadFile(file as never, ...(args as never[])) as never; + }) as never + ); + + const reader = new TeamConfigReader(); + const staleSnapshot = reader.getConfigSnapshot(teamName); + await vi.waitFor(() => expect(intercepted).toBe(true)); + + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Fresh Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'Fresh Prime', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + readDeferred.resolve(staleRaw); + expect((await staleSnapshot)?.name).toBe('Stale Read'); + + vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable')); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh Prime'); + }); + + it('does not let stale in-flight snapshot read failures invalidate a primed config cache', async () => { + const teamName = 'stale-read-failure-prime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Failure', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const readDeferred = createDeferred(); + const realReadFile = nodeFs.promises.readFile.bind(nodeFs.promises); + let intercepted = false; + vi.spyOn(nodeFs.promises, 'readFile').mockImplementation( + ((file: unknown, ...args: unknown[]) => { + if (!intercepted && String(file) === configPath) { + intercepted = true; + return readDeferred.promise as never; + } + return realReadFile(file as never, ...(args as never[])) as never; + }) as never + ); + + const reader = new TeamConfigReader(); + const staleSnapshot = reader.getConfigSnapshot(teamName); + await vi.waitFor(() => expect(intercepted).toBe(true)); + + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Fresh After Failure', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'Fresh After Failure', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + readDeferred.reject(new Error('old read failed')); + await expect(staleSnapshot).resolves.toBeNull(); + + vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable')); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Fresh After Failure'); + }); + + it('does not let stale in-flight snapshot stat results invalidate a primed config cache', async () => { + const teamName = 'stale-stat-prime-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Stat Race', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const statDeferred = createDeferred(); + let statCalls = 0; + vi.spyOn(nodeFs.promises, 'stat').mockImplementation(async () => { + statCalls++; + if (statCalls === 1) { + return (await statDeferred.promise) as never; + } + throw new Error('stat unavailable'); + }); + + const reader = new TeamConfigReader(); + const snapshot = reader.getConfigSnapshot(teamName); + await vi.waitFor(() => expect(statCalls).toBe(1)); + + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Fresh After Stat Race', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await TeamConfigReader.primeConfig(teamName, { + name: 'Fresh After Stat Race', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + } as never); + + statDeferred.resolve({ + size: BigInt(4096), + mode: BigInt(33188), + dev: BigInt(1), + ino: BigInt(2), + mtimeMs: 1000, + ctimeMs: 1000, + birthtimeMs: 1000, + isFile: () => false, + }); + + expect((await snapshot)?.name).toBe('Fresh After Stat Race'); + }); + + it('invalidateTeam forces the next snapshot to reread config content', async () => { + const teamName = 'invalidate-cache-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Invalidate', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Invalidate'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After Invalidate', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + TeamConfigReader.invalidateTeam(teamName); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Invalidate'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); + + it('uses recent snapshot cache on stat failure but verified mode does not', async () => { + const teamName = 'stat-failure-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Recent Cache', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache'); + vi.spyOn(nodeFs.promises, 'stat').mockRejectedValue(new Error('stat unavailable')); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Recent Cache'); + await expect(reader.getConfigVerified(teamName)).resolves.toBeNull(); + }); + + it('clears snapshot cache after parse failure', async () => { + const teamName = 'parse-failure-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Valid Config', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Valid Config'); + await fs.writeFile(configPath, '{"name":', 'utf8'); + + expect(await reader.getConfigSnapshot(teamName)).toBeNull(); + await fs.rm(configPath); + expect(await reader.getConfigSnapshot(teamName)).toBeNull(); + }); + + it('clears snapshot cache when config disappears and reloads after recreation', async () => { + const teamName = 'missing-then-recreated-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Before Delete', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Before Delete'); + await fs.rm(configPath); + + expect(await reader.getConfigSnapshot(teamName)).toBeNull(); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'After Recreate', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('After Recreate'); + }); + + it('bounds stale snapshots on coarse fingerprints with periodic full verification', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-30T12:00:00.000Z')); + + const teamName = 'coarse-fs-team'; + const teamDir = path.join(tempDir, teamName); + const configPath = path.join(teamDir, 'config.json'); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Alpha', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + const readFileSpy = vi.spyOn(nodeFs.promises, 'readFile'); + vi.spyOn(nodeFs.promises, 'stat').mockResolvedValue({ + size: BigInt(4096), + mode: BigInt(33188), + dev: BigInt(1), + ino: BigInt(2), + mtimeMs: 1000, + ctimeMs: 1000, + birthtimeMs: 1000, + isFile: () => true, + } as never); + + const reader = new TeamConfigReader(); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha'); + await fs.writeFile( + configPath, + JSON.stringify({ + name: 'Bravo', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Alpha'); + vi.advanceTimersByTime(1_501); + expect((await reader.getConfigSnapshot(teamName))?.name).toBe('Bravo'); + expect(readFileSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 9a350f98..e828792d 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -423,6 +423,9 @@ function createGetTeamDataHarness( const getConfig = vi.fn(async () => options.config === undefined ? buildDefaultTeamConfig() : options.config ); + const getConfigSnapshot = vi.fn(async () => + options.config === undefined ? buildDefaultTeamConfig() : options.config + ); const getTasks = options.getTasks ?? (async () => { @@ -496,6 +499,7 @@ function createGetTeamDataHarness( { listTeams: vi.fn(), getConfig, + getConfigSnapshot, } as never, taskReader as never, inboxReader as never, @@ -522,6 +526,7 @@ function createGetTeamDataHarness( return { service, getConfig, + getConfigSnapshot, taskReader, inboxReader, membersMetaStore, @@ -4431,6 +4436,24 @@ describe('TeamDataService', () => { expect(harness.listProcessesSpy).not.toHaveBeenCalled(); }); + it('uses snapshot config reads for UI team data snapshots', async () => { + const harness = createGetTeamDataHarness(); + + await harness.service.getTeamData('my-team'); + + expect(harness.getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(harness.getConfig).not.toHaveBeenCalled(); + }); + + it('uses snapshot config reads for UI message feed snapshots', async () => { + const harness = createGetTeamDataHarness(); + + await harness.service.getMessageFeed('my-team'); + + expect(harness.getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(harness.getConfig).not.toHaveBeenCalled(); + }); + it('starts light reads immediately, bounds heavy reads, and keeps processes outside the parallel phase', async () => { const order: string[] = []; const tasksDeferred = createDeferred(); diff --git a/test/main/services/team/TeamDataWorkerClient.test.ts b/test/main/services/team/TeamDataWorkerClient.test.ts new file mode 100644 index 00000000..8628ec61 --- /dev/null +++ b/test/main/services/team/TeamDataWorkerClient.test.ts @@ -0,0 +1,143 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const workers: Array<{ + messages: unknown[]; + handlers: Map void>; + postMessage: (message: unknown) => void; + on: (event: string, handler: (value: unknown) => void) => void; + terminate: () => Promise; + }> = []; + const createMockWorker = vi.fn().mockImplementation(() => { + const worker = { + messages: [] as unknown[], + handlers: new Map void>(), + postMessage(message: unknown) { + worker.messages.push(message); + const request = message as { id: string; op: string; payload?: { teamName?: string } }; + queueMicrotask(() => { + const handler = worker.handlers.get('message'); + if (!handler) return; + handler({ + id: request.id, + ok: true, + result: + request.op === 'getTeamData' + ? { teamName: request.payload?.teamName, config: { name: 'Team' } } + : null, + }); + }); + }, + on(event: string, handler: (value: unknown) => void) { + worker.handlers.set(event, handler); + }, + terminate: vi.fn(async () => undefined), + }; + workers.push(worker); + return worker; + }); + return { + workers, + createMockWorker, + }; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(() => true), + }; +}); + +vi.mock('node:worker_threads', () => ({ + Worker: hoisted.createMockWorker, + default: { + Worker: hoisted.createMockWorker, + }, +})); + +describe('TeamDataWorkerClient', () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + hoisted.workers.length = 0; + }); + + it('deduplicates concurrent getTeamData calls for the same team', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const [first, second] = await Promise.all([ + client.getTeamData('my-team'), + client.getTeamData('my-team'), + ]); + + expect(first).toEqual(second); + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'getTeamData', + payload: { teamName: 'my-team' }, + }); + + client.dispose(); + }); + + it('sends best-effort team config invalidation to the worker', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + await client.getTeamData('my-team'); + hoisted.workers[0].messages.length = 0; + + client.invalidateTeamConfig('my-team'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'invalidateTeamConfig', + payload: { teamName: 'my-team' }, + }); + + client.dispose(); + }); + + it('clears in-flight getTeamData dedupe when invalidating team config', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + const first = client.getTeamData('my-team'); + client.invalidateTeamConfig('my-team'); + const second = client.getTeamData('my-team'); + + await Promise.all([first, second]); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages.map((message) => (message as { op: string }).op)).toEqual([ + 'getTeamData', + 'invalidateTeamConfig', + 'getTeamData', + ]); + + client.dispose(); + }); + + it('does not spawn a worker only to send config invalidation', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + client.invalidateTeamConfig('my-team'); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(hoisted.workers).toHaveLength(0); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 17b236dd..0cb1eddb 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -6463,7 +6463,7 @@ describe('TeamProvisioningService', () => { expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']); }); - it('accepts duplicate OpenCode bootstrap check-ins for the same runtime session without rewriting liveness', async () => { + it('accepts duplicate OpenCode bootstrap check-ins for the same runtime session and refreshes liveness', async () => { const svc = new TeamProvisioningService(); const previousSnapshot = { version: 2 as const, @@ -6520,7 +6520,14 @@ describe('TeamProvisioningService', () => { diagnostics: ['opencode_bootstrap_checkin_duplicate_accepted'], runtimeSessionId: 'session-bob', }); - expect(updateLiveness).not.toHaveBeenCalled(); + expect(updateLiveness).toHaveBeenCalledWith( + expect.objectContaining({ + teamName: 'mixed-team', + runId: 'opencode-run-1', + memberName: 'bob', + runtimeSessionId: 'session-bob', + }) + ); }); it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => { @@ -6869,6 +6876,114 @@ describe('TeamProvisioningService', () => { ]); }); + it('updates the live mixed OpenCode lane when bootstrap check-in arrives after launch command completion', async () => { + const svc = new TeamProvisioningService(); + const persistLaunchStateSnapshot = vi.spyOn(svc as any, 'persistLaunchStateSnapshot'); + const teamName = 'mixed-live-checkin-team'; + const laneId = 'secondary:opencode:tom'; + const runId = 'opencode-run-tom'; + const run = createMemberSpawnRun({ + runId: 'lead-run', + teamName, + expectedMembers: ['bob', 'tom'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + ], + [ + 'tom', + createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }), + ], + ]), + }); + Object.assign(run, { + isLaunch: true, + request: { providerId: 'codex', members: [] }, + effectiveMembers: [{ name: 'bob', providerId: 'codex', model: 'gpt-5.5' }], + allEffectiveMembers: [ + { name: 'bob', providerId: 'codex', model: 'gpt-5.5' }, + { name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' }, + ], + mixedSecondaryLanes: [ + { + laneId, + providerId: 'opencode', + member: { + name: 'tom', + providerId: 'opencode', + model: 'openrouter/minimax/minimax-m2.5', + }, + runId, + state: 'finished', + result: { + runId, + teamName, + launchPhase: 'active', + teamLaunchState: 'partial_pending', + members: { + tom: { + memberName: 'tom', + providerId: 'opencode', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + livenessKind: 'registered_only', + diagnostics: ['registered runtime metadata without live process'], + }, + }, + warnings: [], + diagnostics: ['registered runtime metadata without live process'], + }, + warnings: [], + diagnostics: ['registered runtime metadata without live process'], + }, + ], + }); + (svc as any).aliveRunByTeam.set(teamName, 'lead-run'); + (svc as any).runs.set('lead-run', run); + + await (svc as any).updateOpenCodeRuntimeMemberLiveness({ + teamName, + runId, + memberName: 'tom', + runtimeSessionId: 'ses_tom_live', + observedAt: '2026-04-22T12:05:00.000Z', + diagnostics: undefined, + metadata: undefined, + reason: 'OpenCode runtime bootstrap check-in accepted', + }); + + expect(run.memberSpawnStatuses.get('tom')).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessKind: 'confirmed_bootstrap', + }); + expect(run.mixedSecondaryLanes[0]?.result?.members.tom).toMatchObject({ + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + sessionId: 'ses_tom_live', + }); + expect(persistLaunchStateSnapshot).toHaveBeenCalledWith(run, 'finished'); + }); + it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => { const svc = new TeamProvisioningService(); const delivered = new Map< @@ -13188,6 +13303,222 @@ describe('TeamProvisioningService', () => { }); }); + it('recovers degraded OpenCode file-lock failures when bootstrap evidence committed later', async () => { + const teamName = 'atlas-hq-file-lock-late-evidence'; + const tomLaneId = 'secondary:opencode:tom'; + const tomRunId = 'tom-runtime-run'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + }); + writeMembersMeta(teamName, [ + { name: 'bob', providerId: 'codex', model: 'gpt-5.5' }, + { name: 'jack', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['bob', 'jack']); + writeBootstrapState(teamName, [ + { name: 'bob', status: 'bootstrap_confirmed', lastObservedAt: Date.now() - 60_000 }, + { name: 'jack', status: 'bootstrap_confirmed', lastObservedAt: Date.now() - 60_000 }, + ]); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'finished', + expectedMembers: ['bob', 'jack', 'tom'], + bootstrapExpectedMembers: ['bob', 'jack'], + members: { + bob: { + name: 'bob', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + jack: { + name: 'jack', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + tom: { + name: 'tom', + providerId: 'opencode', + model: 'openrouter/minimax/minimax-m2.5', + laneId: tomLaneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: `File lock timeout: ${path.join( + tempTeamsBase, + teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(tomLaneId), + 'opencode-runtime-receipts.json' + )}`, + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: tomLaneId, + state: 'degraded', + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + }); + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId: tomLaneId, + runId: tomRunId, + sessions: [ + { + id: 'ses_tom_late', + teamName, + memberName: 'tom', + runId: tomRunId, + laneId: tomLaneId, + providerId: 'opencode', + observedAt: '2026-04-23T10:01:00.000Z', + source: 'runtime_bootstrap_checkin', + }, + ], + }); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + bootstrapConfirmed: true, + }); + expect(result.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(result.statuses.jack).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + }); + + it('does not recover degraded OpenCode file-lock failures from stale run evidence', async () => { + const teamName = 'atlas-hq-file-lock-stale-run-evidence'; + const tomLaneId = 'secondary:opencode:tom'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.5', + }); + writeMembersMeta(teamName, [ + { name: 'tom', providerId: 'opencode', model: 'openrouter/minimax/minimax-m2.5' }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', []); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + leadSessionId: 'lead-session', + launchPhase: 'finished', + expectedMembers: ['tom'], + bootstrapExpectedMembers: [], + members: { + tom: { + name: 'tom', + providerId: 'opencode', + model: 'openrouter/minimax/minimax-m2.5', + laneId: tomLaneId, + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'File lock timeout: opencode-runtime-receipts.json', + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: tomLaneId, + state: 'degraded', + diagnostics: ['File lock timeout: opencode-runtime-receipts.json'], + }); + await writeCommittedOpenCodeSessionStore({ + teamName, + laneId: tomLaneId, + runId: 'current-runtime-run', + sessions: [ + { + id: 'ses_tom_old', + teamName, + memberName: 'tom', + runId: 'old-runtime-run', + laneId: tomLaneId, + providerId: 'opencode', + observedAt: '2026-04-23T09:00:00.000Z', + source: 'runtime_bootstrap_checkin', + }, + ], + }); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.tom).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + bootstrapConfirmed: false, + }); + }); + it('reconciles stale persisted mixed pending OpenCode lanes instead of keeping them pending forever', async () => { const teamName = 'signal-ops-7'; writeTeamMeta(teamName, { diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index 90f288a1..8ccc48ac 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -83,6 +83,80 @@ describe('memberHelpers spawn-aware presence', () => { ).toBe('starting'); }); + it('labels queued OpenCode lanes separately from active startup', () => { + const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' }; + + expect( + buildMemberLaunchPresentation({ + member: openCodeMember, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'queued', + launchVisualState: 'queued', + launchStatusLabel: 'queued', + dotClass: expect.stringContaining('bg-zinc-400'), + }); + }); + + it('does not label non-OpenCode waiting lanes as queued', () => { + expect( + buildMemberLaunchPresentation({ + member, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'starting', + launchVisualState: 'waiting', + launchStatusLabel: 'waiting to start', + }); + }); + + it('keeps OpenCode runtime evidence states more specific than queued', () => { + const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' }; + + expect( + buildMemberLaunchPresentation({ + member: openCodeMember, + spawnStatus: 'waiting', + spawnLaunchState: 'starting', + spawnLivenessSource: undefined, + spawnRuntimeAlive: false, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: true, + providerId: 'opencode', + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeAdvisory: undefined, + isLaunchSettling: true, + isTeamAlive: true, + isTeamProvisioning: false, + }) + ).toMatchObject({ + presenceLabel: 'registered', + launchVisualState: 'registered_only', + launchStatusLabel: 'registered', + }); + }); + it('keeps starting visuals after provisioning already transitioned out of active state', () => { expect( getSpawnAwarePresenceLabel( From 9ad32d99785425d3373cbef8473ab6ef92509a8f Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 14:06:42 +0300 Subject: [PATCH 02/51] chore(runtime): pin orchestrator 0.0.16 --- runtime.lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index 05d7d288..4b46f212 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.15", - "sourceRef": "v0.0.15", + "version": "0.0.16", + "sourceRef": "v0.0.16", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.15.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.16.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.15.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.16.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.15.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.16.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.15.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.16.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } From 7609c548c575e22d172e85eb900669398a70d1f3 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:10:42 +0500 Subject: [PATCH 03/51] fix(tests): resolve pre-existing test failures on non-standard environments - TeamProvisioningServiceRelay: add missing stat fields (mode, dev, ino, mtimeMs, ctimeMs, birthtimeMs) to fs mock so new fingerprint-based TeamConfigReader cache can read config in tests - TeamMcpConfigBuilder: export clearResolvedNodePathForTests() to reset module-level node path cache between tests; restore execFileMock implementation in beforeEach after vi.restoreAllMocks() clears it; broaden node binary regex to accept versioned names (node-22, node-20) common on Fedora/RHEL systems - ScheduledTaskExecutor: strip CLAUDECODE at spawn site as last defence so nested-session detection is prevented even when buildProviderAwareCliEnv merges it back in from the outer process environment --- .../schedule/ScheduledTaskExecutor.ts | 4 +- .../services/team/TeamMcpConfigBuilder.ts | 4 + .../team/TeamMcpConfigBuilder.test.ts | 10 +- .../team/TeamProvisioningServiceRelay.test.ts | 142 +++++++++--------- 4 files changed, 88 insertions(+), 72 deletions(-) diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 34cd0761..849250d5 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -178,7 +178,9 @@ export class ScheduledTaskExecutor { cwd: request.config.cwd, // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // take precedence over the cached snapshot inside buildEnrichedEnv. - env, + // CLAUDECODE stripped last to prevent nested-session detection regardless + // of what buildProviderAwareCliEnv merges in. + env: { ...env, CLAUDECODE: undefined }, stdio: ['ignore', 'pipe', 'pipe'], }); diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts index 21ec5c63..32c19341 100644 --- a/src/main/services/team/TeamMcpConfigBuilder.ts +++ b/src/main/services/team/TeamMcpConfigBuilder.ts @@ -154,6 +154,10 @@ async function hasValidServerCopy(dir: string): Promise { let _resolvedNodePath: string | undefined; +export function clearResolvedNodePathForTests(): void { + _resolvedNodePath = undefined; +} + /** * Find the real `node` binary path. In Electron, process.execPath is the * Electron binary — NOT node — so we must resolve node separately. diff --git a/test/main/services/team/TeamMcpConfigBuilder.test.ts b/test/main/services/team/TeamMcpConfigBuilder.test.ts index 01b50283..cf98ab04 100644 --- a/test/main/services/team/TeamMcpConfigBuilder.test.ts +++ b/test/main/services/team/TeamMcpConfigBuilder.test.ts @@ -46,7 +46,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => { }); import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder'; -import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder'; +import { + TeamMcpConfigBuilder, + clearResolvedNodePathForTests, +} from '@main/services/team/TeamMcpConfigBuilder'; describe('TeamMcpConfigBuilder', () => { const createdPaths: string[] = []; @@ -93,7 +96,7 @@ describe('TeamMcpConfigBuilder', () => { entry: string ): void { expect(server?.args).toEqual([entry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function expectNodeTsxSourceEntry( @@ -102,7 +105,7 @@ describe('TeamMcpConfigBuilder', () => { sourceEntry: string ): void { expect(server?.args).toEqual([tsxCli, sourceEntry]); - expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); + expect(server?.command).toMatch(/(^node(?:-\d+)?$|[\\/]node(?:-\d+)?(?:\.exe)?$)/); } function getBuiltWorkspaceEntry(): string { @@ -165,6 +168,7 @@ describe('TeamMcpConfigBuilder', () => { } beforeEach(() => { + clearResolvedNodePathForTests(); originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath; tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-')); createdDirs.push(tempAppData); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 1b5e8b7e..ab386b99 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -16,9 +16,16 @@ const hoisted = vi.hoisted(() => { error.code = 'ENOENT'; throw error; } + const size = Buffer.byteLength(data, 'utf8'); return { isFile: () => true, - size: Buffer.byteLength(data, 'utf8'), + size, + mode: 0o644, + dev: 0, + ino: 0, + mtimeMs: 0, + ctimeMs: 0, + birthtimeMs: 0, }; }); @@ -54,22 +61,20 @@ const hoisted = vi.hoisted(() => { files.set(sentMessagesPath, JSON.stringify(rows)); return message; }), - sendInboxMessage: vi.fn( - (teamName: string, message: Record) => { - const member = - typeof message.member === 'string' - ? message.member - : typeof message.to === 'string' - ? message.to - : 'unknown'; - const p = `/mock/teams/${teamName}/inboxes/${member}.json`; - const current = files.get(p); - const rows = current ? (JSON.parse(current) as unknown[]) : []; - rows.push(message); - files.set(p, JSON.stringify(rows)); - return { deliveredToInbox: true, messageId: 'mock-id', message }; - } - ), + sendInboxMessage: vi.fn((teamName: string, message: Record) => { + const member = + typeof message.member === 'string' + ? message.member + : typeof message.to === 'string' + ? message.to + : 'unknown'; + const p = `/mock/teams/${teamName}/inboxes/${member}.json`; + const current = files.get(p); + const rows = current ? (JSON.parse(current) as unknown[]) : []; + rows.push(message); + files.set(p, JSON.stringify(rows)); + return { deliveredToInbox: true, messageId: 'mock-id', message }; + }), setAtomicWriteShouldFail: (next: boolean) => { atomicWriteShouldFail = next; }, @@ -371,7 +376,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: system_notification'); expect(payload).toContain('summary looks like \\"Comment on #...\\"'); - expect(payload).toContain('reply via task_add_comment only when you have a substantive board update'); + expect(payload).toContain( + 'reply via task_add_comment only when you have a substantive board update' + ); expect(payload).toContain('Do NOT post acknowledgement-only task comments'); (service as any).handleStreamJsonMessage(run, { @@ -492,9 +499,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -538,14 +549,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const { runId: oldRunId } = attachAliveRun(service, teamName, { runId: 'run-old' }); const inboxDeferred = createDeferred<[typeof permissionMessage]>(); - const inboxReader = (service as unknown as { - inboxReader: { - getMessagesFor: ( - team: string, - member: string - ) => Promise<[typeof permissionMessage]>; - }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise<[typeof permissionMessage]>; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -654,7 +664,9 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const payload = String(writeSpy.mock.calls[0]?.[0] ?? ''); expect(payload).toContain('Source: cross_team'); expect(payload).toContain('Cross-team conversationId: conv-explicit'); - expect(payload).toContain('Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"'); + expect(payload).toContain( + 'Call the MCP tool named cross_team_send with toTeam=\\"other-team\\"' + ); expect(payload).toContain('replyToConversationId=\\"conv-explicit\\"'); expect(payload).toContain('NEVER set recipient/to to \\"cross_team_send\\"'); @@ -905,7 +917,11 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { attachAliveRun(service, teamName); const run = (service as unknown as { runs: Map }).runs.get('run-1') as { - silentUserDmForward: { target: string; startedAt: string; mode: 'user_dm' | 'member_inbox_relay' } | null; + silentUserDmForward: { + target: string; + startedAt: string; + mode: 'user_dm' | 'member_inbox_relay'; + } | null; }; run.silentUserDmForward = { target: 'alice', @@ -1072,9 +1088,13 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { runId: 'run-old', }); const inboxDeferred = createDeferred(); - const inboxReader = (service as unknown as { - inboxReader: { getMessagesFor: (team: string, member: string) => Promise }; - }).inboxReader; + const inboxReader = ( + service as unknown as { + inboxReader: { + getMessagesFor: (team: string, member: string) => Promise; + }; + } + ).inboxReader; const inboxSpy = vi .spyOn(inboxReader, 'getMessagesFor') .mockImplementationOnce(async () => await inboxDeferred.promise) @@ -1284,11 +1304,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { await (service as any).markInboxMessagesRead(teamName, 'alice', [ { - messageId: buildLegacyInboxMessageId( - legacyRow.from, - legacyRow.timestamp, - legacyRow.text - ), + messageId: buildLegacyInboxMessageId(legacyRow.from, legacyRow.timestamp, legacyRow.text), }, ]); @@ -1684,9 +1700,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }], }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); @@ -1732,9 +1746,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { failed: 0, lastDelivery: { delivered: true, responsePending: true }, }); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -1866,9 +1878,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-terminal-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([false, true]); }); @@ -1952,9 +1962,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_attachments_not_supported_for_secondary_runtime' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); expect(records[0]).toMatchObject({ inboxMessageId: 'opencode-attachment-1', @@ -1979,7 +1987,10 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { ], }) ); - const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity(teamName, 'jack'); + const identity = await (service as any).resolveOpenCodeMemberDeliveryIdentity( + teamName, + 'jack' + ); expect(identity.ok).toBe(true); const laneId = identity.laneId; const records: any[] = []; @@ -2000,7 +2011,12 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { return record; }), markAcceptanceUnknown: vi.fn( - async (input: { id: string; reason: string; nextAttemptAt: string; markedAt: string }) => { + async (input: { + id: string; + reason: string; + nextAttemptAt: string; + markedAt: string; + }) => { const record = records.find((candidate) => candidate.id === input.id); Object.assign(record, { status: 'failed_retryable', @@ -2132,9 +2148,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { teamName, expect.objectContaining({ messageId: 'opencode-inflight-new' }) ); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows.map((row: { read?: boolean }) => row.read)).toEqual([true, true]); }); @@ -2211,9 +2225,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { const relay = await service.relayInboxFileToLiveRecipient(teamName, 'jack'); expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(true); }); @@ -2303,9 +2315,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'OpenCode inbox relay failed for jack/opencode-relay-failed-1' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); @@ -2337,9 +2347,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { delivered: true, diagnostics: [], }); - vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue( - new Error('write failed') - ); + vi.spyOn(service as any, 'markInboxMessagesRead').mockRejectedValue(new Error('write failed')); const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack'); @@ -2360,9 +2368,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { 'opencode_inbox_mark_read_failed_after_delivery' ); vi.mocked(console.warn).mockClear(); - const rows = JSON.parse( - hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]' - ); + const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); }); From f764af17d8a5e66ba1018a6b979ce64a24c4b52a Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:29:19 +0500 Subject: [PATCH 04/51] perf(renderer): wrap heavy view components in React.memo TeamDetailView (3166L), TeamListView (1180L), DateGroupedSessions (1117L), and MarkdownViewer (1198L) were re-rendering on every parent render cycle. Wrapping them in memo() prevents cascading re-renders when their props and store subscriptions have not changed, targeting VSCode-level UI responsiveness. --- .../chat/viewers/MarkdownViewer.tsx | 424 +- .../sidebar/DateGroupedSessions.tsx | 6 +- .../components/team/TeamDetailView.tsx | 4222 +++++++++-------- src/renderer/components/team/TeamListView.tsx | 6 +- 4 files changed, 2344 insertions(+), 2314 deletions(-) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 5bd3b977..2c7d3922 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -946,47 +946,200 @@ export const CompactMarkdownPreview: React.FC = Rea } ); -export const MarkdownViewer: React.FC = ({ - content, - maxHeight = 'max-h-96', - className = '', - label, - itemId, - searchQueryOverride, - copyable = false, - bare = false, - baseDir, - teamColorByName: providedTeamColorByName, - onTeamClick: providedOnTeamClick, -}) => { - const [showRaw, setShowRaw] = React.useState(false); - const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); - const { isLight } = useTheme(); - const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( - providedTeamColorByName, - providedOnTeamClick - ); +export const MarkdownViewer: React.FC = React.memo( + ({ + content, + maxHeight = 'max-h-96', + className = '', + label, + itemId, + searchQueryOverride, + copyable = false, + bare = false, + baseDir, + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, + }) => { + const [showRaw, setShowRaw] = React.useState(false); + const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); - const isTooLarge = content.length > MAX_MARKDOWN_CHARS; - const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; + const isTooLarge = content.length > MAX_MARKDOWN_CHARS; + const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only re-render if THIS item has search matches - const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => { - const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; - return { - searchQuery: hasMatch ? s.searchQuery : '', - searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, - }; - }) - ); + // Only re-render if THIS item has search matches + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) + ); + + // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). + // For large content, default to a lightweight raw preview with manual expansion. + if (isTooLarge || showRaw) { + const shown = content.slice(0, Math.min(rawLimit, content.length)); + const isTruncated = shown.length < content.length; + return ( +
+ {copyable && !label && ( + + )} + + {label && ( +
+ + + {label} + + + Raw + + + + {copyable && } +
+ )} + + {!label && ( +
+ Raw preview + +
+ )} + + {isTooLarge && ( +
+ Content is very large ({content.length.toLocaleString()} chars). Showing raw preview + to keep the UI responsive. +
+ )} + +
+
+              {shown}
+            
+ {isTruncated && ( +
+ + Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars + +
+ + +
+
+ )} +
+
+ ); + } + + // Create search context (fresh each render so counter starts at 0) + const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); + const effectiveMatches = searchQueryOverride ? [] : searchMatches; + const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; + const searchCtx = + effectiveQuery && itemId + ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) + : null; + // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). + if (searchCtx && searchQueryOverride) { + searchCtx.forceAllActive = true; + } + + // Create markdown components with optional search highlighting + // When search is active, create fresh each render (match counter is stateful and must start at 0) + // useMemo would cache stale closures when parent re-renders without search deps changing + const baseComponents = searchCtx + ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) + : isLight + ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) + : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); + + // When baseDir is set (editor preview), override img to load local files via IPC + const components = baseDir + ? { + ...baseComponents, + img: ({ src, alt }: { src?: string; alt?: string }) => { + if (src && isRelativeUrl(src)) { + return ; + } + return {alt; + }, + } + : baseComponents; - // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). - // For large content, default to a lightweight raw preview with manual expansion. - if (isTooLarge || showRaw) { - const shown = content.slice(0, Math.min(rawLimit, content.length)); - const isTruncated = shown.length < content.length; return (
= ({ } } > + {/* Copy button overlay (when no label header) */} {copyable && !label && ( )} + {/* Optional header - matches CodeBlockViewer style */} {label && (
= ({ {label} - - Raw - - - - {copyable && } -
- )} - - {!label && ( -
- Raw preview - -
- )} - - {isTooLarge && ( -
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to - keep the UI responsive. + {copyable && ( + <> + + + + )}
)} + {/* Markdown content with scroll */}
-
-            {shown}
-          
- {isTruncated && ( -
- - Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars - -
- - -
-
- )} +
+ + {content} + +
); } - - // Create search context (fresh each render so counter starts at 0) - const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); - const effectiveMatches = searchQueryOverride ? [] : searchMatches; - const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; - const searchCtx = - effectiveQuery && itemId - ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) - : null; - // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). - if (searchCtx && searchQueryOverride) { - searchCtx.forceAllActive = true; - } - - // Create markdown components with optional search highlighting - // When search is active, create fresh each render (match counter is stateful and must start at 0) - // useMemo would cache stale closures when parent re-renders without search deps changing - const baseComponents = searchCtx - ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) - : isLight - ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) - : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); - - // When baseDir is set (editor preview), override img to load local files via IPC - const components = baseDir - ? { - ...baseComponents, - img: ({ src, alt }: { src?: string; alt?: string }) => { - if (src && isRelativeUrl(src)) { - return ; - } - return {alt; - }, - } - : baseComponents; - - return ( -
- {/* Copy button overlay (when no label header) */} - {copyable && !label && ( - - )} - - {/* Optional header - matches CodeBlockViewer style */} - {label && ( -
- - - {label} - - {copyable && ( - <> - - - - )} -
- )} - - {/* Markdown content with scroll */} -
-
- - {content} - -
-
-
- ); -}; +); diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index fe578e07..39827dcb 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -4,7 +4,7 @@ * Supports multi-select with bulk actions and hidden session filtering. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; @@ -184,7 +184,7 @@ function matchesSessionSearch(session: Session, query: string): boolean { return haystack.includes(query); } -export const DateGroupedSessions = (): React.JSX.Element => { +export const DateGroupedSessions = memo((): React.JSX.Element => { const { sessions, selectedSessionId, @@ -1114,4 +1114,4 @@ export const DateGroupedSessions = (): React.JSX.Element => { ); -}; +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index dc992781..99f44e83 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -947,1734 +947,2117 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); -export const TeamDetailView = ({ - teamName, - isPaneFocused = false, -}: TeamDetailViewProps): React.JSX.Element => { - const { isLight } = useTheme(); - const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [selectedMember, setSelectedMember] = useState(null); - const [selectedMemberView, setSelectedMemberView] = useState<{ - initialTab?: MemberDetailTab; - initialActivityFilter?: MemberActivityFilter; - } | null>(null); - const [pendingRepliesByMember, setPendingRepliesByMember] = useState>(() => - getTeamPendingRepliesState(teamName) - ); - const [createTaskDialog, setCreateTaskDialog] = useState({ - open: false, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - }); - const [creatingTask, setCreatingTask] = useState(false); - const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); - const [addingMemberLoading, setAddingMemberLoading] = useState(false); - const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); - const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [launchDialogState, setLaunchDialogState] = useState<{ - open: boolean; - mode: TeamLaunchDialogMode; - }>({ - open: false, - mode: 'launch', - }); - const [editorOpen, setEditorOpen] = useState(false); - const [graphOpen, setGraphOpen] = useState(false); - const contentRef = useRef(null); - const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( - null - ); - const provisioningBannerRef = useRef(null); - const wasProvisioningRef = useRef(false); - const handleOpenGraphTab = useCallback(() => { - const state = useStore.getState(); - const displayName = state.teamByName[teamName]?.displayName ?? teamName; - state.openTab({ - type: 'graph', - label: `${displayName} Graph`, - teamName, - }); - }, [teamName]); - const visualizeButtonStyle = useMemo( - () => - isLight - ? { - background: - 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', - borderColor: 'rgba(59,130,246,0.30)', - color: '#0f172a', - boxShadow: '0 10px 24px rgba(59,130,246,0.12)', - } - : { - background: - 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', - borderColor: 'rgba(56,189,248,0.34)', - color: 'rgba(236,253,255,0.96)', - boxShadow: '0 12px 28px rgba(8,145,178,0.22)', - }, - [isLight] - ); - - // Set inert on background content when editor/graph overlay is open (a11y focus trap) - useEffect(() => { - const el = contentRef.current; - if (!el) return; - if (editorOpen || graphOpen) { - el.setAttribute('inert', ''); - } else { - el.removeAttribute('inert'); - } - }, [editorOpen, graphOpen]); - - // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (detail?.teamName === teamName) { - handleOpenGraphTab(); - } - }; - window.addEventListener('toggle-team-graph', handler); - return () => window.removeEventListener('toggle-team-graph', handler); - }, [handleOpenGraphTab, teamName]); - - // Listen for graph tab actions (open task, send message) - useEffect(() => { - const onOpenTask = (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - if (task) setSelectedTask(task); - }; - const onSendMsg = (e: Event) => { - const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }; - const onOpenProfile = (e: Event) => { - const { - teamName: tn, - memberName, - initialTab, - initialActivityFilter, - } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const member = members.find((m: { name: string }) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab, - initialActivityFilter, - }); - } - }; - const onCreateTask = (e: Event) => { - const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - openCreateTaskDialog('', '', owner ?? ''); - }; - window.addEventListener('graph:open-task', onOpenTask); - window.addEventListener('graph:send-message', onSendMsg); - window.addEventListener('graph:open-profile', onOpenProfile); - window.addEventListener('graph:create-task', onCreateTask); - - // Task action events from graph - const taskAction = (handler: (taskId: string) => void) => (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !taskId) return; - handler(taskId); - }; - const onStartTask = taskAction((taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } - } catch { - /* best-effort */ - } - } - } catch { - /* error via store */ - } - })(); - }); - const onCompleteTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onApproveTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - /* */ - } - })(); - }); - const onRequestReviewTask = taskAction((taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - /* */ - } - })(); - }); - const onRequestChangesTask = taskAction((taskId) => { - setRequestChangesTaskId(taskId); - }); - const onCancelTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'pending'); - } catch { - /* */ - } - })(); - }); - const onMoveBackToDoneTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); - - window.addEventListener('graph:start-task', onStartTask); - window.addEventListener('graph:complete-task', onCompleteTask); - window.addEventListener('graph:approve-task', onApproveTask); - window.addEventListener('graph:request-review', onRequestReviewTask); - window.addEventListener('graph:request-changes', onRequestChangesTask); - window.addEventListener('graph:cancel-task', onCancelTask); - window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.addEventListener('graph:delete-task', onDeleteTaskGraph); - return () => { - window.removeEventListener('graph:open-task', onOpenTask); - window.removeEventListener('graph:send-message', onSendMsg); - window.removeEventListener('graph:open-profile', onOpenProfile); - window.removeEventListener('graph:create-task', onCreateTask); - window.removeEventListener('graph:start-task', onStartTask); - window.removeEventListener('graph:complete-task', onCompleteTask); - window.removeEventListener('graph:approve-task', onApproveTask); - window.removeEventListener('graph:request-review', onRequestReviewTask); - window.removeEventListener('graph:request-changes', onRequestChangesTask); - window.removeEventListener('graph:cancel-task', onCancelTask); - window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.removeEventListener('graph:delete-task', onDeleteTaskGraph); - }; - }); - - const [sendDialogOpen, setSendDialogOpen] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [stoppingTeam, setStoppingTeam] = useState(false); - const [trashOpen, setTrashOpen] = useState(false); - const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); - const [sendDialogDefaultText, setSendDialogDefaultText] = useState(undefined); - const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( - undefined - ); - const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( - undefined - ); - const [reviewDialogState, setReviewDialogState] = useState<{ - open: boolean; - mode: 'agent' | 'task'; - memberName?: string; - taskId?: string; - initialFilePath?: string; - taskChangeRequestOptions?: TaskChangeRequestOptions; - }>({ open: false, mode: 'task' }); - - // Active teams for conflict warning in LaunchTeamDialog - const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< - { teamName: string; displayName: string; projectPath: string }[] - >([]); - const launchDialogOpen = launchDialogState.open; - - // Session loading and filtering state - const [sessions, setSessions] = useState([]); - const [sessionsLoading, setSessionsLoading] = useState(false); - const [sessionsError, setSessionsError] = useState(null); - const [kanbanFilter, setKanbanFilter] = useState({ - sessionId: null, - selectedOwners: new Set(), - columns: new Set(), - }); - const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); - - const { - data, - members, - loading, - error, - projects, - repositoryGroups, - initTabUIState, - selectTeam, - updateKanban, - updateKanbanColumnOrder, - updateTaskStatus, - updateTaskOwner, - sendTeamMessage, - requestReview, - createTeamTask, - startTaskByUser, - deleteTeam, - openTeamsTab, - closeTab, - sendingMessage, - sendMessageError, - sendMessageWarning, - sendMessageDebugDetails, - lastSendMessageResult, - reviewActionError, - addMember, - restartMember, - skipMemberForLaunch, - removeMember, - updateMemberRole, - launchTeam, - provisioningError, - clearProvisioningError, - isTeamProvisioning, - refreshTeamData, - refreshTeamMessagesHead, - refreshMemberActivityMeta, - syncTeamPendingReplyRefresh, - kanbanFilterQuery, - clearKanbanFilter, - softDeleteTask, - restoreTask, - fetchDeletedTasks, - deletedTasks, - launchParams, - messagesPanelMode, - messagesPanelWidth, - sidebarLogsHeight, - setMessagesPanelMode, - setMessagesPanelWidth, - setSidebarLogsHeight, - selectReviewFile, - pendingReviewRequest, - setPendingReviewRequest, - } = useStore( - useShallow((s) => ({ - projects: s.projects, - repositoryGroups: s.repositoryGroups, - initTabUIState: s.initTabUIState, - selectTeam: s.selectTeam, - updateKanban: s.updateKanban, - updateKanbanColumnOrder: s.updateKanbanColumnOrder, - updateTaskStatus: s.updateTaskStatus, - updateTaskOwner: s.updateTaskOwner, - sendTeamMessage: s.sendTeamMessage, - requestReview: s.requestReview, - createTeamTask: s.createTeamTask, - startTaskByUser: s.startTaskByUser, - deleteTeam: s.deleteTeam, - openTeamsTab: s.openTeamsTab, - closeTab: s.closeTab, - sendingMessage: s.sendingMessage, - sendMessageError: s.sendMessageError, - sendMessageWarning: s.sendMessageWarning, - sendMessageDebugDetails: s.sendMessageDebugDetails, - lastSendMessageResult: s.lastSendMessageResult, - reviewActionError: s.reviewActionError, - addMember: s.addMember, - restartMember: s.restartMember, - skipMemberForLaunch: s.skipMemberForLaunch, - removeMember: s.removeMember, - updateMemberRole: s.updateMemberRole, - launchTeam: s.launchTeam, - provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, - clearProvisioningError: s.clearProvisioningError, - isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, - data: s.selectedTeamName === teamName ? s.selectedTeamData : null, - members: selectResolvedMembersForTeamName(s, teamName), - loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, - error: s.selectedTeamName === teamName ? s.selectedTeamError : null, - refreshTeamData: s.refreshTeamData, - refreshTeamMessagesHead: s.refreshTeamMessagesHead, - refreshMemberActivityMeta: s.refreshMemberActivityMeta, - syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, - kanbanFilterQuery: s.kanbanFilterQuery, - clearKanbanFilter: s.clearKanbanFilter, - softDeleteTask: s.softDeleteTask, - restoreTask: s.restoreTask, - fetchDeletedTasks: s.fetchDeletedTasks, - deletedTasks: s.deletedTasks, - launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, - messagesPanelMode: s.messagesPanelMode, - messagesPanelWidth: s.messagesPanelWidth, - sidebarLogsHeight: s.sidebarLogsHeight, - setMessagesPanelMode: s.setMessagesPanelMode, - setMessagesPanelWidth: s.setMessagesPanelWidth, - setSidebarLogsHeight: s.setSidebarLogsHeight, - selectReviewFile: s.selectReviewFile, - pendingReviewRequest: s.pendingReviewRequest, - setPendingReviewRequest: s.setPendingReviewRequest, - })) - ); - - const tabId = useTabIdOptional(); - const activeTabId = useStore((s) => s.activeTabId); - const isThisTabActive = tabId ? activeTabId === tabId : false; - const wasInteractiveRef = useRef(false); - - // Messages panel resize - const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = - useResizablePanel({ - width: messagesPanelWidth, - onWidthChange: setMessagesPanelWidth, - minWidth: 280, - maxWidth: 600, - side: 'left', - }); - const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({ - height: sidebarLogsHeight, - onHeightChange: setSidebarLogsHeight, - minHeight: 120, - maxHeight: 520, - side: 'top', - }); - - const changeMessagesPanelMode = useCallback( - (mode: TeamMessagesPanelMode) => { - setMessagesPanelMode(mode); - }, - [setMessagesPanelMode] - ); - - useEffect(() => { - if (tabId) { - initTabUIState(tabId); - } - }, [tabId, initTabUIState]); - - useEffect(() => { - setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); - }, [teamName]); - - useEffect(() => { - setTeamPendingRepliesState(teamName, pendingRepliesByMember); - }, [pendingRepliesByMember, teamName]); - - useEffect(() => { - const wasProvisioning = wasProvisioningRef.current; - wasProvisioningRef.current = isTeamProvisioning; - if (!wasProvisioning && isTeamProvisioning) { - provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [isTeamProvisioning]); - - const [kanbanSearch, setKanbanSearch] = useState(''); - - // Open editor overlay when a file reveal is requested (e.g. from chip click) - const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); - useEffect(() => { - if (pendingRevealFile && data?.config.projectPath) { - setEditorOpen(true); - } - }, [pendingRevealFile, data?.config.projectPath]); - - useEffect(() => { - if (!teamName) { - return; - } - void selectTeam(teamName); - void fetchDeletedTasks(teamName); - }, [teamName, selectTeam, fetchDeletedTasks]); - - // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. - // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins - // and other tabs get stuck with mismatched data (permanent skeleton). - // Re-trigger selectTeam when this tab becomes active and store data is stale. - const storedTeamName = data?.teamName; - useEffect(() => { - if (!isThisTabActive || !teamName || loading) return; - if (storedTeamName != null && storedTeamName !== teamName) { - void selectTeam(teamName); - } - }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); - - useEffect(() => { - const isInteractive = isThisTabActive && isPaneFocused; - const justBecameInteractive = isInteractive && !wasInteractiveRef.current; - wasInteractiveRef.current = isInteractive; - if (!justBecameInteractive || !teamName) { - return; - } - - void (async () => { - try { - const headResult = await refreshTeamMessagesHead(teamName); - if (headResult.feedChanged) { - await refreshMemberActivityMeta(teamName); - } - } catch { - // Best-effort refresh on tab focus. - } - })(); - }, [ - isPaneFocused, - isThisTabActive, - refreshMemberActivityMeta, - refreshTeamMessagesHead, - teamName, - ]); - - // Fetch active teams when launch dialog opens (for conflict warning) - useEffect(() => { - if (!launchDialogOpen) return; - let cancelled = false; - const teamsSnapshot = useStore.getState().teams; - void (async () => { - try { - const aliveList = await api.teams.aliveList(); - if (cancelled) return; - const aliveSet = new Set(aliveList); - const refs = teamsSnapshot - .filter((t) => aliveSet.has(t.teamName) && t.projectPath) - .map((t) => ({ - teamName: t.teamName, - displayName: t.displayName, - projectPath: t.projectPath!, - })); - setActiveTeamsForLaunch(refs); - } catch { - // best-effort - } - })(); - return () => { - cancelled = true; - }; - }, [launchDialogOpen]); - - useEffect(() => { - if (kanbanFilterQuery) { - setKanbanSearch(kanbanFilterQuery); - clearKanbanFilter(); - } - }, [kanbanFilterQuery, clearKanbanFilter]); - - // Load sessions for the team's project - const projectId = useMemo( - () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), - [projects, repositoryGroups, data?.config.projectPath] - ); - - const leadSessionId = data?.config.leadSessionId ?? null; - const pendingReplyRefreshSourceId = useId(); - const sessionHistoryKey = useMemo( - () => (data?.config.sessionHistory ?? []).join('|'), - [data?.config.sessionHistory] - ); - - // Keep team message state fresh while we are explicitly waiting for a reply. - // This stays enabled even for hidden mounted tabs, because the waiting state - // is renderer-local and should keep its lightweight polling until resolved. - useEffect(() => { - const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; - syncTeamPendingReplyRefresh( - teamName, - pendingReplyRefreshSourceId, - Boolean(data?.isAlive) && hasPendingReplies, - TEAM_PENDING_REPLY_REFRESH_DELAY_MS +export const TeamDetailView = memo( + ({ teamName, isPaneFocused = false }: TeamDetailViewProps): React.JSX.Element => { + const { isLight } = useTheme(); + const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [selectedMember, setSelectedMember] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState<{ + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } | null>(null); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>( + () => getTeamPendingRepliesState(teamName) ); - - return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); - }; - }, [ - data?.isAlive, - pendingRepliesByMember, - pendingReplyRefreshSourceId, - syncTeamPendingReplyRefresh, - teamName, - ]); - - useEffect(() => { - if (!projectId) return; - - let cancelled = false; - setSessionsLoading(true); - setSessionsError(null); - - void (async () => { - try { - const result = await api.getSessions(projectId); - if (!cancelled) { - setSessions(result); - } - } catch (e) { - if (!cancelled) { - setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); - } - } finally { - if (!cancelled) { - setSessionsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [projectId]); - - // Live git branch tracking for the lead project and member worktrees - const teamProjectPath = data?.config.projectPath?.trim() ?? null; - const leadProjectPath = useMemo(() => { - const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); - return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [members, teamProjectPath]); - const branchSyncPaths = useMemo(() => { - const uniquePaths = new Map(); - const addPath = (candidate: string | null | undefined): void => { - const trimmed = candidate?.trim(); - if (!trimmed) return; - const key = normalizePath(trimmed); - if (!key || uniquePaths.has(key)) return; - uniquePaths.set(key, trimmed); - }; - - addPath(leadProjectPath); - for (const member of members) { - addPath(member.cwd); - } - - return Array.from(uniquePaths.values()); - }, [members, leadProjectPath]); - useBranchSync(branchSyncPaths, { live: true }); - const trackedBranches = useStore( - useShallow((s) => - Object.fromEntries( - branchSyncPaths.map((projectPath) => { - const normalizedPath = normalizePath(projectPath); - return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; - }) - ) - ) - ); - const leadBranch = leadProjectPath - ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) - : null; - const membersWithLiveBranches = useMemo(() => { - if (!data) return []; - - return members.map((member) => { - const memberPath = member.cwd?.trim(); - const nextGitBranch = - memberPath && !isLeadMember(member) && leadBranch !== null - ? (() => { - const branch = trackedBranches[normalizePath(memberPath)] ?? null; - return branch && branch !== leadBranch ? branch : undefined; - })() - : undefined; - - if (member.gitBranch === nextGitBranch) { - return member; - } - - const nextMember: ResolvedTeamMember = { ...member }; - if (nextGitBranch) { - nextMember.gitBranch = nextGitBranch; - } else { - delete nextMember.gitBranch; - } - return nextMember; - }); - }, [leadBranch, members, trackedBranches]); - const resolvedMemberColorMap = useMemo( - () => buildMemberColorMap(membersWithLiveBranches), - [membersWithLiveBranches] - ); - - // Filter sessions to team-only using sessionHistory + leadSessionId - const teamSessionIds = useMemo(() => { - const sessionIds = new Set(); - if (data?.config.leadSessionId) { - sessionIds.add(data.config.leadSessionId); - } - if (data?.config.sessionHistory) { - for (const id of data.config.sessionHistory) { - sessionIds.add(id); - } - } - return sessionIds; - }, [data?.config.leadSessionId, data?.config.sessionHistory]); - - const teamSessions = useMemo(() => { - // If no session IDs known (backward compat), show all sessions - if (teamSessionIds.size === 0) return sessions; - return sessions.filter((s) => teamSessionIds.has(s.id)); - }, [sessions, teamSessionIds]); - - // Auto-reset session filter if the selected session is no longer in teamSessions - useEffect(() => { - if ( - kanbanFilter.sessionId !== null && - !teamSessions.some((s) => s.id === kanbanFilter.sessionId) - ) { - setKanbanFilter((prev) => ({ ...prev, sessionId: null })); - } - }, [kanbanFilter.sessionId, teamSessions]); - - // Compute time-window for session filtering - const timeWindow = useMemo(() => { - if (kanbanFilter.sessionId === null) return null; - - const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); - const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); - if (idx === -1) return null; - - const start = sorted[idx].createdAt; - const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; - return { start, end }; - }, [kanbanFilter.sessionId, teamSessions]); - - // Filter tasks by time-window and owner - const filteredTasks = useMemo(() => { - if (!data) return []; - let result = data.tasks; - - // Session time-window filter - if (timeWindow) { - result = result.filter((t) => { - if (!t.createdAt) return true; // legacy tasks always included - const ts = new Date(t.createdAt).getTime(); - return ts >= timeWindow.start && ts < timeWindow.end; - }); - } - - // Owner filter - if (kanbanFilter.selectedOwners.size > 0) { - result = result.filter((t) => - t.owner - ? kanbanFilter.selectedOwners.has(t.owner) - : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) - ); - } - - return result; - }, [data, timeWindow, kanbanFilter.selectedOwners]); - - const activeMembers = useStableActiveMembers(membersWithLiveBranches); - - const kanbanDisplayTasks = useMemo(() => { - const query = kanbanSearch.trim(); - if (!query) return filteredTasks; - return filterKanbanTasks(filteredTasks, query); - }, [filteredTasks, kanbanSearch]); - - const activeTeammateCount = useMemo( - () => activeMembers.filter((m) => !isLeadMember(m)).length, - [activeMembers] - ); - const leadProviderId = useMemo(() => { - const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; - if (activeLeadProviderId) return activeLeadProviderId; - const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; - if (configuredLeadProviderId) return configuredLeadProviderId; - return launchParams?.providerId; - }, [activeMembers, data?.config.members, launchParams?.providerId]); - const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); - - const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); - const taskMapRef = useRef(taskMap); - taskMapRef.current = taskMap; - - const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); - - const openCreateTaskDialog = useCallback( - (subject = '', description = '', owner = '', startImmediately?: boolean): void => { - setCreateTaskDialog({ - open: true, - defaultSubject: subject, - defaultDescription: description, - defaultOwner: owner, - defaultStartImmediately: startImmediately, - }); - }, - [] - ); - - const closeCreateTaskDialog = useCallback((): void => { - setCreateTaskDialog({ + const [createTaskDialog, setCreateTaskDialog] = useState({ open: false, defaultSubject: '', defaultDescription: '', defaultOwner: '', - defaultStartImmediately: undefined, }); - }, []); - - const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { - openCreateTaskDialog(subject, description); - }, []); - - const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }, []); - - const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { - setLaunchDialogState({ open: true, mode }); - }, []); - - const closeLaunchDialog = useCallback(() => { - setLaunchDialogState((prev) => ({ ...prev, open: false })); - }, []); - - const handleRestartTeam = useCallback(() => { - openLaunchDialog('relaunch'); - }, [openLaunchDialog]); - - const handleLaunchDialogSubmit = useCallback( - async (request: TeamLaunchRequest): Promise => { - await launchTeam(request); - }, - [launchTeam] - ); - - const handleRelaunchDialogSubmit = useCallback( - async ( - request: TeamLaunchRequest, - nextMembers: TeamCreateRequest['members'] - ): Promise => { - await executeTeamRelaunch({ + const [creatingTask, setCreatingTask] = useState(false); + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); + const [addingMemberLoading, setAddingMemberLoading] = useState(false); + const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); + const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [launchDialogState, setLaunchDialogState] = useState<{ + open: boolean; + mode: TeamLaunchDialogMode; + }>({ + open: false, + mode: 'launch', + }); + const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); + const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); + const provisioningBannerRef = useRef(null); + const wasProvisioningRef = useRef(false); + const handleOpenGraphTab = useCallback(() => { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; + state.openTab({ + type: 'graph', + label: `${displayName} Graph`, teamName, - isTeamAlive: data?.isAlive === true, - request, - members: nextMembers, - stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), - replaceMembers: (nextTeamName, nextRequest) => - api.teams.replaceMembers(nextTeamName, nextRequest), - launchTeam, }); - }, - [data?.isAlive, launchTeam, teamName] - ); + }, [teamName]); + const visualizeButtonStyle = useMemo( + () => + isLight + ? { + background: + 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', + borderColor: 'rgba(59,130,246,0.30)', + color: '#0f172a', + boxShadow: '0 10px 24px rgba(59,130,246,0.12)', + } + : { + background: + 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', + borderColor: 'rgba(56,189,248,0.34)', + color: 'rgba(236,253,255,0.96)', + boxShadow: '0 12px 28px rgba(8,145,178,0.22)', + }, + [isLight] + ); - const handleChangeLeadRuntime = useCallback(() => { - setEditDialogOpen(false); - openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); - }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - - const handleRestartMember = useCallback( - async (memberName: string): Promise => { - await restartMember(teamName, memberName); - }, - [restartMember, teamName] - ); - - const handleSkipMemberForLaunch = useCallback( - async (memberName: string): Promise => { - await skipMemberForLaunch(teamName, memberName); - }, - [skipMemberForLaunch, teamName] - ); - - const handleSelectMember = useCallback((member: ResolvedTeamMember) => { - setSelectedMember(member); - setSelectedMemberView(null); - }, []); - - const closeSelectedMemberDialog = useCallback(() => { - setSelectedMember(null); - setSelectedMemberView(null); - }, []); - - const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { - setSendDialogRecipient(member.name); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }, []); - - const handleAssignTaskToMember = useCallback( - (member: ResolvedTeamMember) => { - openCreateTaskDialog('', '', member.name); - }, - [openCreateTaskDialog] - ); - - const handleOpenTaskById = useCallback((taskId: string) => { - const task = taskMapRef.current.get(taskId); - if (task) { - setSelectedTask(task); - } - }, []); - - const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { - setSelectedTask(task); - }, []); - - const handleTaskIdClick = useCallback( - (taskId: string) => { - const task = - taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }, - [taskMap, data?.tasks] - ); - - const handleEditorAction = useCallback( - (action: EditorSelectionAction) => { - const chip = createChipFromSelection(action, []) ?? undefined; - if (action.type === 'sendMessage') { - setSendDialogDefaultText(chip ? undefined : action.formattedContext); - setSendDialogDefaultChip(chip); - setSendDialogRecipient(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - } else if (action.type === 'createTask') { - if (chip) { - setCreateTaskDialog({ - open: true, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - defaultChip: chip, - }); - } else { - openCreateTaskDialog('', action.formattedContext); - } + // Set inert on background content when editor/graph overlay is open (a11y focus trap) + useEffect(() => { + const el = contentRef.current; + if (!el) return; + if (editorOpen || graphOpen) { + el.setAttribute('inert', ''); + } else { + el.removeAttribute('inert'); } - }, + }, [editorOpen, graphOpen]); - [] - ); + // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + handleOpenGraphTab(); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [handleOpenGraphTab, teamName]); - const handleStopTeam = useCallback(async (): Promise => { - setStoppingTeam(true); - try { - await api.teams.stop(teamName); - // Backend sends 'disconnected' progress which triggers store refresh, - // but refresh here too as a safety net (e.g. if progress event is missed). - await refreshTeamData(teamName); - } catch (err) { - console.error('Failed to stop team:', err); - } finally { - setStoppingTeam(false); - } - }, [teamName, refreshTeamData]); + // Listen for graph tab actions (open task, send message) + useEffect(() => { + const onOpenTask = (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + if (task) setSelectedTask(task); + }; + const onSendMsg = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }; + const onOpenProfile = (e: Event) => { + const { + teamName: tn, + memberName, + initialTab, + initialActivityFilter, + } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const member = members.find((m: { name: string }) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab, + initialActivityFilter, + }); + } + }; + const onCreateTask = (e: Event) => { + const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + openCreateTaskDialog('', '', owner ?? ''); + }; + window.addEventListener('graph:open-task', onOpenTask); + window.addEventListener('graph:send-message', onSendMsg); + window.addEventListener('graph:open-profile', onOpenProfile); + window.addEventListener('graph:create-task', onCreateTask); - // Pick up pending review request from GlobalTaskDetailDialog - useEffect(() => { - if (!pendingReviewRequest) return; - setReviewDialogState({ - open: true, - mode: 'task', - taskId: pendingReviewRequest.taskId, - initialFilePath: pendingReviewRequest.filePath, - taskChangeRequestOptions: pendingReviewRequest.requestOptions, - }); - if (pendingReviewRequest.filePath) { - selectReviewFile(pendingReviewRequest.filePath); - } - setPendingReviewRequest(null); - }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); - - // Pick up pending member profile request from MemberHoverCard - const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); - useEffect(() => { - if (!pendingMemberProfile || !data) return; - const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); - if (member) { - setSelectedMember(member); - setSelectedMemberView(null); - } - useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches]); - - const handleDeleteTask = useCallback( - (taskId: string) => { - void (async () => { - const confirmed = await confirm({ - title: 'Delete task', - message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, - confirmLabel: 'Delete', - cancelLabel: 'Cancel', - variant: 'danger', - }); - if (confirmed) { + // Task action events from graph + const taskAction = (handler: (taskId: string) => void) => (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !taskId) return; + handler(taskId); + }; + const onStartTask = taskAction((taskId) => { + void (async () => { try { - await softDeleteTask(teamName, taskId); + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } + } catch { + /* best-effort */ + } + } } catch { - // error via store + /* error via store */ + } + })(); + }); + const onCompleteTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onApproveTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + /* */ + } + })(); + }); + const onRequestReviewTask = taskAction((taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + /* */ + } + })(); + }); + const onRequestChangesTask = taskAction((taskId) => { + setRequestChangesTaskId(taskId); + }); + const onCancelTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'pending'); + } catch { + /* */ + } + })(); + }); + const onMoveBackToDoneTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); + + window.addEventListener('graph:start-task', onStartTask); + window.addEventListener('graph:complete-task', onCompleteTask); + window.addEventListener('graph:approve-task', onApproveTask); + window.addEventListener('graph:request-review', onRequestReviewTask); + window.addEventListener('graph:request-changes', onRequestChangesTask); + window.addEventListener('graph:cancel-task', onCancelTask); + window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.addEventListener('graph:delete-task', onDeleteTaskGraph); + return () => { + window.removeEventListener('graph:open-task', onOpenTask); + window.removeEventListener('graph:send-message', onSendMsg); + window.removeEventListener('graph:open-profile', onOpenProfile); + window.removeEventListener('graph:create-task', onCreateTask); + window.removeEventListener('graph:start-task', onStartTask); + window.removeEventListener('graph:complete-task', onCompleteTask); + window.removeEventListener('graph:approve-task', onApproveTask); + window.removeEventListener('graph:request-review', onRequestReviewTask); + window.removeEventListener('graph:request-changes', onRequestChangesTask); + window.removeEventListener('graph:cancel-task', onCancelTask); + window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.removeEventListener('graph:delete-task', onDeleteTaskGraph); + }; + }); + + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [stoppingTeam, setStoppingTeam] = useState(false); + const [trashOpen, setTrashOpen] = useState(false); + const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const [sendDialogDefaultText, setSendDialogDefaultText] = useState( + undefined + ); + const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( + undefined + ); + const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( + undefined + ); + const [reviewDialogState, setReviewDialogState] = useState<{ + open: boolean; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + initialFilePath?: string; + taskChangeRequestOptions?: TaskChangeRequestOptions; + }>({ open: false, mode: 'task' }); + + // Active teams for conflict warning in LaunchTeamDialog + const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< + { teamName: string; displayName: string; projectPath: string }[] + >([]); + const launchDialogOpen = launchDialogState.open; + + // Session loading and filtering state + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionsError, setSessionsError] = useState(null); + const [kanbanFilter, setKanbanFilter] = useState({ + sessionId: null, + selectedOwners: new Set(), + columns: new Set(), + }); + const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); + + const { + data, + members, + loading, + error, + projects, + repositoryGroups, + initTabUIState, + selectTeam, + updateKanban, + updateKanbanColumnOrder, + updateTaskStatus, + updateTaskOwner, + sendTeamMessage, + requestReview, + createTeamTask, + startTaskByUser, + deleteTeam, + openTeamsTab, + closeTab, + sendingMessage, + sendMessageError, + sendMessageWarning, + sendMessageDebugDetails, + lastSendMessageResult, + reviewActionError, + addMember, + restartMember, + skipMemberForLaunch, + removeMember, + updateMemberRole, + launchTeam, + provisioningError, + clearProvisioningError, + isTeamProvisioning, + refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, + syncTeamPendingReplyRefresh, + kanbanFilterQuery, + clearKanbanFilter, + softDeleteTask, + restoreTask, + fetchDeletedTasks, + deletedTasks, + launchParams, + messagesPanelMode, + messagesPanelWidth, + sidebarLogsHeight, + setMessagesPanelMode, + setMessagesPanelWidth, + setSidebarLogsHeight, + selectReviewFile, + pendingReviewRequest, + setPendingReviewRequest, + } = useStore( + useShallow((s) => ({ + projects: s.projects, + repositoryGroups: s.repositoryGroups, + initTabUIState: s.initTabUIState, + selectTeam: s.selectTeam, + updateKanban: s.updateKanban, + updateKanbanColumnOrder: s.updateKanbanColumnOrder, + updateTaskStatus: s.updateTaskStatus, + updateTaskOwner: s.updateTaskOwner, + sendTeamMessage: s.sendTeamMessage, + requestReview: s.requestReview, + createTeamTask: s.createTeamTask, + startTaskByUser: s.startTaskByUser, + deleteTeam: s.deleteTeam, + openTeamsTab: s.openTeamsTab, + closeTab: s.closeTab, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, + sendMessageDebugDetails: s.sendMessageDebugDetails, + lastSendMessageResult: s.lastSendMessageResult, + reviewActionError: s.reviewActionError, + addMember: s.addMember, + restartMember: s.restartMember, + skipMemberForLaunch: s.skipMemberForLaunch, + removeMember: s.removeMember, + updateMemberRole: s.updateMemberRole, + launchTeam: s.launchTeam, + provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, + clearProvisioningError: s.clearProvisioningError, + isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, + data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), + loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, + error: s.selectedTeamName === teamName ? s.selectedTeamError : null, + refreshTeamData: s.refreshTeamData, + refreshTeamMessagesHead: s.refreshTeamMessagesHead, + refreshMemberActivityMeta: s.refreshMemberActivityMeta, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, + kanbanFilterQuery: s.kanbanFilterQuery, + clearKanbanFilter: s.clearKanbanFilter, + softDeleteTask: s.softDeleteTask, + restoreTask: s.restoreTask, + fetchDeletedTasks: s.fetchDeletedTasks, + deletedTasks: s.deletedTasks, + launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + sidebarLogsHeight: s.sidebarLogsHeight, + setMessagesPanelMode: s.setMessagesPanelMode, + setMessagesPanelWidth: s.setMessagesPanelWidth, + setSidebarLogsHeight: s.setSidebarLogsHeight, + selectReviewFile: s.selectReviewFile, + pendingReviewRequest: s.pendingReviewRequest, + setPendingReviewRequest: s.setPendingReviewRequest, + })) + ); + + const tabId = useTabIdOptional(); + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + const wasInteractiveRef = useRef(false); + + // Messages panel resize + const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = + useResizablePanel({ + width: messagesPanelWidth, + onWidthChange: setMessagesPanelWidth, + minWidth: 280, + maxWidth: 600, + side: 'left', + }); + const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = + useResizablePanel({ + height: sidebarLogsHeight, + onHeightChange: setSidebarLogsHeight, + minHeight: 120, + maxHeight: 520, + side: 'top', + }); + + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); + + useEffect(() => { + if (tabId) { + initTabUIState(tabId); + } + }, [tabId, initTabUIState]); + + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + + useEffect(() => { + const wasProvisioning = wasProvisioningRef.current; + wasProvisioningRef.current = isTeamProvisioning; + if (!wasProvisioning && isTeamProvisioning) { + provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [isTeamProvisioning]); + + const [kanbanSearch, setKanbanSearch] = useState(''); + + // Open editor overlay when a file reveal is requested (e.g. from chip click) + const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); + useEffect(() => { + if (pendingRevealFile && data?.config.projectPath) { + setEditorOpen(true); + } + }, [pendingRevealFile, data?.config.projectPath]); + + useEffect(() => { + if (!teamName) { + return; + } + void selectTeam(teamName); + void fetchDeletedTasks(teamName); + }, [teamName, selectTeam, fetchDeletedTasks]); + + // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. + // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins + // and other tabs get stuck with mismatched data (permanent skeleton). + // Re-trigger selectTeam when this tab becomes active and store data is stale. + const storedTeamName = data?.teamName; + useEffect(() => { + if (!isThisTabActive || !teamName || loading) return; + if (storedTeamName != null && storedTeamName !== teamName) { + void selectTeam(teamName); + } + }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); + + useEffect(() => { + const isInteractive = isThisTabActive && isPaneFocused; + const justBecameInteractive = isInteractive && !wasInteractiveRef.current; + wasInteractiveRef.current = isInteractive; + if (!justBecameInteractive || !teamName) { + return; + } + + void (async () => { + try { + const headResult = await refreshTeamMessagesHead(teamName); + if (headResult.feedChanged) { + await refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh on tab focus. + } + })(); + }, [ + isPaneFocused, + isThisTabActive, + refreshMemberActivityMeta, + refreshTeamMessagesHead, + teamName, + ]); + + // Fetch active teams when launch dialog opens (for conflict warning) + useEffect(() => { + if (!launchDialogOpen) return; + let cancelled = false; + const teamsSnapshot = useStore.getState().teams; + void (async () => { + try { + const aliveList = await api.teams.aliveList(); + if (cancelled) return; + const aliveSet = new Set(aliveList); + const refs = teamsSnapshot + .filter((t) => aliveSet.has(t.teamName) && t.projectPath) + .map((t) => ({ + teamName: t.teamName, + displayName: t.displayName, + projectPath: t.projectPath!, + })); + setActiveTeamsForLaunch(refs); + } catch { + // best-effort + } + })(); + return () => { + cancelled = true; + }; + }, [launchDialogOpen]); + + useEffect(() => { + if (kanbanFilterQuery) { + setKanbanSearch(kanbanFilterQuery); + clearKanbanFilter(); + } + }, [kanbanFilterQuery, clearKanbanFilter]); + + // Load sessions for the team's project + const projectId = useMemo( + () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), + [projects, repositoryGroups, data?.config.projectPath] + ); + + const leadSessionId = data?.config.leadSessionId ?? null; + const pendingReplyRefreshSourceId = useId(); + const sessionHistoryKey = useMemo( + () => (data?.config.sessionHistory ?? []).join('|'), + [data?.config.sessionHistory] + ); + + // Keep team message state fresh while we are explicitly waiting for a reply. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. + useEffect(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS + ); + + return () => { + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); + }; + }, [ + data?.isAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceId, + syncTeamPendingReplyRefresh, + teamName, + ]); + + useEffect(() => { + if (!projectId) return; + + let cancelled = false; + setSessionsLoading(true); + setSessionsError(null); + + void (async () => { + try { + const result = await api.getSessions(projectId); + if (!cancelled) { + setSessions(result); + } + } catch (e) { + if (!cancelled) { + setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); + } + } finally { + if (!cancelled) { + setSessionsLoading(false); } } })(); - }, - [teamName, softDeleteTask] - ); - const handleViewChanges = useCallback( - (taskId: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - }, - [taskMap] - ); + return () => { + cancelled = true; + }; + }, [projectId]); - const handleViewChangesForFile = useCallback( - (taskId: string, filePath?: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - initialFilePath: filePath, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - if (filePath) { - selectReviewFile(filePath); + // Live git branch tracking for the lead project and member worktrees + const teamProjectPath = data?.config.projectPath?.trim() ?? null; + const leadProjectPath = useMemo(() => { + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); + return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; + }, [members, teamProjectPath]); + const branchSyncPaths = useMemo(() => { + const uniquePaths = new Map(); + const addPath = (candidate: string | null | undefined): void => { + const trimmed = candidate?.trim(); + if (!trimmed) return; + const key = normalizePath(trimmed); + if (!key || uniquePaths.has(key)) return; + uniquePaths.set(key, trimmed); + }; + + addPath(leadProjectPath); + for (const member of members) { + addPath(member.cwd); } - }, - [selectReviewFile, taskMap] - ); - const handleDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(true); - }, []); + return Array.from(uniquePaths.values()); + }, [members, leadProjectPath]); + useBranchSync(branchSyncPaths, { live: true }); + const trackedBranches = useStore( + useShallow((s) => + Object.fromEntries( + branchSyncPaths.map((projectPath) => { + const normalizedPath = normalizePath(projectPath); + return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; + }) + ) + ) + ); + const leadBranch = leadProjectPath + ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) + : null; + const membersWithLiveBranches = useMemo(() => { + if (!data) return []; - const confirmDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(false); - void (async () => { - try { - await deleteTeam(teamName); - if (tabId) closeTab(tabId); - openTeamsTab(); - } catch { - // error is shown via store - } - })(); - }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); + return members.map((member) => { + const memberPath = member.cwd?.trim(); + const nextGitBranch = + memberPath && !isLeadMember(member) && leadBranch !== null + ? (() => { + const branch = trackedBranches[normalizePath(memberPath)] ?? null; + return branch && branch !== leadBranch ? branch : undefined; + })() + : undefined; - const handleCreateTask = ( - subject: string, - description: string, - owner?: string, - blockedBy?: string[], - related?: string[], - prompt?: string, - startImmediately?: boolean, - descriptionTaskRefs?: TaskRef[], - promptTaskRefs?: TaskRef[] - ): void => { - setCreatingTask(true); - void (async () => { - try { - await createTeamTask(teamName, { - subject, - description: description || undefined, - owner, - blockedBy, - related, - prompt, - descriptionTaskRefs, - promptTaskRefs, - startImmediately, - }); - - if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) { - const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; - try { - await api.teams.processSend(teamName, msg); - } catch { - // best-effort - } + if (member.gitBranch === nextGitBranch) { + return member; } - closeCreateTaskDialog(); - } catch { - // error shown via store - } finally { - setCreatingTask(false); - } - })(); - }; - - const sharedMessagesPanelProps = useMemo( - () => ({ - teamName, - onPositionChange: changeMessagesPanelMode, - mountPoint: messagesPanelMountPoint, - members: activeMembers, - tasks: data?.tasks ?? [], - isTeamAlive: data?.isAlive, - timeWindow, - teamSessionIds, - currentLeadSessionId: data?.config.leadSessionId, - pendingRepliesByMember, - onPendingReplyChange: setPendingRepliesByMember, - onMemberClick: handleSelectMember, - onTaskClick: handleOpenTask, - onCreateTaskFromMessage: handleCreateTaskFromMessage, - onReplyToMessage: handleReplyToMessage, - onRestartTeam: handleRestartTeam, - onTaskIdClick: handleTaskIdClick, - inlineScrollContainerRef: contentRef, - }), - [ - activeMembers, - data?.config.leadSessionId, - data?.isAlive, - data?.tasks, - handleCreateTaskFromMessage, - handleOpenTask, - handleReplyToMessage, - handleRestartTeam, - handleSelectMember, - handleTaskIdClick, - messagesPanelMountPoint, - pendingRepliesByMember, - teamName, - teamSessionIds, - timeWindow, - changeMessagesPanelMode, - ] - ); - - if (!teamName) { - return ( -
- Invalid team tab -
+ const nextMember: ResolvedTeamMember = { ...member }; + if (nextGitBranch) { + nextMember.gitBranch = nextGitBranch; + } else { + delete nextMember.gitBranch; + } + return nextMember; + }); + }, [leadBranch, members, trackedBranches]); + const resolvedMemberColorMap = useMemo( + () => buildMemberColorMap(membersWithLiveBranches), + [membersWithLiveBranches] ); - } - const spawnStatusWatcher = ( - - ); - const teamAgentRuntimeWatcher = ( - - ); - const leadContextWatcher = shouldShowLeadContextUi ? ( - - ) : null; + // Filter sessions to team-only using sessionHistory + leadSessionId + const teamSessionIds = useMemo(() => { + const sessionIds = new Set(); + if (data?.config.leadSessionId) { + sessionIds.add(data.config.leadSessionId); + } + if (data?.config.sessionHistory) { + for (const id of data.config.sessionHistory) { + sessionIds.add(id); + } + } + return sessionIds; + }, [data?.config.leadSessionId, data?.config.sessionHistory]); - const renderBody = (): React.JSX.Element => { - if ((loading && !data) || (data && data.teamName !== teamName)) { + const teamSessions = useMemo(() => { + // If no session IDs known (backward compat), show all sessions + if (teamSessionIds.size === 0) return sessions; + return sessions.filter((s) => teamSessionIds.has(s.id)); + }, [sessions, teamSessionIds]); + + // Auto-reset session filter if the selected session is no longer in teamSessions + useEffect(() => { + if ( + kanbanFilter.sessionId !== null && + !teamSessions.some((s) => s.id === kanbanFilter.sessionId) + ) { + setKanbanFilter((prev) => ({ ...prev, sessionId: null })); + } + }, [kanbanFilter.sessionId, teamSessions]); + + // Compute time-window for session filtering + const timeWindow = useMemo(() => { + if (kanbanFilter.sessionId === null) return null; + + const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); + const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); + if (idx === -1) return null; + + const start = sorted[idx].createdAt; + const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; + return { start, end }; + }, [kanbanFilter.sessionId, teamSessions]); + + // Filter tasks by time-window and owner + const filteredTasks = useMemo(() => { + if (!data) return []; + let result = data.tasks; + + // Session time-window filter + if (timeWindow) { + result = result.filter((t) => { + if (!t.createdAt) return true; // legacy tasks always included + const ts = new Date(t.createdAt).getTime(); + return ts >= timeWindow.start && ts < timeWindow.end; + }); + } + + // Owner filter + if (kanbanFilter.selectedOwners.size > 0) { + result = result.filter((t) => + t.owner + ? kanbanFilter.selectedOwners.has(t.owner) + : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) + ); + } + + return result; + }, [data, timeWindow, kanbanFilter.selectedOwners]); + + const activeMembers = useStableActiveMembers(membersWithLiveBranches); + + const kanbanDisplayTasks = useMemo(() => { + const query = kanbanSearch.trim(); + if (!query) return filteredTasks; + return filterKanbanTasks(filteredTasks, query); + }, [filteredTasks, kanbanSearch]); + + const activeTeammateCount = useMemo( + () => activeMembers.filter((m) => !isLeadMember(m)).length, + [activeMembers] + ); + const leadProviderId = useMemo(() => { + const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; + if (activeLeadProviderId) return activeLeadProviderId; + const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; + if (configuredLeadProviderId) return configuredLeadProviderId; + return launchParams?.providerId; + }, [activeMembers, data?.config.members, launchParams?.providerId]); + const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); + + const taskMap = useMemo( + () => new Map((data?.tasks ?? []).map((t) => [t.id, t])), + [data?.tasks] + ); + const taskMapRef = useRef(taskMap); + taskMapRef.current = taskMap; + + const memberTaskCounts = useMemo( + () => buildTaskCountsByOwner(data?.tasks ?? []), + [data?.tasks] + ); + + const openCreateTaskDialog = useCallback( + (subject = '', description = '', owner = '', startImmediately?: boolean): void => { + setCreateTaskDialog({ + open: true, + defaultSubject: subject, + defaultDescription: description, + defaultOwner: owner, + defaultStartImmediately: startImmediately, + }); + }, + [] + ); + + const closeCreateTaskDialog = useCallback((): void => { + setCreateTaskDialog({ + open: false, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + }); + }, []); + + const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { + openCreateTaskDialog(subject, description); + }, []); + + const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }, []); + + const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { + setLaunchDialogState({ open: true, mode }); + }, []); + + const closeLaunchDialog = useCallback(() => { + setLaunchDialogState((prev) => ({ ...prev, open: false })); + }, []); + + const handleRestartTeam = useCallback(() => { + openLaunchDialog('relaunch'); + }, [openLaunchDialog]); + + const handleLaunchDialogSubmit = useCallback( + async (request: TeamLaunchRequest): Promise => { + await launchTeam(request); + }, + [launchTeam] + ); + + const handleRelaunchDialogSubmit = useCallback( + async ( + request: TeamLaunchRequest, + nextMembers: TeamCreateRequest['members'] + ): Promise => { + await executeTeamRelaunch({ + teamName, + isTeamAlive: data?.isAlive === true, + request, + members: nextMembers, + stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), + replaceMembers: (nextTeamName, nextRequest) => + api.teams.replaceMembers(nextTeamName, nextRequest), + launchTeam, + }); + }, + [data?.isAlive, launchTeam, teamName] + ); + + const handleChangeLeadRuntime = useCallback(() => { + setEditDialogOpen(false); + openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); + }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); + + const handleRestartMember = useCallback( + async (memberName: string): Promise => { + await restartMember(teamName, memberName); + }, + [restartMember, teamName] + ); + + const handleSkipMemberForLaunch = useCallback( + async (memberName: string): Promise => { + await skipMemberForLaunch(teamName, memberName); + }, + [skipMemberForLaunch, teamName] + ); + + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + setSelectedMember(member); + setSelectedMemberView(null); + }, []); + + const closeSelectedMemberDialog = useCallback(() => { + setSelectedMember(null); + setSelectedMemberView(null); + }, []); + + const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }, []); + + const handleAssignTaskToMember = useCallback( + (member: ResolvedTeamMember) => { + openCreateTaskDialog('', '', member.name); + }, + [openCreateTaskDialog] + ); + + const handleOpenTaskById = useCallback((taskId: string) => { + const task = taskMapRef.current.get(taskId); + if (task) { + setSelectedTask(task); + } + }, []); + + const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { + setSelectedTask(task); + }, []); + + const handleTaskIdClick = useCallback( + (taskId: string) => { + const task = + taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }, + [taskMap, data?.tasks] + ); + + const handleEditorAction = useCallback( + (action: EditorSelectionAction) => { + const chip = createChipFromSelection(action, []) ?? undefined; + if (action.type === 'sendMessage') { + setSendDialogDefaultText(chip ? undefined : action.formattedContext); + setSendDialogDefaultChip(chip); + setSendDialogRecipient(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + } else if (action.type === 'createTask') { + if (chip) { + setCreateTaskDialog({ + open: true, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + defaultChip: chip, + }); + } else { + openCreateTaskDialog('', action.formattedContext); + } + } + }, + + [] + ); + + const handleStopTeam = useCallback(async (): Promise => { + setStoppingTeam(true); + try { + await api.teams.stop(teamName); + // Backend sends 'disconnected' progress which triggers store refresh, + // but refresh here too as a safety net (e.g. if progress event is missed). + await refreshTeamData(teamName); + } catch (err) { + console.error('Failed to stop team:', err); + } finally { + setStoppingTeam(false); + } + }, [teamName, refreshTeamData]); + + // Pick up pending review request from GlobalTaskDetailDialog + useEffect(() => { + if (!pendingReviewRequest) return; + setReviewDialogState({ + open: true, + mode: 'task', + taskId: pendingReviewRequest.taskId, + initialFilePath: pendingReviewRequest.filePath, + taskChangeRequestOptions: pendingReviewRequest.requestOptions, + }); + if (pendingReviewRequest.filePath) { + selectReviewFile(pendingReviewRequest.filePath); + } + setPendingReviewRequest(null); + }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); + + // Pick up pending member profile request from MemberHoverCard + const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); + useEffect(() => { + if (!pendingMemberProfile || !data) return; + const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); + if (member) { + setSelectedMember(member); + setSelectedMemberView(null); + } + useStore.getState().closeMemberProfile(); + }, [pendingMemberProfile, membersWithLiveBranches]); + + const handleDeleteTask = useCallback( + (taskId: string) => { + void (async () => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + } catch { + // error via store + } + } + })(); + }, + [teamName, softDeleteTask] + ); + + const handleViewChanges = useCallback( + (taskId: string) => { + const task = taskMap.get(taskId); + setReviewDialogState({ + open: true, + mode: 'task', + taskId, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + }, + [taskMap] + ); + + const handleViewChangesForFile = useCallback( + (taskId: string, filePath?: string) => { + const task = taskMap.get(taskId); + setReviewDialogState({ + open: true, + mode: 'task', + taskId, + initialFilePath: filePath, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + if (filePath) { + selectReviewFile(filePath); + } + }, + [selectReviewFile, taskMap] + ); + + const handleDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(true); + }, []); + + const confirmDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(false); + void (async () => { + try { + await deleteTeam(teamName); + if (tabId) closeTab(tabId); + openTeamsTab(); + } catch { + // error is shown via store + } + })(); + }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); + + const handleCreateTask = ( + subject: string, + description: string, + owner?: string, + blockedBy?: string[], + related?: string[], + prompt?: string, + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] + ): void => { + setCreatingTask(true); + void (async () => { + try { + await createTeamTask(teamName, { + subject, + description: description || undefined, + owner, + blockedBy, + related, + prompt, + descriptionTaskRefs, + promptTaskRefs, + startImmediately, + }); + + if ( + prompt && + owner && + data?.isAlive && + !isTeamProvisioning && + startImmediately !== false + ) { + const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; + try { + await api.teams.processSend(teamName, msg); + } catch { + // best-effort + } + } + + closeCreateTaskDialog(); + } catch { + // error shown via store + } finally { + setCreatingTask(false); + } + })(); + }; + + const sharedMessagesPanelProps = useMemo( + () => ({ + teamName, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, + members: activeMembers, + tasks: data?.tasks ?? [], + isTeamAlive: data?.isAlive, + timeWindow, + teamSessionIds, + currentLeadSessionId: data?.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: handleSelectMember, + onTaskClick: handleOpenTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + inlineScrollContainerRef: contentRef, + }), + [ + activeMembers, + data?.config.leadSessionId, + data?.isAlive, + data?.tasks, + handleCreateTaskFromMessage, + handleOpenTask, + handleReplyToMessage, + handleRestartTeam, + handleSelectMember, + handleTaskIdClick, + messagesPanelMountPoint, + pendingRepliesByMember, + teamName, + teamSessionIds, + timeWindow, + changeMessagesPanelMode, + ] + ); + + if (!teamName) { return ( -
-
-
- -
-
-
-
-
-
+
+ Invalid team tab
); } - if (error === 'TEAM_DRAFT') { - const draftTeamSummary = useStore.getState().teamByName[teamName]; - const draftDisplayName = draftTeamSummary?.displayName || teamName; - const draftMemberCount = draftTeamSummary?.memberCount ?? 0; + const spawnStatusWatcher = ( + + ); + const teamAgentRuntimeWatcher = ( + + ); + const leadContextWatcher = shouldShowLeadContextUi ? ( + + ) : null; - return ( - <> -
+ const renderBody = (): React.JSX.Element => { + if ((loading && !data) || (data && data.teamName !== teamName)) { + return ( +
+
-
-
-

Team not launched yet

-

- This is a draft team - {draftDisplayName} has been configured - with {draftMemberCount} member - {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. - Click Launch to select a model and start the team. -

-
- - +
+
+
+
+
+
+ ); + } + + if (error === 'TEAM_DRAFT') { + const draftTeamSummary = useStore.getState().teamByName[teamName]; + const draftDisplayName = draftTeamSummary?.displayName || teamName; + const draftMemberCount = draftTeamSummary?.memberCount ?? 0; + + return ( + <> +
+
+ +
+
+
+

Team not launched yet

+

+ This is a draft team - {draftDisplayName} has been configured + with {draftMemberCount} member + {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. + Click Launch to select a model and start the team. +

+
+ + +
-
- - - ); - } - - if (error) { - return ( -
-
-

Failed to load team

-

{error}

-
-
- ); - } - - if (!data) { - return ( -
-
- -
-
- Team data will appear once provisioning completes -
-
- ); - } - - const headerColorSet = data.config.color - ? getTeamColorSet(data.config.color) - : nameColorSet(data.config.name); - - return ( - <> -
- - - {/* Messages sidebar (left, after context panel) */} - - + + ); + } + + if (error) { + return ( +
+
+

Failed to load team

+

{error}

+
+
+ ); + } + + if (!data) { + return ( +
+
+ +
+
+ Team data will appear once provisioning completes +
+
+ ); + } + + const headerColorSet = data.config.color + ? getTeamColorSet(data.config.color) + : nameColorSet(data.config.name); + + return ( + <> +
+ + + {/* Messages sidebar (left, after context panel) */} + - - - + isActive={isThisTabActive} + isFocused={isPaneFocused} + > + + + -
-
-
- {headerColorSet ? ( +
+
+
+ {headerColorSet ? ( +
+ ) : null}
- ) : null} -
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} + className={cn( + 'flex items-start justify-between gap-2', + headerColorSet && 'relative z-10' + )} + > +
+
+

+ {data.config.name} +

+ {data.isAlive && ( + + + Running + + )} + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
-
-
- {data.isAlive && ( +
+ {data.isAlive && ( + + + + + Stop team + + )} + + + + + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + + - Stop team + Delete team - )} - - - - - - {isTeamProvisioning - ? 'Edit team is unavailable while provisioning is still in progress' - : 'Edit team'} - - - - - - - Delete team - +
-
- {data.config.description && ( -

- {data.config.description} -

- )} -
-
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- - - - - Open team graph - -
- {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); - if (!history || history.length === 0) return null; - return ( -
- - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - + {data.config.description} +

+ )} +
+
+ {data.config.projectPath && ( + + + + + + {data.config.projectPath + .replace(/\\/g, '/') + .split('/') + .filter(Boolean) + .pop() ?? data.config.projectPath} + + + + + {formatProjectPath(data.config.projectPath)} + + + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + + + {leadBranch} + + )}
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - openLaunchDialog('launch')} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. + + + + + Open team graph + +
+ {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter( + (p) => p !== currentPath + ); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} + +
+ ); + })()}
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
+ {!data.isAlive && !isTeamProvisioning ? ( + openLaunchDialog('launch')} + /> + ) : null} + +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
+ +
+ } + > + +
+ + } + defaultOpen={false} + > + + setKanbanFilter((prev) => ({ ...prev, sessionId: id })) + } + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ -
- } - > - -
- - } - defaultOpen={false} - > - setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} - projectPath={data.config.projectPath} - /> - - - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ - - } - > - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); + }} + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` : ''; await api.teams.processSend( teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` ); + } catch { + // best-effort } - } catch { - // best-effort } + } catch { + // error via store } - } catch { - // error via store + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); + }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} + /> + - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } + } + defaultOpen={false} + > + + - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } void (async () => { try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); } catch { - // error via store + // error state is handled in the store and shown in the view } })(); }} + /> + + { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + openCreateTaskDialog('', '', name); + }} + onRestartMember={handleRestartMember} + onTaskClick={(task) => { + closeSelectedMemberDialog(); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + closeSelectedMemberDialog(); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + + + !isLeadMember(m))} + leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} + resolvedMemberColorMap={resolvedMemberColorMap} + isTeamAlive={data.isAlive && !isTeamProvisioning} + isTeamProvisioning={isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} + onSaved={() => void selectTeam(teamName)} + /> + + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + isolation: entry.isolation, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); + } + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> + + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + + + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. + All team data and tasks will be deleted. + + + + + + + + + + + + { + const sentAtMs = Date.now(); + setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); + try { + const result = await sendTeamMessage(teamName, { + member, + text, + summary, + attachments, + actionMode, + taskRefs, + }); + if ( + result?.runtimeDelivery?.attempted === true && + result.runtimeDelivery.delivered === false + ) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + } + return result; + } catch (error) { + setPendingRepliesByMember((prev) => { + if (prev[member] !== sentAtMs) return prev; + const next = { ...prev }; + delete next[member]; + return next; + }); + throw error; + } + }} + onClose={() => { + setSendDialogOpen(false); + setReplyQuote(undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + }} + /> + + setSelectedTask(null)} onScrollToTask={(taskId) => { + setSelectedTask(null); const el = document.querySelector(`[data-task-id="${taskId}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -2688,479 +3071,124 @@ export const TeamDetailView = ({ ); } }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => - openCreateTaskDialog('', '', '', startImmediately) - } - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - openCreateTaskDialog('', '', name); - }} - onRestartMember={handleRestartMember} - onTaskClick={(task) => { - closeSelectedMemberDialog(); - setSelectedTask(task); - }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); - } - }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - closeSelectedMemberDialog(); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} - /> - - - - !isLeadMember(m))} - leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} - resolvedMemberColorMap={resolvedMemberColorMap} - isTeamAlive={data.isAlive && !isTeamProvisioning} - isTeamProvisioning={isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onChangeLeadRuntime={handleChangeLeadRuntime} - onSaved={() => void selectTeam(teamName)} - /> - - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - isolation: entry.isolation, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); + onOwnerChange={(taskId, owner) => { + void (async () => { + try { + await updateTaskOwner(teamName, taskId, owner); + } catch { + // error via store } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); - } - })(); - }} - /> + })(); + }} + onViewChanges={handleViewChangesForFile} + onOpenInEditor={(filePath) => { + const { revealFileInEditor } = useStore.getState(); + revealFileInEditor(filePath); + }} + onDeleteTask={handleDeleteTask} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. All - team data and tasks will be deleted. - - - - - - - - - - - - { - const sentAtMs = Date.now(); - setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); - try { - const result = await sendTeamMessage(teamName, { - member, - text, - summary, - attachments, - actionMode, - taskRefs, - }); - if ( - result?.runtimeDelivery?.attempted === true && - result.runtimeDelivery.delivered === false - ) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - } - return result; - } catch (error) { - setPendingRepliesByMember((prev) => { - if (prev[member] !== sentAtMs) return prev; - const next = { ...prev }; - delete next[member]; - return next; - }); - throw error; + + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open + ? {} + : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), + })) } - }} - onClose={() => { - setSendDialogOpen(false); - setReplyQuote(undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - }} + teamName={teamName} + mode={reviewDialogState.mode} + memberName={reviewDialogState.memberName} + taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} + taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + projectPath={data.config.projectPath} + onEditorAction={handleEditorAction} + /> +
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )} +
+
- setSelectedTask(null)} - onScrollToTask={(taskId) => { - setSelectedTask(null); - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onOwnerChange={(taskId, owner) => { - void (async () => { - try { - await updateTaskOwner(teamName, taskId, owner); - } catch { - // error via store - } - })(); - }} - onViewChanges={handleViewChangesForFile} - onOpenInEditor={(filePath) => { - const { revealFileInEditor } = useStore.getState(); - revealFileInEditor(filePath); - }} - onDeleteTask={handleDeleteTask} - /> - - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> - - - setReviewDialogState((prev) => ({ - ...prev, - open, - ...(open - ? {} - : { initialFilePath: undefined, taskChangeRequestOptions: undefined }), - })) - } - teamName={teamName} - mode={reviewDialogState.mode} - memberName={reviewDialogState.memberName} - taskId={reviewDialogState.taskId} - initialFilePath={reviewDialogState.initialFilePath} - taskChangeRequestOptions={reviewDialogState.taskChangeRequestOptions} + {editorOpen && data.config.projectPath && ( + + setEditorOpen(false)} onEditorAction={handleEditorAction} /> -
-
- {messagesPanelMode === 'bottom-sheet' && ( - - )} -
-
+ + )} - {editorOpen && data.config.projectPath && ( - - setEditorOpen(false)} - onEditorAction={handleEditorAction} - /> - - )} + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + onSendMessage={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + onOpenTaskDetail={(taskId) => { + const task = data.tasks.find((t) => t.id === taskId); + if (task) setSelectedTask(task); + }} + onOpenMemberProfile={(memberName, options) => { + const member = members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab: options?.initialTab, + initialActivityFilter: options?.initialActivityFilter, + }); + } + }} + /> + + )} + + ); + }; - {graphOpen && ( - - setGraphOpen(false)} - onPinAsTab={() => { - setGraphOpen(false); - useStore - .getState() - .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); - }} - onSendMessage={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }} - onOpenTaskDetail={(taskId) => { - const task = data.tasks.find((t) => t.id === taskId); - if (task) setSelectedTask(task); - }} - onOpenMemberProfile={(memberName, options) => { - const member = members.find((m) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab: options?.initialTab, - initialActivityFilter: options?.initialActivityFilter, - }); - } - }} - /> - - )} + return ( + <> + {spawnStatusWatcher} + {teamAgentRuntimeWatcher} + {leadContextWatcher} + {renderBody()} ); - }; - - return ( - <> - {spawnStatusWatcher} - {teamAgentRuntimeWatcher} - {leadContextWatcher} - {renderBody()} - - ); -}; + } +); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 357ee15a..cc07708c 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { recordRecentProjectOpenPaths } from '@features/recent-projects/renderer'; import { api, isElectronMode } from '@renderer/api'; @@ -233,7 +233,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { } }; -export const TeamListView = (): React.JSX.Element => { +export const TeamListView = memo((): React.JSX.Element => { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); @@ -1177,4 +1177,4 @@ export const TeamListView = (): React.JSX.Element => {
); -}; +}); From fa38b90f9cf20869a59734662b0d993bbc4cc707 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 20:49:16 +0500 Subject: [PATCH 05/51] perf(renderer): memoize chat and sidebar list item components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap SessionItem, SubagentItem, ExecutionTrace, TextItem, ThinkingItem, and DisplayItemList in React.memo. These components render repeatedly in virtualized lists and AI chat groups — memoizing them eliminates redundant renders when their props have not changed, reducing CPU work in active sessions with many messages or long session sidebars. --- .../components/chat/DisplayItemList.tsx | 678 ++++++------ .../components/chat/items/ExecutionTrace.tsx | 453 ++++---- .../components/chat/items/SubagentItem.tsx | 966 +++++++++--------- .../components/chat/items/TextItem.tsx | 96 +- .../components/chat/items/ThinkingItem.tsx | 96 +- .../components/sidebar/SessionItem.tsx | 452 ++++---- 6 files changed, 1385 insertions(+), 1356 deletions(-) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index ec0412d5..cd2c5754 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -87,345 +87,353 @@ function truncateText(text: string, maxLength: number): string { * * The list is completely flat with no nested toggles or hierarchies. */ -export const DisplayItemList = ({ - items, - onItemClick, - expandedItemIds, - aiGroupId, - order = 'chronological', - searchQueryOverride, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, - previewMaxLength, - timestampFormat, - showItemMetaTooltip = false, -}: Readonly): React.JSX.Element => { - // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair - const [replyLinkToolId, setReplyLinkToolId] = useState(null); +export const DisplayItemList = React.memo( + ({ + items, + onItemClick, + expandedItemIds, + aiGroupId, + order = 'chronological', + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, + }: Readonly): React.JSX.Element => { + // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair + const [replyLinkToolId, setReplyLinkToolId] = useState(null); - const handleReplyHover = useCallback((toolId: string | null) => { - setReplyLinkToolId(toolId); - }, []); + const handleReplyHover = useCallback((toolId: string | null) => { + setReplyLinkToolId(toolId); + }, []); - /** Check if an item is part of the currently highlighted reply link */ - const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { - if (!replyLinkToolId) return false; - if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; - if (item.type === 'teammate_message' && item.teammateMessage.replyToToolId === replyLinkToolId) - return true; - return false; - }; + /** Check if an item is part of the currently highlighted reply link */ + const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { + if (!replyLinkToolId) return false; + if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; + if ( + item.type === 'teammate_message' && + item.teammateMessage.replyToToolId === replyLinkToolId + ) + return true; + return false; + }; + + if (!items || items.length === 0) { + return ( +
+ No items to display +
+ ); + } - if (!items || items.length === 0) { return ( -
- No items to display +
+ {items.map((item, index) => { + let itemKey = ''; + let element: React.ReactNode = null; + + switch (item.type) { + case 'thinking': { + itemKey = `thinking-${index}`; + const thinkingStep = { + id: itemKey, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') + : undefined + } + markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} + searchQueryOverride={searchQueryOverride} + /> + ); + break; + } + + case 'output': { + itemKey = `output-${index}`; + const textStep = { + id: itemKey, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') + : undefined + } + markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} + searchQueryOverride={searchQueryOverride} + /> + ); + break; + } + + case 'tool': { + itemKey = `tool-${item.tool.id}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.tool.startTime} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip( + item.tool.startTime, + getToolContextTokens(item.tool), + 'tokens' + ) + : undefined + } + searchQueryOverride={searchQueryOverride} + isHighlighted={highlightToolUseId === item.tool.id} + highlightColor={highlightColor} + notificationDotColor={notificationColorMap?.get(item.tool.id)} + registerRef={ + registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined + } + /> + ); + break; + } + + case 'subagent': { + itemKey = `subagent-${item.subagent.id}-${index}`; + const subagentStep = { + id: itemKey, + type: 'subagent' as const, + startTime: item.subagent.startTime, + endTime: item.subagent.endTime, + durationMs: item.subagent.durationMs, + content: { + subagentId: item.subagent.id, + subagentDescription: item.subagent.description, + }, + isParallel: item.subagent.isParallel, + context: 'main' as const, + }; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + aiGroupId={aiGroupId} + highlightToolUseId={highlightToolUseId} + highlightColor={highlightColor} + notificationColorMap={notificationColorMap} + registerToolRef={registerToolRef} + /> + ); + break; + } + + case 'slash': { + itemKey = `slash-${item.slash.name}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + timestamp={item.slash.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip( + item.slash.timestamp, + item.slash.instructionsTokenCount, + 'tokens' + ) + : undefined + } + /> + ); + break; + } + + case 'teammate_message': { + itemKey = `teammate-${item.teammateMessage.id}-${index}`; + element = ( + onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + onReplyHover={handleReplyHover} + /> + ); + break; + } + + case 'subagent_input': { + itemKey = `input-${index}`; + const inputContent = item.content; + const inputTokenCount = item.tokenCount; + element = ( + } + label="Input" + summary={truncateText(inputContent, previewMaxLength ?? 80)} + tokenCount={inputTokenCount} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') + : undefined + } + onClick={() => onItemClick(itemKey)} + isExpanded={expandedItemIds.has(itemKey)} + > + + + ); + break; + } + + case 'compact_boundary': { + itemKey = `compact-${index}`; + const compactContent = item.content; + const compactExpanded = expandedItemIds.has(itemKey); + element = ( +
+ + {compactExpanded && compactContent && ( +
+
+ +
+
+ )} +
+ ); + break; + } + + default: + return null; + } + + // Apply reply-link spotlight: dim items not in the highlighted pair + const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); + return ( +
+ {element} +
+ ); + })}
); } - - return ( -
- {items.map((item, index) => { - let itemKey = ''; - let element: React.ReactNode = null; - - switch (item.type) { - case 'thinking': { - itemKey = `thinking-${index}`; - const thinkingStep = { - id: itemKey, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'output': { - itemKey = `output-${index}`; - const textStep = { - id: itemKey, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'tool': { - itemKey = `tool-${item.tool.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.tool.startTime} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.tool.startTime, - getToolContextTokens(item.tool), - 'tokens' - ) - : undefined - } - searchQueryOverride={searchQueryOverride} - isHighlighted={highlightToolUseId === item.tool.id} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - break; - } - - case 'subagent': { - itemKey = `subagent-${item.subagent.id}-${index}`; - const subagentStep = { - id: itemKey, - type: 'subagent' as const, - startTime: item.subagent.startTime, - endTime: item.subagent.endTime, - durationMs: item.subagent.durationMs, - content: { - subagentId: item.subagent.id, - subagentDescription: item.subagent.description, - }, - isParallel: item.subagent.isParallel, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - aiGroupId={aiGroupId} - highlightToolUseId={highlightToolUseId} - highlightColor={highlightColor} - notificationColorMap={notificationColorMap} - registerToolRef={registerToolRef} - /> - ); - break; - } - - case 'slash': { - itemKey = `slash-${item.slash.name}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.slash.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.slash.timestamp, - item.slash.instructionsTokenCount, - 'tokens' - ) - : undefined - } - /> - ); - break; - } - - case 'teammate_message': { - itemKey = `teammate-${item.teammateMessage.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - onReplyHover={handleReplyHover} - /> - ); - break; - } - - case 'subagent_input': { - itemKey = `input-${index}`; - const inputContent = item.content; - const inputTokenCount = item.tokenCount; - element = ( - } - label="Input" - summary={truncateText(inputContent, previewMaxLength ?? 80)} - tokenCount={inputTokenCount} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') - : undefined - } - onClick={() => onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - > - - - ); - break; - } - - case 'compact_boundary': { - itemKey = `compact-${index}`; - const compactContent = item.content; - const compactExpanded = expandedItemIds.has(itemKey); - element = ( -
- - {compactExpanded && compactContent && ( -
-
- -
-
- )} -
- ); - break; - } - - default: - return null; - } - - // Apply reply-link spotlight: dim items not in the highlighted pair - const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); - return ( -
- {element} -
- ); - })} -
- ); -}; +); diff --git a/src/renderer/components/chat/items/ExecutionTrace.tsx b/src/renderer/components/chat/items/ExecutionTrace.tsx index f13241fd..e81ebe20 100644 --- a/src/renderer/components/chat/items/ExecutionTrace.tsx +++ b/src/renderer/components/chat/items/ExecutionTrace.tsx @@ -46,234 +46,239 @@ interface ExecutionTraceProps { // Execution Trace Component // ============================================================================= -export const ExecutionTrace: React.FC = ({ - items, - aiGroupId: _aiGroupId, - highlightToolUseId, - highlightColor, - notificationColorMap, - searchExpandedItemId, - registerToolRef, -}): React.JSX.Element => { - const [manualExpandedItemId, setManualExpandedItemId] = useState(null); +export const ExecutionTrace: React.FC = React.memo( + ({ + items, + aiGroupId: _aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + searchExpandedItemId, + registerToolRef, + }): React.JSX.Element => { + const [manualExpandedItemId, setManualExpandedItemId] = useState(null); - // Use searchExpandedItemId if set, otherwise use manually expanded item - const expandedItemId = searchExpandedItemId ?? manualExpandedItemId; + // Use searchExpandedItemId if set, otherwise use manually expanded item + const expandedItemId = searchExpandedItemId ?? manualExpandedItemId; - const handleItemClick = (itemId: string): void => { - setManualExpandedItemId((prev) => (prev === itemId ? null : itemId)); - }; + const handleItemClick = (itemId: string): void => { + setManualExpandedItemId((prev) => (prev === itemId ? null : itemId)); + }; + + if (!items || items.length === 0) { + return ( +
+ No execution items +
+ ); + } - if (!items || items.length === 0) { return ( -
- No execution items +
+ {items.map((item, index) => { + switch (item.type) { + case 'thinking': { + const itemId = `subagent-thinking-${index}`; + const thinkingStep = { + id: itemId, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.timestamp} + /> + ); + } + + case 'output': { + const itemId = `subagent-output-${index}`; + const textStep = { + id: itemId, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'subagent' as const, + }; + const preview = truncateText(item.content, 150); + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.timestamp} + /> + ); + } + + case 'tool': { + const itemId = `subagent-tool-${item.tool.id}`; + const isExpanded = expandedItemId === itemId; + const isHighlighted = highlightToolUseId === item.tool.id; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + timestamp={item.tool.startTime} + isHighlighted={isHighlighted} + highlightColor={highlightColor} + notificationDotColor={notificationColorMap?.get(item.tool.id)} + registerRef={ + registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined + } + /> + ); + } + + case 'subagent': + return ( +
+ Nested: {item.subagent.description ?? item.subagent.id} +
+ ); + + case 'subagent_input': { + const itemId = `subagent-input-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + } + label="Input" + summary={truncateText(item.content, 80)} + tokenCount={item.tokenCount} + timestamp={item.timestamp} + onClick={() => handleItemClick(itemId)} + isExpanded={isExpanded} + > + + + ); + } + + case 'teammate_message': { + const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`; + const isExpanded = expandedItemId === itemId; + return ( + handleItemClick(itemId)} + isExpanded={isExpanded} + /> + ); + } + + case 'compact_boundary': { + const itemId = `subagent-compact-${index}`; + const isExpanded = expandedItemId === itemId; + return ( +
+ {/* Header — matches CompactBoundary.tsx amber styling */} + + {/* Expanded content */} + {isExpanded && item.content && ( +
+
+ +
+
+ )} +
+ ); + } + + default: + return null; + } + })}
); } - - return ( -
- {items.map((item, index) => { - switch (item.type) { - case 'thinking': { - const itemId = `subagent-thinking-${index}`; - const thinkingStep = { - id: itemId, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'subagent' as const, - }; - const preview = truncateText(item.content, 150); - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.timestamp} - /> - ); - } - - case 'output': { - const itemId = `subagent-output-${index}`; - const textStep = { - id: itemId, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'subagent' as const, - }; - const preview = truncateText(item.content, 150); - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.timestamp} - /> - ); - } - - case 'tool': { - const itemId = `subagent-tool-${item.tool.id}`; - const isExpanded = expandedItemId === itemId; - const isHighlighted = highlightToolUseId === item.tool.id; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - timestamp={item.tool.startTime} - isHighlighted={isHighlighted} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - } - - case 'subagent': - return ( -
- Nested: {item.subagent.description ?? item.subagent.id} -
- ); - - case 'subagent_input': { - const itemId = `subagent-input-${index}`; - const isExpanded = expandedItemId === itemId; - return ( - } - label="Input" - summary={truncateText(item.content, 80)} - tokenCount={item.tokenCount} - timestamp={item.timestamp} - onClick={() => handleItemClick(itemId)} - isExpanded={isExpanded} - > - - - ); - } - - case 'teammate_message': { - const itemId = `subagent-teammate-${item.teammateMessage.id}-${index}`; - const isExpanded = expandedItemId === itemId; - return ( - handleItemClick(itemId)} - isExpanded={isExpanded} - /> - ); - } - - case 'compact_boundary': { - const itemId = `subagent-compact-${index}`; - const isExpanded = expandedItemId === itemId; - return ( -
- {/* Header — matches CompactBoundary.tsx amber styling */} - - {/* Expanded content */} - {isExpanded && item.content && ( -
-
- -
-
- )} -
- ); - } - - default: - return null; - } - })} -
- ); -}; +); diff --git a/src/renderer/components/chat/items/SubagentItem.tsx b/src/renderer/components/chat/items/SubagentItem.tsx index c2aeeca4..0c79394e 100644 --- a/src/renderer/components/chat/items/SubagentItem.tsx +++ b/src/renderer/components/chat/items/SubagentItem.tsx @@ -67,249 +67,178 @@ interface SubagentItemProps { // Main Component - Linear-style DevTools Card // ============================================================================= -export const SubagentItem: React.FC = ({ - step, - subagent, - onClick, - isExpanded, - aiGroupId, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, -}) => { - const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; - const subagentType = subagent.subagentType ?? 'Task'; - const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; +export const SubagentItem: React.FC = React.memo( + ({ + step, + subagent, + onClick, + isExpanded, + aiGroupId, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + }) => { + const description = subagent.description ?? step.content.subagentDescription ?? 'Subagent'; + const subagentType = subagent.subagentType ?? 'Task'; + const truncatedDesc = description.length > 60 ? description.slice(0, 60) + '...' : description; - // Agent configs from .claude/agents/ for color lookup - const agentConfigs = useStore(useShallow((s) => s.agentConfigs)); + // Agent configs from .claude/agents/ for color lookup + const agentConfigs = useStore(useShallow((s) => s.agentConfigs)); - // Team member colors (when this subagent is a team member) - const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; - const { isLight } = useTheme(); - // Type-based colors for non-team subagents (from agent config or deterministic hash) - const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; + // Team member colors (when this subagent is a team member) + const teamColors = subagent.team ? getTeamColorSet(subagent.team.memberColor) : null; + const { isLight } = useTheme(); + // Type-based colors for non-team subagents (from agent config or deterministic hash) + const typeColors = !teamColors ? getSubagentTypeColorSet(subagentType, agentConfigs) : null; - // Detect shutdown-only team activations (trivial: just a shutdown_response) - const isShutdownOnly = useMemo(() => { - if (!subagent.team || !subagent.messages?.length) return false; - const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); - if (assistantMsgs.length !== 1) return false; - const calls = assistantMsgs[0].toolCalls ?? []; - return ( - calls.length === 1 && - calls[0].name === 'SendMessage' && - calls[0].input?.type === 'shutdown_response' - ); - }, [subagent.team, subagent.messages]); + // Detect shutdown-only team activations (trivial: just a shutdown_response) + const isShutdownOnly = useMemo(() => { + if (!subagent.team || !subagent.messages?.length) return false; + const assistantMsgs = subagent.messages.filter((m) => m.type === 'assistant'); + if (assistantMsgs.length !== 1) return false; + const calls = assistantMsgs[0].toolCalls ?? []; + return ( + calls.length === 1 && + calls[0].name === 'SendMessage' && + calls[0].input?.type === 'shutdown_response' + ); + }, [subagent.team, subagent.messages]); - // Per-tab trace expansion state (replaces local useState for true per-tab isolation) - const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); - const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); + // Per-tab trace expansion state (replaces local useState for true per-tab isolation) + const { isSubagentTraceExpanded, toggleSubagentTraceExpansion } = useTabUI(); + const isTraceManuallyExpanded = isSubagentTraceExpanded(subagent.id); - // Check if contains highlighted error - // Also matches when the highlight targets the parent Task tool_use that spawned this subagent - const containsHighlightedError = useMemo(() => { - if (!highlightToolUseId) return false; - // Match parent Task tool_use ID (trigger matched the Task call itself) - if (subagent.parentTaskId === highlightToolUseId) return true; - // Match inner tool calls/results within the subagent - if (!subagent.messages) return false; - for (const msg of subagent.messages) { - if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; - if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; - } - return false; - }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); - - // Build display items - const displayItems = useMemo(() => { - if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { - return []; - } - return buildDisplayItemsFromMessages(subagent.messages, []); - }, [isExpanded, containsHighlightedError, subagent.messages]); - - // Build summary - const itemsSummary = useMemo(() => { - if (!isExpanded && !containsHighlightedError) { - const toolCount = - subagent.messages?.filter( - (m) => - m.type === 'assistant' && - Array.isArray(m.content) && - m.content.some((b) => b.type === 'tool_use') - ).length ?? 0; - return toolCount > 0 ? `${toolCount} tools` : ''; - } - return buildSummary(displayItems); - }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); - - // Model info - const modelInfo = useMemo(() => { - const msg = subagent.messages?.find( - (m) => m.type === 'assistant' && m.model && m.model !== '' - ); - return msg?.model ? parseModelString(msg.model) : null; - }, [subagent.messages]); - - // Last usage - const lastUsage = useMemo(() => { - const messages = subagent.messages ?? []; - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].type === 'assistant' && messages[i].usage) { - return messages[i].usage; + // Check if contains highlighted error + // Also matches when the highlight targets the parent Task tool_use that spawned this subagent + const containsHighlightedError = useMemo(() => { + if (!highlightToolUseId) return false; + // Match parent Task tool_use ID (trigger matched the Task call itself) + if (subagent.parentTaskId === highlightToolUseId) return true; + // Match inner tool calls/results within the subagent + if (!subagent.messages) return false; + for (const msg of subagent.messages) { + if (msg.toolCalls?.some((tc) => tc.id === highlightToolUseId)) return true; + if (msg.toolResults?.some((tr) => tr.toolUseId === highlightToolUseId)) return true; } - } - return null; - }, [subagent.messages]); + return false; + }, [highlightToolUseId, subagent.parentTaskId, subagent.messages]); - // Multi-phase context breakdown (for subagents with compaction) - const phaseData = useMemo(() => { - if (!subagent.messages?.length) return null; - return computeSubagentPhaseBreakdown(subagent.messages); - }, [subagent.messages]); - - // Search expansion - const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds)); - const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); - const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); - - // Combine manual expansion with auto-expansion for errors/search - const isTraceExpanded = - isTraceManuallyExpanded || containsHighlightedError || shouldExpandForSearch; - const [isTraceHeaderHovered, setIsTraceHeaderHovered] = useState(false); - - // Outer card highlight when this subagent contains the highlighted tool - const outerHighlight = useMemo(() => { - if (!containsHighlightedError) - return { className: '', style: undefined as React.CSSProperties | undefined }; - return getHighlightProps(highlightColor); - }, [containsHighlightedError, highlightColor]); - - // Register outer card as a tool ref target for the parent Task tool_use ID - // so the navigation controller can scroll directly to this SubagentItem - const outerCardRef = useCallback( - (el: HTMLDivElement | null) => { - if (subagent.parentTaskId && registerToolRef) { - registerToolRef(subagent.parentTaskId, el); + // Build display items + const displayItems = useMemo(() => { + if ((!isExpanded && !containsHighlightedError) || !subagent.messages?.length) { + return []; } - }, - [subagent.parentTaskId, registerToolRef] - ); + return buildDisplayItemsFromMessages(subagent.messages, []); + }, [isExpanded, containsHighlightedError, subagent.messages]); - // Cumulative metrics for team members — show total output generated - const cumulativeMetrics = useMemo(() => { - if (!subagent.team || !subagent.metrics) return undefined; - const turnCount = - subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; - return { - outputTokens: subagent.metrics.outputTokens, - turnCount, - }; - }, [subagent.team, subagent.metrics, subagent.messages]); + // Build summary + const itemsSummary = useMemo(() => { + if (!isExpanded && !containsHighlightedError) { + const toolCount = + subagent.messages?.filter( + (m) => + m.type === 'assistant' && + Array.isArray(m.content) && + m.content.some((b) => b.type === 'tool_use') + ).length ?? 0; + return toolCount > 0 ? `${toolCount} tools` : ''; + } + return buildSummary(displayItems); + }, [isExpanded, containsHighlightedError, displayItems, subagent.messages]); - // Computed values for metrics - const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; - const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; - const isMultiPhase = phaseData != null && phaseData.compactionCount > 0; - const isolatedTotal = isMultiPhase - ? phaseData.totalConsumption - : lastUsage - ? lastUsage.input_tokens + - lastUsage.output_tokens + - (lastUsage.cache_read_input_tokens ?? 0) + - (lastUsage.cache_creation_input_tokens ?? 0) - : 0; + // Model info + const modelInfo = useMemo(() => { + const msg = subagent.messages?.find( + (m) => m.type === 'assistant' && m.model && m.model !== '' + ); + return msg?.model ? parseModelString(msg.model) : null; + }, [subagent.messages]); - // Shutdown-only team activations: minimal inline row (no metrics, no expand) - if (isShutdownOnly && teamColors && subagent.team) { - return ( -
- - { + const messages = subagent.messages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].type === 'assistant' && messages[i].usage) { + return messages[i].usage; + } + } + return null; + }, [subagent.messages]); + + // Multi-phase context breakdown (for subagents with compaction) + const phaseData = useMemo(() => { + if (!subagent.messages?.length) return null; + return computeSubagentPhaseBreakdown(subagent.messages); + }, [subagent.messages]); + + // Search expansion + const searchExpandedSubagentIds = useStore(useShallow((s) => s.searchExpandedSubagentIds)); + const searchCurrentSubagentItemId = useStore((s) => s.searchCurrentSubagentItemId); + const shouldExpandForSearch = searchExpandedSubagentIds.has(subagent.id); + + // Combine manual expansion with auto-expansion for errors/search + const isTraceExpanded = + isTraceManuallyExpanded || containsHighlightedError || shouldExpandForSearch; + const [isTraceHeaderHovered, setIsTraceHeaderHovered] = useState(false); + + // Outer card highlight when this subagent contains the highlighted tool + const outerHighlight = useMemo(() => { + if (!containsHighlightedError) + return { className: '', style: undefined as React.CSSProperties | undefined }; + return getHighlightProps(highlightColor); + }, [containsHighlightedError, highlightColor]); + + // Register outer card as a tool ref target for the parent Task tool_use ID + // so the navigation controller can scroll directly to this SubagentItem + const outerCardRef = useCallback( + (el: HTMLDivElement | null) => { + if (subagent.parentTaskId && registerToolRef) { + registerToolRef(subagent.parentTaskId, el); + } + }, + [subagent.parentTaskId, registerToolRef] + ); + + // Cumulative metrics for team members — show total output generated + const cumulativeMetrics = useMemo(() => { + if (!subagent.team || !subagent.metrics) return undefined; + const turnCount = + subagent.messages?.filter((m) => m.type === 'assistant' && m.usage).length ?? 0; + return { + outputTokens: subagent.metrics.outputTokens, + turnCount, + }; + }, [subagent.team, subagent.metrics, subagent.messages]); + + // Computed values for metrics + const hasMainImpact = subagent.mainSessionImpact && subagent.mainSessionImpact.totalTokens > 0; + const hasIsolated = lastUsage && lastUsage.input_tokens + lastUsage.output_tokens > 0; + const isMultiPhase = phaseData != null && phaseData.compactionCount > 0; + const isolatedTotal = isMultiPhase + ? phaseData.totalConsumption + : lastUsage + ? lastUsage.input_tokens + + lastUsage.output_tokens + + (lastUsage.cache_read_input_tokens ?? 0) + + (lastUsage.cache_creation_input_tokens ?? 0) + : 0; + + // Shutdown-only team activations: minimal inline row (no metrics, no expand) + if (isShutdownOnly && teamColors && subagent.team) { + return ( +
- {subagent.team.memberName} - - - Shutdown confirmed - - - - {formatDuration(subagent.durationMs)} - -
- ); - } - - return ( -
- {/* ========== Level 1: Clickable Header ========== */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick(); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" - style={{ - backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', - borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', - }} - > - {/* Expand chevron */} - - - {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} - {teamColors || typeColors ? ( - ) : ( - - )} - - {/* Type badge - team member name or typed subagent */} - {teamColors && subagent.team ? ( = ({ > {subagent.team.memberName} - ) : ( + + Shutdown confirmed + + - {subagentType} + {formatDuration(subagent.durationMs)} - )} +
+ ); + } - {/* Model */} - {modelInfo && ( - - {modelInfo.name} - - )} - - {/* Description */} - - {truncatedDesc} - - - {/* Status indicator */} - {subagent.isOngoing ? ( - - ) : ( - - )} - - {/* Unified Metrics Pill — team members don't show mainSessionImpact - (spawn cost only; real main impact comes from teammate messages) */} - 0 ? phaseData.totalConsumption : undefined - } - phaseBreakdown={phaseData?.phases} - /> - - {/* Duration */} - + {/* ========== Level 1: Clickable Header ========== */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + backgroundColor: isExpanded ? CARD_HEADER_BG : 'transparent', + borderBottom: isExpanded ? CARD_BORDER_STYLE : 'none', + }} > - {formatDuration(subagent.durationMs)} - + {/* Expand chevron */} + - {/* Timestamp — rightmost info element */} - - {format(subagent.startTime, 'HH:mm:ss')} - -
- - {/* ========== Level 1 Expanded: Dashboard Content ========== */} - {isExpanded && ( -
- {/* ========== Row 1: Meta Info (Horizontal Flow) ========== */} -
- - Type{' '} - - {subagentType} - - - - - Duration{' '} - - {formatDuration(subagent.durationMs)} - - - {modelInfo && ( - <> - - - Model{' '} - - {modelInfo.name} - - - - )} - - - ID{' '} - - {subagent.id.slice(0, 8)} - - -
- - {/* ========== Row 2: Context Usage (Clean List) ========== */} - {(hasMainImpact ?? hasIsolated) && ( -
- {/* Overline title */} -
- Context Usage -
- - {/* Token rows - floating alignment */} -
- {hasMainImpact && !subagent.team && ( -
-
- - - Main Context - -
- - {subagent.mainSessionImpact!.totalTokens.toLocaleString()} - -
- )} - - {cumulativeMetrics && ( -
-
- - - Total Output - -
- - {cumulativeMetrics.outputTokens.toLocaleString()} - - {' '} - ({cumulativeMetrics.turnCount} turns) - - -
- )} - - {hasIsolated && ( -
-
- - - {subagent.team ? 'Context Window' : 'Subagent Context'} - -
- - {isolatedTotal.toLocaleString()} - -
- )} - - {/* Per-phase breakdown when multi-phase */} - {isMultiPhase && - phaseData.phases.map((phase) => ( -
- - Phase {phase.phaseNumber} - - - {formatTokensCompact(phase.peakTokens)} - {phase.postCompaction != null && ( - - {' '} - → {formatTokensCompact(phase.postCompaction)} - - )} - -
- ))} -
-
+ {/* Icon - colored dot for team members/typed subagents, Bot icon for generic */} + {teamColors || typeColors ? ( + + ) : ( + )} - {/* ========== Level 2: Execution Trace Toggle ========== */} - {displayItems.length > 0 && ( -
- {/* Trace Header (clickable) */} + {subagent.team.memberName} + + ) : ( + + {subagentType} + + )} + + {/* Model */} + {modelInfo && ( + + {modelInfo.name} + + )} + + {/* Description */} + + {truncatedDesc} + + + {/* Status indicator */} + {subagent.isOngoing ? ( + + ) : ( + + )} + + {/* Unified Metrics Pill — team members don't show mainSessionImpact + (spawn cost only; real main impact comes from teammate messages) */} + 0 ? phaseData.totalConsumption : undefined + } + phaseBreakdown={phaseData?.phases} + /> + + {/* Duration */} + + {formatDuration(subagent.durationMs)} + + + {/* Timestamp — rightmost info element */} + + {format(subagent.startTime, 'HH:mm:ss')} + +
+ + {/* ========== Level 1 Expanded: Dashboard Content ========== */} + {isExpanded && ( +
+ {/* ========== Row 1: Meta Info (Horizontal Flow) ========== */} +
+ + Type{' '} + + {subagentType} + + + + + Duration{' '} + + {formatDuration(subagent.durationMs)} + + + {modelInfo && ( + <> + + + Model{' '} + + {modelInfo.name} + + + + )} + + + ID{' '} + + {subagent.id.slice(0, 8)} + + +
+ + {/* ========== Row 2: Context Usage (Clean List) ========== */} + {(hasMainImpact ?? hasIsolated) && ( +
+ {/* Overline title */} +
+ Context Usage +
+ + {/* Token rows - floating alignment */} +
+ {hasMainImpact && !subagent.team && ( +
+
+ + + Main Context + +
+ + {subagent.mainSessionImpact!.totalTokens.toLocaleString()} + +
+ )} + + {cumulativeMetrics && ( +
+
+ + + Total Output + +
+ + {cumulativeMetrics.outputTokens.toLocaleString()} + + {' '} + ({cumulativeMetrics.turnCount} turns) + + +
+ )} + + {hasIsolated && ( +
+
+ + + {subagent.team ? 'Context Window' : 'Subagent Context'} + +
+ + {isolatedTotal.toLocaleString()} + +
+ )} + + {/* Per-phase breakdown when multi-phase */} + {isMultiPhase && + phaseData.phases.map((phase) => ( +
+ + Phase {phase.phaseNumber} + + + {formatTokensCompact(phase.peakTokens)} + {phase.postCompaction != null && ( + + {' '} + → {formatTokensCompact(phase.postCompaction)} + + )} + +
+ ))} +
+
+ )} + + {/* ========== Level 2: Execution Trace Toggle ========== */} + {displayItems.length > 0 && (
{ - e.stopPropagation(); - toggleSubagentTraceExpansion(subagent.id); + className="overflow-hidden rounded-md" + style={{ + border: CARD_BORDER_STYLE, + backgroundColor: CARD_HEADER_BG, }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); + > + {/* Trace Header (clickable) */} +
{ e.stopPropagation(); toggleSubagentTraceExpansion(subagent.id); - } - }} - className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" - style={{ - borderBottom: isTraceExpanded ? CARD_BORDER_STYLE : 'none', - backgroundColor: isTraceHeaderHovered ? CARD_HEADER_HOVER : 'transparent', - }} - onMouseEnter={() => setIsTraceHeaderHovered(true)} - onMouseLeave={() => setIsTraceHeaderHovered(false)} - > - - - - Execution Trace - - - · {itemsSummary} - -
- - {/* Trace Content */} - {isTraceExpanded && ( -
- { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + toggleSubagentTraceExpansion(subagent.id); } - registerToolRef={registerToolRef} + }} + className="flex cursor-pointer items-center gap-2 px-3 py-2 transition-colors" + style={{ + borderBottom: isTraceExpanded ? CARD_BORDER_STYLE : 'none', + backgroundColor: isTraceHeaderHovered ? CARD_HEADER_HOVER : 'transparent', + }} + onMouseEnter={() => setIsTraceHeaderHovered(true)} + onMouseLeave={() => setIsTraceHeaderHovered(false)} + > + + + + Execution Trace + + + · {itemsSummary} +
- )} -
- )} -
- )} -
- ); -}; + + {/* Trace Content */} + {isTraceExpanded && ( +
+ +
+ )} +
+ )} +
+ )} +
+ ); + } +); diff --git a/src/renderer/components/chat/items/TextItem.tsx b/src/renderer/components/chat/items/TextItem.tsx index 9e94e566..ed53418d 100644 --- a/src/renderer/components/chat/items/TextItem.tsx +++ b/src/renderer/components/chat/items/TextItem.tsx @@ -31,52 +31,54 @@ interface TextItemProps { titleText?: string; } -export const TextItem: React.FC = ({ - step, - preview, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - markdownItemId, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const fullContent = step.content.outputText ?? preview; - const summary = searchQueryOverride - ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) - : preview; +export const TextItem: React.FC = React.memo( + ({ + step, + preview, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + markdownItemId, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }) => { + const fullContent = step.content.outputText ?? preview; + const summary = searchQueryOverride + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; - // Get token count from step.tokens.output or step.content.tokenCount - const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; - return ( - } - label="Output" - summary={summary} - tokenCount={tokenCount} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - - - ); -}; + return ( + } + label="Output" + summary={summary} + tokenCount={tokenCount} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); + } +); diff --git a/src/renderer/components/chat/items/ThinkingItem.tsx b/src/renderer/components/chat/items/ThinkingItem.tsx index 116a9680..5a681ff5 100644 --- a/src/renderer/components/chat/items/ThinkingItem.tsx +++ b/src/renderer/components/chat/items/ThinkingItem.tsx @@ -31,52 +31,54 @@ interface ThinkingItemProps { titleText?: string; } -export const ThinkingItem: React.FC = ({ - step, - preview, - onClick, - isExpanded, - timestamp, - timestampFormat, - searchQueryOverride, - markdownItemId, - highlightClasses, - highlightStyle, - notificationDotColor, - titleText, -}) => { - const fullContent = step.content.thinkingText ?? preview; - const summary = searchQueryOverride - ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { - forceAllActive: true, - }) - : preview; +export const ThinkingItem: React.FC = React.memo( + ({ + step, + preview, + onClick, + isExpanded, + timestamp, + timestampFormat, + searchQueryOverride, + markdownItemId, + highlightClasses, + highlightStyle, + notificationDotColor, + titleText, + }) => { + const fullContent = step.content.thinkingText ?? preview; + const summary = searchQueryOverride + ? highlightQueryInText(preview, searchQueryOverride, `${markdownItemId ?? step.id}:summary`, { + forceAllActive: true, + }) + : preview; - // Get token count from step.tokens.output or step.content.tokenCount - const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; + // Get token count from step.tokens.output or step.content.tokenCount + const tokenCount = step.tokens?.output ?? step.content.tokenCount ?? 0; - return ( - } - label="Thinking" - summary={summary} - tokenCount={tokenCount} - timestamp={timestamp} - timestampFormat={timestampFormat} - titleText={titleText} - onClick={onClick} - isExpanded={isExpanded} - highlightClasses={highlightClasses} - highlightStyle={highlightStyle} - notificationDotColor={notificationDotColor} - > - - - ); -}; + return ( + } + label="Thinking" + summary={summary} + tokenCount={tokenCount} + timestamp={timestamp} + timestampFormat={timestampFormat} + titleText={titleText} + onClick={onClick} + isExpanded={isExpanded} + highlightClasses={highlightClasses} + highlightStyle={highlightStyle} + notificationDotColor={notificationDotColor} + > + + + ); + } +); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index ab6b72a9..10477dc9 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -4,7 +4,7 @@ * Supports right-click context menu for pane management. */ -import { useCallback, useRef, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo'; @@ -156,238 +156,242 @@ const SessionRuntimeBadge = ({ ); }; -export const SessionItem = ({ - session, - isActive, - isPinned, - isHidden, - multiSelectActive, - isSelected, - onToggleSelect, -}: Readonly): React.JSX.Element => { - const { - openTab, - activeProjectId, - selectSession, - paneCount, - splitPane, - togglePinSession, - toggleHideSession, - } = useStore( - useShallow((s) => ({ - openTab: s.openTab, - activeProjectId: s.activeProjectId, - selectSession: s.selectSession, - paneCount: s.paneLayout.panes.length, - splitPane: s.splitPane, - togglePinSession: s.togglePinSession, - toggleHideSession: s.toggleHideSession, - })) - ); - - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - - const handleClick = (event: React.MouseEvent): void => { - if (!activeProjectId) return; - - // In multi-select mode, clicks toggle selection - if (multiSelectActive && onToggleSelect) { - onToggleSelect(); - return; - } - - // Cmd/Ctrl+click: open in new tab; plain click: replace current tab - const forceNewTab = event.ctrlKey || event.metaKey; - - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: formatSessionLabel(session.firstMessage), - }, - forceNewTab ? { forceNewTab } : { replaceActiveTab: true } +export const SessionItem = memo( + ({ + session, + isActive, + isPinned, + isHidden, + multiSelectActive, + isSelected, + onToggleSelect, + }: Readonly): React.JSX.Element => { + const { + openTab, + activeProjectId, + selectSession, + paneCount, + splitPane, + togglePinSession, + toggleHideSession, + } = useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + toggleHideSession: s.toggleHideSession, + })) ); - selectSession(session.id); - }; + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }, []); + const handleClick = (event: React.MouseEvent): void => { + if (!activeProjectId) return; - const sessionLabel = formatSessionLabel(session.firstMessage); + // In multi-select mode, clicks toggle selection + if (multiSelectActive && onToggleSelect) { + onToggleSelect(); + return; + } - const handleOpenInCurrentPane = useCallback(() => { - if (!activeProjectId) return; - openTab( - { + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab + const forceNewTab = event.ctrlKey || event.metaKey; + + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: formatSessionLabel(session.firstMessage), + }, + forceNewTab ? { forceNewTab } : { replaceActiveTab: true } + ); + + selectSession(session.id); + }; + + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); + + const sessionLabel = formatSessionLabel(session.firstMessage); + + const handleOpenInCurrentPane = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { replaceActiveTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleOpenInNewTab = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { forceNewTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleSplitRightAndOpen = useCallback(() => { + if (!activeProjectId) return; + // First open the tab in the focused pane + openTab({ type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, - }, - { replaceActiveTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + }); + selectSession(session.id); + // Then split it to the right + const state = useStore.getState(); + const focusedPaneId = state.paneLayout.focusedPaneId; + const activeTabId = state.activeTabId; + if (activeTabId) { + splitPane(focusedPaneId, activeTabId, 'right'); + } + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - const handleOpenInNewTab = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { forceNewTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleSplitRightAndOpen = useCallback(() => { - if (!activeProjectId) return; - // First open the tab in the focused pane - openTab({ - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }); - selectSession(session.id); - // Then split it to the right - const state = useStore.getState(); - const focusedPaneId = state.paneLayout.focusedPaneId; - const activeTabId = state.activeTabId; - if (activeTabId) { - splitPane(focusedPaneId, activeTabId, 'right'); - } - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - - // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll - return ( - <> - + )} + {session.isOngoing && } + {isPinned && } + {isHidden && } + {isTeam ? ( + + + {parsed.displayText} + + ) : ( + + {parsed.displayText} + + )} +
- {contextMenu && - activeProjectId && - createPortal( - setContextMenu(null)} - onOpenInCurrentPane={handleOpenInCurrentPane} - onOpenInNewTab={handleOpenInNewTab} - onSplitRightAndOpen={handleSplitRightAndOpen} - onTogglePin={() => void togglePinSession(session.id)} - onToggleHide={() => void toggleHideSession(session.id)} - />, - document.body - )} - - ); -}; + {/* Second line: metadata */} +
+ {isTeam && parsed.projectName && ( + <> + {parsed.projectName} + · + + )} + {isTeam && ( + <> + + {parsed.kind === 'team-resume' ? ( + + ) : ( + + )} + {parsed.kind === 'team-resume' ? 'resume' : 'new'} + + · + + )} + + + {session.messageCount} + + · + + {formatShortTime(new Date(session.createdAt))} + + {session.model && ( + <> + · + + + )} + {session.contextConsumption != null && session.contextConsumption > 0 && ( + <> + · + + + )} +
+ + ); + })()} + + + {contextMenu && + activeProjectId && + createPortal( + setContextMenu(null)} + onOpenInCurrentPane={handleOpenInCurrentPane} + onOpenInNewTab={handleOpenInNewTab} + onSplitRightAndOpen={handleSplitRightAndOpen} + onTogglePin={() => void togglePinSession(session.id)} + onToggleHide={() => void toggleHideSession(session.id)} + />, + document.body + )} + + ); + } +); From 2bda324e1a2a7e8bd4c1665e1c3daefac2f6453c Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 21:10:24 +0500 Subject: [PATCH 06/51] perf(renderer): stable callbacks and lazy-load large dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move toggleSidebarSessionSelection into SessionItem's own store subscription, eliminating the inline arrow function prop that was breaking its memo on every sidebar render. Lazy-load LaunchTeamDialog (2918L) and CreateTeamDialog (2208L) in all four host components (TeamDetailView, TeamListView, SchedulesView, ScheduleSection). These dialogs are never needed at initial mount — they only open on user action. Deferring their parse/compile saves ~175KB of JS from the initial render path. --- .../components/schedules/SchedulesView.tsx | 23 ++++--- .../sidebar/DateGroupedSessions.tsx | 3 - .../components/sidebar/SessionItem.tsx | 10 +-- .../components/team/TeamDetailView.tsx | 59 +++++++++------- src/renderer/components/team/TeamListView.tsx | 69 +++++++++++-------- .../team/schedule/ScheduleSection.tsx | 24 ++++--- 6 files changed, 105 insertions(+), 83 deletions(-) diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 4ee09057..3535cc28 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Input } from '@renderer/components/ui/input'; @@ -24,8 +24,11 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { LaunchTeamDialog } from '../team/dialogs/LaunchTeamDialog'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; + +const LaunchTeamDialog = lazy(() => + import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; @@ -562,13 +565,15 @@ export const SchedulesView = (): React.JSX.Element => {
{/* Create/Edit Dialog */} - + + +
); }; diff --git a/src/renderer/components/sidebar/DateGroupedSessions.tsx b/src/renderer/components/sidebar/DateGroupedSessions.tsx index 39827dcb..5efd93bf 100644 --- a/src/renderer/components/sidebar/DateGroupedSessions.tsx +++ b/src/renderer/components/sidebar/DateGroupedSessions.tsx @@ -202,7 +202,6 @@ export const DateGroupedSessions = memo((): React.JSX.Element => { toggleShowHiddenSessions, sidebarSelectedSessionIds, sidebarMultiSelectActive, - toggleSidebarSessionSelection, clearSidebarSelection, toggleSidebarMultiSelect, hideMultipleSessions, @@ -239,7 +238,6 @@ export const DateGroupedSessions = memo((): React.JSX.Element => { toggleShowHiddenSessions: s.toggleShowHiddenSessions, sidebarSelectedSessionIds: s.sidebarSelectedSessionIds, sidebarMultiSelectActive: s.sidebarMultiSelectActive, - toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, clearSidebarSelection: s.clearSidebarSelection, toggleSidebarMultiSelect: s.toggleSidebarMultiSelect, hideMultipleSessions: s.hideMultipleSessions, @@ -1104,7 +1102,6 @@ export const DateGroupedSessions = memo((): React.JSX.Element => { isHidden={item.isHidden} multiSelectActive={sidebarMultiSelectActive} isSelected={selectedSet.has(item.session.id)} - onToggleSelect={() => toggleSidebarSessionSelection(item.session.id)} /> )}
diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index 10477dc9..dbf534c4 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -30,7 +30,6 @@ interface SessionItemProps { isHidden?: boolean; multiSelectActive?: boolean; isSelected?: boolean; - onToggleSelect?: () => void; } /** @@ -164,7 +163,6 @@ export const SessionItem = memo( isHidden, multiSelectActive, isSelected, - onToggleSelect, }: Readonly): React.JSX.Element => { const { openTab, @@ -174,6 +172,7 @@ export const SessionItem = memo( splitPane, togglePinSession, toggleHideSession, + toggleSidebarSessionSelection, } = useStore( useShallow((s) => ({ openTab: s.openTab, @@ -183,6 +182,7 @@ export const SessionItem = memo( splitPane: s.splitPane, togglePinSession: s.togglePinSession, toggleHideSession: s.toggleHideSession, + toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, })) ); @@ -192,8 +192,8 @@ export const SessionItem = memo( if (!activeProjectId) return; // In multi-select mode, clicks toggle selection - if (multiSelectActive && onToggleSelect) { - onToggleSelect(); + if (multiSelectActive) { + toggleSidebarSessionSelection(session.id); return; } @@ -291,7 +291,7 @@ export const SessionItem = memo( onToggleSelect?.()} + onChange={() => toggleSidebarSessionSelection(session.id)} onClick={(e) => e.stopPropagation()} className="size-3.5 shrink-0 accent-blue-500" /> diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 99f44e83..d949b9b2 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -80,7 +80,7 @@ import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { CreateTaskDialog } from './dialogs/CreateTaskDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; -import { LaunchTeamDialog, type TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; +import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; import { SendMessageDialog } from './dialogs/SendMessageDialog'; import { TaskDetailDialog } from './dialogs/TaskDetailDialog'; @@ -96,6 +96,9 @@ import type { AddMemberEntry } from './dialogs/AddMemberDialog'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); const ProjectEditorOverlay = lazy(() => import('./editor/ProjectEditorOverlay').then((m) => ({ default: m.ProjectEditorOverlay })) ); @@ -2176,18 +2179,20 @@ export const TeamDetailView = memo(
- + + + ); } @@ -2976,19 +2981,21 @@ export const TeamDetailView = memo( - + + + + import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) +); +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); + import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { @@ -732,35 +737,39 @@ export const TeamListView = memo((): React.JSX.Element => { } const createDialogElement = ( - t.teamName)} - provisioningTeamNames={provisioningTeamNames} - activeTeams={activeTeams} - initialData={copyData ?? undefined} - defaultProjectPath={currentProjectPath} - onClose={handleCreateDialogClose} - onCreate={handleCreateSubmit} - onOpenTeam={openTeamTab} - /> + + t.teamName)} + provisioningTeamNames={provisioningTeamNames} + activeTeams={activeTeams} + initialData={copyData ?? undefined} + defaultProjectPath={currentProjectPath} + onClose={handleCreateDialogClose} + onCreate={handleCreateSubmit} + onOpenTeam={openTeamTab} + /> + ); const launchDialogElement = ( - setLaunchDialogOpen(false)} - onLaunch={handleLaunchSubmit} - /> + + setLaunchDialogOpen(false)} + onLaunch={handleLaunchSubmit} + /> + ); const renderHeader = (): React.JSX.Element => ( diff --git a/src/renderer/components/team/schedule/ScheduleSection.tsx b/src/renderer/components/team/schedule/ScheduleSection.tsx index b8f44bd4..9de22a43 100644 --- a/src/renderer/components/team/schedule/ScheduleSection.tsx +++ b/src/renderer/components/team/schedule/ScheduleSection.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useState } from 'react'; import { Button } from '@renderer/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; @@ -18,9 +18,11 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import { LaunchTeamDialog } from '../dialogs/LaunchTeamDialog'; - import { ScheduleEmptyState } from './ScheduleEmptyState'; + +const LaunchTeamDialog = lazy(() => + import('../dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); import { ScheduleRunLogDialog } from './ScheduleRunLogDialog'; import { ScheduleRunRow } from './ScheduleRunRow'; import { ScheduleStatusBadge } from './ScheduleStatusBadge'; @@ -305,13 +307,15 @@ export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.E )} {/* Create/Edit Dialog */} - + + +
); }; From 8b30930c043a97bd420ba4f07db9725f32d3f501 Mon Sep 17 00:00:00 2001 From: Mike Date: Sat, 2 May 2026 21:20:25 +0500 Subject: [PATCH 07/51] perf: memoize KanbanBoard, KanbanGridLayout, MemberCard, TaskRow, SidebarTaskItem Wrap five hot-path components in React.memo to prevent unnecessary re-renders when parent state changes don't affect their props. --- .../components/sidebar/SidebarTaskItem.tsx | 378 +++--- .../components/team/kanban/KanbanBoard.tsx | 837 ++++++------ .../team/kanban/KanbanGridLayout.tsx | 118 +- .../components/team/members/MemberCard.tsx | 1162 +++++++++-------- .../components/team/tasks/TaskRow.tsx | 6 +- 5 files changed, 1263 insertions(+), 1238 deletions(-) diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 688aa958..5469b20f 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { getTeamColorSet } from '@renderer/constants/teamColors'; @@ -69,218 +69,220 @@ interface SidebarTaskItemProps { getDisplaySubject?: (task: GlobalTask) => string | undefined; } -export const SidebarTaskItem = ({ - task, - hideTeamName, - showTeamName, - renamingKey, - onRenameComplete, - onRenameCancel, - getDisplaySubject, -}: SidebarTaskItemProps): React.JSX.Element => { - const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); - const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); - const { isLight } = useTheme(); +export const SidebarTaskItem = memo( + ({ + task, + hideTeamName, + showTeamName, + renamingKey, + onRenameComplete, + onRenameCancel, + getDisplaySubject, + }: SidebarTaskItemProps): React.JSX.Element => { + const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); + const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); + const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); + const { isLight } = useTheme(); - const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; - const [editValue, setEditValue] = useState(displaySubject); - const inputRef = useRef(null); - // Focus input when rename starts - useEffect(() => { - if (!isRenaming) return; - const raf = requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - return () => cancelAnimationFrame(raf); - }, [isRenaming]); + const isRenaming = renamingKey === `${task.teamName}:${task.id}`; + const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const [editValue, setEditValue] = useState(displaySubject); + const inputRef = useRef(null); + // Focus input when rename starts + useEffect(() => { + if (!isRenaming) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [isRenaming]); - // Reset edit value when renaming starts - useEffect(() => { - if (isRenaming) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change - setEditValue(displaySubject); - } - }, [isRenaming, displaySubject]); + // Reset edit value when renaming starts + useEffect(() => { + if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + setEditValue(displaySubject); + } + }, [isRenaming, displaySubject]); - const reviewColumn = getTaskKanbanColumn(task); - const cfg = - reviewColumn === 'approved' - ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) - : reviewColumn === 'review' - ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) - : (statusConfig[task.status] ?? statusConfig.pending); - const StatusIcon = cfg.icon; - const updatedLabel = formatUpdatedLabel(task); - const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); + const reviewColumn = getTaskKanbanColumn(task); + const cfg = + reviewColumn === 'approved' + ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) + : reviewColumn === 'review' + ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) + : (statusConfig[task.status] ?? statusConfig.pending); + const StatusIcon = cfg.icon; + const updatedLabel = formatUpdatedLabel(task); + const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); - const ownerColorSet = useMemo(() => { - if (!teamMembers || !task.owner) return null; - const colorMap = buildMemberColorMap(teamMembers); - const colorName = colorMap.get(task.owner); - return colorName ? getTeamColorSet(colorName) : null; - }, [teamMembers, task.owner]); + const ownerColorSet = useMemo(() => { + if (!teamMembers || !task.owner) return null; + const colorMap = buildMemberColorMap(teamMembers); + const colorName = colorMap.get(task.owner); + return colorName ? getTeamColorSet(colorName) : null; + }, [teamMembers, task.owner]); - const ownerTextColor = useMemo(() => { - if (!ownerColorSet) return undefined; - return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; - }, [ownerColorSet, isLight]); + const ownerTextColor = useMemo(() => { + if (!ownerColorSet) return undefined; + return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; + }, [ownerColorSet, isLight]); - const projectLabel = useMemo(() => { - if (!task.projectPath?.trim()) return null; - return projectLabelFromPath(task.projectPath); - }, [task.projectPath]); + const projectLabel = useMemo(() => { + if (!task.projectPath?.trim()) return null; + return projectLabelFromPath(task.projectPath); + }, [task.projectPath]); - const projectColorSet = useMemo( - () => (projectLabel ? projectColor(projectLabel, isLight) : null), - [projectLabel, isLight] - ); + const projectColorSet = useMemo( + () => (projectLabel ? projectColor(projectLabel, isLight) : null), + [projectLabel, isLight] + ); - const teamColor = useMemo( - () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), - [showTeamName, task.teamDisplayName, isLight] - ); + const teamColor = useMemo( + () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), + [showTeamName, task.teamDisplayName, isLight] + ); - const showTeamRow = showTeamName && !hideTeamName; - const unreadBackgroundClass = - unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; + const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; - return ( -
+ + + )} +
- {/* Row 2: project + owner (when no team row) + date */} -
- {task.teamDeleted && } - {projectLabel && ( - + {task.teamDeleted && } + {projectLabel && ( + + {projectLabel} + + )} + {!showTeamRow && ( + <> + {projectLabel && ·} + + {task.owner ?? 'unassigned'} + + + )} + {dateLabel && ( + + {dateLabel} + + )} +
+ + {/* Row 3: Team: name · owner */} + {showTeamRow && ( +
- {projectLabel} - - )} - {!showTeamRow && ( - <> - {projectLabel && ·} + Team: + + {task.teamDisplayName} + + · {task.owner ?? 'unassigned'} - +
)} - {dateLabel && ( - - {dateLabel} - - )} -
- - {/* Row 3: Team: name · owner */} - {showTeamRow && ( -
- Team: - - {task.teamDisplayName} - - · - - {task.owner ?? 'unassigned'} - -
- )} - - ); -}; + + ); + } +); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 62d5759e..056f0014 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { arrayMove } from '@dnd-kit/sortable'; @@ -311,445 +311,454 @@ const SortableKanbanTaskCard = ({ ); }; -export const KanbanBoard = ({ - tasks, - teamName, - kanbanState, - filter, - sort, - sessions, - leadSessionId, - members, - onFilterChange, - onSortChange, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onColumnOrderChange, - toolbarLeft, - onAddTask, - onDeleteTask, - deletedTaskCount, - onOpenTrash, -}: KanbanBoardProps): React.JSX.Element => { - const boardRef = useRef(null); - const scrollRestoreTimeoutsRef = useRef([]); - const [viewMode, setViewMode] = useState('grid'); - const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); - const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); - const hasReviewers = kanbanState.reviewers.length > 0; - const enableTaskSorting = - viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; +export const KanbanBoard = memo( + ({ + tasks, + teamName, + kanbanState, + filter, + sort, + sessions, + leadSessionId, + members, + onFilterChange, + onSortChange, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onColumnOrderChange, + toolbarLeft, + onAddTask, + onDeleteTask, + deletedTaskCount, + onOpenTrash, + }: KanbanBoardProps): React.JSX.Element => { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); + const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; - const stableTaskMapRef = useRef<{ - signatures: string[]; - map: Map; - } | null>(null); - const taskMap = useMemo(() => { - const signatures = tasks.map( - (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` - ); - const previous = stableTaskMapRef.current; - if ( - previous?.signatures.length === signatures.length && - previous.signatures.every((signature, index) => signature === signatures[index]) - ) { - return previous.map; - } - - const next = new Map(tasks.map((task) => [task.id, task])); - stableTaskMapRef.current = { signatures, map: next }; - return next; - }, [tasks]); - const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); - const grouped = useMemo(() => { - const result = new Map( - COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) - ); - for (const task of tasks) { - const column = getTaskColumn(task, kanbanState); - if (!column) { - continue; - } - result.get(column)?.push(task); - } - return result; - }, [tasks, kanbanState]); - - const groupedOrdered = useMemo(() => { - const result = new Map(); - for (const column of COLUMNS) { - const columnTasks = grouped.get(column.id) ?? []; - const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); - } - return result; - }, [grouped, kanbanState.columnOrder, sort.field]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }) - ); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!onColumnOrderChange || !over || active.id === over.id) { - return; - } - const activeData = active.data.current; - if (activeData?.type !== 'kanban-task') { - return; - } - const columnId = activeData.columnId as KanbanColumnId; - const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; - const oldIndex = orderedIds.indexOf(active.id as string); - const newIndex = orderedIds.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return; - } - const newOrder = arrayMove(orderedIds, oldIndex, newIndex); - onColumnOrderChange(columnId, newOrder); - }, - [onColumnOrderChange, groupedOrdered] - ); - - const renderCards = ( - columnId: KanbanColumnId, - columnTasks: TeamTask[], - compact?: boolean - ): React.JSX.Element => { - const addHandler = - onAddTask && columnId === 'todo' - ? () => onAddTask(false) - : onAddTask && columnId === 'in_progress' - ? () => onAddTask(true) - : undefined; - - const addButton = addHandler ? ( - - ) : null; - - if (columnTasks.length === 0) { - return ( - addButton ?? ( -
- No tasks -
- ) + const stableTaskMapRef = useRef<{ + signatures: string[]; + map: Map; + } | null>(null); + const taskMap = useMemo(() => { + const signatures = tasks.map( + (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` ); - } - if (enableTaskSorting) { - const itemIds = columnTasks.map((t) => t.id); + const previous = stableTaskMapRef.current; + if ( + previous?.signatures.length === signatures.length && + previous.signatures.every((signature, index) => signature === signatures[index]) + ) { + return previous.map; + } + + const next = new Map(tasks.map((task) => [task.id, task])); + stableTaskMapRef.current = { signatures, map: next }; + return next; + }, [tasks]); + const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); + const grouped = useMemo(() => { + const result = new Map( + COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) + ); + for (const task of tasks) { + const column = getTaskColumn(task, kanbanState); + if (!column) { + continue; + } + result.get(column)?.push(task); + } + return result; + }, [tasks, kanbanState]); + + const groupedOrdered = useMemo(() => { + const result = new Map(); + for (const column of COLUMNS) { + const columnTasks = grouped.get(column.id) ?? []; + const order = kanbanState.columnOrder?.[column.id]; + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); + } + return result; + }, [grouped, kanbanState.columnOrder, sort.field]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!onColumnOrderChange || !over || active.id === over.id) { + return; + } + const activeData = active.data.current; + if (activeData?.type !== 'kanban-task') { + return; + } + const columnId = activeData.columnId as KanbanColumnId; + const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; + const oldIndex = orderedIds.indexOf(active.id as string); + const newIndex = orderedIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return; + } + const newOrder = arrayMove(orderedIds, oldIndex, newIndex); + onColumnOrderChange(columnId, newOrder); + }, + [onColumnOrderChange, groupedOrdered] + ); + + const renderCards = ( + columnId: KanbanColumnId, + columnTasks: TeamTask[], + compact?: boolean + ): React.JSX.Element => { + const addHandler = + onAddTask && columnId === 'todo' + ? () => onAddTask(false) + : onAddTask && columnId === 'in_progress' + ? () => onAddTask(true) + : undefined; + + const addButton = addHandler ? ( + + ) : null; + + if (columnTasks.length === 0) { + return ( + addButton ?? ( +
+ No tasks +
+ ) + ); + } + if (enableTaskSorting) { + const itemIds = columnTasks.map((t) => t.id); + return ( + <> + + {columnTasks.map((task) => ( + + ))} + + {addButton} + + ); + } return ( <> - - {columnTasks.map((task) => ( - - ))} - + {columnTasks.map((task) => ( + + ))} {addButton} ); - } - return ( - <> - {columnTasks.map((task) => ( - - ))} - {addButton} - + }; + + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] ); - }; + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; - const visibleColumns = useMemo( - () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), - [filter.columns] - ); - const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' + ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) + : columnModeSearchWidth; - const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); - const { widths: columnWidths, getHandleProps } = useResizableColumns({ - storageKey: teamName, - columnIds: resizableColumnIds, - }); - const columnModeSearchWidth = - primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; - const toolbarLeftWidth = - viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; - - const clearScheduledScrollRestore = useCallback(() => { - for (const timeoutId of scrollRestoreTimeoutsRef.current) { - window.clearTimeout(timeoutId); - } - scrollRestoreTimeoutsRef.current = []; - }, []); - - useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); - - const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { - let current = startNode?.parentElement ?? null; - while (current) { - const { overflowY } = window.getComputedStyle(current); - if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { - return current; + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); } - current = current.parentElement; - } - return null; - }, []); + scrollRestoreTimeoutsRef.current = []; + }, []); - const scheduleScrollRestore = useCallback( - (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { - const container = findScrollContainer(boardRef.current); - if (!container) { - return; + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; + } + current = current.parentElement; } + return null; + }, []); - const savedScrollTop = container.scrollTop; - clearScheduledScrollRestore(); + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; + } - const restore = (): void => { - container.scrollTop = savedScrollTop; - }; + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); - const delays = - nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; - scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); - }, - [clearScheduledScrollRestore, findScrollContainer] - ); + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; - const switchViewMode = useCallback( - (nextViewMode: KanbanViewMode) => { - const nextSkeletonDelayMs = - nextViewMode === 'grid' && viewMode === 'columns' - ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH - : SKELETON_HIDE_DELAY_MS; + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); - setGridSkeletonDelayMs(nextSkeletonDelayMs); - scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); - setViewMode(nextViewMode); - }, - [scheduleScrollRestore, viewMode] - ); + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; - const boardContent = ( -
-
- {toolbarLeft != null && ( -
- {toolbarLeft} -
- )} -
-
- -
- -
- {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( - - - - - Trash - - ) : null} -
- - - - - Grid view - - - - - - Columns view - + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); + + const boardContent = ( +
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft} +
+ )} +
+
+ +
+ +
+ {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( + + + + + Trash + + ) : null} +
+ + + + + Grid view + + + + + + Columns view + +
-
- {viewMode === 'grid' ? ( - column.id)} - primaryColumnId={primaryVisibleColumnId} - onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} - skeletonDelayMs={gridSkeletonDelayMs} - columns={visibleColumns.map((column) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - - return { - id: column.id, - title: column.title, - count: columnTasks.length, - icon: accent.icon, - headerBg: accent.headerBg, - bodyBg: accent.bodyBg, - content: renderCards(column.id, columnTasks), - showAddButton: columnSupportsAddButton(column.id, onAddTask), - skeletonCards: columnTasks.map((task) => ({ - key: task.id, - height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), - })), - }; - })} - /> - ) : ( -
-
- {visibleColumns.map((column, index) => { + {viewMode === 'grid' ? ( + column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} + columns={visibleColumns.map((column) => { const columnTasks = groupedOrdered.get(column.id) ?? []; const accent = COLUMN_ACCENTS[column.id]; - const width = columnWidths.get(column.id) ?? 256; - const handleProps = getHandleProps(column.id); - return ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
-
- ) : null} -
- ); + + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight( + task, + column.id, + kanbanState, + hasReviewers + ), + })), + }; })} + /> + ) : ( +
+
+ {visibleColumns.map((column, index) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); + return ( +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
-
- )} -
- ); - - if (enableTaskSorting) { - return ( - - {boardContent} - + )} +
); - } - return boardContent; -}; + if (enableTaskSorting) { + return ( + + {boardContent} + + ); + } + + return boardContent; + } +); diff --git a/src/renderer/components/team/kanban/KanbanGridLayout.tsx b/src/renderer/components/team/kanban/KanbanGridLayout.tsx index 6b5f868e..9b30de1a 100644 --- a/src/renderer/components/team/kanban/KanbanGridLayout.tsx +++ b/src/renderer/components/team/kanban/KanbanGridLayout.tsx @@ -1,5 +1,5 @@ /* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy'; import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout'; @@ -387,74 +387,76 @@ const LoadedKanbanGridLayout = ({ ); }; -export const KanbanGridLayout = ({ - columns, - allColumnIds, - primaryColumnId, - onPrimaryColumnWidthChange, - skeletonDelayMs = SKELETON_HIDE_DELAY_MS, -}: KanbanGridLayoutProps): React.JSX.Element => { - const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); - const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ - scopeKey: GRID_SCOPE_KEY, - allItemIds: allColumnIds, - visibleItemIds: visibleColumnIds, - cols: GRID_COLS, - repository: browserGridLayoutRepository, - buildDefaultItems, - }); - const [showResolvedLayout, setShowResolvedLayout] = useState(false); +export const KanbanGridLayout = memo( + ({ + columns, + allColumnIds, + primaryColumnId, + onPrimaryColumnWidthChange, + skeletonDelayMs = SKELETON_HIDE_DELAY_MS, + }: KanbanGridLayoutProps): React.JSX.Element => { + const visibleColumnIds = useMemo(() => columns.map((column) => column.id), [columns]); + const { visibleItems, applyVisibleItems, isLoaded } = usePersistedGridLayout({ + scopeKey: GRID_SCOPE_KEY, + allItemIds: allColumnIds, + visibleItemIds: visibleColumnIds, + cols: GRID_COLS, + repository: browserGridLayoutRepository, + buildDefaultItems, + }); + const [showResolvedLayout, setShowResolvedLayout] = useState(false); - useEffect(() => { - if (showResolvedLayout) return; + useEffect(() => { + if (showResolvedLayout) return; - const timeoutId = window.setTimeout(() => { - setShowResolvedLayout(true); - }, skeletonDelayMs); + const timeoutId = window.setTimeout(() => { + setShowResolvedLayout(true); + }, skeletonDelayMs); - return () => { - window.clearTimeout(timeoutId); - }; - }, [showResolvedLayout, skeletonDelayMs]); + return () => { + window.clearTimeout(timeoutId); + }; + }, [showResolvedLayout, skeletonDelayMs]); - const applyReactGridLayout = useCallback( - (layout: Layout, options?: { persist?: boolean }) => { - if (options?.persist) { - applyVisibleItems(fromReactGridLayout(layout), options); - } - }, - [applyVisibleItems] - ); - const showSkeletonOverlay = !showResolvedLayout || !isLoaded; + const applyReactGridLayout = useCallback( + (layout: Layout, options?: { persist?: boolean }) => { + if (options?.persist) { + applyVisibleItems(fromReactGridLayout(layout), options); + } + }, + [applyVisibleItems] + ); + const showSkeletonOverlay = !showResolvedLayout || !isLoaded; - const gridKey = visibleItems.map((item) => item.id).join('|'); + const gridKey = visibleItems.map((item) => item.id).join('|'); - return ( -
- - {showSkeletonOverlay ? ( - + - ) : null} -
- ); -}; + {showSkeletonOverlay ? ( + + ) : null} +
+ ); + } +); export { SKELETON_HIDE_DELAY_MS, SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH }; /* eslint-enable tailwindcss/no-custom-classname -- stable class hooks remain scoped to this file. */ diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 6c9453e3..dbfe9c34 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { memo, useMemo, useState } from 'react'; import { Badge } from '@renderer/components/ui/badge'; import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; @@ -91,622 +91,632 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { }; } -export const MemberCard = ({ - member, - memberColor, - runtimeSummary, - runtimeEntry, - runtimeRunId, - taskCounts, - isTeamAlive, - isTeamProvisioning, - leadActivity, - currentTask, - reviewTask, - isAwaitingReply, - isRemoved, - spawnStatus, - spawnEntry, - spawnError, - spawnLivenessSource, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - onOpenTask, - onOpenReviewTask, - onClick, - onSendMessage, - onAssignTask, - onRestartMember, - onSkipMemberForLaunch, -}: MemberCardProps): React.JSX.Element => { - // NOTE: lead context display disabled — usage formula is inaccurate - // const teamName = useStore((s) => s.selectedTeamName); - // const leadContext = useStore((s) => - // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - // ); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const [retryingLaunch, setRetryingLaunch] = useState(false); - const [retryLaunchError, setRetryLaunchError] = useState(null); - const [skippingLaunch, setSkippingLaunch] = useState(false); - const [skipLaunchError, setSkipLaunchError] = useState(null); - const teamMembers = useStore((s) => - selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const launchPresentation = buildMemberLaunchPresentation({ +export const MemberCard = memo( + ({ member, - spawnStatus, - spawnLaunchState, - spawnLivenessSource, - spawnRuntimeAlive, + memberColor, + runtimeSummary, runtimeEntry, - runtimeAdvisory: member.runtimeAdvisory, - isLaunchSettling, + runtimeRunId, + taskCounts, isTeamAlive, isTeamProvisioning, leadActivity, - }); - const dotClass = launchPresentation.dotClass; - const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; - const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; - const presenceLabel = launchPresentation.presenceLabel; - const spawnCardClass = launchPresentation.cardClass; - const launchVisualState = launchPresentation.launchVisualState; - const launchStatusLabel = launchPresentation.launchStatusLabel; - const displayPresenceLabel = - launchVisualState === 'queued' || - launchVisualState === 'runtime_pending' || - launchVisualState === 'permission_pending' || - launchVisualState === 'shell_only' || - launchVisualState === 'runtime_candidate' || - launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime' - ? (launchStatusLabel ?? presenceLabel) - : presenceLabel; - const colors = getTeamColorSet(memberColor); - const { isLight } = useTheme(); - const pending = taskCounts?.pending ?? 0; - const inProgress = taskCounts?.inProgress ?? 0; - const completed = taskCounts?.completed ?? 0; - const totalTasks = pending + inProgress + completed; - const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const { summary: runtimeSummaryText, memory: memoryLabel } = - splitRuntimeSummaryMemory(runtimeSummary); - const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); - const isLead = isLeadMember(member); - const workspacePath = member.cwd?.trim(); - const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; - const workspaceTooltipLines = [ - 'Worktree isolation is enabled.', - workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', - member.gitBranch ? `Branch: ${member.gitBranch}` : null, - ].filter((line): line is string => Boolean(line)); - const activityTask = currentTask ?? reviewTask ?? null; - const activityTitle = currentTask - ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` - : reviewTask - ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` - : undefined; - const showStartingSkeleton = - !isRemoved && - presenceLabel === 'starting' && - spawnLaunchState !== 'failed_to_start' && - !activityTask && - !runtimeSummary; - const showLaunchBadge = - !isRemoved && - !runtimeAdvisoryLabel && - (presenceLabel === 'starting' || - presenceLabel === 'connecting' || + currentTask, + reviewTask, + isAwaitingReply, + isRemoved, + spawnStatus, + spawnEntry, + spawnError, + spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + onOpenTask, + onOpenReviewTask, + onClick, + onSendMessage, + onAssignTask, + onRestartMember, + onSkipMemberForLaunch, + }: MemberCardProps): React.JSX.Element => { + // NOTE: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const [retryingLaunch, setRetryingLaunch] = useState(false); + const [retryLaunchError, setRetryLaunchError] = useState(null); + const [skippingLaunch, setSkippingLaunch] = useState(false); + const [skipLaunchError, setSkipLaunchError] = useState(null); + const teamMembers = useStore((s) => + selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const launchPresentation = buildMemberLaunchPresentation({ + member, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, + runtimeEntry, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, + isTeamAlive, + isTeamProvisioning, + leadActivity, + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; + const launchVisualState = launchPresentation.launchVisualState; + const launchStatusLabel = launchPresentation.launchStatusLabel; + const displayPresenceLabel = launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime'); - const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; - const launchDiagnosticsPayload = useMemo( - () => - buildMemberLaunchDiagnosticsPayload({ - teamName: selectedTeamName, - runId: runtimeRunId, - memberName: member.name, - spawnStatus, - launchState: spawnLaunchState, - livenessSource: spawnLivenessSource, - spawnEntry, + launchVisualState === 'stale_runtime' + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; + const colors = getTeamColorSet(memberColor); + const { isLight } = useTheme(); + const pending = taskCounts?.pending ?? 0; + const inProgress = taskCounts?.inProgress ?? 0; + const completed = taskCounts?.completed ?? 0; + const totalTasks = pending + inProgress + completed; + const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); + const { summary: runtimeSummaryText, memory: memoryLabel } = + splitRuntimeSummaryMemory(runtimeSummary); + const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); + const isLead = isLeadMember(member); + const workspacePath = member.cwd?.trim(); + const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; + const workspaceTooltipLines = [ + 'Worktree isolation is enabled.', + workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', + member.gitBranch ? `Branch: ${member.gitBranch}` : null, + ].filter((line): line is string => Boolean(line)); + const activityTask = currentTask ?? reviewTask ?? null; + const activityTitle = currentTask + ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` + : reviewTask + ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` + : undefined; + const showStartingSkeleton = + !isRemoved && + presenceLabel === 'starting' && + spawnLaunchState !== 'failed_to_start' && + !activityTask && + !runtimeSummary; + const showLaunchBadge = + !isRemoved && + !runtimeAdvisoryLabel && + (presenceLabel === 'starting' || + presenceLabel === 'connecting' || + launchVisualState === 'queued' || + launchVisualState === 'runtime_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime'); + const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; + const launchDiagnosticsPayload = useMemo( + () => + buildMemberLaunchDiagnosticsPayload({ + teamName: selectedTeamName, + runId: runtimeRunId, + memberName: member.name, + spawnStatus, + launchState: spawnLaunchState, + livenessSource: spawnLivenessSource, + spawnEntry, + runtimeEntry, + }), + [ + member.name, runtimeEntry, - }), - [ - member.name, - runtimeEntry, - runtimeRunId, - selectedTeamName, + runtimeRunId, + selectedTeamName, + spawnEntry, + spawnLaunchState, + spawnLivenessSource, + spawnStatus, + ] + ); + const showCopyDiagnostics = + !isRemoved && + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isSkippedLaunch = + spawnStatus === 'skipped' || + spawnLaunchState === 'skipped_for_launch' || + spawnEntry?.skippedForLaunch === true; + const showFailedLaunchBadge = !isRemoved && isFailedLaunch; + const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; + const hasLiveLaunchControls = + isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; + const hasRestartMemberControl = + !isRemoved && + !isLeadMember(member) && + Boolean(onRestartMember) && + hasLiveLaunchControls && + runtimeEntry?.restartable !== false; + const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ + member, spawnEntry, - spawnLaunchState, - spawnLivenessSource, - spawnStatus, - ] - ); - const showCopyDiagnostics = - !isRemoved && - hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && - hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; - const isSkippedLaunch = - spawnStatus === 'skipped' || - spawnLaunchState === 'skipped_for_launch' || - spawnEntry?.skippedForLaunch === true; - const showFailedLaunchBadge = !isRemoved && isFailedLaunch; - const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; - const hasLiveLaunchControls = - isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; - const hasRestartMemberControl = - !isRemoved && - !isLeadMember(member) && - Boolean(onRestartMember) && - hasLiveLaunchControls && - runtimeEntry?.restartable !== false; - const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ - member, - spawnEntry, - runtimeEntry, - }); - const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; - const canRetryLaunch = - (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; - const canSkipFailedLaunch = - showFailedLaunchBadge && - !isLeadMember(member) && - Boolean(onSkipMemberForLaunch) && - hasLiveLaunchControls; - const showRuntimeAdvisoryBadge = - !isRemoved && - Boolean(runtimeAdvisoryLabel) && - !showLaunchBadge && - !isFailedLaunch && - !isSkippedLaunch && - (Boolean(activityTask) || !isAwaitingReply); - const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; - const restartActionBusyLabel = canRelaunchOpenCode - ? 'Relaunching OpenCode teammate' - : 'Retrying teammate'; - const restartActionErrorFallback = canRelaunchOpenCode - ? 'Failed to relaunch OpenCode teammate' - : 'Failed to retry teammate'; - const handleRestartMember = async (event: React.MouseEvent): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onRestartMember || retryingLaunch) { - return; - } - setRetryLaunchError(null); - setRetryingLaunch(true); - try { - await onRestartMember(member.name); - } catch (error) { - setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); - } finally { - setRetryingLaunch(false); - } - }; - const handleSkipFailedLaunch = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onSkipMemberForLaunch || skippingLaunch) { - return; - } - setSkipLaunchError(null); - setSkippingLaunch(true); - try { - await onSkipMemberForLaunch(member.name); - } catch (error) { - setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); - } finally { - setSkippingLaunch(false); - } - }; + runtimeEntry, + }); + const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; + const canSkipFailedLaunch = + showFailedLaunchBadge && + !isLeadMember(member) && + Boolean(onSkipMemberForLaunch) && + hasLiveLaunchControls; + const showRuntimeAdvisoryBadge = + !isRemoved && + Boolean(runtimeAdvisoryLabel) && + !showLaunchBadge && + !isFailedLaunch && + !isSkippedLaunch && + (Boolean(activityTask) || !isAwaitingReply); + const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; + const restartActionBusyLabel = canRelaunchOpenCode + ? 'Relaunching OpenCode teammate' + : 'Retrying teammate'; + const restartActionErrorFallback = canRelaunchOpenCode + ? 'Failed to relaunch OpenCode teammate' + : 'Failed to retry teammate'; + const handleRestartMember = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onRestartMember || retryingLaunch) { + return; + } + setRetryLaunchError(null); + setRetryingLaunch(true); + try { + await onRestartMember(member.name); + } catch (error) { + setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); + } finally { + setRetryingLaunch(false); + } + }; + const handleSkipFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onSkipMemberForLaunch || skippingLaunch) { + return; + } + setSkipLaunchError(null); + setSkippingLaunch(true); + try { + await onSkipMemberForLaunch(member.name); + } catch (error) { + setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); + } finally { + setSkippingLaunch(false); + } + }; - return ( -
+ return (
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }} + className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`} > -
-
-
-
- {member.name} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} + > +
+
+
+
+ {member.name} +
+
- -
-
-
- - {displayMemberName(member.name)} - - {member.gitBranch && !showWorkspaceBadge ? ( - - - {member.gitBranch} +
+
+ + {displayMemberName(member.name)} + {member.gitBranch && !showWorkspaceBadge ? ( + + + {member.gitBranch} + + ) : null} + {showWorkspaceBadge ? ( + + + + worktree + + + +
+ {workspaceTooltipLines.map((line) => ( +

+ {line} +

+ ))} +
+
+
+ ) : null} + {currentTask ? ( + + ) : null} + {reviewTask ? ( + + ) : null} + {!activityTask && isAwaitingReply ? ( + <> + {runtimeAdvisoryTone === 'error' ? ( + + ) : ( + + )} + + {runtimeAdvisoryLabel ?? 'awaiting reply'} + + + ) : null} +
+ {showStartingSkeleton ? ( + - {showStartingSkeleton ? ( -
); -}; +}); diff --git a/src/renderer/components/chat/viewers/DiffViewer.tsx b/src/renderer/components/chat/viewers/DiffViewer.tsx index 3400051d..efb74cd6 100644 --- a/src/renderer/components/chat/viewers/DiffViewer.tsx +++ b/src/renderer/components/chat/viewers/DiffViewer.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { CODE_BG, @@ -349,14 +349,14 @@ const DiffLineRow: React.FC = ({ line, highlightedHtml }): Rea // Main Component // ============================================================================= -export const DiffViewer: React.FC = ({ +export const DiffViewer = memo(function DiffViewer({ fileName, oldString, newString, maxHeight = 'max-h-96', tokenCount, syntaxHighlight = false, -}): React.JSX.Element => { +}: DiffViewerProps): React.JSX.Element { // Compute diff const oldLines = oldString.split(/\r?\n/); const newLines = newString.split(/\r?\n/); @@ -456,4 +456,4 @@ export const DiffViewer: React.FC = ({
); -}; +}); From a652c44794aa86cbda06918b1dca5c6e881b1b33 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 21:29:22 +0300 Subject: [PATCH 15/51] perf(team): reduce verified config reads --- .../services/team/ChangeExtractorService.ts | 10 +- src/main/services/team/TeamDataService.ts | 14 +- .../services/team/TeamProvisioningService.ts | 284 ++++++++++-------- .../team/TeamTranscriptProjectResolver.ts | 15 +- .../stream/BoardTaskLogStreamService.ts | 8 +- .../stream/CodexNativeTaskLogStreamSource.ts | 8 +- .../services/team/TeamDataService.test.ts | 46 ++- .../team/TeamProvisioningService.test.ts | 147 ++++++++- .../team/TeamProvisioningServiceRelay.test.ts | 39 +++ .../TeamTranscriptProjectResolver.test.ts | 24 ++ 10 files changed, 453 insertions(+), 142 deletions(-) diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 6b84d7c6..143c76b7 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -123,6 +123,12 @@ export class ChangeExtractorService { this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser); } + private readConfigForObservation(teamName: string) { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + setTaskChangePresenceServices( repository: TaskChangePresenceRepository, tracker: TeamLogSourceTracker @@ -671,7 +677,7 @@ export class ChangeExtractorService { try { const [meta, config] = await Promise.all([ this.teamMetaStore.getMeta(teamName).catch(() => null), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const hasOpenCodeMember = (config?.members ?? []).some( (member) => member.providerId === 'opencode' @@ -996,7 +1002,7 @@ export class ChangeExtractorService { /** Получить projectPath из конфига команды */ private async resolveProjectPath(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForObservation(teamName); return config?.projectPath?.trim() || undefined; } catch { return undefined; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 3a366b93..ca226987 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -1898,7 +1898,7 @@ export class TeamDataService { let projectPath: string | undefined; try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); projectPath = config?.projectPath; } catch { /* best-effort */ @@ -2237,7 +2237,7 @@ export class TeamDataService { let enrichedRequest = request; if (!enrichedRequest.leadSessionId) { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); if (config?.leadSessionId) { enrichedRequest = { ...enrichedRequest, leadSessionId: config.leadSessionId }; } @@ -2310,7 +2310,7 @@ export class TeamDataService { private async resolveLeadName(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); return this.resolveLeadNameFromConfig(config); } catch { return 'team-lead'; @@ -2321,7 +2321,7 @@ export class TeamDataService { teamName: string ): Promise<{ leadName: string; leadSessionId?: string }> { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); return { leadName: this.resolveLeadNameFromConfig(config), leadSessionId: config?.leadSessionId, @@ -2638,7 +2638,7 @@ export class TeamDataService { const recoverPending = options?.recoverPending === true; let config: TeamConfig | null = null; try { - config = await this.configReader.getConfig(teamName); + config = await readConfigForUiSnapshot(this.configReader, teamName); } catch { return; } @@ -2793,7 +2793,7 @@ export class TeamDataService { ): Promise { let leadSessionId: string | undefined; try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); leadSessionId = config?.leadSessionId; } catch { // non-critical — proceed without sessionId @@ -2826,7 +2826,7 @@ export class TeamDataService { async getLeadMemberName(teamName: string): Promise { try { - const config = await this.configReader.getConfig(teamName); + const config = await readConfigForUiSnapshot(this.configReader, teamName); // Check config.json members first (Claude Code-created teams) if (config?.members?.length) { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 05336ee6..0aae9695 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4435,6 +4435,27 @@ interface OpenCodeMemberInboxDelivery { diagnostics?: string[]; } +interface OpenCodeMemberDirectory { + config: TeamConfig | null; + teamMeta: Awaited> | null; + metaMembers: Awaited>; +} + +type OpenCodeMemberIdentityResolution = + | { + ok: true; + canonicalMemberName: string; + laneId: string; + laneIdentity: ReturnType; + configMember?: TeamMember; + metaMember?: TeamMember; + memberRuntimeCwd?: string; + } + | { + ok: false; + reason: 'recipient_is_not_opencode' | 'recipient_removed' | 'opencode_recipient_unavailable'; + }; + interface OpenCodeMemberInboxRelayResult { relayed: number; attempted: number; @@ -4613,6 +4634,106 @@ export class TeamProvisioningService { : configReader.getConfig(teamName); } + private readConfigForObservation(teamName: string): Promise { + return this.readConfigSnapshot(teamName); + } + + private readConfigForStrictDecision(teamName: string): Promise { + return this.configReader.getConfig(teamName); + } + + private async readOpenCodeMemberDirectory(teamName: string): Promise { + const [config, teamMeta, metaMembers] = await Promise.all([ + this.readConfigForObservation(teamName).catch(() => null), + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + return { config, teamMeta, metaMembers }; + } + + private resolveOpenCodeMemberIdentityFromDirectory( + teamName: string, + memberName: string, + directory: OpenCodeMemberDirectory + ): OpenCodeMemberIdentityResolution { + const normalizedMemberName = memberName.trim(); + const configMember = directory.config?.members?.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + const metaMember = directory.metaMembers.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + if (!configMember && !metaMember) { + return { ok: false, reason: 'opencode_recipient_unavailable' }; + } + + const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; + const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; + const providerId = + normalizeTeamProviderLike(metaMember?.providerId) ?? + normalizeTeamProviderLike(metaProvider) ?? + normalizeTeamProviderLike(configMember?.providerId) ?? + normalizeTeamProviderLike(configProvider) ?? + inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); + if (providerId !== 'opencode') { + return { ok: false, reason: 'recipient_is_not_opencode' }; + } + + const removedAt = + metaMember != null + ? metaMember.removedAt + : (configMember as { removedAt?: unknown } | undefined)?.removedAt; + if (removedAt != null) { + return { ok: false, reason: 'recipient_removed' }; + } + + const canonicalMemberName = + metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.providerId === 'opencode') { + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId: 'opencode', + member: { + name: canonicalMemberName, + providerId: 'opencode', + }, + }); + const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); + return { + ok: true, + canonicalMemberName, + laneId: laneIdentity.laneId, + laneIdentity, + ...(configMember ? { configMember } : {}), + ...(metaMember ? { metaMember } : {}), + ...(memberRuntimeCwd ? { memberRuntimeCwd } : {}), + }; + } + + const leadMember = directory.config?.members?.find((member) => isLeadMember(member)); + const leadProviderId = + normalizeOptionalTeamProviderId(directory.teamMeta?.launchIdentity?.providerId) ?? + normalizeOptionalTeamProviderId(directory.teamMeta?.providerId) ?? + normalizeOptionalTeamProviderId(leadMember?.providerId); + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: canonicalMemberName, + providerId, + }, + }); + const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); + return { + ok: true, + canonicalMemberName, + laneId: laneIdentity.laneId, + laneIdentity, + ...(configMember ? { configMember } : {}), + ...(metaMember ? { metaMember } : {}), + ...(memberRuntimeCwd ? { memberRuntimeCwd } : {}), + }; + } + setRuntimeAdapterRegistry(registry: TeamRuntimeAdapterRegistry | null): void { this.runtimeAdapterRegistry = registry; } @@ -5283,7 +5404,7 @@ export class TeamProvisioningService { const adapter = this.getOpenCodeRuntimeAdapter(); const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null); const [config, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), this.membersMetaStore.getMembers(teamName).catch(() => []), ]); const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({ @@ -5813,8 +5934,7 @@ export class TeamProvisioningService { }): Promise { const explicitRecipient = input.replyRecipient?.trim() || 'user'; const candidates = [explicitRecipient]; - const configuredLeadName = await this.configReader - .getConfig(input.teamName) + const configuredLeadName = await this.readConfigForObservation(input.teamName) .then( (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null ) @@ -6236,51 +6356,25 @@ export class TeamProvisioningService { if (!adapter) { return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' }; } - const [config, teamMeta, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.teamMetaStore.getMeta(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); + const directory = await this.readOpenCodeMemberDirectory(teamName); + const identity = this.resolveOpenCodeMemberIdentityFromDirectory( + teamName, + input.memberName, + directory + ); + if (!identity.ok) { + return { + delivered: false, + reason: + identity.reason === 'opencode_recipient_unavailable' + ? 'recipient_is_not_opencode' + : identity.reason, + }; + } + const { config } = directory; + const { canonicalMemberName, laneIdentity, configMember, metaMember, memberRuntimeCwd } = + identity; const normalizedMemberName = input.memberName.trim(); - const configMember = config?.members?.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - const metaMember = metaMembers.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; - const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; - const providerId = - normalizeTeamProviderLike(metaMember?.providerId) ?? - normalizeTeamProviderLike(metaProvider) ?? - normalizeTeamProviderLike(configMember?.providerId) ?? - normalizeTeamProviderLike(configProvider) ?? - inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); - if (providerId !== 'opencode') { - return { delivered: false, reason: 'recipient_is_not_opencode' }; - } - const removedAt = - metaMember != null - ? metaMember.removedAt - : (configMember as { removedAt?: unknown } | undefined)?.removedAt; - if (removedAt != null) { - return { delivered: false, reason: 'recipient_removed' }; - } - const canonicalMemberName = - metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; - - const leadMember = config?.members?.find((member) => isLeadMember(member)); - const leadProviderId = - normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? - normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? - normalizeOptionalTeamProviderId(leadMember?.providerId); - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: canonicalMemberName, - providerId, - }, - }); if ( laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' && @@ -6288,7 +6382,6 @@ export class TeamProvisioningService { ) { return { delivered: false, reason: 'opencode_runtime_not_active' }; } - const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim(); const cwd = laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' ? memberRuntimeCwd || @@ -7150,64 +7243,18 @@ export class TeamProvisioningService { | 'opencode_recipient_unavailable'; } > { - const [config, teamMeta, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.teamMetaStore.getMeta(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); - const normalizedMemberName = memberName.trim(); - const configMember = config?.members?.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + const directory = await this.readOpenCodeMemberDirectory(teamName); + const laneIdentity = this.resolveOpenCodeMemberIdentityFromDirectory( + teamName, + memberName, + directory ); - const metaMember = metaMembers.find( - (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() - ); - if (!configMember && !metaMember) { - return { ok: false, reason: 'opencode_recipient_unavailable' }; + if (!laneIdentity.ok) { + return laneIdentity; } - const configProvider = (configMember as { provider?: unknown } | undefined)?.provider; - const metaProvider = (metaMember as { provider?: unknown } | undefined)?.provider; - const providerId = - normalizeTeamProviderLike(metaMember?.providerId) ?? - normalizeTeamProviderLike(metaProvider) ?? - normalizeTeamProviderLike(configMember?.providerId) ?? - normalizeTeamProviderLike(configProvider) ?? - inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); - if (providerId !== 'opencode') { - return { ok: false, reason: 'recipient_is_not_opencode' }; - } - const removedAt = - metaMember != null - ? metaMember.removedAt - : (configMember as { removedAt?: unknown } | undefined)?.removedAt; - if (removedAt != null) { - return { ok: false, reason: 'recipient_removed' }; - } - const canonicalMemberName = - metaMember?.name?.trim() || configMember?.name?.trim() || normalizedMemberName; - const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); - if (runtimeRun?.providerId === 'opencode') { - return { - ok: true, - canonicalMemberName, - laneId: 'primary', - }; - } - const leadMember = config?.members?.find((member) => isLeadMember(member)); - const leadProviderId = - normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? - normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? - normalizeOptionalTeamProviderId(leadMember?.providerId); - const laneIdentity = buildPlannedMemberLaneIdentity({ - leadProviderId, - member: { - name: canonicalMemberName, - providerId, - }, - }); return { ok: true, - canonicalMemberName, + canonicalMemberName: laneIdentity.canonicalMemberName, laneId: laneIdentity.laneId, }; } @@ -7216,24 +7263,21 @@ export class TeamProvisioningService { teamName: string, laneId: string ): Promise { - const [config, metaMembers] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), - this.membersMetaStore.getMembers(teamName).catch(() => []), - ]); + const directory = await this.readOpenCodeMemberDirectory(teamName); const names = new Set(); - for (const member of config?.members ?? []) { + for (const member of directory.config?.members ?? []) { if (member.name?.trim()) { names.add(member.name.trim()); } } - for (const member of metaMembers) { + for (const member of directory.metaMembers) { if (member.name?.trim()) { names.add(member.name.trim()); } } const resolved: string[] = []; for (const name of names) { - const identity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, name); + const identity = this.resolveOpenCodeMemberIdentityFromDirectory(teamName, name, directory); if (identity.ok && identity.laneId === laneId) { resolved.push(identity.canonicalMemberName); } @@ -7306,7 +7350,7 @@ export class TeamProvisioningService { } const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([ - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), this.teamMetaStore.getMeta(teamName).catch(() => null), this.membersMetaStore.getMembers(teamName).catch(() => []), readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null), @@ -8831,7 +8875,7 @@ export class TeamProvisioningService { memberName: string; previousMember?: PersistedTeamLaunchMemberState; }): Promise { - const config = await this.configReader.getConfig(input.teamName).catch(() => null); + const config = await this.readConfigForStrictDecision(input.teamName).catch(() => null); const metaMembers = await this.membersMetaStore.getMembers(input.teamName).catch(() => []); const configuredMember = this.resolveEffectiveConfiguredMember( config?.members ?? [], @@ -10886,7 +10930,7 @@ export class TeamProvisioningService { metaMembers: Awaited>; configuredMember: ReturnType; }> => { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); const configuredMembers = config?.members ?? []; let metaMembers: Awaited> = []; try { @@ -11202,7 +11246,7 @@ export class TeamProvisioningService { throw new Error('Member name is required'); } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) { throw new Error(`Team "${teamName}" configuration is no longer available`); } @@ -11362,7 +11406,7 @@ export class TeamProvisioningService { throw new Error('OpenCode runtime adapter is not available for controlled lane reattach.'); } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) { throw new Error(`Team "${teamName}" configuration is no longer available`); } @@ -16255,7 +16299,7 @@ export class TeamProvisioningService { // as read after native delivery, so we must scan ALL messages (including read). let config: Awaited> | null = null; try { - config = await this.configReader.getConfig(teamName); + config = await this.readConfigForObservation(teamName); } catch { // config not ready yet during early provisioning — skip scan } @@ -16304,7 +16348,7 @@ export class TeamProvisioningService { // Re-read config if needed (already fetched above but guard provisioningComplete path) if (!config) { try { - config = await this.configReader.getConfig(teamName); + config = await this.readConfigForObservation(teamName); } catch { return 0; } @@ -16782,7 +16826,7 @@ export class TeamProvisioningService { for (const teamName of aliveTeams) { try { - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForStrictDecision(teamName); if (!config) continue; const oldCode = config.language || 'system'; @@ -21962,7 +22006,7 @@ export class TeamProvisioningService { let currentMembers: TeamCreateRequest['members'] = run.request.members; let leadName = 'team-lead'; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForObservation(run.teamName); if (config?.members) { const configLead = config.members.find((m) => isLeadMember(m)); leadName = configLead?.name?.trim() || 'team-lead'; @@ -22122,7 +22166,7 @@ export class TeamProvisioningService { let leadName = run.effectiveMembers.find((m) => m.role?.toLowerCase().includes('lead'))?.name || 'team-lead'; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForObservation(run.teamName); if (config?.members) { const configLead = config.members.find((m) => isLeadMember(m)); leadName = configLead?.name?.trim() || leadName; @@ -22833,7 +22877,7 @@ export class TeamProvisioningService { // Resolve project cwd from team config let projectCwd: string | undefined; try { - const config = await this.configReader.getConfig(run.teamName); + const config = await this.readConfigForStrictDecision(run.teamName); projectCwd = config?.projectPath ?? config?.members?.[0]?.cwd; } catch { // best-effort diff --git a/src/main/services/team/TeamTranscriptProjectResolver.ts b/src/main/services/team/TeamTranscriptProjectResolver.ts index cee44f65..dc4c0cfe 100644 --- a/src/main/services/team/TeamTranscriptProjectResolver.ts +++ b/src/main/services/team/TeamTranscriptProjectResolver.ts @@ -46,6 +46,11 @@ interface SessionProjectMatch extends ProjectDirCandidate { matchedSessionId: string; } +interface TeamTranscriptProjectConfigReader { + getConfig(teamName: string): Promise; + getConfigSnapshot?: (teamName: string) => Promise; +} + type ScannedSessionProjectMatch = Omit & { projectPath?: string; }; @@ -187,9 +192,15 @@ export class TeamTranscriptProjectResolver { >(); constructor( - private readonly configReader: Pick = new TeamConfigReader() + private readonly configReader: TeamTranscriptProjectConfigReader = new TeamConfigReader() ) {} + private readConfigForObservation(teamName: string): Promise { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + async getContext( teamName: string, options?: { forceRefresh?: boolean } @@ -203,7 +214,7 @@ export class TeamTranscriptProjectResolver { return cached.value; } - const config = await this.configReader.getConfig(teamName); + const config = await this.readConfigForObservation(teamName); if (!config) { return null; } diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2789c37f..b7f973dc 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1669,6 +1669,12 @@ export class BoardTaskLogStreamService { private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe() ) {} + private readConfigForObservation(teamName: string) { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + private buildLayoutCacheKey(teamName: string, taskId: string): string { return `${teamName}::${taskId}`; } @@ -2199,7 +2205,7 @@ export class BoardTaskLogStreamService { this.taskReader.getTasks(teamName).catch(() => []), this.taskReader.getDeletedTasks(teamName).catch(() => []), this.membersMetaStore.getMembers(teamName).catch(() => []), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); const ownerName = task?.owner?.trim(); diff --git a/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts index 10148c5e..74a051df 100644 --- a/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource.ts @@ -54,6 +54,12 @@ export class CodexNativeTaskLogStreamSource { private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() ) {} + private readConfigForObservation(teamName: string) { + return typeof this.configReader.getConfigSnapshot === 'function' + ? this.configReader.getConfigSnapshot(teamName) + : this.configReader.getConfig(teamName); + } + async getTaskLogStream( teamName: string, taskId: string, @@ -163,7 +169,7 @@ export class CodexNativeTaskLogStreamSource { const normalizedOwner = normalizeMemberName(ownerName); const [metaMembers, config] = await Promise.all([ this.membersMetaStore.getMembers(teamName).catch(() => []), - this.configReader.getConfig(teamName).catch(() => null), + this.readConfigForObservation(teamName).catch(() => null), ]); const member = [...metaMembers, ...(config?.members ?? [])].find( (candidate) => normalizeMemberName(candidate.name) === normalizedOwner diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 424ffc9e..441f7d2e 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -339,6 +339,35 @@ describe('TeamDataService task projection cache invalidation', () => { expect(configInvalidateSpy).toHaveBeenCalledWith('gone-team'); expect(taskInvalidateSpy).toHaveBeenCalledTimes(1); }); + + it('keeps team deletion mutations on verified config reads', async () => { + const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-delete-verified-')); + tempPaths.push(claudeRoot); + setClaudeBasePathOverride(claudeRoot); + await fs.mkdir(path.join(claudeRoot, 'teams', 'my-team'), { recursive: true }); + + const getConfig = vi.fn(async () => ({ + name: 'My team', + members: [], + })); + const getConfigSnapshot = vi.fn(async () => { + throw new Error('snapshot config read should not be used for team deletion'); + }); + const service = new TeamDataService({ + listTeams: vi.fn(), + getConfig, + getConfigSnapshot, + } as never); + + await service.deleteTeam('my-team'); + + const written = JSON.parse( + await fs.readFile(path.join(claudeRoot, 'teams', 'my-team', 'config.json'), 'utf8') + ) as TeamConfig; + expect(written.deletedAt).toBeTruthy(); + expect(getConfig).toHaveBeenCalledWith('my-team'); + expect(getConfigSnapshot).not.toHaveBeenCalled(); + }); }); describe('TeamDataService draft metadata', () => { @@ -1370,15 +1399,20 @@ describe('TeamDataService', () => { it('includes projectPath from config when creating a task', async () => { const createTaskMock = vi.fn((task) => task); + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for task enrichment'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + name: 'My team', + members: [], + projectPath: '/Users/dev/my-project', + })); const service = new TeamDataService( { listTeams: vi.fn(), - getConfig: vi.fn(async () => ({ - name: 'My team', - members: [], - projectPath: '/Users/dev/my-project', - })), + getConfig, + getConfigSnapshot, } as never, { getNextTaskId: vi.fn(async () => '1'), @@ -1417,6 +1451,8 @@ describe('TeamDataService', () => { expect(createTaskMock).toHaveBeenCalledWith( expect.objectContaining({ projectPath: '/Users/dev/my-project' }) ); + expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(getConfig).not.toHaveBeenCalled(); }); it('returns lightweight notification context from config without hydrating team data', async () => { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 2e79fad5..e0f21a28 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3439,6 +3439,111 @@ describe('TeamProvisioningService', () => { }); }); + it('uses snapshot config reads for OpenCode member delivery routing', async () => { + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for delivery routing'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })); + const svc = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + diagnostics: [], + })); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]) + ); + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' }, + ]), + }; + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-1', + }) + ).resolves.toMatchObject({ delivered: true }); + + expect(getConfigSnapshot).toHaveBeenCalledWith('team-a'); + expect(getConfig).not.toHaveBeenCalled(); + }); + + it('resolves OpenCode runtime lane members from one snapshot directory read', async () => { + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for lane member resolution'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + { name: 'alice', providerId: 'codex', model: 'gpt-5.4' }, + ], + })); + const svc = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { name: 'bob', providerId: 'opencode', model: 'opencode/minimax-m2.5-free' }, + ]), + }; + + await expect( + (svc as any).resolveOpenCodeMembersForRuntimeLane( + 'team-a', + 'secondary:opencode:bob' + ) + ).resolves.toEqual(['bob']); + + expect(getConfigSnapshot).toHaveBeenCalledTimes(1); + expect(getConfig).not.toHaveBeenCalled(); + }); + it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', async () => { const svc = new TeamProvisioningService(); const sendMessageToMember = vi.fn(async (input: Record) => ({ @@ -6531,6 +6636,33 @@ describe('TeamProvisioningService', () => { ); }); + it('keeps OpenCode bootstrap check-in allowlist on verified config reads', async () => { + const getConfig = vi.fn(async () => ({ + teamName: 'mixed-team', + members: [{ name: 'bob', providerId: 'opencode' }], + })); + const getConfigSnapshot = vi.fn(async () => { + throw new Error('snapshot config read should not be used for bootstrap check-in guards'); + }); + const svc = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => []), + }; + + await expect( + (svc as any).assertOpenCodeRuntimeMemberCheckinAllowed({ + teamName: 'mixed-team', + memberName: 'bob', + }) + ).resolves.toBeUndefined(); + + expect(getConfig).toHaveBeenCalledWith('mixed-team'); + expect(getConfigSnapshot).not.toHaveBeenCalled(); + }); + it('rejects duplicate OpenCode bootstrap check-ins for members removed after the first check-in', async () => { const svc = new TeamProvisioningService(); const previousSnapshot = { @@ -9664,11 +9796,16 @@ describe('TeamProvisioningService', () => { it('expands teammate permission suggestions to the operational tool set only', async () => { allowConsoleLogs(); + const getConfig = vi.fn(async () => ({ + projectPath: tempClaudeRoot, + members: [{ cwd: tempClaudeRoot }], + })); + const getConfigSnapshot = vi.fn(async () => { + throw new Error('snapshot config read should not be used for permission writes'); + }); const svc = new TeamProvisioningService({ - getConfig: vi.fn(async () => ({ - projectPath: tempClaudeRoot, - members: [{ cwd: tempClaudeRoot }], - })), + getConfig, + getConfigSnapshot, } as any); await (svc as any).respondToTeammatePermission( @@ -9696,6 +9833,8 @@ describe('TeamProvisioningService', () => { ); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__team_stop'); expect(settings.permissions?.allow).not.toContain('mcp__agent-teams__kanban_clear'); + expect(getConfig).toHaveBeenCalledWith('ops-team'); + expect(getConfigSnapshot).not.toHaveBeenCalled(); }); it('does not broaden admin/runtime teammate permission suggestions', async () => { diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index af7aab5a..4773a70e 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -307,6 +307,45 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => { expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); }); + it('uses snapshot config reads for lead inbox relay routing', async () => { + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for inbox relay routing'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + name: 'My Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })); + const service = new TeamProvisioningService({ + getConfig, + getConfigSnapshot, + } as any); + const teamName = 'my-team'; + seedLeadInbox(teamName, [ + { + from: 'bob', + text: 'Please assign this to Alice.', + timestamp: '2026-02-23T10:00:00.000Z', + read: false, + summary: 'Need delegation', + messageId: 'm-1', + }, + ]); + + const { writeSpy } = attachAliveRun(service, teamName); + const relayPromise = service.relayLeadInboxMessages(teamName); + const run = await waitForCapture(service); + (service as any).handleStreamJsonMessage(run, { + type: 'assistant', + content: [{ type: 'text', text: 'OK, will do.' }], + }); + (service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' }); + + await expect(relayPromise).resolves.toBe(1); + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(getConfigSnapshot).toHaveBeenCalledWith(teamName); + expect(getConfig).not.toHaveBeenCalled(); + }); + it('shows assistant text after relay capture has already settled', () => { const service = new TeamProvisioningService(); const teamName = 'my-team'; diff --git a/test/main/services/team/TeamTranscriptProjectResolver.test.ts b/test/main/services/team/TeamTranscriptProjectResolver.test.ts index e914942e..bcf81a67 100644 --- a/test/main/services/team/TeamTranscriptProjectResolver.test.ts +++ b/test/main/services/team/TeamTranscriptProjectResolver.test.ts @@ -140,6 +140,30 @@ describe('TeamTranscriptProjectResolver', () => { return { projectDir, jsonlPath }; } + it('uses snapshot-capable config readers for resolver observations', async () => { + await setupClaudeRoot(); + const { projectDir } = await createSessionFile('/repo/current', 'lead-session-1'); + const getConfig = vi.fn(async () => { + throw new Error('verified config read should not be used for transcript observations'); + }); + const getConfigSnapshot = vi.fn(async () => ({ + name: 'My Team', + projectPath: '/repo/current', + leadSessionId: 'lead-session-1', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })); + const resolver = new TeamTranscriptProjectResolver({ + getConfig, + getConfigSnapshot, + }); + + const context = await resolver.getContext('my-team'); + + expect(context?.projectDir).toBe(projectDir); + expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); + expect(getConfig).not.toHaveBeenCalled(); + }); + it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => { await setupClaudeRoot(); From b187bbcdd04b7414333369beba40a8a9ae529925 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 22:14:08 +0300 Subject: [PATCH 16/51] perf: add launch IO governor for team summaries --- src/main/index.ts | 11 +- src/main/ipc/handlers.ts | 7 +- src/main/ipc/teams.ts | 134 +++++-- src/main/services/team/LaunchIoGovernor.ts | 366 ++++++++++++++++++ test/main/ipc/teams.test.ts | 163 +++++++- .../services/team/LaunchIoGovernor.test.ts | 338 ++++++++++++++++ 6 files changed, 977 insertions(+), 42 deletions(-) create mode 100644 src/main/services/team/LaunchIoGovernor.ts create mode 100644 test/main/services/team/LaunchIoGovernor.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index b8d9a205..e7aae28b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -144,6 +144,7 @@ import { type TeamReconcileTrigger, } from './services/team/TeamReconcileDrainScheduler'; import { TeamSentMessagesStore } from './services/team/TeamSentMessagesStore'; +import { LaunchIoGovernor } from './services/team/LaunchIoGovernor'; import { getAppIconPath } from './utils/appIcon'; import { getClaudeBasePath, @@ -590,6 +591,7 @@ let runtimeProviderManagementFeature: RuntimeProviderManagementFeatureFacade; let memberWorkSyncFeature: MemberWorkSyncFeatureFacade | null = null; let teamDataService: TeamDataService; let teamProvisioningService: TeamProvisioningService; +let launchIoGovernor: LaunchIoGovernor | null = null; let cliInstallerService: CliInstallerService; let ptyTerminalService: PtyTerminalService; let httpServer: HttpServer; @@ -826,6 +828,7 @@ function wireFileWatcherEvents(context: ServiceContext): void { if (typeof row.teamName !== 'string' || row.teamName.trim().length === 0) return; const teamName = row.teamName.trim(); const detail = typeof row.detail === 'string' ? row.detail : ''; + launchIoGovernor?.noteTeamChange(row as TeamChangeEvent); memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent); if (row.type === 'config') { @@ -1070,6 +1073,10 @@ async function initializeServices(): Promise { // Set notification manager on local context's file watcher localContext.fileWatcher.setNotificationManager(notificationManager); + launchIoGovernor = new LaunchIoGovernor({ + logger: createLogger('Service:LaunchIoGovernor'), + }); + // Wire file watcher events for local context wireFileWatcherEvents(localContext); @@ -1240,6 +1247,7 @@ async function initializeServices(): Promise { }); const forwardTeamChange = (event: TeamChangeEvent): void => { + launchIoGovernor?.noteTeamChange(event); if (event.type === 'config') { if (event.detail === 'config.json') { TeamConfigReader.invalidateTeam(event.teamName); @@ -1437,7 +1445,8 @@ async function initializeServices(): Promise { skillsMutationService, skillsWatcherService, crossTeamService, - teamBackupService ?? undefined + teamBackupService ?? undefined, + launchIoGovernor ?? undefined ); registerCodexAccountIpc(ipcMain, codexAccountFeature); registerRecentProjectsIpc(ipcMain, recentProjectsFeature); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index b7636889..da77da67 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -123,6 +123,7 @@ import type { McpHealthDiagnosticsService } from '../services/extensions/state/M import type { HttpServer } from '../services/infrastructure/HttpServer'; import type { SchedulerService } from '../services/schedule/SchedulerService'; import type { CrossTeamService } from '../services/team/CrossTeamService'; +import type { LaunchIoGovernor } from '../services/team/LaunchIoGovernor'; import type { TeamBackupService } from '../services/team/TeamBackupService'; /** @@ -169,7 +170,8 @@ export function initializeIpcHandlers( skillsMutationService?: SkillsMutationService, skillsWatcherService?: SkillsWatcherService, crossTeamService?: CrossTeamService, - teamBackupService?: TeamBackupService + teamBackupService?: TeamBackupService, + launchIoGovernor?: LaunchIoGovernor ): void { // Initialize domain handlers with registry initializeProjectHandlers(registry); @@ -192,7 +194,8 @@ export function initializeIpcHandlers( boardTaskActivityDetailService, boardTaskLogStreamService, boardTaskExactLogsService, - boardTaskExactLogDetailService + boardTaskExactLogDetailService, + launchIoGovernor ); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 80bf626b..49002931 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -135,6 +135,10 @@ import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService'; +import { + cloneLaunchIoGovernorPayload, + type LaunchIoGovernor, +} from '../services/team/LaunchIoGovernor'; import { validateFromField, @@ -512,6 +516,7 @@ let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; let teamLogSourceTracker: TeamLogSourceTracker | null = null; let branchStatusService: BranchStatusService | null = null; +let launchIoGovernor: LaunchIoGovernor | null = null; let boardTaskActivityService: BoardTaskActivityService | null = null; let boardTaskActivityDetailService: BoardTaskActivityDetailService | null = null; let boardTaskLogStreamService: BoardTaskLogStreamService | null = null; @@ -564,7 +569,8 @@ export function initializeTeamHandlers( taskActivityDetailService?: BoardTaskActivityDetailService, taskLogStreamService?: BoardTaskLogStreamService, taskExactLogsService?: BoardTaskExactLogsService, - taskExactLogDetailService?: BoardTaskExactLogDetailService + taskExactLogDetailService?: BoardTaskExactLogDetailService, + ioGovernor?: LaunchIoGovernor ): void { teamDataService = service; teamProvisioningService = provisioningService; @@ -575,6 +581,7 @@ export function initializeTeamHandlers( teammateToolTracker = toolTracker ?? null; teamLogSourceTracker = logSourceTracker ?? null; branchStatusService = branchTracker ?? null; + launchIoGovernor = ioGovernor ?? null; boardTaskActivityService = taskActivityService ?? null; boardTaskActivityDetailService = taskActivityDetailService ?? null; boardTaskLogStreamService = taskLogStreamService ?? null; @@ -896,7 +903,14 @@ async function handleListTeams(_event: IpcMainInvokeEvent): Promise getTeamDataService().listTeams()); + return await wrapTeamHandler('list', () => { + const loadFresh = () => getTeamDataService().listTeams(); + return launchIoGovernor + ? launchIoGovernor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }) + : loadFresh(); + }); } finally { const ms = Date.now() - startedAt; if (ms >= 1500) { @@ -1851,6 +1865,21 @@ function sendProvisioningProgress( safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress); } +function noteLaunchIntentFailed(teamName: string, source: string): void { + if (!launchIoGovernor) { + return; + } + const now = new Date().toISOString(); + launchIoGovernor.noteProvisioningProgress({ + runId: `${source}:failed-before-progress`, + teamName, + state: 'failed', + message: 'Launch failed before provisioning progress', + startedAt: now, + updatedAt: now, + } as TeamProvisioningProgress); +} + async function handleCreateTeam( event: IpcMainInvokeEvent, request: unknown @@ -1861,11 +1890,18 @@ async function handleCreateTeam( } const progressTargetWindow = BrowserWindow.fromWebContents(event.sender); - return wrapTeamHandler('create', () => { + return wrapTeamHandler('create', async () => { addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName }); - return getTeamProvisioningService().createTeam(validation.value, (progress) => { - sendProvisioningProgress(progressTargetWindow, progress); - }); + launchIoGovernor?.noteLaunchIntent(validation.value.teamName, 'create'); + try { + return await getTeamProvisioningService().createTeam(validation.value, (progress) => { + launchIoGovernor?.noteProvisioningProgress(progress); + sendProvisioningProgress(progressTargetWindow, progress); + }); + } catch (error) { + noteLaunchIntentFailed(validation.value.teamName, 'create'); + throw error; + } }); } @@ -1998,11 +2034,18 @@ async function handleLaunchTeam( members: savedRequest.members, }; - return wrapTeamHandler('create', () => - getTeamProvisioningService().createTeam(createRequest, (progress) => { - sendProvisioningProgress(progressTargetWindow, progress); - }) - ); + return wrapTeamHandler('create', async () => { + launchIoGovernor?.noteLaunchIntent(tn, 'draft-launch'); + try { + return await getTeamProvisioningService().createTeam(createRequest, (progress) => { + launchIoGovernor?.noteProvisioningProgress(progress); + sendProvisioningProgress(progressTargetWindow, progress); + }); + } catch (error) { + noteLaunchIntentFailed(tn, 'draft-launch'); + throw error; + } + }); } const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null); @@ -2040,33 +2083,41 @@ async function handleLaunchTeam( const launchLimitContext = typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext; - return wrapTeamHandler('launch', () => { + return wrapTeamHandler('launch', async () => { addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! }); - return getTeamProvisioningService().launchTeam( - { - teamName: validatedTeamName.value!, - cwd, - prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, - providerId: launchProviderId, - providerBackendId: launchProviderBackendValidation.value, - model: rawLaunchModel, - effort: effortValidation.value, - fastMode: fastModeValidation.value, - limitContext: launchLimitContext, - clearContext: payload.clearContext === true ? true : undefined, - skipPermissions: - typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, - worktree: - typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined, - extraCliArgs: - typeof payload.extraCliArgs === 'string' - ? payload.extraCliArgs.trim() || undefined - : undefined, - }, - (progress) => { - sendProvisioningProgress(progressTargetWindow, progress); - } - ); + launchIoGovernor?.noteLaunchIntent(validatedTeamName.value!, 'launch'); + try { + return await getTeamProvisioningService().launchTeam( + { + teamName: validatedTeamName.value!, + cwd, + prompt: + typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, + providerId: launchProviderId, + providerBackendId: launchProviderBackendValidation.value, + model: rawLaunchModel, + effort: effortValidation.value, + fastMode: fastModeValidation.value, + limitContext: launchLimitContext, + clearContext: payload.clearContext === true ? true : undefined, + skipPermissions: + typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined, + worktree: + typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined, + extraCliArgs: + typeof payload.extraCliArgs === 'string' + ? payload.extraCliArgs.trim() || undefined + : undefined, + }, + (progress) => { + launchIoGovernor?.noteProvisioningProgress(progress); + sendProvisioningProgress(progressTargetWindow, progress); + } + ); + } catch (error) { + noteLaunchIntentFailed(validatedTeamName.value!, 'launch'); + throw error; + } }); } @@ -3786,7 +3837,14 @@ async function handleGetAllTasks(_event: IpcMainInvokeEvent): Promise getTeamDataService().getAllTasks()); + return await wrapTeamHandler('getAllTasks', () => { + const loadFresh = () => getTeamDataService().getAllTasks(); + return launchIoGovernor + ? launchIoGovernor.runSummaryOperation('teams:getAllTasks', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }) + : loadFresh(); + }); } finally { const ms = Date.now() - startedAt; if (ms >= 1500) { diff --git a/src/main/services/team/LaunchIoGovernor.ts b/src/main/services/team/LaunchIoGovernor.ts new file mode 100644 index 00000000..c7fafa1e --- /dev/null +++ b/src/main/services/team/LaunchIoGovernor.ts @@ -0,0 +1,366 @@ +import type { + GlobalTask, + TeamChangeEvent, + TeamProvisioningProgress, + TeamSummary, +} from '@shared/types'; + +export type LaunchIoGovernorOperationKey = 'teams:list' | 'teams:getAllTasks'; + +type GovernedPayload = TeamSummary[] | GlobalTask[]; +type CloneFn = (value: T) => T; + +interface LaunchIoGovernorLogger { + debug?: (message: string) => void; + warn?: (message: string) => void; +} + +export interface LaunchIoGovernorOptions { + quietWindowMs?: number; + maxStaleAgeMs?: number; + stuckLaunchPressureMs?: number; + warningCooldownMs?: number; + now?: () => number; + logger?: LaunchIoGovernorLogger; +} + +interface ActiveLaunch { + teamName: string; + source: string; + startedAt: number; + updatedAt: number; +} + +interface CachedValue { + value: T; + cachedAt: number; +} + +interface OperationState { + key: LaunchIoGovernorOperationKey; + cache: CachedValue | null; + dirty: boolean; + generation: number; + inFlight: Promise | null; + loadFresh: (() => Promise) | null; + clone: CloneFn | null; + scheduledRefresh: ReturnType | null; + lastWarningAt: number; +} + +export const DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS = 3_000; +export const DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS = 15_000; +export const DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS = 10 * 60_000; +const DEFAULT_WARNING_COOLDOWN_MS = 10_000; + +const TERMINAL_PROVISIONING_STATES = new Set(['ready', 'failed', 'cancelled', 'disconnected']); + +export function cloneLaunchIoGovernorPayload(value: T): T { + if (typeof structuredClone === 'function') { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +} + +export class LaunchIoGovernor { + private readonly quietWindowMs: number; + private readonly maxStaleAgeMs: number; + private readonly stuckLaunchPressureMs: number; + private readonly warningCooldownMs: number; + private readonly now: () => number; + private readonly logger: LaunchIoGovernorLogger; + private readonly activeLaunches = new Map(); + private readonly operations = new Map< + LaunchIoGovernorOperationKey, + OperationState + >(); + private quietUntil = 0; + + constructor(options: LaunchIoGovernorOptions = {}) { + this.quietWindowMs = options.quietWindowMs ?? DEFAULT_LAUNCH_IO_QUIET_WINDOW_MS; + this.maxStaleAgeMs = options.maxStaleAgeMs ?? DEFAULT_LAUNCH_IO_MAX_STALE_AGE_MS; + this.stuckLaunchPressureMs = + options.stuckLaunchPressureMs ?? DEFAULT_LAUNCH_IO_STUCK_PRESSURE_MS; + this.warningCooldownMs = options.warningCooldownMs ?? DEFAULT_WARNING_COOLDOWN_MS; + this.now = options.now ?? (() => Date.now()); + this.logger = options.logger ?? {}; + this.operations.set('teams:list', this.createOperationState('teams:list')); + this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks')); + } + + noteLaunchIntent(teamName: string, source = 'unknown'): void { + const normalized = this.normalizeTeamName(teamName); + if (!normalized) { + return; + } + const now = this.now(); + this.pruneStuckLaunches(now); + this.activeLaunches.set(normalized, { + teamName: normalized, + source, + startedAt: now, + updatedAt: now, + }); + this.markDirty('teams:list'); + this.scheduleDirtyRefreshes(false); + } + + noteProvisioningProgress(progress: TeamProvisioningProgress): void { + const teamName = this.normalizeTeamName(progress.teamName); + if (!teamName) { + return; + } + const now = this.now(); + this.pruneStuckLaunches(now); + this.markDirty('teams:list'); + + if (TERMINAL_PROVISIONING_STATES.has(String(progress.state))) { + this.activeLaunches.delete(teamName); + this.quietUntil = Math.max(this.quietUntil, now + this.quietWindowMs); + this.scheduleDirtyRefreshes(true); + return; + } + + const existing = this.activeLaunches.get(teamName); + this.activeLaunches.set(teamName, { + teamName, + source: existing?.source ?? 'progress', + startedAt: existing?.startedAt ?? now, + updatedAt: now, + }); + this.scheduleDirtyRefreshes(false); + } + + noteTeamChange(event: TeamChangeEvent): void { + if (event.type === 'config') { + this.markDirty('teams:list'); + this.markDirty('teams:getAllTasks'); + } else if (event.type === 'task') { + this.markDirty('teams:getAllTasks'); + } + if (this.hasLaunchPressure(this.now())) { + this.scheduleDirtyRefreshes(false); + } + } + + async runSummaryOperation( + key: LaunchIoGovernorOperationKey, + loadFresh: () => Promise, + options: { clone: CloneFn } + ): Promise { + const state = this.getOperationState(key); + state.loadFresh = loadFresh; + state.clone = options.clone; + + if (this.canServeStale(state)) { + if (state.dirty) { + this.scheduleDeferredRefresh(key, state, false); + } + return options.clone(state.cache!.value); + } + + return this.runFresh(key, state, false); + } + + clearForTests(): void { + for (const state of this.operations.values()) { + if (state.scheduledRefresh) { + clearTimeout(state.scheduledRefresh); + } + } + this.activeLaunches.clear(); + this.quietUntil = 0; + this.operations.clear(); + this.operations.set('teams:list', this.createOperationState('teams:list')); + this.operations.set('teams:getAllTasks', this.createOperationState('teams:getAllTasks')); + } + + hasLaunchPressureForTests(): boolean { + return this.hasLaunchPressure(this.now()); + } + + private createOperationState( + key: LaunchIoGovernorOperationKey + ): OperationState { + return { + key, + cache: null, + dirty: false, + generation: 0, + inFlight: null, + loadFresh: null, + clone: null, + scheduledRefresh: null, + lastWarningAt: Number.NEGATIVE_INFINITY, + }; + } + + private getOperationState( + key: LaunchIoGovernorOperationKey + ): OperationState { + const state = this.operations.get(key); + if (!state) { + throw new Error(`Unknown launch IO governor operation: ${key}`); + } + return state as unknown as OperationState; + } + + private canServeStale(state: OperationState): boolean { + const now = this.now(); + if (!this.hasLaunchPressure(now) || !state.cache) { + return false; + } + return now - state.cache.cachedAt <= this.maxStaleAgeMs; + } + + private async runFresh( + key: LaunchIoGovernorOperationKey, + state: OperationState, + background: boolean + ): Promise { + if (!state.loadFresh || !state.clone) { + throw new Error(`Launch IO governor operation ${key} has no loader`); + } + + if (state.inFlight) { + try { + const joined = await state.inFlight; + return state.clone(joined); + } catch (error) { + if (background) { + this.warnRefreshFailure(key, state, error); + } + throw error; + } + } + + const generationAtStart = state.generation; + const loadFresh = state.loadFresh; + const clone = state.clone; + const promise = loadFresh(); + state.inFlight = promise; + + try { + const fresh = await promise; + if (state.generation === generationAtStart) { + state.cache = { + value: clone(fresh), + cachedAt: this.now(), + }; + state.dirty = false; + } + return clone(fresh); + } catch (error) { + if (background) { + this.warnRefreshFailure(key, state, error); + } + throw error; + } finally { + if (state.inFlight === promise) { + state.inFlight = null; + } + } + } + + private markDirty(key: LaunchIoGovernorOperationKey): void { + const state = this.getOperationState(key); + state.dirty = true; + state.generation += 1; + } + + private scheduleDirtyRefreshes(force: boolean): void { + for (const [key, state] of this.operations) { + if (state.dirty) { + this.scheduleDeferredRefresh(key, state, force); + } + } + } + + private scheduleDeferredRefresh( + key: LaunchIoGovernorOperationKey, + state: OperationState, + force: boolean + ): void { + if (!state.loadFresh || !state.clone) { + return; + } + if (state.scheduledRefresh) { + if (!force) { + return; + } + clearTimeout(state.scheduledRefresh); + state.scheduledRefresh = null; + } + + const delayMs = this.getDelayUntilFreshAllowed(this.now()); + state.scheduledRefresh = setTimeout(() => { + state.scheduledRefresh = null; + void this.flushOperation(key); + }, delayMs); + state.scheduledRefresh.unref?.(); + } + + private async flushOperation(key: LaunchIoGovernorOperationKey): Promise { + const state = this.getOperationState(key); + const now = this.now(); + if (this.hasLaunchPressure(now)) { + this.scheduleDeferredRefresh(key, state, true); + return; + } + if (!state.dirty || !state.loadFresh || !state.clone) { + return; + } + try { + await this.runFresh(key, state, true); + } catch { + // runFresh already emitted a bounded warning. Keep dirty=true so the next + // request or quiet-window timer can retry without losing the last-good cache. + } + } + + private getDelayUntilFreshAllowed(now: number): number { + this.pruneStuckLaunches(now); + if (this.activeLaunches.size > 0) { + return this.quietWindowMs; + } + return Math.max(0, this.quietUntil - now); + } + + private hasLaunchPressure(now: number): boolean { + this.pruneStuckLaunches(now); + return this.activeLaunches.size > 0 || now < this.quietUntil; + } + + private pruneStuckLaunches(now: number): void { + for (const [teamName, launch] of this.activeLaunches) { + if (now - launch.updatedAt > this.stuckLaunchPressureMs) { + this.activeLaunches.delete(teamName); + this.logger.warn?.( + `[LaunchIoGovernor] launch pressure expired team=${teamName} source=${launch.source} ageMs=${now - launch.startedAt}` + ); + } + } + } + + private warnRefreshFailure( + key: LaunchIoGovernorOperationKey, + state: OperationState, + error: unknown + ): void { + const now = this.now(); + if (now - state.lastWarningAt < this.warningCooldownMs) { + return; + } + state.lastWarningAt = now; + const ageMs = state.cache ? now - state.cache.cachedAt : null; + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.warn?.( + `[LaunchIoGovernor] deferred refresh failed op=${key} ageMs=${ageMs ?? 'none'} dirty=${state.dirty} activeLaunchCount=${this.activeLaunches.size} error=${errorMessage}` + ); + } + + private normalizeTeamName(teamName: string | undefined | null): string | null { + const normalized = teamName?.trim(); + return normalized && normalized.length > 0 ? normalized : null; + } +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index acbbf9ff..ae41c0da 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -157,6 +157,7 @@ import { removeTeamHandlers, } from '../../../src/main/ipc/teams'; import { ConfigManager } from '../../../src/main/services/infrastructure/ConfigManager'; +import { LaunchIoGovernor } from '../../../src/main/services/team/LaunchIoGovernor'; import { getAppDataPath } from '../../../src/main/utils/pathDecoder'; describe('ipc teams handlers', () => { @@ -169,6 +170,7 @@ describe('ipc teams handlers', () => { handlers.delete(channel); }), }; + let launchIoGovernor: LaunchIoGovernor; const service = { listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]), @@ -187,6 +189,7 @@ describe('ipc teams handlers', () => { feedRevision: 'rev-1', messages: [] as InboxMessage[], })), + getAllTasks: vi.fn(async () => [{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]), getMessagesPage: vi.fn( async (..._args: unknown[]): Promise => ({ messages: [] as InboxMessage[], @@ -322,6 +325,12 @@ describe('ipc teams handlers', () => { beforeEach(() => { handlers.clear(); vi.clearAllMocks(); + service.listTeams.mockReset(); + service.getAllTasks.mockReset(); + service.listTeams.mockResolvedValue([{ teamName: 'my-team', displayName: 'My Team' }]); + service.getAllTasks.mockResolvedValue([ + { id: 'task-1', teamName: 'my-team', subject: 'Task 1' }, + ]); mockGetMembersMeta.mockReset(); mockGetMembersMeta.mockResolvedValue([]); mockGetMembersMetaFile.mockReset(); @@ -339,6 +348,7 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.findLogsForTask.mockReset(); mockTeamDataWorkerClient.invalidateTeamConfig.mockReset(); mockTeamDataWorkerClient.invalidateTeamMessageFeed.mockReset(); + launchIoGovernor = new LaunchIoGovernor({ quietWindowMs: 100 }); initializeTeamHandlers( service as never, provisioningService as never, @@ -352,12 +362,14 @@ describe('ipc teams handlers', () => { boardTaskActivityDetailService as never, boardTaskLogStreamService as never, boardTaskExactLogsService as never, - boardTaskExactLogDetailService as never + boardTaskExactLogDetailService as never, + launchIoGovernor ); registerTeamHandlers(ipcMain as never); }); afterEach(() => { + launchIoGovernor.clearForTests(); vi.useRealTimers(); setClaudeBasePathOverride(null); }); @@ -1071,6 +1083,155 @@ describe('ipc teams handlers', () => { }); }); + it('returns cached TEAM_LIST data under active launch pressure without starting another scan', async () => { + const first = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + data: { teamName: string }[]; + }; + expect(first.success).toBe(true); + expect(first.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]); + + service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]); + launchIoGovernor.noteLaunchIntent('my-team', 'test'); + + const second = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + data: { teamName: string }[]; + }; + + expect(second.success).toBe(true); + expect(second.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]); + expect(service.listTeams).toHaveBeenCalledTimes(1); + }); + + it('returns cached TEAM_GET_ALL_TASKS data under active launch pressure without starting another scan', async () => { + const first = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as { + success: boolean; + data: { id: string }[]; + }; + expect(first.success).toBe(true); + expect(first.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]); + + service.getAllTasks.mockResolvedValueOnce([ + { id: 'task-2', teamName: 'my-team', subject: 'Task 2' }, + ]); + launchIoGovernor.noteLaunchIntent('my-team', 'test'); + + const second = (await handlers.get(TEAM_GET_ALL_TASKS)!({} as never)) as { + success: boolean; + data: { id: string }[]; + }; + + expect(second.success).toBe(true); + expect(second.data).toEqual([{ id: 'task-1', teamName: 'my-team', subject: 'Task 1' }]); + expect(service.getAllTasks).toHaveBeenCalledTimes(1); + }); + + it('keeps current fresh behavior for TEAM_LIST when launch pressure has no cached data', async () => { + launchIoGovernor.clearForTests(); + launchIoGovernor.noteLaunchIntent('my-team', 'test'); + + const result = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + data: { teamName: string }[]; + }; + + expect(result.success).toBe(true); + expect(result.data).toEqual([{ teamName: 'my-team', displayName: 'My Team' }]); + expect(service.listTeams).toHaveBeenCalledTimes(1); + }); + + it('flushes TEAM_LIST once after terminal provisioning progress quiet window', async () => { + vi.useFakeTimers(); + const first = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + }; + expect(first.success).toBe(true); + + service.listTeams.mockResolvedValue([{ teamName: 'fresh-team', displayName: 'Fresh' }]); + launchIoGovernor.noteLaunchIntent('my-team', 'test'); + await handlers.get(TEAM_LIST)!({} as never); + launchIoGovernor.noteProvisioningProgress({ + runId: 'run-1', + teamName: 'my-team', + state: 'ready', + message: 'ready', + startedAt: '2026-05-02T00:00:00.000Z', + updatedAt: '2026-05-02T00:00:00.000Z', + } as TeamProvisioningProgress); + + await vi.advanceTimersByTimeAsync(100); + await flushMicrotasks(); + expect(service.listTeams).toHaveBeenCalledTimes(2); + }); + + it('does not let provisioning status polling activate launch IO stale mode', async () => { + const first = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + data: { teamName: string }[]; + }; + expect(first.success).toBe(true); + + service.listTeams.mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]); + const status = (await handlers.get(TEAM_PROVISIONING_STATUS)!({} as never, 'run-1')) as { + success: boolean; + }; + expect(status.success).toBe(true); + + const second = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + data: { teamName: string }[]; + }; + expect(second.success).toBe(true); + expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]); + expect(service.listTeams).toHaveBeenCalledTimes(2); + }); + + it('clears launch IO pressure when create fails before first provisioning progress', async () => { + vi.useFakeTimers(); + const first = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + }; + expect(first.success).toBe(true); + provisioningService.createTeam.mockRejectedValueOnce(new Error('bootstrap failed early')); + service.listTeams + .mockResolvedValueOnce([{ teamName: 'background-fresh', displayName: 'Background Fresh' }]) + .mockResolvedValueOnce([{ teamName: 'fresh-team', displayName: 'Fresh' }]); + + const createResult = (await handlers.get(TEAM_CREATE)!( + { sender: { send: vi.fn() } } as never, + { + teamName: 'my-team', + members: [{ name: 'alice' }], + cwd: os.tmpdir(), + } + )) as { success: boolean }; + expect(createResult.success).toBe(false); + vi.mocked(console.error).mockClear(); + + await vi.advanceTimersByTimeAsync(100); + await flushMicrotasks(); + const second = (await handlers.get(TEAM_LIST)!({} as never)) as { + success: boolean; + data: { teamName: string }[]; + }; + expect(second.success).toBe(true); + expect(second.data).toEqual([{ teamName: 'fresh-team', displayName: 'Fresh' }]); + expect(service.listTeams).toHaveBeenCalledTimes(3); + }); + + it('does not route TEAM_GET_MESSAGES_PAGE through the launch IO governor', async () => { + launchIoGovernor.noteLaunchIntent('my-team', 'test'); + + const result = (await handlers.get(TEAM_GET_MESSAGES_PAGE)!({} as never, 'my-team', { + limit: 50, + })) as { success: boolean; data?: { feedRevision: string } }; + + expect(result.success).toBe(true); + expect(result.data?.feedRevision).toBe('rev-1'); + expect(service.getMessagesPage).toHaveBeenCalledTimes(1); + }); + it('keeps TEAM_GET_DATA structural and does not expose message transport', async () => { provisioningService.getLiveLeadProcessMessages.mockReturnValueOnce([ { diff --git a/test/main/services/team/LaunchIoGovernor.test.ts b/test/main/services/team/LaunchIoGovernor.test.ts new file mode 100644 index 00000000..1b989282 --- /dev/null +++ b/test/main/services/team/LaunchIoGovernor.test.ts @@ -0,0 +1,338 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + cloneLaunchIoGovernorPayload, + LaunchIoGovernor, +} from '../../../../src/main/services/team/LaunchIoGovernor'; +import type { GlobalTask, TeamProvisioningProgress, TeamSummary } from '../../../../src/shared/types'; + +function team(teamName: string): TeamSummary { + return { teamName, displayName: teamName } as TeamSummary; +} + +function task(id: string): GlobalTask { + return { id, teamName: 'team-a', subject: id } as GlobalTask; +} + +function progress(teamName: string, state: string): TeamProvisioningProgress { + return { + runId: `run-${teamName}`, + teamName, + state, + message: state, + startedAt: '2026-05-02T00:00:00.000Z', + updatedAt: '2026-05-02T00:00:00.000Z', + } as TeamProvisioningProgress; +} + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('LaunchIoGovernor', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs fresh and caches success when there is no launch pressure', async () => { + const governor = new LaunchIoGovernor(); + const loadFresh = vi.fn(async () => [team('fresh')]); + + const result = await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + + expect(result).toEqual([team('fresh')]); + expect(loadFresh).toHaveBeenCalledTimes(1); + }); + + it('returns bounded stale cache under active launch pressure and schedules no duplicate fresh read', async () => { + vi.useFakeTimers(); + let now = 0; + const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 }); + const loadFresh = vi.fn(async () => [team('old')]); + + await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + loadFresh.mockResolvedValue([team('new')]); + + governor.noteLaunchIntent('team-a', 'launch'); + const result = await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + + expect(result).toEqual([team('old')]); + expect(loadFresh).toHaveBeenCalledTimes(1); + + now += 99; + await vi.advanceTimersByTimeAsync(99); + expect(loadFresh).toHaveBeenCalledTimes(1); + }); + + it('isolates cached payload from caller-side mutations', async () => { + const governor = new LaunchIoGovernor(); + const loadFresh = vi.fn(async () => [team('old')]); + + const first = await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + first[0]!.displayName = 'mutated'; + + governor.noteLaunchIntent('team-a', 'launch'); + const second = await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + + expect(second).toEqual([team('old')]); + expect(loadFresh).toHaveBeenCalledTimes(1); + }); + + it('runs one fresh read and coalesces callers when pressure has no cache', async () => { + const governor = new LaunchIoGovernor(); + const deferred = createDeferred(); + const loadFresh = vi.fn(() => deferred.promise); + + governor.noteLaunchIntent('team-a', 'launch'); + const first = governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + const second = governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + + expect(loadFresh).toHaveBeenCalledTimes(1); + deferred.resolve([team('fresh')]); + await expect(Promise.all([first, second])).resolves.toEqual([[team('fresh')], [team('fresh')]]); + }); + + it('does not serve cache beyond max stale age during launch pressure', async () => { + let now = 0; + const governor = new LaunchIoGovernor({ now: () => now, maxStaleAgeMs: 100 }); + const loadFresh = vi.fn(async () => [team('old')]); + + await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + now = 101; + loadFresh.mockResolvedValue([team('new')]); + governor.noteLaunchIntent('team-a', 'launch'); + + await expect( + governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }) + ).resolves.toEqual([team('new')]); + expect(loadFresh).toHaveBeenCalledTimes(2); + }); + + it('does not cache an in-flight result when a dirty generation arrives before it resolves', async () => { + const governor = new LaunchIoGovernor(); + const deferred = createDeferred(); + const loadFresh = vi.fn(() => deferred.promise); + + const first = governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + governor.noteLaunchIntent('team-a', 'launch'); + deferred.resolve([team('stale-inflight')]); + await expect(first).resolves.toEqual([team('stale-inflight')]); + + loadFresh.mockResolvedValue([team('fresh-after-dirty')]); + await expect( + governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }) + ).resolves.toEqual([team('fresh-after-dirty')]); + expect(loadFresh).toHaveBeenCalledTimes(2); + }); + + it('marks config and task changes dirty for the correct summary operations', async () => { + const governor = new LaunchIoGovernor(); + const loadTeams = vi.fn(async () => [team('team-old')]); + const loadTasks = vi.fn(async () => [task('task-old')]); + + await governor.runSummaryOperation('teams:list', loadTeams, { + clone: cloneLaunchIoGovernorPayload, + }); + await governor.runSummaryOperation('teams:getAllTasks', loadTasks, { + clone: cloneLaunchIoGovernorPayload, + }); + + loadTeams.mockResolvedValue([team('team-new')]); + loadTasks.mockResolvedValue([task('task-new')]); + governor.noteLaunchIntent('team-a', 'launch'); + governor.noteTeamChange({ type: 'task', teamName: 'team-a', detail: 'task.json' }); + + await expect( + governor.runSummaryOperation('teams:list', loadTeams, { + clone: cloneLaunchIoGovernorPayload, + }) + ).resolves.toEqual([team('team-old')]); + await expect( + governor.runSummaryOperation('teams:getAllTasks', loadTasks, { + clone: cloneLaunchIoGovernorPayload, + }) + ).resolves.toEqual([task('task-old')]); + expect(loadTeams).toHaveBeenCalledTimes(1); + expect(loadTasks).toHaveBeenCalledTimes(1); + }); + + it('does not start background refresh for dirty events outside launch pressure', async () => { + vi.useFakeTimers(); + const governor = new LaunchIoGovernor({ quietWindowMs: 100 }); + const loadTeams = vi.fn(async () => [team('old')]); + + await governor.runSummaryOperation('teams:list', loadTeams, { + clone: cloneLaunchIoGovernorPayload, + }); + loadTeams.mockResolvedValue([team('new')]); + + governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' }); + await vi.advanceTimersByTimeAsync(1_000); + await flushMicrotasks(); + expect(loadTeams).toHaveBeenCalledTimes(1); + + await expect( + governor.runSummaryOperation('teams:list', loadTeams, { + clone: cloneLaunchIoGovernorPayload, + }) + ).resolves.toEqual([team('new')]); + expect(loadTeams).toHaveBeenCalledTimes(2); + }); + + it('does not mark global tasks dirty from launch intent alone', async () => { + vi.useFakeTimers(); + let now = 0; + const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 }); + const loadTeams = vi.fn(async () => [team('old-team')]); + const loadTasks = vi.fn(async () => [task('old-task')]); + + await governor.runSummaryOperation('teams:list', loadTeams, { + clone: cloneLaunchIoGovernorPayload, + }); + await governor.runSummaryOperation('teams:getAllTasks', loadTasks, { + clone: cloneLaunchIoGovernorPayload, + }); + loadTeams.mockResolvedValue([team('new-team')]); + loadTasks.mockResolvedValue([task('new-task')]); + + governor.noteLaunchIntent('team-a', 'launch'); + await governor.runSummaryOperation('teams:list', loadTeams, { + clone: cloneLaunchIoGovernorPayload, + }); + await governor.runSummaryOperation('teams:getAllTasks', loadTasks, { + clone: cloneLaunchIoGovernorPayload, + }); + governor.noteProvisioningProgress(progress('team-a', 'ready')); + + now += 100; + await vi.advanceTimersByTimeAsync(100); + await flushMicrotasks(); + + expect(loadTeams).toHaveBeenCalledTimes(2); + expect(loadTasks).toHaveBeenCalledTimes(1); + }); + + it('keeps quiet window after terminal progress and flushes dirty cache once timer expires', async () => { + vi.useFakeTimers(); + let now = 0; + const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 }); + const loadFresh = vi.fn(async () => [team('old')]); + const loadTasks = vi.fn(async () => [task('old')]); + + await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + await governor.runSummaryOperation('teams:getAllTasks', loadTasks, { + clone: cloneLaunchIoGovernorPayload, + }); + loadFresh.mockResolvedValue([team('new')]); + loadTasks.mockResolvedValue([task('new')]); + + governor.noteLaunchIntent('team-a', 'launch'); + await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + await governor.runSummaryOperation('teams:getAllTasks', loadTasks, { + clone: cloneLaunchIoGovernorPayload, + }); + governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' }); + governor.noteProvisioningProgress(progress('team-a', 'ready')); + + now += 99; + await vi.advanceTimersByTimeAsync(99); + await flushMicrotasks(); + expect(loadFresh).toHaveBeenCalledTimes(1); + expect(loadTasks).toHaveBeenCalledTimes(1); + + now += 1; + await vi.advanceTimersByTimeAsync(1); + await flushMicrotasks(); + expect(loadFresh).toHaveBeenCalledTimes(2); + expect(loadTasks).toHaveBeenCalledTimes(2); + }); + + it('keeps launch pressure until all concurrent launches reach terminal states', () => { + let now = 0; + const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100 }); + + governor.noteLaunchIntent('team-a', 'launch'); + governor.noteLaunchIntent('team-b', 'launch'); + governor.noteProvisioningProgress(progress('team-a', 'failed')); + expect(governor.hasLaunchPressureForTests()).toBe(true); + + governor.noteProvisioningProgress(progress('team-b', 'ready')); + expect(governor.hasLaunchPressureForTests()).toBe(true); + + now += 100; + expect(governor.hasLaunchPressureForTests()).toBe(false); + }); + + it('preserves old cache and dirty state when a deferred refresh fails', async () => { + vi.useFakeTimers(); + let now = 0; + const logger = { warn: vi.fn() }; + const governor = new LaunchIoGovernor({ now: () => now, quietWindowMs: 100, logger }); + const loadFresh = vi.fn(async () => [team('old')]); + + await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + loadFresh.mockRejectedValueOnce(new Error('worker timeout')); + + governor.noteLaunchIntent('team-a', 'launch'); + governor.noteTeamChange({ type: 'config', teamName: 'team-a', detail: 'config.json' }); + await governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }); + governor.noteProvisioningProgress(progress('team-a', 'ready')); + + now += 100; + await vi.advanceTimersByTimeAsync(100); + await flushMicrotasks(); + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('deferred refresh failed')); + governor.noteLaunchIntent('team-b', 'launch'); + await expect( + governor.runSummaryOperation('teams:list', loadFresh, { + clone: cloneLaunchIoGovernorPayload, + }) + ).resolves.toEqual([team('old')]); + }); +}); From fcb91799902bd9b742467f8395fa9889c574c55f Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 23:15:34 +0300 Subject: [PATCH 17/51] perf(team): reduce launch IO pressure --- src/main/index.ts | 25 + .../services/team/TeamDataWorkerClient.ts | 18 + src/main/services/team/TeamFsWorkerClient.ts | 78 ++- .../services/team/TeamProvisioningService.ts | 201 ++++-- src/main/services/team/teamDataWorkerTypes.ts | 1 + src/main/workers/team-data-worker.ts | 8 +- src/main/workers/team-fs-worker.ts | 603 ++++++++++++++++-- .../team/TeamDataWorkerClient.test.ts | 19 + .../team/TeamFsWorker.integration.test.ts | 216 ++++++- .../services/team/TeamFsWorkerClient.test.ts | 152 +++++ .../team/TeamProvisioningService.test.ts | 186 ++++++ 11 files changed, 1380 insertions(+), 127 deletions(-) create mode 100644 test/main/services/team/TeamFsWorkerClient.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index e7aae28b..1bf686f0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -138,6 +138,7 @@ import { } from './services/team/TeamControlApiState'; import { TeamInboxReader } from './services/team/TeamInboxReader'; import { getTeamDataWorkerClient } from './services/team/TeamDataWorkerClient'; +import { getTeamFsWorkerClient } from './services/team/TeamFsWorkerClient'; import { TeamMemberRuntimeAdvisoryService } from './services/team/TeamMemberRuntimeAdvisoryService'; import { createTeamReconcileDrainScheduler, @@ -1841,6 +1842,30 @@ function createWindow(): void { updaterService.startPeriodicCheck(60 * 60 * 1000); } + scheduleStartupTask( + () => { + void getTeamFsWorkerClient() + .prewarm() + .catch((error: unknown) => + logger.debug( + `[startup] team-fs-worker prewarm skipped: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + void getTeamDataWorkerClient() + .prewarm() + .catch((error: unknown) => + logger.debug( + `[startup] team-data-worker prewarm skipped: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + }, + process.platform === 'win32' ? 2500 : 1000 + ); + // Defer non-critical startup work to avoid thread pool contention. // The window is now visible and responsive; these run in the background. scheduleStartupTask(() => { diff --git a/src/main/services/team/TeamDataWorkerClient.ts b/src/main/services/team/TeamDataWorkerClient.ts index f53259a5..ed7398a7 100644 --- a/src/main/services/team/TeamDataWorkerClient.ts +++ b/src/main/services/team/TeamDataWorkerClient.ts @@ -66,6 +66,9 @@ interface PendingEntry { function summarizeWorkerPayload( payload: TeamDataWorkerRequest['payload'] ): Record { + if (!payload) { + return {}; + } if ('taskId' in payload) { return { teamName: payload.teamName, @@ -213,6 +216,21 @@ export class TeamDataWorkerClient { }); } + async prewarm(): Promise { + if (this.worker) { + return; + } + if (!this.isAvailable()) { + return; + } + const startedAt = Date.now(); + await this.call('warmup', {}); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`worker prewarm slow ms=${ms}`); + } + } + private postBestEffort( op: TeamDataWorkerRequest['op'], payload: TeamDataWorkerRequest['payload'] diff --git a/src/main/services/team/TeamFsWorkerClient.ts b/src/main/services/team/TeamFsWorkerClient.ts index f2089afd..f9991efb 100644 --- a/src/main/services/team/TeamFsWorkerClient.ts +++ b/src/main/services/team/TeamFsWorkerClient.ts @@ -36,6 +36,7 @@ interface GetAllTasksPayload { } type WorkerRequest = + | { id: string; op: 'warmup'; payload?: Record } | { id: string; op: 'listTeams'; payload: ListTeamsPayload } | { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload }; @@ -44,6 +45,9 @@ type WorkerResponse = | { id: string; ok: false; error: string }; function summarizeWorkerPayload(payload: WorkerRequest['payload']): Record { + if (!payload) { + return {}; + } if ('teamsDir' in payload) { return { teamsDir: payload.teamsDir, @@ -52,6 +56,9 @@ function summarizeWorkerPayload(payload: WorkerRequest['payload']): Record void; reject: (e: Error) => void } >(); + private failWorker(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + + this.worker = null; + const pendingEntries = Array.from(this.pending.values()); + this.pending.clear(); + + for (const entry of pendingEntries) { + entry.reject(error); + } + } + isAvailable(): boolean { if (!this.workerPath && !this.warnedUnavailable && shouldWarnUnavailableWorker()) { this.warnedUnavailable = true; @@ -134,8 +153,9 @@ export class TeamFsWorkerClient { return this.worker; } - this.worker = new Worker(this.workerPath); - this.worker.on('message', (msg: WorkerResponse) => { + const worker = new Worker(this.workerPath); + this.worker = worker; + worker.on('message', (msg: WorkerResponse) => { const entry = this.pending.get(msg.id); if (!entry) return; this.pending.delete(msg.id); @@ -145,26 +165,18 @@ export class TeamFsWorkerClient { entry.reject(new Error(msg.error)); } }); - this.worker.on('error', (err) => { + 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.failWorker(worker, err instanceof Error ? err : new Error(String(err))); }); - this.worker.on('exit', (code) => { + 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; + this.failWorker(worker, new Error(`Worker exited with code ${code}`)); }); - return this.worker; + return worker; } private call( @@ -177,21 +189,22 @@ export class TeamFsWorkerClient { const pendingAtStart = this.pending.size; 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; - } + const timeoutError = new Error( + `Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})` + ); logger.warn( `worker call timeout op=${op} ms=${Date.now() - startedAt} pendingAtStart=${pendingAtStart} pendingNow=${this.pending.size} payload=${JSON.stringify( summarizeWorkerPayload(payload) )}` ); - reject(new Error(`Worker call timeout after ${WORKER_CALL_TIMEOUT_MS}ms (${op})`)); + this.failWorker(worker, timeoutError); + try { + // Terminate and recreate on next call - worker may be stuck in native IO. + worker.terminate().catch(() => undefined); + } catch { + // ignore + } + reject(timeoutError); }, WORKER_CALL_TIMEOUT_MS); this.pending.set(id, { @@ -224,6 +237,21 @@ export class TeamFsWorkerClient { }); } + async prewarm(): Promise { + if (this.worker) { + return; + } + if (!this.isAvailable()) { + return; + } + const startedAt = Date.now(); + await this.call('warmup', {}); + const ms = Date.now() - startedAt; + if (ms >= 1500) { + logger.warn(`worker prewarm slow ms=${ms}`); + } + } + async listTeams(options: { largeConfigBytes: number; configHeadBytes: number; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 0aae9695..6374cb2e 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -4565,10 +4565,31 @@ export class TeamProvisioningService { string, { expiresAtMs: number; snapshot: TeamAgentRuntimeSnapshot } >(); + private readonly agentRuntimeSnapshotInFlightByTeam = new Map< + string, + { + generationAtStart: number; + runIdAtStart: string | null; + promise: Promise; + } + >(); private readonly liveTeamAgentRuntimeMetadataCache = new Map< string, - { expiresAtMs: number; metadata: Map } + { + expiresAtMs: number; + metadata: Map; + runId: string | null; + } >(); + private readonly liveTeamAgentRuntimeMetadataInFlightByTeam = new Map< + string, + { + generationAtStart: number; + runIdAtStart: string | null; + promise: Promise>; + } + >(); + private readonly runtimeSnapshotCacheGenerationByTeam = new Map(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly launchStateStoreQueue = new Map>(); private readonly memberLogsFinder: TeamMemberLogsFinder; @@ -4651,6 +4672,35 @@ export class TeamProvisioningService { return { config, teamMeta, metaMembers }; } + private getRuntimeSnapshotCacheGeneration(teamName: string): number { + return this.runtimeSnapshotCacheGenerationByTeam.get(teamName) ?? 0; + } + + private invalidateRuntimeSnapshotCaches(teamName: string): void { + this.runtimeSnapshotCacheGenerationByTeam.set( + teamName, + this.getRuntimeSnapshotCacheGeneration(teamName) + 1 + ); + this.agentRuntimeSnapshotCache.delete(teamName); + this.agentRuntimeSnapshotInFlightByTeam.delete(teamName); + this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); + } + + private cloneLiveTeamAgentRuntimeMetadata( + metadata: ReadonlyMap + ): Map { + return new Map( + [...metadata.entries()].map(([memberName, entry]) => [ + memberName, + { + ...entry, + ...(entry.diagnostics ? { diagnostics: [...entry.diagnostics] } : {}), + }, + ]) + ); + } + private resolveOpenCodeMemberIdentityFromDirectory( teamName: string, memberName: string, @@ -5463,6 +5513,7 @@ export class TeamProvisioningService { this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); this.provisioningRunByTeam.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); } } if (cleaned > 0) { @@ -7625,6 +7676,7 @@ export class TeamProvisioningService { private resetTeamScopedTransientStateForNewRun(teamName: string): void { peekAutoResumeService()?.cancelPendingAutoResume(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.retainedClaudeLogsByTeam.delete(teamName); this.persistedTranscriptClaudeLogsCache.delete(teamName); this.leadInboxRelayInFlight.delete(teamName); @@ -9070,8 +9122,7 @@ export class TeamProvisioningService { trackedUpdate.run, this.getMixedSecondaryLaunchPhase(trackedUpdate.run) ); - this.agentRuntimeSnapshotCache.delete(input.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); + this.invalidateRuntimeSnapshotCaches(input.teamName); if (trackedUpdate.changed) { this.teamChangeEmitter?.({ type: 'member-spawn', @@ -9158,8 +9209,7 @@ export class TeamProvisioningService { updatedAt: input.observedAt, }); await this.writeLaunchStateSnapshot(input.teamName, snapshot); - this.agentRuntimeSnapshotCache.delete(input.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(input.teamName); + this.invalidateRuntimeSnapshotCaches(input.teamName); if (shouldEmitMemberSpawnChange) { this.teamChangeEmitter?.({ type: 'member-spawn', @@ -9936,8 +9986,7 @@ export class TeamProvisioningService { ); return; } - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting'); this.appendMemberBootstrapDiagnostic( run, @@ -10405,6 +10454,36 @@ export class TeamProvisioningService { return cached.snapshot; } + const generationAtStart = this.getRuntimeSnapshotCacheGeneration(teamName); + const existingRequest = this.agentRuntimeSnapshotInFlightByTeam.get(teamName); + if ( + existingRequest && + existingRequest.generationAtStart === generationAtStart && + existingRequest.runIdAtStart === runId + ) { + return existingRequest.promise; + } + + const request = this.buildTeamAgentRuntimeSnapshot(teamName, runId, generationAtStart).finally( + () => { + if (this.agentRuntimeSnapshotInFlightByTeam.get(teamName)?.promise === request) { + this.agentRuntimeSnapshotInFlightByTeam.delete(teamName); + } + } + ); + this.agentRuntimeSnapshotInFlightByTeam.set(teamName, { + generationAtStart, + runIdAtStart: runId, + promise: request, + }); + return request; + } + + private async buildTeamAgentRuntimeSnapshot( + teamName: string, + runId: string | null, + generationAtStart: number + ): Promise { const updatedAt = nowIso(); const run = runId ? (this.runs.get(runId) ?? null) : null; const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName); @@ -10627,10 +10706,15 @@ export class TeamProvisioningService { members: snapshotMembers, }; - this.agentRuntimeSnapshotCache.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, - snapshot, - }); + if ( + this.getRuntimeSnapshotCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === runId + ) { + this.agentRuntimeSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + snapshot, + }); + } return snapshot; } @@ -11004,8 +11088,7 @@ export class TeamProvisioningService { ); } - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName); const livePids = new Set(); let hasAliveRuntimeWithoutPid = false; @@ -11150,8 +11233,7 @@ export class TeamProvisioningService { throw new Error('Lead restart is not supported from member controls'); } - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, memberName); this.clearMemberSpawnToolTracking(run, memberName); this.setMemberSpawnStatus(run, memberName, 'spawning'); @@ -11311,8 +11393,7 @@ export class TeamProvisioningService { : 'Skipped by user for this launch'; if (run && !run.processKilled && !run.cancelRequested) { - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, normalizedMemberName); this.clearMemberSpawnToolTracking(run, normalizedMemberName); this.setMemberSpawnStatus(run, normalizedMemberName, 'skipped', reason); @@ -11374,8 +11455,7 @@ export class TeamProvisioningService { updatedAt, }); await this.writeLaunchStateSnapshot(teamName, nextSnapshot); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); } private getMutableAliveRunOrThrow(teamName: string): ProvisioningRun { @@ -11489,8 +11569,7 @@ export class TeamProvisioningService { } this.upsertRunAllEffectiveMember(run, memberSpec); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, memberName); this.clearMemberSpawnToolTracking(run, memberName); run.pendingMemberRestarts.delete(memberName); @@ -11544,8 +11623,7 @@ export class TeamProvisioningService { ); if (laneIndex < 0) { this.removeRunAllEffectiveMember(run, memberName); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); await this.persistLaunchStateSnapshot(run, this.getMixedSecondaryLaunchPhase(run)); return; } @@ -11553,8 +11631,7 @@ export class TeamProvisioningService { const [lane] = run.mixedSecondaryLanes.splice(laneIndex, 1); await this.stopSingleMixedSecondaryRuntimeLane(run, lane, 'cleanup'); this.removeRunAllEffectiveMember(run, memberName); - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.resetRuntimeToolActivity(run, memberName); this.clearMemberSpawnToolTracking(run, memberName); run.pendingMemberRestarts.delete(memberName); @@ -14429,6 +14506,7 @@ export class TeamProvisioningService { }).catch(() => undefined); this.runtimeAdapterRunByTeam.delete(input.request.teamName); this.aliveRunByTeam.delete(input.request.teamName); + this.invalidateRuntimeSnapshotCaches(input.request.teamName); } else { this.runtimeAdapterRunByTeam.set(input.request.teamName, { runId, @@ -14437,6 +14515,7 @@ export class TeamProvisioningService { members: result.members, }); this.aliveRunByTeam.set(input.request.teamName, runId); + this.invalidateRuntimeSnapshotCaches(input.request.teamName); } if (this.provisioningRunByTeam.get(input.request.teamName) === runId) { this.provisioningRunByTeam.delete(input.request.teamName); @@ -15399,6 +15478,7 @@ export class TeamProvisioningService { if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } + this.invalidateRuntimeSnapshotCaches(teamName); this.setRuntimeAdapterProgress({ ...runtimeProgress, state: 'cancelled', @@ -15478,6 +15558,7 @@ export class TeamProvisioningService { if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } + this.invalidateRuntimeSnapshotCaches(teamName); } private recordCancelledOpenCodeRuntimeAdapterLaunch( @@ -15490,6 +15571,7 @@ export class TeamProvisioningService { this.provisioningRunByTeam.delete(teamName); this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); const progress: TeamProvisioningProgress = { runId, teamName, @@ -17519,12 +17601,44 @@ export class TeamProvisioningService { private async getLiveTeamAgentRuntimeMetadata( teamName: string ): Promise> { + const runId = this.getTrackedRunId(teamName); const cached = this.liveTeamAgentRuntimeMetadataCache.get(teamName); - if (cached && cached.expiresAtMs > Date.now()) { - return cached.metadata; + if (cached && cached.expiresAtMs > Date.now() && cached.runId === runId) { + return this.cloneLiveTeamAgentRuntimeMetadata(cached.metadata); } - const runId = this.getTrackedRunId(teamName); + const generationAtStart = this.getRuntimeSnapshotCacheGeneration(teamName); + const existingRequest = this.liveTeamAgentRuntimeMetadataInFlightByTeam.get(teamName); + if ( + existingRequest && + existingRequest.generationAtStart === generationAtStart && + existingRequest.runIdAtStart === runId + ) { + return this.cloneLiveTeamAgentRuntimeMetadata(await existingRequest.promise); + } + + const request = this.buildLiveTeamAgentRuntimeMetadata( + teamName, + runId, + generationAtStart + ).finally(() => { + if (this.liveTeamAgentRuntimeMetadataInFlightByTeam.get(teamName)?.promise === request) { + this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); + } + }); + this.liveTeamAgentRuntimeMetadataInFlightByTeam.set(teamName, { + generationAtStart, + runIdAtStart: runId, + promise: request, + }); + return this.cloneLiveTeamAgentRuntimeMetadata(await request); + } + + private async buildLiveTeamAgentRuntimeMetadata( + teamName: string, + runId: string | null, + generationAtStart: number + ): Promise> { const run = runId ? (this.runs.get(runId) ?? null) : null; let configuredMembers: TeamConfig['members'] = []; @@ -17865,10 +17979,16 @@ export class TeamProvisioningService { }); } - this.liveTeamAgentRuntimeMetadataCache.set(teamName, { - expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, - metadata: metadataByMember, - }); + if ( + this.getRuntimeSnapshotCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === runId + ) { + this.liveTeamAgentRuntimeMetadataCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS, + metadata: this.cloneLiveTeamAgentRuntimeMetadata(metadataByMember), + runId, + }); + } return metadataByMember; } @@ -18884,14 +19004,12 @@ export class TeamProvisioningService { if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') { await this.clearPersistedLaunchStateNow(run.teamName); - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); return null; } const writtenSnapshot = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot); - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); return writtenSnapshot; } @@ -20772,8 +20890,7 @@ export class TeamProvisioningService { * Always uses SIGKILL via killTeamProcess() to prevent CLI cleanup. */ async stopTeam(teamName: string): Promise { - this.agentRuntimeSnapshotCache.delete(teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); this.stopPersistentTeamMembers(teamName); const runId = this.getTrackedRunId(teamName); @@ -21001,6 +21118,7 @@ export class TeamProvisioningService { this.runtimeAdapterRunByTeam.delete(teamName); this.aliveRunByTeam.delete(teamName); this.provisioningRunByTeam.delete(teamName); + this.invalidateRuntimeSnapshotCaches(teamName); return; } const startedAt = nowIso(); @@ -21019,6 +21137,7 @@ export class TeamProvisioningService { if (this.provisioningRunByTeam.get(teamName) === runId) { this.provisioningRunByTeam.delete(teamName); } + this.invalidateRuntimeSnapshotCaches(teamName); try { await clearOpenCodeRuntimeLaneStorage({ teamsBasePath: getTeamsBasePath(), @@ -21364,8 +21483,7 @@ export class TeamProvisioningService { ); return true; } - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); this.setMemberSpawnStatus(run, memberName, 'waiting'); this.appendMemberBootstrapDiagnostic( run, @@ -23760,8 +23878,7 @@ export class TeamProvisioningService { this.clearSecondaryRuntimeRuns(run.teamName); } if (!hasNewerTrackedRun) { - this.agentRuntimeSnapshotCache.delete(run.teamName); - this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName); + this.invalidateRuntimeSnapshotCaches(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); diff --git a/src/main/services/team/teamDataWorkerTypes.ts b/src/main/services/team/teamDataWorkerTypes.ts index 6b14860f..11a75219 100644 --- a/src/main/services/team/teamDataWorkerTypes.ts +++ b/src/main/services/team/teamDataWorkerTypes.ts @@ -56,6 +56,7 @@ export interface TeamDataWorkerDiag { // ── Request / Response ── export type TeamDataWorkerRequest = + | { id: string; op: 'warmup'; payload?: Record } | { id: string; op: 'getTeamData'; payload: GetTeamDataPayload } | { id: string; op: 'getMessagesPage'; payload: GetMessagesPagePayload } | { id: string; op: 'getMemberActivityMeta'; payload: GetMemberActivityMetaPayload } diff --git a/src/main/workers/team-data-worker.ts b/src/main/workers/team-data-worker.ts index 361685c4..931f26ec 100644 --- a/src/main/workers/team-data-worker.ts +++ b/src/main/workers/team-data-worker.ts @@ -39,12 +39,16 @@ parentPort?.on('message', async (msg: TeamDataWorkerRequest) => { const startedAt = Date.now(); const buildDiag = (): NonNullable['diag']> => ({ op: msg.op, - ...('teamName' in msg.payload ? { teamName: msg.payload.teamName } : {}), - ...('taskId' in msg.payload ? { taskId: msg.payload.taskId } : {}), + ...(msg.payload && 'teamName' in msg.payload ? { teamName: msg.payload.teamName } : {}), + ...(msg.payload && 'taskId' in msg.payload ? { taskId: msg.payload.taskId } : {}), totalMs: Date.now() - startedAt, }); try { switch (msg.op) { + case 'warmup': { + respond({ id: msg.id, ok: true, result: null, diag: buildDiag() }); + break; + } case 'getTeamData': { const result = await teamDataService.getTeamData(msg.payload.teamName); respond({ id: msg.id, ok: true, result, diag: buildDiag() }); diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index aa7f3bf6..b4eb215f 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -33,6 +33,7 @@ interface GetAllTasksPayload { } type WorkerRequest = + | { id: string; op: 'warmup'; payload?: Record } | { id: string; op: 'listTeams'; payload: ListTeamsPayload } | { id: string; op: 'getAllTasks'; payload: GetAllTasksPayload }; @@ -75,6 +76,10 @@ interface ListTeamsDiag { skipped: number; skipReasons: Record; slowest: SlowEntry[]; + cacheHits: number; + cacheMisses: number; + cacheWriteSkips: number; + cacheEvictions: number; totalMs: number; } @@ -87,12 +92,19 @@ interface GetAllTasksDiag { skipped: number; skipReasons: Record; slowestTeams: SlowEntry[]; + cacheHits: number; + cacheMisses: number; + cacheWriteSkips: number; + cacheEvictions: number; totalMs: number; } interface TaskReadDiag { skipped: number; skipReasons: Record; + cacheHits: number; + cacheMisses: number; + cacheWriteSkips: number; } const MAX_LAUNCH_STATE_BYTES = 32 * 1024; @@ -104,6 +116,60 @@ const REVIEW_LIFECYCLE_EVENTS = new Set([ 'review_started', ]); const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']); +const TEAM_SUMMARY_CACHE_MAX_ENTRIES = 1000; +const TASK_FILE_CACHE_MAX_ENTRIES = 10000; +const BOOTSTRAP_STATE_FILE = 'bootstrap-state.json'; +const BOOTSTRAP_JOURNAL_FILE = 'bootstrap-journal.jsonl'; + +interface PathFingerprint { + exists: boolean; + isFile?: boolean; + isDirectory?: boolean; + highResolution?: boolean; + size?: string; + mode?: string; + dev?: string; + ino?: string; + mtimeNs?: string; + ctimeNs?: string; + birthtimeNs?: string; + mtimeMs?: number; + ctimeMs?: number; + birthtimeMs?: number; + errorCode?: string; +} + +interface TeamSummaryCacheEntry { + fingerprint: string; + summary: Record; + teamsDir: string; + optionKey: string; + lastUsedAt: number; +} + +type CachedTaskReadResult = + | { task: Record; skipReason?: undefined } + | { task?: undefined; skipReason: string }; + +interface TaskFileCacheEntry { + fingerprint: string; + result: CachedTaskReadResult; + tasksBase: string; + lastUsedAt: number; +} + +const teamSummaryCache = new Map(); +const taskFileCache = new Map(); + +interface TeamSummaryDependencyFingerprint { + value: string; + cacheSafe: boolean; +} + +interface LaunchStateSummaryRead { + summary: ReturnType | null; + cacheable: boolean; +} // --------------------------------------------------------------------------- // Parsed JSON types (loose shapes from disk) @@ -272,6 +338,319 @@ function pushSlowest(list: SlowEntry[], entry: SlowEntry, maxLen: number): void if (list.length > maxLen) list.length = maxLen; } +function cloneCached(value: T): T { + return typeof structuredClone === 'function' + ? structuredClone(value) + : (JSON.parse(JSON.stringify(value)) as T); +} + +async function statPathFingerprint(filePath: string): Promise { + try { + const stat = await fs.promises.stat(filePath, { bigint: true }); + const mtimeNs = + typeof (stat as fs.BigIntStats & { mtimeNs?: bigint }).mtimeNs === 'bigint' + ? (stat as fs.BigIntStats & { mtimeNs: bigint }).mtimeNs + : undefined; + const ctimeNs = + typeof (stat as fs.BigIntStats & { ctimeNs?: bigint }).ctimeNs === 'bigint' + ? (stat as fs.BigIntStats & { ctimeNs: bigint }).ctimeNs + : undefined; + const birthtimeNs = + typeof (stat as fs.BigIntStats & { birthtimeNs?: bigint }).birthtimeNs === 'bigint' + ? (stat as fs.BigIntStats & { birthtimeNs: bigint }).birthtimeNs + : undefined; + return { + exists: true, + isFile: stat.isFile(), + isDirectory: stat.isDirectory(), + highResolution: typeof mtimeNs === 'bigint' && typeof ctimeNs === 'bigint', + size: stat.size.toString(), + mode: stat.mode.toString(), + dev: stat.dev.toString(), + ino: stat.ino.toString(), + mtimeNs: mtimeNs?.toString(), + ctimeNs: ctimeNs?.toString(), + birthtimeNs: birthtimeNs?.toString(), + mtimeMs: Number(stat.mtimeMs), + ctimeMs: Number(stat.ctimeMs), + birthtimeMs: Number(stat.birthtimeMs), + }; + } catch (error) { + return { + exists: false, + errorCode: + typeof (error as NodeJS.ErrnoException | undefined)?.code === 'string' + ? (error as NodeJS.ErrnoException).code + : undefined, + }; + } +} + +function fingerprintToString(value: unknown): string { + return JSON.stringify(value); +} + +function isCacheSafeFingerprint(fingerprint: PathFingerprint): boolean { + if (fingerprint.exists) { + return fingerprint.highResolution === true; + } + return fingerprint.errorCode === 'ENOENT' || fingerprint.errorCode === 'ENOTDIR'; +} + +function makeTeamSummaryOptionKey(payload: ListTeamsPayload): string { + return fingerprintToString({ + largeConfigBytes: payload.largeConfigBytes, + configHeadBytes: payload.configHeadBytes, + maxConfigBytes: payload.maxConfigBytes, + maxConfigReadMs: payload.maxConfigReadMs, + maxMembersMetaBytes: payload.maxMembersMetaBytes, + maxSessionHistoryInSummary: payload.maxSessionHistoryInSummary, + maxProjectPathHistoryInSummary: payload.maxProjectPathHistoryInSummary, + }); +} + +function makeTeamSummaryCacheKey(teamsDir: string, teamName: string, optionKey: string): string { + return `${teamsDir}\0${teamName}\0${optionKey}`; +} + +function canCacheTeamSummary(summary: Record): boolean { + if (summary.teamLaunchState === 'partial_pending') { + return false; + } + const pendingKeys = [ + 'pendingCount', + 'runtimeAlivePendingCount', + 'shellOnlyPendingCount', + 'runtimeProcessPendingCount', + 'runtimeCandidatePendingCount', + 'noRuntimePendingCount', + 'permissionPendingCount', + ]; + return pendingKeys.every((key) => { + const value = summary[key]; + return typeof value !== 'number' || value <= 0; + }); +} + +async function readInboxNamesFingerprint(inboxDir: string): Promise<{ + dir: PathFingerprint; + names: string[]; + cacheSafe: boolean; +}> { + const dir = await statPathFingerprint(inboxDir); + if (!dir.exists || !dir.isDirectory) { + return { dir, names: [], cacheSafe: isCacheSafeFingerprint(dir) }; + } + try { + const entries = await fs.promises.readdir(inboxDir, { withFileTypes: true }); + const names = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort(); + return { dir, names, cacheSafe: isCacheSafeFingerprint(dir) }; + } catch (error) { + return { + dir: { + ...dir, + errorCode: + typeof (error as NodeJS.ErrnoException | undefined)?.code === 'string' + ? (error as NodeJS.ErrnoException).code + : 'READDIR_FAILED', + }, + names: [], + cacheSafe: false, + }; + } +} + +async function buildTeamSummaryFingerprint( + teamsDir: string, + teamName: string, + optionKey: string +): Promise { + const teamDir = path.join(teamsDir, teamName); + const [ + config, + teamMeta, + membersMeta, + launchState, + launchSummary, + bootstrapState, + bootstrapJournal, + ] = await Promise.all([ + statPathFingerprint(path.join(teamDir, 'config.json')), + statPathFingerprint(path.join(teamDir, 'team.meta.json')), + statPathFingerprint(path.join(teamDir, 'members.meta.json')), + statPathFingerprint(path.join(teamDir, TEAM_LAUNCH_STATE_FILE)), + statPathFingerprint(path.join(teamDir, TEAM_LAUNCH_SUMMARY_FILE)), + statPathFingerprint(path.join(teamDir, BOOTSTRAP_STATE_FILE)), + statPathFingerprint(path.join(teamDir, BOOTSTRAP_JOURNAL_FILE)), + ]); + const inbox = await readInboxNamesFingerprint(path.join(teamDir, 'inboxes')); + + const dependencyFingerprint = { + version: 1, + optionKey, + config, + teamMeta, + membersMeta, + launchState, + launchSummary, + bootstrapState, + bootstrapJournal, + inbox, + }; + + return { + value: fingerprintToString(dependencyFingerprint), + cacheSafe: + [ + config, + teamMeta, + membersMeta, + launchState, + launchSummary, + bootstrapState, + bootstrapJournal, + ].every(isCacheSafeFingerprint) && inbox.cacheSafe, + }; +} + +async function cacheTeamSummaryIfStable( + cacheKey: string, + teamsDir: string, + teamName: string, + optionKey: string, + fingerprintBefore: TeamSummaryDependencyFingerprint, + summary: Record, + cacheAllowed: boolean, + diag: ListTeamsDiag +): Promise { + if (!cacheAllowed) { + teamSummaryCache.delete(cacheKey); + diag.cacheWriteSkips++; + return; + } + if (!canCacheTeamSummary(summary)) { + teamSummaryCache.delete(cacheKey); + diag.cacheWriteSkips++; + return; + } + if (!fingerprintBefore.cacheSafe) { + diag.cacheWriteSkips++; + return; + } + const fingerprintAfter = await buildTeamSummaryFingerprint(teamsDir, teamName, optionKey); + if (!fingerprintAfter.cacheSafe || fingerprintAfter.value !== fingerprintBefore.value) { + diag.cacheWriteSkips++; + return; + } + teamSummaryCache.set(cacheKey, { + fingerprint: fingerprintAfter.value, + summary: cloneCached(summary), + teamsDir, + optionKey, + lastUsedAt: nowMs(), + }); +} + +function pruneTeamSummaryCache( + teamsDir: string, + optionKey: string, + liveTeamNames: ReadonlySet, + diag: ListTeamsDiag +): void { + for (const [key, entry] of teamSummaryCache) { + if (entry.teamsDir === teamsDir && entry.optionKey === optionKey) { + const teamName = key.split('\0')[1] ?? ''; + if (!liveTeamNames.has(teamName)) { + teamSummaryCache.delete(key); + diag.cacheEvictions++; + } + } + } + while (teamSummaryCache.size > TEAM_SUMMARY_CACHE_MAX_ENTRIES) { + const oldest = teamSummaryCache.keys().next().value; + if (typeof oldest !== 'string') break; + teamSummaryCache.delete(oldest); + diag.cacheEvictions++; + } +} + +function makeTaskOptionKey(payload: GetAllTasksPayload): string { + return fingerprintToString({ + maxTaskBytes: payload.maxTaskBytes, + maxTaskReadMs: payload.maxTaskReadMs, + }); +} + +function makeTaskCacheKey( + tasksBase: string, + teamName: string, + fileName: string, + optionKey: string +): string { + return `${tasksBase}\0${teamName}\0${fileName}\0${optionKey}`; +} + +async function cacheTaskReadResultIfStable( + cacheKey: string, + taskPath: string, + tasksBase: string, + fingerprintBefore: string, + fingerprintBeforeCacheSafe: boolean, + result: CachedTaskReadResult, + taskDiag: TaskReadDiag +): Promise { + if (!fingerprintBeforeCacheSafe) { + taskDiag.cacheWriteSkips++; + return; + } + const after = await statPathFingerprint(taskPath); + if (!isCacheSafeFingerprint(after) || fingerprintToString(after) !== fingerprintBefore) { + taskDiag.cacheWriteSkips++; + return; + } + taskFileCache.set(cacheKey, { + fingerprint: fingerprintBefore, + result: cloneCached(result), + tasksBase, + lastUsedAt: nowMs(), + }); +} + +function applyCachedTaskReadResult( + cached: CachedTaskReadResult, + tasks: unknown[], + taskDiag: TaskReadDiag +): void { + if (cached.skipReason) { + taskDiag.skipped++; + bumpSkipReason(taskDiag.skipReasons, cached.skipReason); + return; + } + tasks.push(cloneCached(cached.task)); +} + +function pruneTaskFileCache( + tasksBase: string, + liveCacheKeys: ReadonlySet, + diag: GetAllTasksDiag +): void { + for (const [key, entry] of taskFileCache) { + if (entry.tasksBase === tasksBase && !liveCacheKeys.has(key)) { + taskFileCache.delete(key); + diag.cacheEvictions++; + } + } + while (taskFileCache.size > TASK_FILE_CACHE_MAX_ENTRIES) { + const oldest = taskFileCache.keys().next().value; + if (typeof oldest !== 'string') break; + taskFileCache.delete(oldest); + diag.cacheEvictions++; + } +} + // --------------------------------------------------------------------------- // listTeams // --------------------------------------------------------------------------- @@ -340,7 +719,7 @@ function dropCliProvisionerMembers( async function readLaunchState( teamsDir: string, teamName: string -): Promise> { +): Promise { const bootstrapSnapshot = await readBootstrapLaunchSnapshot(teamName); const launchStatePath = path.join(teamsDir, teamName, TEAM_LAUNCH_STATE_FILE); const launchSummaryPath = path.join(teamsDir, teamName, TEAM_LAUNCH_SUMMARY_FILE); @@ -371,11 +750,24 @@ async function readLaunchState( })(), ]); - return choosePreferredLaunchStateSummary({ + const summary = choosePreferredLaunchStateSummary({ bootstrapSnapshot, launchSnapshot, launchSummaryProjection, }); + if (launchSnapshot) { + return { summary, cacheable: true }; + } + if (!bootstrapSnapshot) { + return { summary, cacheable: true }; + } + if ( + bootstrapSnapshot.launchPhase === 'finished' && + bootstrapSnapshot.teamLaunchState !== 'partial_pending' + ) { + return { summary, cacheable: true }; + } + return { summary, cacheable: false }; } /** @@ -465,6 +857,10 @@ async function listTeams( skipped: 0, skipReasons: {}, slowest: [], + cacheHits: 0, + cacheMisses: 0, + cacheWriteSkips: 0, + cacheEvictions: 0, totalMs: 0, }; @@ -478,11 +874,26 @@ async function listTeams( const teamDirs = entries.filter((e) => e.isDirectory()); diag.totalDirs = teamDirs.length; + const optionKey = makeTeamSummaryOptionKey(payload); + const liveTeamNames = new Set(teamDirs.map((entry) => entry.name)); 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 cacheKey = makeTeamSummaryCacheKey(payload.teamsDir, teamName, optionKey); + const dependencyFingerprint = await buildTeamSummaryFingerprint( + payload.teamsDir, + teamName, + optionKey + ); + const cached = teamSummaryCache.get(cacheKey); + if (dependencyFingerprint.cacheSafe && cached?.fingerprint === dependencyFingerprint.value) { + cached.lastUsedAt = nowMs(); + diag.cacheHits++; + return cloneCached(cached.summary); + } + diag.cacheMisses++; const skip = (reason: string): null => { diag.skipped++; @@ -496,12 +907,36 @@ async function listTeams( } catch { // Fallback: check for draft team (team.meta.json without config.json) const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload); - if (draft) return draft; + if (draft) { + await cacheTeamSummaryIfStable( + cacheKey, + payload.teamsDir, + teamName, + optionKey, + dependencyFingerprint, + draft, + true, + diag + ); + return draft; + } return skip('config_stat_failed'); } if (!stat.isFile()) { const draft = await readDraftTeamMeta(payload.teamsDir, teamName, payload); - if (draft) return draft; + if (draft) { + await cacheTeamSummaryIfStable( + cacheKey, + payload.teamsDir, + teamName, + optionKey, + dependencyFingerprint, + draft, + true, + diag + ); + return draft; + } return skip('config_not_file'); } if (stat.size > payload.maxConfigBytes) return skip('config_too_large'); @@ -692,32 +1127,28 @@ async function listTeams( leadProviderId, members: metaRuntimeMembers, }); - const launchStateSummary = - (await readLaunchState(payload.teamsDir, teamName)) ?? - (() => { - if (suppressLegacyLaunchArtifactHeuristic) { - return null; - } - if ( - !leadSessionId || - expectedTeammateNames.size === 0 || - confirmedArtifactNames.size === 0 - ) { - return null; - } - const missingMembers = Array.from(expectedTeammateNames).filter( - (name) => !confirmedArtifactNames.has(name) - ); - if (missingMembers.length === 0) { - return null; - } - return { - partialLaunchFailure: true as const, - expectedMemberCount: expectedTeammateNames.size, - confirmedMemberCount: confirmedArtifactNames.size, - missingMembers, - }; - })(); + const launchStateRead = await readLaunchState(payload.teamsDir, teamName); + const fallbackLaunchStateSummary = (): ReturnType => { + if (suppressLegacyLaunchArtifactHeuristic) { + return null; + } + if (!leadSessionId || expectedTeammateNames.size === 0 || confirmedArtifactNames.size === 0) { + return null; + } + const missingMembers = Array.from(expectedTeammateNames).filter( + (name) => !confirmedArtifactNames.has(name) + ); + if (missingMembers.length === 0) { + return null; + } + return { + partialLaunchFailure: true as const, + expectedMemberCount: expectedTeammateNames.size, + confirmedMemberCount: confirmedArtifactNames.size, + missingMembers, + }; + }; + const launchStateSummary = launchStateRead.summary ?? fallbackLaunchStateSummary(); const summary = { teamName, displayName, @@ -741,10 +1172,21 @@ async function listTeams( if (ms >= 250) { pushSlowest(diag.slowest, { teamName, ms }, 10); } + await cacheTeamSummaryIfStable( + cacheKey, + payload.teamsDir, + teamName, + optionKey, + dependencyFingerprint, + summary, + launchStateRead.cacheable, + diag + ); return summary; }); const teams = perTeam.filter((t): t is NonNullable => t !== null); + pruneTeamSummaryCache(payload.teamsDir, optionKey, liveTeamNames, diag); diag.returned = teams.length; diag.totalMs = nowMs() - startedAt; return { teams, diag }; @@ -880,19 +1322,27 @@ async function readTasksDirForTeam( tasksDir: string, teamName: string, payload: GetAllTasksPayload -): Promise<{ tasks: unknown[]; taskDiag: TaskReadDiag }> { - const taskDiag: TaskReadDiag = { skipped: 0, skipReasons: {} }; +): Promise<{ tasks: unknown[]; taskDiag: TaskReadDiag; liveCacheKeys: Set }> { + const taskDiag: TaskReadDiag = { + skipped: 0, + skipReasons: {}, + cacheHits: 0, + cacheMisses: 0, + cacheWriteSkips: 0, + }; let entries: string[]; try { entries = await fs.promises.readdir(tasksDir); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return { tasks: [], taskDiag }; + return { tasks: [], taskDiag, liveCacheKeys: new Set() }; } throw error; } const tasks: unknown[] = []; + const liveCacheKeys = new Set(); + const optionKey = makeTaskOptionKey(payload); for (const file of entries) { if ( !file.endsWith('.json') || @@ -904,25 +1354,61 @@ async function readTasksDirForTeam( } const taskPath = path.join(tasksDir, file); + const cacheKey = makeTaskCacheKey(payload.tasksBase, teamName, file, optionKey); + liveCacheKeys.add(cacheKey); try { - const stat = await fs.promises.stat(taskPath); - if (!stat.isFile() || stat.size > payload.maxTaskBytes) { + const pathFingerprint = await statPathFingerprint(taskPath); + const taskSize = Number(pathFingerprint.size ?? Number.NaN); + if ( + !pathFingerprint.isFile || + !Number.isFinite(taskSize) || + taskSize > payload.maxTaskBytes + ) { taskDiag.skipped++; bumpSkipReason(taskDiag.skipReasons, 'task_not_file_or_large'); continue; } + const fingerprint = fingerprintToString(pathFingerprint); + const fingerprintCacheSafe = isCacheSafeFingerprint(pathFingerprint); + const cached = taskFileCache.get(cacheKey); + if (fingerprintCacheSafe && cached?.fingerprint === fingerprint) { + cached.lastUsedAt = nowMs(); + taskDiag.cacheHits++; + applyCachedTaskReadResult(cached.result, tasks, taskDiag); + continue; + } + taskDiag.cacheMisses++; + const stat = await fs.promises.stat(taskPath); const raw = await readFileUtf8WithTimeout(taskPath, payload.maxTaskReadMs); const parsed = JSON.parse(raw) as ParsedTask; const metadata = parsed.metadata; if (metadata?._internal === true) { taskDiag.skipped++; bumpSkipReason(taskDiag.skipReasons, 'task_internal'); + await cacheTaskReadResultIfStable( + cacheKey, + taskPath, + payload.tasksBase, + fingerprint, + fingerprintCacheSafe, + { skipReason: 'task_internal' }, + taskDiag + ); continue; } if (parsed.status === 'deleted') { taskDiag.skipped++; bumpSkipReason(taskDiag.skipReasons, 'task_deleted'); + await cacheTaskReadResultIfStable( + cacheKey, + taskPath, + payload.tasksBase, + fingerprint, + fingerprintCacheSafe, + { skipReason: 'task_deleted' }, + taskDiag + ); continue; } @@ -962,7 +1448,7 @@ async function readTasksDirForTeam( deriveReviewStateFromEvents(historyEvents) ?? normalizeFallbackReviewState(parsed.reviewState, status); - tasks.push({ + const task = { id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '', displayId: typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0 @@ -1018,7 +1504,17 @@ async function readTasksDirForTeam( ? (parsed.sourceMessage as Record) : undefined, teamName, - }); + }; + tasks.push(task); + await cacheTaskReadResultIfStable( + cacheKey, + taskPath, + payload.tasksBase, + fingerprint, + fingerprintCacheSafe, + { task }, + taskDiag + ); } catch (error) { taskDiag.skipped++; const code = (error as NodeJS.ErrnoException).code; @@ -1029,11 +1525,14 @@ async function readTasksDirForTeam( } } } - return { tasks, taskDiag }; + return { tasks, taskDiag, liveCacheKeys }; } function mergeTaskDiag(target: GetAllTasksDiag, source: TaskReadDiag): void { target.skipped += source.skipped; + target.cacheHits += source.cacheHits; + target.cacheMisses += source.cacheMisses; + target.cacheWriteSkips += source.cacheWriteSkips; for (const [reason, count] of Object.entries(source.skipReasons)) { target.skipReasons[reason] = (target.skipReasons[reason] || 0) + count; } @@ -1052,6 +1551,10 @@ async function getAllTasks( skipped: 0, skipReasons: {}, slowestTeams: [], + cacheHits: 0, + cacheMisses: 0, + cacheWriteSkips: 0, + cacheEvictions: 0, totalMs: 0, }; @@ -1068,13 +1571,21 @@ async function getAllTasks( const dirs = entries.filter((e) => e.isDirectory()); diag.teamDirs = dirs.length; + const liveCacheKeys = new Set(); 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, taskDiag } = await readTasksDirForTeam(tasksDir, teamName, payload); + const { + tasks, + taskDiag, + liveCacheKeys: teamLiveCacheKeys, + } = await readTasksDirForTeam(tasksDir, teamName, payload); + for (const key of teamLiveCacheKeys) { + liveCacheKeys.add(key); + } mergeTaskDiag(diag, taskDiag); const ms = nowMs() - t0; if (ms >= 250) { @@ -1089,6 +1600,7 @@ async function getAllTasks( }); const tasks = chunks.flat(); + pruneTaskFileCache(payload.tasksBase, liveCacheKeys, diag); diag.returned = tasks.length; diag.totalMs = nowMs() - startedAt; return { tasks, diag }; @@ -1105,6 +1617,19 @@ function post(msg: WorkerResponse): void { parentPort?.on('message', async (msg: WorkerRequest) => { const { id, op } = msg; try { + if (op === 'warmup') { + post({ + id, + ok: true, + result: { + ready: true, + teamSummaryCacheEntries: teamSummaryCache.size, + taskFileCacheEntries: taskFileCache.size, + }, + diag: { op, totalMs: 0 }, + }); + return; + } if (op === 'listTeams') { const { teams, diag } = await listTeams(msg.payload); post({ id, ok: true, result: teams, diag }); diff --git a/test/main/services/team/TeamDataWorkerClient.test.ts b/test/main/services/team/TeamDataWorkerClient.test.ts index fac20503..a3cd518a 100644 --- a/test/main/services/team/TeamDataWorkerClient.test.ts +++ b/test/main/services/team/TeamDataWorkerClient.test.ts @@ -93,6 +93,25 @@ describe('TeamDataWorkerClient', () => { client.dispose(); }); + it('does not queue warmup behind an already running worker', async () => { + const { TeamDataWorkerClient } = await import( + '../../../../src/main/services/team/TeamDataWorkerClient' + ); + const client = new TeamDataWorkerClient(); + + await client.getTeamData('my-team'); + await client.prewarm(); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'getTeamData', + payload: { teamName: 'my-team' }, + }); + + client.dispose(); + }); + it('sends best-effort team config invalidation to the worker', async () => { const { TeamDataWorkerClient } = await import( '../../../../src/main/services/team/TeamDataWorkerClient' diff --git a/test/main/services/team/TeamFsWorker.integration.test.ts b/test/main/services/team/TeamFsWorker.integration.test.ts index 5cacefb2..e2085b93 100644 --- a/test/main/services/team/TeamFsWorker.integration.test.ts +++ b/test/main/services/team/TeamFsWorker.integration.test.ts @@ -11,6 +11,7 @@ interface WorkerResponse { id: string; ok: boolean; result?: unknown; + diag?: unknown; error?: string; } @@ -44,7 +45,11 @@ function createWorker(workerPath: string): Worker { return new Worker(workerPath); } -function callListTeams(worker: Worker, teamsDir: string): Promise { +function callWorker( + worker: Worker, + op: string, + payload: Record = {} +): Promise<{ result: unknown; diag?: unknown }> { const requestId = `req-${Date.now()}`; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -72,29 +77,56 @@ function callListTeams(worker: Worker, teamsDir: string): Promise { reject(new Error(message.error || 'team-fs-worker returned an unknown error')); return; } - resolve(Array.isArray(message.result) ? message.result : []); + resolve({ result: message.result, diag: message.diag }); }; worker.on('message', onMessage); worker.on('error', onError); - worker.postMessage({ - id: requestId, - op: 'listTeams', - payload: { - teamsDir, - largeConfigBytes: 8 * 1024, - configHeadBytes: 4 * 1024, - maxConfigBytes: 256 * 1024, - maxConfigReadMs: 5_000, - maxMembersMetaBytes: 256 * 1024, - maxSessionHistoryInSummary: 10, - maxProjectPathHistoryInSummary: 10, - concurrency: 2, - }, - }); + worker.postMessage({ id: requestId, op, payload }); }); } +async function callListTeams(worker: Worker, teamsDir: string): Promise<{ + teams: unknown[]; + diag?: Record; +}> { + const { result, diag } = await callWorker(worker, 'listTeams', { + teamsDir, + largeConfigBytes: 8 * 1024, + configHeadBytes: 4 * 1024, + maxConfigBytes: 256 * 1024, + maxConfigReadMs: 5_000, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: 10, + maxProjectPathHistoryInSummary: 10, + concurrency: 2, + }); + return { + teams: Array.isArray(result) ? result : [], + diag: diag && typeof diag === 'object' ? (diag as Record) : undefined, + }; +} + +async function callGetAllTasks(worker: Worker, tasksBase: string): Promise<{ + tasks: unknown[]; + diag?: Record; +}> { + const { result, diag } = await callWorker(worker, 'getAllTasks', { + tasksBase, + maxTaskBytes: 256 * 1024, + maxTaskReadMs: 5_000, + concurrency: 2, + }); + return { + tasks: Array.isArray(result) ? result : [], + diag: diag && typeof diag === 'object' ? (diag as Record) : undefined, + }; +} + +async function callWarmup(worker: Worker): Promise { + await callWorker(worker, 'warmup'); +} + describe('team-fs-worker integration', () => { let tempDir = ''; @@ -183,7 +215,7 @@ describe('team-fs-worker integration', () => { const worker = createWorker(workerPath); try { - const teams = (await callListTeams(worker, tempDir)) as Array>; + const { teams } = await callListTeams(worker, tempDir); expect(teams).toHaveLength(1); expect(teams[0]).toMatchObject({ teamName, @@ -234,7 +266,7 @@ describe('team-fs-worker integration', () => { const worker = createWorker(workerPath); try { - const teams = (await callListTeams(worker, tempDir)) as Array>; + const { teams } = await callListTeams(worker, tempDir); expect(teams).toHaveLength(1); expect(teams[0]).toMatchObject({ teamName, @@ -247,4 +279,150 @@ describe('team-fs-worker integration', () => { await worker.terminate(); } }); + + it('prewarms and reuses unchanged team summaries by fingerprint', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'cached-worker-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(path.join(teamDir, 'inboxes'), { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Cached Worker Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ version: 1, members: [{ name: 'alice' }] }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + await callWarmup(worker); + const first = await callListTeams(worker, tempDir); + expect(first.teams[0]).toMatchObject({ teamName, memberCount: 1 }); + expect(first.diag?.cacheMisses).toBe(1); + + const second = await callListTeams(worker, tempDir); + expect(second.teams[0]).toMatchObject({ teamName, memberCount: 1 }); + expect(second.diag?.cacheHits).toBe(1); + + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + JSON.stringify({ version: 1, members: [{ name: 'alice' }, { name: 'bob' }] }), + 'utf8' + ); + const changed = await callListTeams(worker, tempDir); + expect(changed.teams[0]).toMatchObject({ teamName, memberCount: 2 }); + expect(changed.diag?.cacheMisses).toBe(1); + } finally { + await worker.terminate(); + } + }); + + it('does not cache pending launch summaries because liveness can change without file writes', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const teamName = 'pending-launch-team'; + const teamDir = path.join(tempDir, teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: 'Pending Launch Team', + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }), + 'utf8' + ); + await fs.writeFile( + path.join(teamDir, 'launch-summary.json'), + JSON.stringify({ + version: 1, + teamName, + updatedAt: '2026-05-02T12:00:00.000Z', + teamLaunchState: 'partial_pending', + expectedMemberCount: 1, + pendingCount: 1, + }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + const first = await callListTeams(worker, tempDir); + expect(first.teams[0]).toMatchObject({ + teamName, + teamLaunchState: 'partial_pending', + pendingCount: 1, + }); + expect(first.diag?.cacheMisses).toBe(1); + expect(first.diag?.cacheWriteSkips).toBe(1); + + const second = await callListTeams(worker, tempDir); + expect(second.teams[0]).toMatchObject({ + teamName, + teamLaunchState: 'partial_pending', + pendingCount: 1, + }); + expect(second.diag?.cacheHits).toBe(0); + expect(second.diag?.cacheMisses).toBe(1); + expect(second.diag?.cacheWriteSkips).toBe(1); + } finally { + await worker.terminate(); + } + }); + + it('reuses unchanged parsed tasks and rereads changed task files by fingerprint', async () => { + const workerPath = await getWorkerPath(); + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); + const tasksBase = path.join(tempDir, 'tasks'); + const teamName = 'task-cache-team'; + const tasksDir = path.join(tasksBase, teamName); + await fs.mkdir(tasksDir, { recursive: true }); + const taskPath = path.join(tasksDir, '1.json'); + await fs.writeFile( + taskPath, + JSON.stringify({ + id: '1', + subject: 'First subject', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + }), + 'utf8' + ); + + const worker = createWorker(workerPath); + try { + const first = await callGetAllTasks(worker, tasksBase); + expect(first.tasks[0]).toMatchObject({ teamName, subject: 'First subject' }); + expect(first.diag?.cacheMisses).toBe(1); + + const second = await callGetAllTasks(worker, tasksBase); + expect(second.tasks[0]).toMatchObject({ teamName, subject: 'First subject' }); + expect(second.diag?.cacheHits).toBe(1); + + await fs.writeFile( + taskPath, + JSON.stringify({ + id: '1', + subject: 'Changed subject with a different size', + status: 'pending', + createdAt: '2026-05-02T12:00:00.000Z', + }), + 'utf8' + ); + const changed = await callGetAllTasks(worker, tasksBase); + expect(changed.tasks[0]).toMatchObject({ + teamName, + subject: 'Changed subject with a different size', + }); + expect(changed.diag?.cacheMisses).toBe(1); + } finally { + await worker.terminate(); + } + }); }); diff --git a/test/main/services/team/TeamFsWorkerClient.test.ts b/test/main/services/team/TeamFsWorkerClient.test.ts new file mode 100644 index 00000000..48a3a9f9 --- /dev/null +++ b/test/main/services/team/TeamFsWorkerClient.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => { + const skipResponsesForOps = new Set(); + const workers: Array<{ + messages: unknown[]; + handlers: Map void>; + postMessage: (message: unknown) => void; + on: (event: string, handler: (value: unknown) => void) => void; + terminate: ReturnType; + }> = []; + const createMockWorker = vi.fn().mockImplementation(() => { + const worker = { + messages: [] as unknown[], + handlers: new Map void>(), + postMessage(message: unknown) { + worker.messages.push(message); + const request = message as { id: string; op: string }; + if (skipResponsesForOps.has(request.op)) return; + queueMicrotask(() => { + const handler = worker.handlers.get('message'); + if (!handler) return; + handler({ + id: request.id, + ok: true, + result: request.op === 'listTeams' || request.op === 'getAllTasks' ? [] : null, + diag: { op: request.op, totalMs: 0 }, + }); + }); + }, + on(event: string, handler: (value: unknown) => void) { + worker.handlers.set(event, handler); + }, + terminate: vi.fn(async () => undefined), + }; + workers.push(worker); + return worker; + }); + return { + workers, + createMockWorker, + skipResponsesForOps, + }; +}); + +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + existsSync: vi.fn(() => true), + }; +}); + +vi.mock('node:worker_threads', () => ({ + Worker: hoisted.createMockWorker, + default: { + Worker: hoisted.createMockWorker, + }, +})); + +describe('TeamFsWorkerClient', () => { + afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.useRealTimers(); + hoisted.workers.length = 0; + hoisted.skipResponsesForOps.clear(); + }); + + it('prewarms the worker without running a scan', async () => { + const { TeamFsWorkerClient } = await import( + '../../../../src/main/services/team/TeamFsWorkerClient' + ); + const client = new TeamFsWorkerClient(); + + await client.prewarm(); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'warmup', + payload: {}, + }); + }); + + it('does not queue warmup behind an already running worker', async () => { + const { TeamFsWorkerClient } = await import( + '../../../../src/main/services/team/TeamFsWorkerClient' + ); + const client = new TeamFsWorkerClient(); + + await client.listTeams({ + largeConfigBytes: 8 * 1024, + configHeadBytes: 4 * 1024, + maxConfigBytes: 256 * 1024, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: 10, + maxProjectPathHistoryInSummary: 10, + }); + await client.prewarm(); + + expect(hoisted.workers).toHaveLength(1); + expect(hoisted.workers[0].messages).toHaveLength(1); + expect(hoisted.workers[0].messages[0]).toMatchObject({ + op: 'listTeams', + }); + }); + + it('ignores stale worker exit after timeout when a replacement worker owns pending work', async () => { + vi.useFakeTimers(); + hoisted.skipResponsesForOps.add('warmup'); + hoisted.skipResponsesForOps.add('listTeams'); + const { TeamFsWorkerClient } = await import( + '../../../../src/main/services/team/TeamFsWorkerClient' + ); + const client = new TeamFsWorkerClient(); + + const prewarmResult = client.prewarm().catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(20_001); + const prewarmError = await prewarmResult; + expect(prewarmError).toBeInstanceOf(Error); + expect((prewarmError as Error).message).toContain('Worker call timeout'); + expect(hoisted.workers).toHaveLength(1); + + const listPromise = client.listTeams({ + largeConfigBytes: 8 * 1024, + configHeadBytes: 4 * 1024, + maxConfigBytes: 256 * 1024, + maxMembersMetaBytes: 256 * 1024, + maxSessionHistoryInSummary: 10, + maxProjectPathHistoryInSummary: 10, + }); + + expect(hoisted.workers).toHaveLength(2); + const staleWorker = hoisted.workers[0]; + const replacementWorker = hoisted.workers[1]; + const listRequest = replacementWorker.messages[0] as { id: string }; + + staleWorker.handlers.get('exit')?.(1); + replacementWorker.handlers.get('message')?.({ + id: listRequest.id, + ok: true, + result: [{ teamName: 'fresh-team', displayName: 'Fresh Team' }], + diag: { op: 'listTeams', totalMs: 1 }, + }); + + await expect(listPromise).resolves.toEqual({ + teams: [{ teamName: 'fresh-team', displayName: 'Fresh Team' }], + diag: { op: 'listTeams', totalMs: 1 }, + }); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e0f21a28..02a791b4 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -206,6 +206,16 @@ function createPidusageStat(pid: number, memory: number) { }; } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + function writeLaunchConfig( teamName: string, projectPath: string, @@ -619,6 +629,182 @@ describe('TeamProvisioningService', () => { }); describe('getTeamAgentRuntimeSnapshot', () => { + it('dedupes concurrent runtime snapshot probes for the same team', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@runtime-team', + tmuxPaneId: '%1', + backendType: 'tmux', + }, + ]); + (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); + (svc as any).runs.set('run-1', { + runId: 'run-1', + child: { pid: 111 }, + request: { model: 'gpt-5.4' }, + processKilled: false, + cancelRequested: false, + spawnContext: null, + }); + const paneInfo = createDeferred>(); + vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReturnValueOnce( + paneInfo.promise as ReturnType + ); + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + '222': createPidusageStat(222, 456_000_000), + } as any); + + const first = svc.getTeamAgentRuntimeSnapshot('runtime-team'); + const second = svc.getTeamAgentRuntimeSnapshot('runtime-team'); + paneInfo.resolve( + new Map([ + [ + '%1', + { + paneId: '%1', + panePid: 222, + }, + ], + ]) + ); + const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]); + + expect(listTmuxPaneRuntimeInfoForCurrentPlatform).toHaveBeenCalledTimes(1); + expect(pidusage).toHaveBeenCalledTimes(1); + expect(firstSnapshot.members.alice?.pid).toBe(222); + expect(secondSnapshot.members.alice?.pid).toBe(222); + }); + + it('does not cache live runtime metadata when invalidated while the probe is in flight', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + const processRows = createDeferred>>(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) + .mockReturnValueOnce(processRows.promise) + .mockResolvedValueOnce([]); + + const first = (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team') as Promise< + Map + >; + (svc as any).invalidateRuntimeSnapshotCaches('runtime-team'); + processRows.resolve([]); + await first; + + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2); + }); + + it('returns cloned live runtime metadata maps from cache', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([]); + + const first = (await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team')) as Map< + string, + unknown + >; + expect(first.has('alice')).toBe(true); + first.delete('alice'); + + const second = (await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team')) as Map< + string, + unknown + >; + + expect(second.has('alice')).toBe(true); + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(1); + }); + + it('clears runtime probe caches when starting a new run for the team', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + (svc as any).resetTeamScopedTransientStateForNewRun('runtime-team'); + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2); + }); + + it('does not cache a probe that started before runtime adapter evidence was installed', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', providerId: 'opencode', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).provisioningRunByTeam.set('runtime-team', 'run-1'); + const processRows = createDeferred>>(); + vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) + .mockReturnValueOnce(processRows.promise) + .mockResolvedValueOnce([]); + + const first = (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team') as Promise< + Map + >; + (svc as any).runtimeAdapterRunByTeam.set('runtime-team', { + runId: 'run-1', + providerId: 'opencode', + cwd: '/tmp/runtime-project', + members: { + alice: { + providerId: 'opencode', + runtimeAlive: true, + bootstrapConfirmed: false, + runtimePid: 333, + livenessKind: 'runtime_process', + pidSource: 'agent_process_table', + }, + }, + }); + (svc as any).invalidateRuntimeSnapshotCaches('runtime-team'); + processRows.resolve([]); + await first; + + await (svc as any).getLiveTeamAgentRuntimeMetadata('runtime-team'); + + expect(listRuntimeProcessesForCurrentTmuxPlatform).toHaveBeenCalledTimes(2); + }); + it('uses batched pidusage rss values for lead and teammates', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { From ad7c4e24ad832bd69d33986f55188c776a41f78f Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 2 May 2026 23:55:09 +0300 Subject: [PATCH 18/51] perf(team): reduce launch status IO churn --- .../services/team/TeamProvisioningService.ts | 241 +++++++++++- .../team/TeamProvisioningService.test.ts | 363 +++++++++++++++++- 2 files changed, 589 insertions(+), 15 deletions(-) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6374cb2e..20ba67b2 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -313,6 +313,11 @@ interface OpenCodeRuntimeControlAck { observedAt: string; } +interface LaunchStateWriteResult { + snapshot: PersistedTeamLaunchSnapshot; + wrote: boolean; +} + type BootstrapTranscriptOutcome = | { kind: 'success'; @@ -336,6 +341,7 @@ import type { LeadContextUsage, MemberLaunchState, MemberSpawnLivenessSource, + MemberSpawnStatusesSnapshot, MemberSpawnStatus, MemberSpawnStatusEntry, PersistedTeamLaunchMemberState, @@ -4505,6 +4511,8 @@ export class TeamProvisioningService { private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000; private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_000; private static readonly AGENT_RUNTIME_SNAPSHOT_CACHE_TTL_MS = 2_000; + private static readonly MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS = 500; + private static readonly LAUNCH_STATE_NOOP_REFRESH_MS = 15_000; private readonly runs = new Map(); private readonly provisioningRunByTeam = new Map(); @@ -4590,8 +4598,27 @@ export class TeamProvisioningService { } >(); private readonly runtimeSnapshotCacheGenerationByTeam = new Map(); + private readonly memberSpawnStatusesSnapshotCache = new Map< + string, + { + expiresAtMs: number; + generation: number; + runId: string; + snapshot: MemberSpawnStatusesSnapshot; + } + >(); + private readonly memberSpawnStatusesInFlightByTeam = new Map< + string, + { + generationAtStart: number; + runIdAtStart: string; + promise: Promise; + } + >(); + private readonly memberSpawnStatusesCacheGenerationByTeam = new Map(); private readonly launchStateStore = new TeamLaunchStateStore(); private readonly launchStateStoreQueue = new Map>(); + private readonly launchStateWrittenRunIdByTeam = new Map(); private readonly memberLogsFinder: TeamMemberLogsFinder; private readonly transcriptProjectResolver: TeamTranscriptProjectResolver; private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null; @@ -4676,6 +4703,19 @@ export class TeamProvisioningService { return this.runtimeSnapshotCacheGenerationByTeam.get(teamName) ?? 0; } + private getMemberSpawnStatusesCacheGeneration(teamName: string): number { + return this.memberSpawnStatusesCacheGenerationByTeam.get(teamName) ?? 0; + } + + private invalidateMemberSpawnStatusesCache(teamName: string): void { + this.memberSpawnStatusesCacheGenerationByTeam.set( + teamName, + this.getMemberSpawnStatusesCacheGeneration(teamName) + 1 + ); + this.memberSpawnStatusesSnapshotCache.delete(teamName); + this.memberSpawnStatusesInFlightByTeam.delete(teamName); + } + private invalidateRuntimeSnapshotCaches(teamName: string): void { this.runtimeSnapshotCacheGenerationByTeam.set( teamName, @@ -4687,6 +4727,27 @@ export class TeamProvisioningService { this.liveTeamAgentRuntimeMetadataInFlightByTeam.delete(teamName); } + private cloneMemberSpawnStatusesSnapshot( + snapshot: MemberSpawnStatusesSnapshot + ): MemberSpawnStatusesSnapshot { + return { + ...snapshot, + statuses: Object.fromEntries( + Object.entries(snapshot.statuses).map(([memberName, entry]) => [ + memberName, + { + ...entry, + ...(entry.pendingPermissionRequestIds + ? { pendingPermissionRequestIds: [...entry.pendingPermissionRequestIds] } + : {}), + }, + ]) + ), + ...(snapshot.expectedMembers ? { expectedMembers: [...snapshot.expectedMembers] } : {}), + ...(snapshot.summary ? { summary: { ...snapshot.summary } } : {}), + }; + } + private cloneLiveTeamAgentRuntimeMetadata( metadata: ReadonlyMap ): Map { @@ -10409,6 +10470,68 @@ export class TeamProvisioningService { return readPersistedStatuses(runId); } + if (!this.shouldCacheMemberSpawnStatusesSnapshot(run)) { + return this.buildMemberSpawnStatusesSnapshotForRun(run); + } + + const generationAtStart = this.getMemberSpawnStatusesCacheGeneration(teamName); + const cached = this.memberSpawnStatusesSnapshotCache.get(teamName); + if ( + cached && + cached.expiresAtMs > Date.now() && + cached.runId === run.runId && + cached.generation === generationAtStart + ) { + return this.cloneMemberSpawnStatusesSnapshot(cached.snapshot); + } + + const existingRequest = this.memberSpawnStatusesInFlightByTeam.get(teamName); + if ( + existingRequest && + existingRequest.generationAtStart === generationAtStart && + existingRequest.runIdAtStart === run.runId + ) { + const snapshot = await existingRequest.promise; + if ( + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + return this.cloneMemberSpawnStatusesSnapshot(snapshot); + } + return this.getMemberSpawnStatuses(teamName); + } + + const request = this.buildMemberSpawnStatusesSnapshotForRun(run, generationAtStart).finally( + () => { + if (this.memberSpawnStatusesInFlightByTeam.get(teamName)?.promise === request) { + this.memberSpawnStatusesInFlightByTeam.delete(teamName); + } + } + ); + this.memberSpawnStatusesInFlightByTeam.set(teamName, { + generationAtStart, + runIdAtStart: run.runId, + promise: request, + }); + const snapshot = await request; + if ( + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + return this.cloneMemberSpawnStatusesSnapshot(snapshot); + } + return this.getMemberSpawnStatuses(teamName); + } + + private shouldCacheMemberSpawnStatusesSnapshot(run: ProvisioningRun): boolean { + return run.isLaunch === true && run.provisioningComplete !== true; + } + + private async buildMemberSpawnStatusesSnapshotForRun( + run: ProvisioningRun, + generationAtStart?: number + ): Promise { + const teamName = run.teamName; await this.refreshMemberSpawnStatusesFromLeadInbox(run); await this.maybeAuditMemberSpawnStatuses(run); await this.persistLaunchStateSnapshot(run, run.provisioningComplete ? 'finished' : 'active'); @@ -10428,23 +10551,37 @@ export class TeamProvisioningService { }); const rawSnapshot = liveSnapshot ?? persisted; const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); - const snapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers); + const launchSnapshot = this.filterRemovedMembersFromLaunchSnapshot(rawSnapshot, metaMembers); const statuses = await this.attachLiveRuntimeMetadataToStatuses( teamName, - snapshotToMemberSpawnStatuses(snapshot) + snapshotToMemberSpawnStatuses(launchSnapshot) ); - const expectedMembers = this.getPersistedLaunchMemberNames(snapshot); + const expectedMembers = this.getPersistedLaunchMemberNames(launchSnapshot); const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses); - return { + const spawnSnapshot: MemberSpawnStatusesSnapshot = { statuses, - runId, + runId: run.runId, teamLaunchState: deriveTeamLaunchAggregateState(summary), - launchPhase: snapshot.launchPhase, + launchPhase: launchSnapshot.launchPhase, expectedMembers, - updatedAt: snapshot.updatedAt, + updatedAt: launchSnapshot.updatedAt, summary, source: persisted ? 'merged' : 'live', }; + if ( + generationAtStart != null && + this.shouldCacheMemberSpawnStatusesSnapshot(run) && + this.getMemberSpawnStatusesCacheGeneration(teamName) === generationAtStart && + this.getTrackedRunId(teamName) === run.runId + ) { + this.memberSpawnStatusesSnapshotCache.set(teamName, { + expiresAtMs: Date.now() + TeamProvisioningService.MEMBER_SPAWN_STATUS_SNAPSHOT_CACHE_TTL_MS, + generation: generationAtStart, + runId: run.runId, + snapshot: this.cloneMemberSpawnStatusesSnapshot(spawnSnapshot), + }); + } + return spawnSnapshot; } async getTeamAgentRuntimeSnapshot(teamName: string): Promise { @@ -18041,6 +18178,7 @@ export class TeamProvisioningService { private async clearPersistedLaunchStateNow(teamName: string): Promise { await this.launchStateStore.clear(teamName); + this.launchStateWrittenRunIdByTeam.delete(teamName); await clearBootstrapState(teamName); } @@ -18302,15 +18440,17 @@ export class TeamProvisioningService { teamName: string, snapshot: PersistedTeamLaunchSnapshot ): Promise { - return this.enqueueLaunchStateStoreOperation(teamName, () => + const result = await this.enqueueLaunchStateStoreOperation(teamName, () => this.writeLaunchStateSnapshotNow(teamName, snapshot) ); + return result.snapshot; } private async writeLaunchStateSnapshotNow( teamName: string, - snapshot: PersistedTeamLaunchSnapshot - ): Promise { + snapshot: PersistedTeamLaunchSnapshot, + options?: { allowNoopSkip?: boolean; runId?: string } + ): Promise { const previousSnapshot = await this.launchStateStore.read(teamName).catch(() => null); const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); const overlaidSnapshot = await this.applyOpenCodeSecondaryEvidenceOverlay({ @@ -18319,8 +18459,74 @@ export class TeamProvisioningService { previousSnapshot, metaMembers, }); + if ( + options?.allowNoopSkip === true && + typeof options.runId === 'string' && + this.launchStateWrittenRunIdByTeam.get(teamName) === options.runId && + previousSnapshot && + this.areLaunchStateSnapshotsSemanticallyEqual(previousSnapshot, overlaidSnapshot) && + !this.isLaunchStateNoopRefreshDue(previousSnapshot) + ) { + return { snapshot: previousSnapshot, wrote: false }; + } await this.launchStateStore.write(teamName, overlaidSnapshot); - return overlaidSnapshot; + if (typeof options?.runId === 'string') { + this.launchStateWrittenRunIdByTeam.set(teamName, options.runId); + } + return { snapshot: overlaidSnapshot, wrote: true }; + } + + private isLaunchStateNoopRefreshDue(snapshot: PersistedTeamLaunchSnapshot): boolean { + const updatedAtMs = Date.parse(snapshot.updatedAt); + return ( + !Number.isFinite(updatedAtMs) || + Date.now() - updatedAtMs >= TeamProvisioningService.LAUNCH_STATE_NOOP_REFRESH_MS + ); + } + + private areLaunchStateSnapshotsSemanticallyEqual( + left: PersistedTeamLaunchSnapshot, + right: PersistedTeamLaunchSnapshot + ): boolean { + return ( + JSON.stringify(this.toLaunchStateSemanticValue(left)) === + JSON.stringify(this.toLaunchStateSemanticValue(right)) + ); + } + + private toLaunchStateSemanticValue(snapshot: PersistedTeamLaunchSnapshot): unknown { + const { updatedAt: _updatedAt, members, ...rest } = snapshot; + const stableMembers = Object.fromEntries( + Object.entries(members) + .sort(([leftName], [rightName]) => leftName.localeCompare(rightName)) + .map(([memberName, member]) => { + const { + lastEvaluatedAt: _lastEvaluatedAt, + lastRuntimeAliveAt: _lastRuntimeAliveAt, + ...stableMember + } = member; + return [memberName, stableMember]; + }) + ); + return this.toStableJsonValue({ + ...rest, + members: stableMembers, + }); + } + + private toStableJsonValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.toStableJsonValue(item)); + } + if (!value || typeof value !== 'object') { + return value; + } + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, entryValue]) => [key, this.toStableJsonValue(entryValue)]) + ); } private async enqueueLaunchStateStoreOperation( @@ -18657,6 +18863,7 @@ export class TeamProvisioningService { run: Pick, memberName: string ) { + this.invalidateMemberSpawnStatusesCache(run.teamName); this.teamChangeEmitter?.({ type: 'member-spawn', teamName: run.teamName, @@ -19008,9 +19215,14 @@ export class TeamProvisioningService { return null; } - const writtenSnapshot = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot); - this.invalidateRuntimeSnapshotCaches(run.teamName); - return writtenSnapshot; + const writeResult = await this.writeLaunchStateSnapshotNow(run.teamName, filteredSnapshot, { + allowNoopSkip: true, + runId: run.runId, + }); + if (writeResult.wrote) { + this.invalidateRuntimeSnapshotCaches(run.teamName); + } + return writeResult.snapshot; } private async launchSingleMixedSecondaryLane( @@ -23879,6 +24091,7 @@ export class TeamProvisioningService { } if (!hasNewerTrackedRun) { this.invalidateRuntimeSnapshotCaches(run.teamName); + this.invalidateMemberSpawnStatusesCache(run.teamName); this.leadInboxRelayInFlight.delete(run.teamName); this.relayedLeadInboxMessageIds.delete(run.teamName); this.pendingCrossTeamFirstReplies.delete(run.teamName); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 02a791b4..67a6c146 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -129,7 +129,10 @@ import { initializeAutoResumeService, } from '@main/services/team/AutoResumeService'; import { getTeamBootstrapStatePath } from '@main/services/team/TeamBootstrapStateReader'; -import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchStateEvaluator'; +import { + createPersistedLaunchSnapshot, + snapshotFromRuntimeMemberStatuses, +} from '@main/services/team/TeamLaunchStateEvaluator'; import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore'; import { TeamConfigReader } from '@main/services/team/TeamConfigReader'; import { @@ -628,6 +631,364 @@ describe('TeamProvisioningService', () => { }); }); + describe('member spawn status launch reads', () => { + it('coalesces concurrent active launch status reads and serves a short cached follow-up', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const refreshDeferred = createDeferred(); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockImplementation(async () => refreshDeferred.promise); + const auditStatuses = vi + .spyOn(svc as any, 'maybeAuditMemberSpawnStatuses') + .mockResolvedValue(undefined); + const persistSnapshot = vi + .spyOn(svc as any, 'persistLaunchStateSnapshot') + .mockResolvedValue(null); + + const first = svc.getMemberSpawnStatuses(teamName); + const second = svc.getMemberSpawnStatuses(teamName); + await Promise.resolve(); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + refreshDeferred.resolve(); + const [firstSnapshot, secondSnapshot] = await Promise.all([first, second]); + + expect(firstSnapshot).toEqual(secondSnapshot); + expect(auditStatuses).toHaveBeenCalledTimes(1); + expect(persistSnapshot).toHaveBeenCalledTimes(1); + + await svc.getMemberSpawnStatuses(teamName); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + expect(auditStatuses).toHaveBeenCalledTimes(1); + expect(persistSnapshot).toHaveBeenCalledTimes(1); + }); + + it('invalidates the short status cache when a real member-spawn change is emitted', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-invalidated-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockResolvedValue(undefined); + vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null); + + await svc.getMemberSpawnStatuses(teamName); + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:05.000Z' + ); + await svc.getMemberSpawnStatuses(teamName); + + expect(refreshLeadInbox).toHaveBeenCalledTimes(2); + }); + + it('retries the owner status request when a member-spawn change lands while it is building', async () => { + const svc = new TeamProvisioningService(); + const teamName = 'spawn-cache-owner-retry-team'; + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + }); + run.isLaunch = true; + run.progress = { + teamName, + state: 'launching', + message: 'Launching', + updatedAt: '2026-05-02T10:00:00.000Z', + }; + run.onProgress = vi.fn(); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + (svc as any).launchStateStore = { + read: vi.fn(async () => null), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const firstRefresh = createDeferred(); + const refreshLeadInbox = vi + .spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox') + .mockImplementationOnce(async () => firstRefresh.promise) + .mockResolvedValue(undefined); + vi.spyOn(svc as any, 'maybeAuditMemberSpawnStatuses').mockResolvedValue(undefined); + vi.spyOn(svc as any, 'persistLaunchStateSnapshot').mockResolvedValue(null); + + const pending = svc.getMemberSpawnStatuses(teamName); + await Promise.resolve(); + expect(refreshLeadInbox).toHaveBeenCalledTimes(1); + + (svc as any).setMemberSpawnStatus( + run, + 'alice', + 'online', + undefined, + 'heartbeat', + '2026-05-02T10:00:05.000Z' + ); + firstRefresh.resolve(); + const result = await pending; + + expect(refreshLeadInbox).toHaveBeenCalledTimes(2); + expect(result.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + }); + }); + }); + + describe('launch-state no-op persistence guard', () => { + it('does not rewrite launch-state or invalidate runtime cache for a recent semantic no-op', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-noop-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).launchStateWrittenRunIdByTeam.set(teamName, run.runId); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result).toBe(previousSnapshot); + expect((svc as any).launchStateStore.write).not.toHaveBeenCalled(); + expect(invalidateRuntime).not.toHaveBeenCalled(); + }); + + it('keeps a bounded launch-state heartbeat for unchanged active snapshots', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:20.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-heartbeat-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:00.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.updatedAt).toBe('2026-05-02T10:00:20.000Z'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('does not skip the first service-owned launch-state write for an existing snapshot', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-first-write-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('does not skip the first write for a new run even when the previous snapshot is semantically equal', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-new-run-team'; + const status = createMemberSpawnStatusEntry({ + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: status as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', status]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).launchStateWrittenRunIdByTeam.set(teamName, 'previous-run-id'); + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.updatedAt).toBe('2026-05-02T10:00:05.000Z'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + + it('writes and invalidates runtime cache when launch-state semantics change', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-02T10:00:05.000Z')); + const svc = new TeamProvisioningService(); + const teamName = 'launch-state-change-team'; + const previousStatus = createMemberSpawnStatusEntry({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + updatedAt: '2026-05-02T10:00:00.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + }); + const nextStatus = createMemberSpawnStatusEntry({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + livenessSource: 'heartbeat', + updatedAt: '2026-05-02T10:00:05.000Z', + firstSpawnAcceptedAt: '2026-05-02T10:00:00.000Z', + lastHeartbeatAt: '2026-05-02T10:00:05.000Z', + }); + const previousSnapshot = snapshotFromRuntimeMemberStatuses({ + teamName, + expectedMembers: ['alice'], + launchPhase: 'active', + updatedAt: '2026-05-02T10:00:02.000Z', + statuses: { alice: previousStatus as any }, + }); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['alice'], + memberSpawnStatuses: new Map([['alice', nextStatus]]), + }); + run.isLaunch = true; + (svc as any).launchStateStore = { + read: vi.fn(async () => previousSnapshot), + write: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + }; + (svc as any).membersMetaStore = { getMembers: vi.fn(async () => []) }; + const invalidateRuntime = vi.spyOn(svc as any, 'invalidateRuntimeSnapshotCaches'); + + const result = await (svc as any).persistLaunchStateSnapshotNow(run, 'active'); + + expect(result.members.alice?.launchState).toBe('confirmed_alive'); + expect((svc as any).launchStateStore.write).toHaveBeenCalledTimes(1); + expect(invalidateRuntime).toHaveBeenCalledTimes(1); + }); + }); + describe('getTeamAgentRuntimeSnapshot', () => { it('dedupes concurrent runtime snapshot probes for the same team', async () => { const svc = new TeamProvisioningService(); From 053caed8b6141190abaffcc9fcd0e742869b707d Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 3 May 2026 08:57:59 +0500 Subject: [PATCH 19/51] fix(perf): resolve all PR #93 review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix react/display-name in DisplayItemList, MarkdownViewer, SessionItem, SidebarTaskItem, TeamDetailView, TeamListView, KanbanBoard, GlobalTaskList, MemberCard, TaskRow — all anonymous arrows inside memo() replaced with named function form - Fix simple-import-sort violations in TeamDetailView, TeamListView, SchedulesView, ScheduleSection — static imports moved before lazy() consts - Gate all lazy dialogs in TeamDetailView by their open flag so dynamic import() only fires when the dialog is actually opened: launchDialogOpen, createTaskDialog.open, sendDialogOpen, selectedTask !== null, reviewDialogState.open --- .../components/chat/DisplayItemList.tsx | 754 ++-- .../chat/viewers/MarkdownViewer.tsx | 422 +- .../components/schedules/SchedulesView.tsx | 4 +- .../components/sidebar/GlobalTaskList.tsx | 1244 +++--- .../components/sidebar/SessionItem.tsx | 456 +- .../components/sidebar/SidebarTaskItem.tsx | 376 +- .../components/team/TeamDetailView.tsx | 3898 ++++++++--------- src/renderer/components/team/TeamListView.tsx | 18 +- .../components/team/kanban/KanbanBoard.tsx | 696 +-- .../components/team/members/MemberCard.tsx | 1162 +++-- .../team/schedule/ScheduleSection.tsx | 6 +- .../components/team/tasks/TaskRow.tsx | 2 +- 12 files changed, 4537 insertions(+), 4501 deletions(-) diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index cd2c5754..8ae4e8c9 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { memo, useCallback, useState } from 'react'; import { CODE_BG, @@ -65,9 +65,6 @@ function buildItemMetaTooltip( return parts.length > 0 ? parts.join(' • ') : undefined; } -/** - * Truncates text to a maximum length and adds ellipsis if needed. - */ function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; @@ -75,6 +72,345 @@ function truncateText(text: string, maxLength: number): string { return text.substring(0, maxLength) + '...'; } +function getItemKey(item: AIGroupDisplayItem, index: number): string { + switch (item.type) { + case 'thinking': + return `thinking-${index}`; + case 'output': + return `output-${index}`; + case 'tool': + return `tool-${item.tool.id}-${index}`; + case 'subagent': + return `subagent-${item.subagent.id}-${index}`; + case 'slash': + return `slash-${item.slash.name}-${index}`; + case 'teammate_message': + return `teammate-${item.teammateMessage.id}-${index}`; + case 'subagent_input': + return `input-${index}`; + case 'compact_boundary': + return `compact-${index}`; + default: + return `unknown-${index}`; + } +} + +// ============================================================================= +// Per-item row — memoized to prevent re-renders from parent state changes +// ============================================================================= + +interface DisplayItemRowProps { + item: AIGroupDisplayItem; + index: number; + itemKey: string; + isExpanded: boolean; + isDimmed: boolean; + hasReplyLink: boolean; + onItemClick: (key: string) => void; + onReplyHover: (toolId: string | null) => void; + aiGroupId: string; + searchQueryOverride?: string; + highlightToolUseId?: string; + highlightColor?: TriggerColor; + notificationColorMap?: Map; + registerToolRef?: (toolId: string, el: HTMLDivElement | null) => void; + previewMaxLength?: number; + timestampFormat?: string; + showItemMetaTooltip?: boolean; +} + +const DisplayItemRow = memo(function DisplayItemRow({ + item, + index: _index, + itemKey, + isExpanded, + isDimmed, + hasReplyLink, + onItemClick, + onReplyHover, + aiGroupId, + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, +}: DisplayItemRowProps): React.JSX.Element | null { + const handleClick = useCallback(() => onItemClick(itemKey), [onItemClick, itemKey]); + + let element: React.ReactNode = null; + + switch (item.type) { + case 'thinking': { + const thinkingStep = { + id: itemKey, + type: 'thinking' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { thinkingText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'output': { + const textStep = { + id: itemKey, + type: 'output' as const, + startTime: item.timestamp, + endTime: item.timestamp, + durationMs: 0, + content: { outputText: item.content, tokenCount: item.tokenCount }, + tokens: { input: 0, output: item.tokenCount ?? 0 }, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'tool': { + element = ( + registerToolRef(item.tool.id, el) : undefined} + /> + ); + break; + } + + case 'subagent': { + const subagentStep = { + id: itemKey, + type: 'subagent' as const, + startTime: item.subagent.startTime, + endTime: item.subagent.endTime, + durationMs: item.subagent.durationMs, + content: { + subagentId: item.subagent.id, + subagentDescription: item.subagent.description, + }, + isParallel: item.subagent.isParallel, + context: 'main' as const, + }; + element = ( + + ); + break; + } + + case 'slash': { + element = ( + + ); + break; + } + + case 'teammate_message': { + element = ( + + ); + break; + } + + case 'subagent_input': { + const inputContent = item.content; + const inputTokenCount = item.tokenCount; + element = ( + } + label="Input" + summary={truncateText(inputContent, previewMaxLength ?? 80)} + tokenCount={inputTokenCount} + timestamp={item.timestamp} + timestampFormat={timestampFormat} + titleText={ + showItemMetaTooltip + ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') + : undefined + } + onClick={handleClick} + isExpanded={isExpanded} + > + + + ); + break; + } + + case 'compact_boundary': { + const compactContent = item.content; + element = ( +
+ + {isExpanded && compactContent && ( +
+
+ +
+
+ )} +
+ ); + break; + } + + default: + return null; + } + + return ( +
+ {element} +
+ ); +}); + +// ============================================================================= +// Main component +// ============================================================================= + /** * Renders a flat list of AIGroupDisplayItem[] into the appropriate components. * @@ -87,353 +423,75 @@ function truncateText(text: string, maxLength: number): string { * * The list is completely flat with no nested toggles or hierarchies. */ -export const DisplayItemList = React.memo( - ({ - items, - onItemClick, - expandedItemIds, - aiGroupId, - order = 'chronological', - searchQueryOverride, - highlightToolUseId, - highlightColor, - notificationColorMap, - registerToolRef, - previewMaxLength, - timestampFormat, - showItemMetaTooltip = false, - }: Readonly): React.JSX.Element => { - // Reply-link highlight: when hovering a reply badge, dim everything except the linked pair - const [replyLinkToolId, setReplyLinkToolId] = useState(null); +export const DisplayItemList = React.memo(function DisplayItemList({ + items, + onItemClick, + expandedItemIds, + aiGroupId, + order = 'chronological', + searchQueryOverride, + highlightToolUseId, + highlightColor, + notificationColorMap, + registerToolRef, + previewMaxLength, + timestampFormat, + showItemMetaTooltip = false, +}: Readonly): React.JSX.Element { + const [replyLinkToolId, setReplyLinkToolId] = useState(null); - const handleReplyHover = useCallback((toolId: string | null) => { - setReplyLinkToolId(toolId); - }, []); - - /** Check if an item is part of the currently highlighted reply link */ - const isItemInReplyLink = (item: AIGroupDisplayItem): boolean => { - if (!replyLinkToolId) return false; - if (item.type === 'tool' && item.tool.id === replyLinkToolId) return true; - if ( - item.type === 'teammate_message' && - item.teammateMessage.replyToToolId === replyLinkToolId - ) - return true; - return false; - }; - - if (!items || items.length === 0) { - return ( -
- No items to display -
- ); - } + const handleReplyHover = useCallback((toolId: string | null) => { + setReplyLinkToolId(toolId); + }, []); + if (!items || items.length === 0) { return ( -
- {items.map((item, index) => { - let itemKey = ''; - let element: React.ReactNode = null; - - switch (item.type) { - case 'thinking': { - itemKey = `thinking-${index}`; - const thinkingStep = { - id: itemKey, - type: 'thinking' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { thinkingText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'output': { - itemKey = `output-${index}`; - const textStep = { - id: itemKey, - type: 'output' as const, - startTime: item.timestamp, - endTime: item.timestamp, - durationMs: 0, - content: { outputText: item.content, tokenCount: item.tokenCount }, - tokens: { input: 0, output: item.tokenCount ?? 0 }, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, item.tokenCount, 'tokens') - : undefined - } - markdownItemId={searchQueryOverride ? `${aiGroupId}:${itemKey}` : undefined} - searchQueryOverride={searchQueryOverride} - /> - ); - break; - } - - case 'tool': { - itemKey = `tool-${item.tool.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.tool.startTime} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.tool.startTime, - getToolContextTokens(item.tool), - 'tokens' - ) - : undefined - } - searchQueryOverride={searchQueryOverride} - isHighlighted={highlightToolUseId === item.tool.id} - highlightColor={highlightColor} - notificationDotColor={notificationColorMap?.get(item.tool.id)} - registerRef={ - registerToolRef ? (el) => registerToolRef(item.tool.id, el) : undefined - } - /> - ); - break; - } - - case 'subagent': { - itemKey = `subagent-${item.subagent.id}-${index}`; - const subagentStep = { - id: itemKey, - type: 'subagent' as const, - startTime: item.subagent.startTime, - endTime: item.subagent.endTime, - durationMs: item.subagent.durationMs, - content: { - subagentId: item.subagent.id, - subagentDescription: item.subagent.description, - }, - isParallel: item.subagent.isParallel, - context: 'main' as const, - }; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - aiGroupId={aiGroupId} - highlightToolUseId={highlightToolUseId} - highlightColor={highlightColor} - notificationColorMap={notificationColorMap} - registerToolRef={registerToolRef} - /> - ); - break; - } - - case 'slash': { - itemKey = `slash-${item.slash.name}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - timestamp={item.slash.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip( - item.slash.timestamp, - item.slash.instructionsTokenCount, - 'tokens' - ) - : undefined - } - /> - ); - break; - } - - case 'teammate_message': { - itemKey = `teammate-${item.teammateMessage.id}-${index}`; - element = ( - onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - onReplyHover={handleReplyHover} - /> - ); - break; - } - - case 'subagent_input': { - itemKey = `input-${index}`; - const inputContent = item.content; - const inputTokenCount = item.tokenCount; - element = ( - } - label="Input" - summary={truncateText(inputContent, previewMaxLength ?? 80)} - tokenCount={inputTokenCount} - timestamp={item.timestamp} - timestampFormat={timestampFormat} - titleText={ - showItemMetaTooltip - ? buildItemMetaTooltip(item.timestamp, inputTokenCount, 'tokens') - : undefined - } - onClick={() => onItemClick(itemKey)} - isExpanded={expandedItemIds.has(itemKey)} - > - - - ); - break; - } - - case 'compact_boundary': { - itemKey = `compact-${index}`; - const compactContent = item.content; - const compactExpanded = expandedItemIds.has(itemKey); - element = ( -
- - {compactExpanded && compactContent && ( -
-
- -
-
- )} -
- ); - break; - } - - default: - return null; - } - - // Apply reply-link spotlight: dim items not in the highlighted pair - const isDimmed = replyLinkToolId !== null && !isItemInReplyLink(item); - return ( -
- {element} -
- ); - })} +
+ No items to display
); } -); + + return ( +
+ {items.map((item, index) => { + const itemKey = getItemKey(item, index); + const isExpanded = expandedItemIds.has(itemKey); + + const isInReplyLink = + replyLinkToolId !== null && + ((item.type === 'tool' && item.tool.id === replyLinkToolId) || + (item.type === 'teammate_message' && + item.teammateMessage.replyToToolId === replyLinkToolId)); + const isDimmed = replyLinkToolId !== null && !isInReplyLink; + + return ( + + ); + })} +
+ ); +}); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 2c7d3922..d5412610 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -946,200 +946,47 @@ export const CompactMarkdownPreview: React.FC = Rea } ); -export const MarkdownViewer: React.FC = React.memo( - ({ - content, - maxHeight = 'max-h-96', - className = '', - label, - itemId, - searchQueryOverride, - copyable = false, - bare = false, - baseDir, - teamColorByName: providedTeamColorByName, - onTeamClick: providedOnTeamClick, - }) => { - const [showRaw, setShowRaw] = React.useState(false); - const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); - const { isLight } = useTheme(); - const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( - providedTeamColorByName, - providedOnTeamClick - ); +export const MarkdownViewer: React.FC = React.memo(function MarkdownViewer({ + content, + maxHeight = 'max-h-96', + className = '', + label, + itemId, + searchQueryOverride, + copyable = false, + bare = false, + baseDir, + teamColorByName: providedTeamColorByName, + onTeamClick: providedOnTeamClick, +}) { + const [showRaw, setShowRaw] = React.useState(false); + const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS); + const { isLight } = useTheme(); + const { teamColorByName, onTeamClick } = useResolvedViewerTeamContext( + providedTeamColorByName, + providedOnTeamClick + ); - const isTooLarge = content.length > MAX_MARKDOWN_CHARS; - const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; + const isTooLarge = content.length > MAX_MARKDOWN_CHARS; + const disableHighlight = content.length > DISABLE_HIGHLIGHT_CHARS; - // Only re-render if THIS item has search matches - const { searchQuery, searchMatches, currentSearchIndex } = useStore( - useShallow((s) => { - const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; - return { - searchQuery: hasMatch ? s.searchQuery : '', - searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, - currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, - }; - }) - ); - - // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). - // For large content, default to a lightweight raw preview with manual expansion. - if (isTooLarge || showRaw) { - const shown = content.slice(0, Math.min(rawLimit, content.length)); - const isTruncated = shown.length < content.length; - return ( -
- {copyable && !label && ( - - )} - - {label && ( -
- - - {label} - - - Raw - - - - {copyable && } -
- )} - - {!label && ( -
- Raw preview - -
- )} - - {isTooLarge && ( -
- Content is very large ({content.length.toLocaleString()} chars). Showing raw preview - to keep the UI responsive. -
- )} - -
-
-              {shown}
-            
- {isTruncated && ( -
- - Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars - -
- - -
-
- )} -
-
- ); - } - - // Create search context (fresh each render so counter starts at 0) - const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); - const effectiveMatches = searchQueryOverride ? [] : searchMatches; - const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; - const searchCtx = - effectiveQuery && itemId - ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) - : null; - // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). - if (searchCtx && searchQueryOverride) { - searchCtx.forceAllActive = true; - } - - // Create markdown components with optional search highlighting - // When search is active, create fresh each render (match counter is stateful and must start at 0) - // useMemo would cache stale closures when parent re-renders without search deps changing - const baseComponents = searchCtx - ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) - : isLight - ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) - : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); - - // When baseDir is set (editor preview), override img to load local files via IPC - const components = baseDir - ? { - ...baseComponents, - img: ({ src, alt }: { src?: string; alt?: string }) => { - if (src && isRelativeUrl(src)) { - return ; - } - return {alt; - }, - } - : baseComponents; + // Only re-render if THIS item has search matches + const { searchQuery, searchMatches, currentSearchIndex } = useStore( + useShallow((s) => { + const hasMatch = itemId ? s.searchMatchItemIds.has(itemId) : false; + return { + searchQuery: hasMatch ? s.searchQuery : '', + searchMatches: hasMatch ? s.searchMatches : EMPTY_SEARCH_MATCHES, + currentSearchIndex: hasMatch ? s.currentSearchIndex : -1, + }; + }) + ); + // Guard: very large markdown can freeze the renderer (remark/rehype + highlighting). + // For large content, default to a lightweight raw preview with manual expansion. + if (isTooLarge || showRaw) { + const shown = content.slice(0, Math.min(rawLimit, content.length)); + const isTruncated = shown.length < content.length; return (
= React.memo( } } > - {/* Copy button overlay (when no label header) */} {copyable && !label && ( )} - {/* Optional header - matches CodeBlockViewer style */} {label && (
= React.memo( {label} - {copyable && ( - <> - - - - )} + + Raw + + + + {copyable && }
)} - {/* Markdown content with scroll */} -
-
- + Raw preview +
+ )} + + {isTooLarge && ( +
+ Content is very large ({content.length.toLocaleString()} chars). Showing raw preview to + keep the UI responsive. +
+ )} + +
+
+            {shown}
+          
+ {isTruncated && ( +
+ + Showing {shown.length.toLocaleString()} / {content.length.toLocaleString()} chars + +
+ + +
+
+ )}
); } -); + + // Create search context (fresh each render so counter starts at 0) + const effectiveQuery = (searchQueryOverride ?? searchQuery).trim(); + const effectiveMatches = searchQueryOverride ? [] : searchMatches; + const effectiveIndex = searchQueryOverride ? -1 : currentSearchIndex; + const searchCtx = + effectiveQuery && itemId + ? createSearchContext(effectiveQuery, itemId, effectiveMatches, effectiveIndex) + : null; + // Local search (Claude logs): use bright highlight for all matches (no "current result" concept). + if (searchCtx && searchQueryOverride) { + searchCtx.forceAllActive = true; + } + + // Create markdown components with optional search highlighting + // When search is active, create fresh each render (match counter is stateful and must start at 0) + // useMemo would cache stale closures when parent re-renders without search deps changing + const baseComponents = searchCtx + ? createViewerMarkdownComponents(searchCtx, isLight, teamColorByName, onTeamClick, copyable) + : isLight + ? createViewerMarkdownComponents(null, true, teamColorByName, onTeamClick, copyable) + : createViewerMarkdownComponents(null, false, teamColorByName, onTeamClick, copyable); + + // When baseDir is set (editor preview), override img to load local files via IPC + const components = baseDir + ? { + ...baseComponents, + img: ({ src, alt }: { src?: string; alt?: string }) => { + if (src && isRelativeUrl(src)) { + return ; + } + return {alt; + }, + } + : baseComponents; + + return ( +
+ {/* Copy button overlay (when no label header) */} + {copyable && !label && ( + + )} + + {/* Optional header - matches CodeBlockViewer style */} + {label && ( +
+ + + {label} + + {copyable && ( + <> + + + + )} +
+ )} + + {/* Markdown content with scroll */} +
+
+ + {content} + +
+
+
+ ); +}); diff --git a/src/renderer/components/schedules/SchedulesView.tsx b/src/renderer/components/schedules/SchedulesView.tsx index 3535cc28..96d72051 100644 --- a/src/renderer/components/schedules/SchedulesView.tsx +++ b/src/renderer/components/schedules/SchedulesView.tsx @@ -25,12 +25,12 @@ import { import { useShallow } from 'zustand/react/shallow'; import { ScheduleRunLogDialog } from '../team/schedule/ScheduleRunLogDialog'; +import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; +import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; const LaunchTeamDialog = lazy(() => import('../team/dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) ); -import { ScheduleRunRow } from '../team/schedule/ScheduleRunRow'; -import { ScheduleStatusBadge } from '../team/schedule/ScheduleStatusBadge'; import type { Schedule, ScheduleRun, ScheduleStatus } from '@shared/types'; diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index d4a48051..f236f190 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -173,116 +173,118 @@ function applyProjectFilter(tasks: GlobalTask[], projectPath: string | null): Gl return tasks.filter((t) => t.projectPath && normalizePath(t.projectPath) === normalized); } -export const GlobalTaskList = memo( - ({ - hideHeader = false, - filters: externalFilters, - onFiltersChange: externalOnFiltersChange, - filtersPopoverOpen: externalFiltersPopoverOpen, - onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, - }: GlobalTaskListProps = {}): React.JSX.Element => { - const { - globalTasks, - globalTasksLoading, - globalTasksInitialized, - fetchAllTasks, - softDeleteTask, - projects, - viewMode, - repositoryGroups, - teams, - } = useStore( - useShallow((s) => ({ - globalTasks: s.globalTasks, - globalTasksLoading: s.globalTasksLoading, - globalTasksInitialized: s.globalTasksInitialized, - fetchAllTasks: s.fetchAllTasks, - softDeleteTask: s.softDeleteTask, - projects: s.projects, - viewMode: s.viewMode, - repositoryGroups: s.repositoryGroups, - teams: s.teams, - })) - ); +export const GlobalTaskList = memo(function GlobalTaskList({ + hideHeader = false, + filters: externalFilters, + onFiltersChange: externalOnFiltersChange, + filtersPopoverOpen: externalFiltersPopoverOpen, + onFiltersPopoverOpenChange: externalOnFiltersPopoverOpenChange, +}: GlobalTaskListProps = {}): React.JSX.Element { + const { + globalTasks, + globalTasksLoading, + globalTasksInitialized, + fetchAllTasks, + softDeleteTask, + projects, + viewMode, + repositoryGroups, + teams, + } = useStore( + useShallow((s) => ({ + globalTasks: s.globalTasks, + globalTasksLoading: s.globalTasksLoading, + globalTasksInitialized: s.globalTasksInitialized, + fetchAllTasks: s.fetchAllTasks, + softDeleteTask: s.softDeleteTask, + projects: s.projects, + viewMode: s.viewMode, + repositoryGroups: s.repositoryGroups, + teams: s.teams, + })) + ); - const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); - const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); - const filters = externalFilters ?? internalFilters; - const setFilters = externalOnFiltersChange ?? setInternalFilters; - const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; - const setFiltersPopoverOpen = - externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; - const [searchQuery, setSearchQuery] = useState(''); - const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); - const [sortMode, setSortModeState] = useState(loadSortMode); - const [sortPopoverOpen, setSortPopoverOpen] = useState(false); - const [showArchived, setShowArchived] = useState(false); - const [renamingTaskKey, setRenamingTaskKey] = useState(null); - const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< - Record - >({}); - const searchInputRef = useRef(null); - const hasFetchedRef = useRef(false); - const readState = useReadStateSnapshot(); - const taskLocalState = useTaskLocalState(); + const [internalFilters, setInternalFilters] = useState(defaultTaskFiltersState); + const [internalFiltersPopoverOpen, setInternalFiltersPopoverOpen] = useState(false); + const filters = externalFilters ?? internalFilters; + const setFilters = externalOnFiltersChange ?? setInternalFilters; + const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen; + const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen; + const [searchQuery, setSearchQuery] = useState(''); + const [groupingMode, setGroupingModeState] = useState(loadGroupingMode); + const [sortMode, setSortModeState] = useState(loadSortMode); + const [sortPopoverOpen, setSortPopoverOpen] = useState(false); + const [showArchived, setShowArchived] = useState(false); + const [renamingTaskKey, setRenamingTaskKey] = useState(null); + const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< + Record + >({}); + const searchInputRef = useRef(null); + const hasFetchedRef = useRef(false); + const readState = useReadStateSnapshot(); + const taskLocalState = useTaskLocalState(); - // --- New-task animation tracking (same pattern as ChatHistory) --- - const knownTaskIdsRef = useRef>(new Set()); - const isInitialTaskLoadRef = useRef(true); + // --- New-task animation tracking (same pattern as ChatHistory) --- + const knownTaskIdsRef = useRef>(new Set()); + const isInitialTaskLoadRef = useRef(true); - const newTaskIds = useMemo(() => { - if (!globalTasksInitialized || globalTasks.length === 0) { - return new Set(); - } + const newTaskIds = useMemo(() => { + if (!globalTasksInitialized || globalTasks.length === 0) { + return new Set(); + } - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (isInitialTaskLoadRef.current) { - isInitialTaskLoadRef.current = false; - for (const t of globalTasks) { - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); - } - return new Set(); - } - - const newIds = new Set(); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + if (isInitialTaskLoadRef.current) { + isInitialTaskLoadRef.current = false; for (const t of globalTasks) { - const key = `${t.teamName}:${t.id}`; // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - if (!knownTaskIdsRef.current.has(key)) { - newIds.add(key); - // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. - knownTaskIdsRef.current.add(key); - } + knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); } - return newIds; - }, [globalTasks, globalTasksInitialized]); + return new Set(); + } - const isNewTask = useCallback( - (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), - [newTaskIds] - ); + const newIds = new Set(); + for (const t of globalTasks) { + const key = `${t.teamName}:${t.id}`; + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + if (!knownTaskIdsRef.current.has(key)) { + newIds.add(key); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. + knownTaskIdsRef.current.add(key); + } + } + return newIds; + }, [globalTasks, globalTasksInitialized]); - const setGroupingMode = (mode: TaskGroupingMode): void => { - setGroupingModeState(mode); - saveGroupingMode(mode); - }; + const isNewTask = useCallback( + (task: GlobalTask): boolean => newTaskIds.has(`${task.teamName}:${task.id}`), + [newTaskIds] + ); - const setSortMode = (mode: TaskSortMode): void => { - setSortModeState(mode); - saveSortMode(mode); - }; + const setGroupingMode = (mode: TaskGroupingMode): void => { + setGroupingModeState(mode); + saveGroupingMode(mode); + }; - const handleRenameComplete = (teamName: string, taskId: string, newSubject: string): void => { + const setSortMode = (mode: TaskSortMode): void => { + setSortModeState(mode); + saveSortMode(mode); + }; + + const handleRenameComplete = useCallback( + (teamName: string, taskId: string, newSubject: string): void => { taskLocalState.renameTask(teamName, taskId, newSubject); setRenamingTaskKey(null); - }; + }, + [taskLocalState] + ); - const handleRenameCancel = (): void => { - setRenamingTaskKey(null); - }; + const handleRenameCancel = useCallback((): void => { + setRenamingTaskKey(null); + }, []); - const handleDeleteTask = async (teamName: string, taskId: string): Promise => { + const handleDeleteTask = useCallback( + async (teamName: string, taskId: string): Promise => { const confirmed = await confirm({ title: 'Delete task', message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, @@ -303,559 +305,555 @@ export const GlobalTaskList = memo( }); } } - }; + }, + [fetchAllTasks, softDeleteTask] + ); - // Fetch tasks on mount — loading guard in the store action prevents - // duplicate IPC calls when the centralized init chain is already fetching. - useEffect(() => { - if (!hasFetchedRef.current && !globalTasksLoading) { - hasFetchedRef.current = true; - void fetchAllTasks(); - } - }, [fetchAllTasks, globalTasksLoading]); + // Fetch tasks on mount — loading guard in the store action prevents + // duplicate IPC calls when the centralized init chain is already fetching. + useEffect(() => { + if (!hasFetchedRef.current && !globalTasksLoading) { + hasFetchedRef.current = true; + void fetchAllTasks(); + } + }, [fetchAllTasks, globalTasksLoading]); - // Build project combobox options from available projects/repos - const projectFilterOptions = useMemo((): ComboboxOption[] => { - const items = - viewMode === 'grouped' - ? repositoryGroups - .filter((r) => r.totalSessions > 0) - .map((r) => ({ - value: r.worktrees[0]?.path ?? r.id, - label: r.name, - path: r.worktrees[0]?.path, - })) - : projects - .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) - .map((p) => ({ - value: p.path, - label: p.name, - path: p.path, - })); + // Build project combobox options from available projects/repos + const projectFilterOptions = useMemo((): ComboboxOption[] => { + const items = + viewMode === 'grouped' + ? repositoryGroups + .filter((r) => r.totalSessions > 0) + .map((r) => ({ + value: r.worktrees[0]?.path ?? r.id, + label: r.name, + path: r.worktrees[0]?.path, + })) + : projects + .filter((p) => (p.totalSessions ?? p.sessions.length) > 0) + .map((p) => ({ + value: p.path, + label: p.name, + path: p.path, + })); - return items.map((item) => ({ - value: item.value, - label: item.label, - description: item.path, - })); - }, [viewMode, repositoryGroups, projects]); + return items.map((item) => ({ + value: item.value, + label: item.label, + description: item.path, + })); + }, [viewMode, repositoryGroups, projects]); - // Resolve project filter from filters state - const selectedProjectPath = filters.projectPath; - const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] - ); - const effectiveShowArchived = showArchived && hasArchivedTasks; + // Resolve project filter from filters state + const selectedProjectPath = filters.projectPath; + const hasArchivedTasks = useMemo( + () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), + [globalTasks, taskLocalState] + ); + const effectiveShowArchived = showArchived && hasArchivedTasks; - const filtered = useMemo(() => { - let result = globalTasks; - result = applyProjectFilter(result, selectedProjectPath); - result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); - if (filters.teamName) { - result = result.filter((t) => t.teamName === filters.teamName); - } - if (filters.readFilter === 'unread') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 - ); - } else if (filters.readFilter === 'read') { - result = result.filter( - (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 - ); - } - result = applySearch(result, searchQuery); - // Archive filtering - if (effectiveShowArchived) { - result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); - } else { - result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); - } - return result; - }, [ - globalTasks, - selectedProjectPath, - filters.statusIds, - filters.teamName, - filters.readFilter, - searchQuery, - readState, - effectiveShowArchived, - taskLocalState, - ]); + const filtered = useMemo(() => { + let result = globalTasks; + result = applyProjectFilter(result, selectedProjectPath); + result = result.filter((t) => taskMatchesStatus(t, filters.statusIds)); + if (filters.teamName) { + result = result.filter((t) => t.teamName === filters.teamName); + } + if (filters.readFilter === 'unread') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) > 0 + ); + } else if (filters.readFilter === 'read') { + result = result.filter( + (t) => getTaskUnreadCount(readState, t.teamName, t.id, t.comments) === 0 + ); + } + result = applySearch(result, searchQuery); + // Archive filtering + if (effectiveShowArchived) { + result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); + } else { + result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); + } + return result; + }, [ + globalTasks, + selectedProjectPath, + filters.statusIds, + filters.teamName, + filters.readFilter, + searchQuery, + readState, + effectiveShowArchived, + taskLocalState, + ]); - // Split into pinned and normal (non-pinned) tasks - const pinnedTasks = useMemo( - () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); - const normalTasks = useMemo( - () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), - [filtered, taskLocalState] - ); + // Split into pinned and normal (non-pinned) tasks + const pinnedTasks = useMemo( + () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); + const normalTasks = useMemo( + () => filtered.filter((t) => !taskLocalState.isPinned(t.teamName, t.id)), + [filtered, taskLocalState] + ); - const sortedFlat = useMemo( - () => applySortMode(normalTasks, sortMode, readState), - [normalTasks, sortMode, readState] - ); - const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); - const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); - const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); + const sortedFlat = useMemo( + () => applySortMode(normalTasks, sortMode, readState), + [normalTasks, sortMode, readState] + ); + const grouped = useMemo(() => groupTasksByDate(normalTasks), [normalTasks]); + const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); + const projectGroups = useMemo(() => groupTasksByProject(normalTasks), [normalTasks]); - // Collapsed group keys for each grouping mode - const projectGroupKeys = useMemo( - () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), - [projectGroups] - ); - const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); - const projectGroupVisibility = useMemo( - () => - projectGroups.map((group) => ({ - projectKey: group.projectKey, - taskCount: group.tasks.length, - })), - [projectGroups] - ); - const projectVisibleCountByKey = useMemo( - () => - syncProjectGroupVisibleCountByKey( - projectRequestedVisibleCountByKey, - projectGroupVisibility - ), - [projectRequestedVisibleCountByKey, projectGroupVisibility] - ); + // Collapsed group keys for each grouping mode + const projectGroupKeys = useMemo( + () => projectGroups.filter((g) => g.tasks.length > 0).map((g) => g.projectKey), + [projectGroups] + ); + const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); + const projectGroupVisibility = useMemo( + () => + projectGroups.map((group) => ({ + projectKey: group.projectKey, + taskCount: group.tasks.length, + })), + [projectGroups] + ); + const projectVisibleCountByKey = useMemo( + () => + syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), + [projectRequestedVisibleCountByKey, projectGroupVisibility] + ); - const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); - const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); + const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); + const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); - const hasContent = - pinnedTasks.length > 0 || - (groupingMode === 'none' - ? sortedFlat.length > 0 - : groupingMode === 'time' - ? categories.length > 0 - : projectGroups.some((g) => g.tasks.length > 0)); + const hasContent = + pinnedTasks.length > 0 || + (groupingMode === 'none' + ? sortedFlat.length > 0 + : groupingMode === 'time' + ? categories.length > 0 + : projectGroups.some((g) => g.tasks.length > 0)); - const noProjectGroupColor = useMemo( - () => ({ - border: 'var(--color-border)', - glow: 'transparent', - icon: 'var(--color-text-muted)', - text: 'var(--color-text-secondary)', - }), - [] - ); + const noProjectGroupColor = useMemo( + () => ({ + border: 'var(--color-border)', + glow: 'transparent', + icon: 'var(--color-text-muted)', + text: 'var(--color-text-secondary)', + }), + [] + ); - return ( -
- {!hideHeader && ( -
- Tasks -
- )} - - {/* Search bar */} + return ( +
+ {!hideHeader && (
- - setSearchQuery(e.target.value)} - className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" - /> - {searchQuery && ( + Tasks +
+ )} + + {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-[12px] text-text placeholder:text-text-muted focus:outline-none" + /> + {searchQuery && ( + + )} + + - )} - - - - - -
- {SORT_OPTIONS.map((opt) => ( - - ))} -
-
-
- ({ teamName: t.teamName, displayName: t.displayName }))} - projectOptions={projectFilterOptions} - filters={filters} - onFiltersChange={setFilters} - onApply={() => {}} - /> -
- - {/* Pinned tasks section */} - {pinnedTasks.length > 0 && !effectiveShowArchived && ( -
-
- - Pinned -
- {sortTasksByFreshness(pinnedTasks).map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} -
- )} - - {/* Grouping mode — compact text toggle */} -
- Group by: -
- {(['none', 'project', 'time'] as const).map((mode) => { - const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; - return ( + + +
+ {SORT_OPTIONS.map((opt) => ( + ))} +
+
+ + ({ teamName: t.teamName, displayName: t.displayName }))} + projectOptions={projectFilterOptions} + filters={filters} + onFiltersChange={setFilters} + onApply={() => {}} + /> +
+ + {/* Pinned tasks section */} + {pinnedTasks.length > 0 && !effectiveShowArchived && ( +
+
+ + Pinned +
+ {sortTasksByFreshness(pinnedTasks).map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + + ))} +
+ )} + + {/* Grouping mode — compact text toggle */} +
+ Group by: +
+ {(['none', 'project', 'time'] as const).map((mode) => { + const label = mode === 'none' ? 'None' : mode === 'project' ? 'Project' : 'Time'; + return ( + + ); + })} +
+ {/* Archive toggle — only visible when archived tasks exist */} + {hasArchivedTasks && ( +
+ + + - ); - })} + + + {effectiveShowArchived ? 'Hide archived' : 'Show archived'} + +
- {/* Archive toggle — only visible when archived tasks exist */} - {hasArchivedTasks && ( -
- - - - - - {effectiveShowArchived ? 'Hide archived' : 'Show archived'} - - -
- )} -
- - {/* Content */} -
- {globalTasksLoading && !globalTasksInitialized && ( -
- {[1, 2, 3].map((i) => ( -
- ))} -
- )} - - {globalTasksInitialized && !hasContent && ( -
- - - {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} - -
- )} - - {groupingMode === 'none' && - sortedFlat.map((task) => ( - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - taskLocalState.getRenamedSubject(t.teamName, t.id)} - /> - - - ))} - - {groupingMode === 'project' && - projectGroups.map((group) => { - if (group.tasks.length === 0) return null; - const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); - const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; - const groupColor = isNoProjectGroup - ? noProjectGroupColor - : projectColor(group.projectLabel); - const visibleCount = getProjectGroupVisibleCount( - projectVisibleCountByKey[group.projectKey], - group.tasks.length - ); - const visibleTasks = group.tasks.slice(0, visibleCount); - const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); - const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); - let lastTeam: string | null = null; - return ( -
- - {!isGroupCollapsed && - visibleTasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} - {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( -
- {showMoreVisible && ( - - )} - {showLessVisible && ( - - )} -
- )} -
- ); - })} - - {groupingMode === 'time' && - categories.map((category) => { - const tasks = grouped[category]; - const isGroupCollapsed = timeCollapsed.isCollapsed(category); - let lastTeam: string | null = null; - - return ( -
- - - {!isGroupCollapsed && - tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; - - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - taskLocalState.togglePin(task.teamName, task.id)} - onToggleArchive={() => - taskLocalState.toggleArchive(task.teamName, task.id) - } - onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} - onDelete={() => handleDeleteTask(task.teamName, task.id)} - > - - - taskLocalState.getRenamedSubject(t.teamName, t.id) - } - /> - - -
- ); - })} -
- ); - })} -
+ )}
- ); - } -); + + {/* Content */} +
+ {globalTasksLoading && !globalTasksInitialized && ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ )} + + {globalTasksInitialized && !hasContent && ( +
+ + + {searchQuery || selectedProjectPath ? 'No matching tasks' : 'No tasks found'} + +
+ )} + + {groupingMode === 'none' && + sortedFlat.map((task) => ( + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)} + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + taskLocalState.getRenamedSubject(t.teamName, t.id)} + /> + + + ))} + + {groupingMode === 'project' && + projectGroups.map((group) => { + if (group.tasks.length === 0) return null; + const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); + const isNoProjectGroup = group.projectKey === NO_PROJECT_KEY; + const groupColor = isNoProjectGroup + ? noProjectGroupColor + : projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); + let lastTeam: string | null = null; + return ( +
+ + {!isGroupCollapsed && + visibleTasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )} +
+ ); + })} + + {groupingMode === 'time' && + categories.map((category) => { + const tasks = grouped[category]; + const isGroupCollapsed = timeCollapsed.isCollapsed(category); + let lastTeam: string | null = null; + + return ( +
+ + + {!isGroupCollapsed && + tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + taskLocalState.togglePin(task.teamName, task.id)} + onToggleArchive={() => + taskLocalState.toggleArchive(task.teamName, task.id) + } + onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)} + onDelete={() => handleDeleteTask(task.teamName, task.id)} + > + + + taskLocalState.getRenamedSubject(t.teamName, t.id) + } + /> + + +
+ ); + })} +
+ ); + })} +
+
+ ); +}); diff --git a/src/renderer/components/sidebar/SessionItem.tsx b/src/renderer/components/sidebar/SessionItem.tsx index dbf534c4..f9726a75 100644 --- a/src/renderer/components/sidebar/SessionItem.tsx +++ b/src/renderer/components/sidebar/SessionItem.tsx @@ -155,243 +155,239 @@ const SessionRuntimeBadge = ({ ); }; -export const SessionItem = memo( - ({ - session, - isActive, - isPinned, - isHidden, - multiSelectActive, - isSelected, - }: Readonly): React.JSX.Element => { - const { - openTab, - activeProjectId, - selectSession, - paneCount, - splitPane, - togglePinSession, - toggleHideSession, - toggleSidebarSessionSelection, - } = useStore( - useShallow((s) => ({ - openTab: s.openTab, - activeProjectId: s.activeProjectId, - selectSession: s.selectSession, - paneCount: s.paneLayout.panes.length, - splitPane: s.splitPane, - togglePinSession: s.togglePinSession, - toggleHideSession: s.toggleHideSession, - toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, - })) +export const SessionItem = memo(function SessionItem({ + session, + isActive, + isPinned, + isHidden, + multiSelectActive, + isSelected, +}: Readonly): React.JSX.Element { + const { + openTab, + activeProjectId, + selectSession, + paneCount, + splitPane, + togglePinSession, + toggleHideSession, + toggleSidebarSessionSelection, + } = useStore( + useShallow((s) => ({ + openTab: s.openTab, + activeProjectId: s.activeProjectId, + selectSession: s.selectSession, + paneCount: s.paneLayout.panes.length, + splitPane: s.splitPane, + togglePinSession: s.togglePinSession, + toggleHideSession: s.toggleHideSession, + toggleSidebarSessionSelection: s.toggleSidebarSessionSelection, + })) + ); + + const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + + const handleClick = (event: React.MouseEvent): void => { + if (!activeProjectId) return; + + // In multi-select mode, clicks toggle selection + if (multiSelectActive) { + toggleSidebarSessionSelection(session.id); + return; + } + + // Cmd/Ctrl+click: open in new tab; plain click: replace current tab + const forceNewTab = event.ctrlKey || event.metaKey; + + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: formatSessionLabel(session.firstMessage), + }, + forceNewTab ? { forceNewTab } : { replaceActiveTab: true } ); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); + selectSession(session.id); + }; - const handleClick = (event: React.MouseEvent): void => { - if (!activeProjectId) return; + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY }); + }, []); - // In multi-select mode, clicks toggle selection - if (multiSelectActive) { - toggleSidebarSessionSelection(session.id); - return; - } + const sessionLabel = formatSessionLabel(session.firstMessage); - // Cmd/Ctrl+click: open in new tab; plain click: replace current tab - const forceNewTab = event.ctrlKey || event.metaKey; - - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: formatSessionLabel(session.firstMessage), - }, - forceNewTab ? { forceNewTab } : { replaceActiveTab: true } - ); - - selectSession(session.id); - }; - - const handleContextMenu = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - setContextMenu({ x: e.clientX, y: e.clientY }); - }, []); - - const sessionLabel = formatSessionLabel(session.firstMessage); - - const handleOpenInCurrentPane = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { replaceActiveTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleOpenInNewTab = useCallback(() => { - if (!activeProjectId) return; - openTab( - { - type: 'session', - sessionId: session.id, - projectId: activeProjectId, - label: sessionLabel, - }, - { forceNewTab: true } - ); - selectSession(session.id); - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); - - const handleSplitRightAndOpen = useCallback(() => { - if (!activeProjectId) return; - // First open the tab in the focused pane - openTab({ + const handleOpenInCurrentPane = useCallback(() => { + if (!activeProjectId) return; + openTab( + { type: 'session', sessionId: session.id, projectId: activeProjectId, label: sessionLabel, - }); - selectSession(session.id); - // Then split it to the right - const state = useStore.getState(); - const focusedPaneId = state.paneLayout.focusedPaneId; - const activeTabId = state.activeTabId; - if (activeTabId) { - splitPane(focusedPaneId, activeTabId, 'right'); - } - }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); - - // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll - return ( - <> - - - {contextMenu && - activeProjectId && - createPortal( - setContextMenu(null)} - onOpenInCurrentPane={handleOpenInCurrentPane} - onOpenInNewTab={handleOpenInNewTab} - onSplitRightAndOpen={handleSplitRightAndOpen} - onTogglePin={() => void togglePinSession(session.id)} - onToggleHide={() => void toggleHideSession(session.id)} - />, - document.body - )} - + }, + { replaceActiveTab: true } ); - } -); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleOpenInNewTab = useCallback(() => { + if (!activeProjectId) return; + openTab( + { + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }, + { forceNewTab: true } + ); + selectSession(session.id); + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel]); + + const handleSplitRightAndOpen = useCallback(() => { + if (!activeProjectId) return; + // First open the tab in the focused pane + openTab({ + type: 'session', + sessionId: session.id, + projectId: activeProjectId, + label: sessionLabel, + }); + selectSession(session.id); + // Then split it to the right + const state = useStore.getState(); + const focusedPaneId = state.paneLayout.focusedPaneId; + const activeTabId = state.activeTabId; + if (activeTabId) { + splitPane(focusedPaneId, activeTabId, 'right'); + } + }, [activeProjectId, openTab, selectSession, session.id, sessionLabel, splitPane]); + + // Height must match SESSION_HEIGHT (54px) in DateGroupedSessions.tsx for virtual scroll + return ( + <> + + + {contextMenu && + activeProjectId && + createPortal( + setContextMenu(null)} + onOpenInCurrentPane={handleOpenInCurrentPane} + onOpenInNewTab={handleOpenInNewTab} + onSplitRightAndOpen={handleSplitRightAndOpen} + onTogglePin={() => void togglePinSession(session.id)} + onToggleHide={() => void toggleHideSession(session.id)} + />, + document.body + )} + + ); +}); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 5469b20f..ef6d959b 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -69,220 +69,218 @@ interface SidebarTaskItemProps { getDisplaySubject?: (task: GlobalTask) => string | undefined; } -export const SidebarTaskItem = memo( - ({ - task, - hideTeamName, - showTeamName, - renamingKey, - onRenameComplete, - onRenameCancel, - getDisplaySubject, - }: SidebarTaskItemProps): React.JSX.Element => { - const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); - const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); - const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); - const { isLight } = useTheme(); +export const SidebarTaskItem = memo(function SidebarTaskItem({ + task, + hideTeamName, + showTeamName, + renamingKey, + onRenameComplete, + onRenameCancel, + getDisplaySubject, +}: SidebarTaskItemProps): React.JSX.Element { + const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail); + const teamMembers = useStore(useShallow((s) => s.teamByName[task.teamName]?.members)); + const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); + const { isLight } = useTheme(); - const isRenaming = renamingKey === `${task.teamName}:${task.id}`; - const displaySubject = getDisplaySubject?.(task) ?? task.subject; - const [editValue, setEditValue] = useState(displaySubject); - const inputRef = useRef(null); - // Focus input when rename starts - useEffect(() => { - if (!isRenaming) return; - const raf = requestAnimationFrame(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - return () => cancelAnimationFrame(raf); - }, [isRenaming]); + const isRenaming = renamingKey === `${task.teamName}:${task.id}`; + const displaySubject = getDisplaySubject?.(task) ?? task.subject; + const [editValue, setEditValue] = useState(displaySubject); + const inputRef = useRef(null); + // Focus input when rename starts + useEffect(() => { + if (!isRenaming) return; + const raf = requestAnimationFrame(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }); + return () => cancelAnimationFrame(raf); + }, [isRenaming]); - // Reset edit value when renaming starts - useEffect(() => { - if (isRenaming) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change - setEditValue(displaySubject); - } - }, [isRenaming, displaySubject]); + // Reset edit value when renaming starts + useEffect(() => { + if (isRenaming) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional sync on prop change + setEditValue(displaySubject); + } + }, [isRenaming, displaySubject]); - const reviewColumn = getTaskKanbanColumn(task); - const cfg = - reviewColumn === 'approved' - ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) - : reviewColumn === 'review' - ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) - : (statusConfig[task.status] ?? statusConfig.pending); - const StatusIcon = cfg.icon; - const updatedLabel = formatUpdatedLabel(task); - const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); + const reviewColumn = getTaskKanbanColumn(task); + const cfg = + reviewColumn === 'approved' + ? ({ icon: ShieldCheck, color: 'text-teal-400', label: 'approved' } as const) + : reviewColumn === 'review' + ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) + : (statusConfig[task.status] ?? statusConfig.pending); + const StatusIcon = cfg.icon; + const updatedLabel = formatUpdatedLabel(task); + const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); - const ownerColorSet = useMemo(() => { - if (!teamMembers || !task.owner) return null; - const colorMap = buildMemberColorMap(teamMembers); - const colorName = colorMap.get(task.owner); - return colorName ? getTeamColorSet(colorName) : null; - }, [teamMembers, task.owner]); + const ownerColorSet = useMemo(() => { + if (!teamMembers || !task.owner) return null; + const colorMap = buildMemberColorMap(teamMembers); + const colorName = colorMap.get(task.owner); + return colorName ? getTeamColorSet(colorName) : null; + }, [teamMembers, task.owner]); - const ownerTextColor = useMemo(() => { - if (!ownerColorSet) return undefined; - return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; - }, [ownerColorSet, isLight]); + const ownerTextColor = useMemo(() => { + if (!ownerColorSet) return undefined; + return isLight && ownerColorSet.textLight ? ownerColorSet.textLight : ownerColorSet.text; + }, [ownerColorSet, isLight]); - const projectLabel = useMemo(() => { - if (!task.projectPath?.trim()) return null; - return projectLabelFromPath(task.projectPath); - }, [task.projectPath]); + const projectLabel = useMemo(() => { + if (!task.projectPath?.trim()) return null; + return projectLabelFromPath(task.projectPath); + }, [task.projectPath]); - const projectColorSet = useMemo( - () => (projectLabel ? projectColor(projectLabel, isLight) : null), - [projectLabel, isLight] - ); + const projectColorSet = useMemo( + () => (projectLabel ? projectColor(projectLabel, isLight) : null), + [projectLabel, isLight] + ); - const teamColor = useMemo( - () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), - [showTeamName, task.teamDisplayName, isLight] - ); + const teamColor = useMemo( + () => (showTeamName ? nameColorSet(task.teamDisplayName, isLight) : null), + [showTeamName, task.teamDisplayName, isLight] + ); - const showTeamRow = showTeamName && !hideTeamName; - const unreadBackgroundClass = - unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; + const showTeamRow = showTeamName && !hideTeamName; + const unreadBackgroundClass = + unreadCount > 0 ? (isLight ? 'bg-blue-500/[0.03]' : 'bg-blue-500/[0.05]') : ''; - return ( -
+ + + {displaySubject} + + + )} +
- {/* Row 3: Team: name · owner */} - {showTeamRow && ( -
+ {task.teamDeleted && } + {projectLabel && ( + - Team: - - {task.teamDisplayName} - - · + {projectLabel} + + )} + {!showTeamRow && ( + <> + {projectLabel && ·} {task.owner ?? 'unassigned'} -
+ )} - - ); - } -); + {dateLabel && ( + + {dateLabel} + + )} +
+ + {/* Row 3: Team: name · owner */} + {showTeamRow && ( +
+ Team: + + {task.teamDisplayName} + + · + + {task.owner ?? 'unassigned'} + +
+ )} + + ); +}); diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index fdd68f1c..011bb0df 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -79,7 +79,6 @@ import { useShallow } from 'zustand/react/shallow'; import { AddMemberDialog } from './dialogs/AddMemberDialog'; import { EditTeamDialog } from './dialogs/EditTeamDialog'; -import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import { ReviewDialog } from './dialogs/ReviewDialog'; import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow'; import { KanbanBoard } from './kanban/KanbanBoard'; @@ -90,6 +89,7 @@ import { MemberDetailDialog } from './members/MemberDetailDialog'; import { type MemberActivityFilter, type MemberDetailTab } from './members/memberDetailTypes'; import type { AddMemberEntry } from './dialogs/AddMemberDialog'; +import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; import type { ComponentProps, CSSProperties } from 'react'; @@ -958,1235 +958,1223 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge( ); }); -export const TeamDetailView = memo( - ({ teamName, isPaneFocused = false }: TeamDetailViewProps): React.JSX.Element => { - const { isLight } = useTheme(); - const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [selectedMember, setSelectedMember] = useState(null); - const [selectedMemberView, setSelectedMemberView] = useState<{ - initialTab?: MemberDetailTab; - initialActivityFilter?: MemberActivityFilter; - } | null>(null); - const [pendingRepliesByMember, setPendingRepliesByMember] = useState>( - () => getTeamPendingRepliesState(teamName) +export const TeamDetailView = memo(function TeamDetailView({ + teamName, + isPaneFocused = false, +}: TeamDetailViewProps): React.JSX.Element { + const { isLight } = useTheme(); + const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); + const [selectedMember, setSelectedMember] = useState(null); + const [selectedMemberView, setSelectedMemberView] = useState<{ + initialTab?: MemberDetailTab; + initialActivityFilter?: MemberActivityFilter; + } | null>(null); + const [pendingRepliesByMember, setPendingRepliesByMember] = useState>(() => + getTeamPendingRepliesState(teamName) + ); + const [createTaskDialog, setCreateTaskDialog] = useState({ + open: false, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + }); + const [creatingTask, setCreatingTask] = useState(false); + const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); + const [addingMemberLoading, setAddingMemberLoading] = useState(false); + const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); + const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [launchDialogState, setLaunchDialogState] = useState<{ + open: boolean; + mode: TeamLaunchDialogMode; + }>({ + open: false, + mode: 'launch', + }); + const [editorOpen, setEditorOpen] = useState(false); + const [graphOpen, setGraphOpen] = useState(false); + const contentRef = useRef(null); + const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( + null + ); + const provisioningBannerRef = useRef(null); + const wasProvisioningRef = useRef(false); + const handleOpenGraphTab = useCallback(() => { + const state = useStore.getState(); + const displayName = state.teamByName[teamName]?.displayName ?? teamName; + state.openTab({ + type: 'graph', + label: `${displayName} Graph`, + teamName, + }); + }, [teamName]); + const visualizeButtonStyle = useMemo( + () => + isLight + ? { + background: + 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', + borderColor: 'rgba(59,130,246,0.30)', + color: '#0f172a', + boxShadow: '0 10px 24px rgba(59,130,246,0.12)', + } + : { + background: + 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', + borderColor: 'rgba(56,189,248,0.34)', + color: 'rgba(236,253,255,0.96)', + boxShadow: '0 12px 28px rgba(8,145,178,0.22)', + }, + [isLight] + ); + + // Set inert on background content when editor/graph overlay is open (a11y focus trap) + useEffect(() => { + const el = contentRef.current; + if (!el) return; + if (editorOpen || graphOpen) { + el.setAttribute('inert', ''); + } else { + el.removeAttribute('inert'); + } + }, [editorOpen, graphOpen]); + + // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail?.teamName === teamName) { + handleOpenGraphTab(); + } + }; + window.addEventListener('toggle-team-graph', handler); + return () => window.removeEventListener('toggle-team-graph', handler); + }, [handleOpenGraphTab, teamName]); + + // Listen for graph tab actions (open task, send message) + useEffect(() => { + const onOpenTask = (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + if (task) setSelectedTask(task); + }; + const onSendMsg = (e: Event) => { + const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }; + const onOpenProfile = (e: Event) => { + const { + teamName: tn, + memberName, + initialTab, + initialActivityFilter, + } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !data) return; + const member = members.find((m: { name: string }) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab, + initialActivityFilter, + }); + } + }; + const onCreateTask = (e: Event) => { + const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName) return; + openCreateTaskDialog('', '', owner ?? ''); + }; + window.addEventListener('graph:open-task', onOpenTask); + window.addEventListener('graph:send-message', onSendMsg); + window.addEventListener('graph:open-profile', onOpenProfile); + window.addEventListener('graph:create-task', onCreateTask); + + // Task action events from graph + const taskAction = (handler: (taskId: string) => void) => (e: Event) => { + const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; + if (tn !== teamName || !taskId) return; + handler(taskId); + }; + const onStartTask = taskAction((taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t: { id: string }) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } + } catch { + /* best-effort */ + } + } + } catch { + /* error via store */ + } + })(); + }); + const onCompleteTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onApproveTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); + } catch { + /* */ + } + })(); + }); + const onRequestReviewTask = taskAction((taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + /* */ + } + })(); + }); + const onRequestChangesTask = taskAction((taskId) => { + setRequestChangesTaskId(taskId); + }); + const onCancelTask = taskAction((taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'pending'); + } catch { + /* */ + } + })(); + }); + const onMoveBackToDoneTask = taskAction((taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + /* */ + } + })(); + }); + const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); + + window.addEventListener('graph:start-task', onStartTask); + window.addEventListener('graph:complete-task', onCompleteTask); + window.addEventListener('graph:approve-task', onApproveTask); + window.addEventListener('graph:request-review', onRequestReviewTask); + window.addEventListener('graph:request-changes', onRequestChangesTask); + window.addEventListener('graph:cancel-task', onCancelTask); + window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.addEventListener('graph:delete-task', onDeleteTaskGraph); + return () => { + window.removeEventListener('graph:open-task', onOpenTask); + window.removeEventListener('graph:send-message', onSendMsg); + window.removeEventListener('graph:open-profile', onOpenProfile); + window.removeEventListener('graph:create-task', onCreateTask); + window.removeEventListener('graph:start-task', onStartTask); + window.removeEventListener('graph:complete-task', onCompleteTask); + window.removeEventListener('graph:approve-task', onApproveTask); + window.removeEventListener('graph:request-review', onRequestReviewTask); + window.removeEventListener('graph:request-changes', onRequestChangesTask); + window.removeEventListener('graph:cancel-task', onCancelTask); + window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); + window.removeEventListener('graph:delete-task', onDeleteTaskGraph); + }; + }); + + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [stoppingTeam, setStoppingTeam] = useState(false); + const [trashOpen, setTrashOpen] = useState(false); + const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); + const [sendDialogDefaultText, setSendDialogDefaultText] = useState(undefined); + const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( + undefined + ); + const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( + undefined + ); + const [reviewDialogState, setReviewDialogState] = useState<{ + open: boolean; + mode: 'agent' | 'task'; + memberName?: string; + taskId?: string; + initialFilePath?: string; + taskChangeRequestOptions?: TaskChangeRequestOptions; + }>({ open: false, mode: 'task' }); + + // Active teams for conflict warning in LaunchTeamDialog + const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< + { teamName: string; displayName: string; projectPath: string }[] + >([]); + const launchDialogOpen = launchDialogState.open; + + // Session loading and filtering state + const [sessions, setSessions] = useState([]); + const [sessionsLoading, setSessionsLoading] = useState(false); + const [sessionsError, setSessionsError] = useState(null); + const [kanbanFilter, setKanbanFilter] = useState({ + sessionId: null, + selectedOwners: new Set(), + columns: new Set(), + }); + const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); + + const { + data, + members, + loading, + error, + projects, + repositoryGroups, + initTabUIState, + selectTeam, + updateKanban, + updateKanbanColumnOrder, + updateTaskStatus, + updateTaskOwner, + sendTeamMessage, + requestReview, + createTeamTask, + startTaskByUser, + deleteTeam, + openTeamsTab, + closeTab, + sendingMessage, + sendMessageError, + sendMessageWarning, + sendMessageDebugDetails, + lastSendMessageResult, + reviewActionError, + addMember, + restartMember, + skipMemberForLaunch, + removeMember, + updateMemberRole, + launchTeam, + provisioningError, + clearProvisioningError, + isTeamProvisioning, + refreshTeamData, + refreshTeamMessagesHead, + refreshMemberActivityMeta, + syncTeamPendingReplyRefresh, + kanbanFilterQuery, + clearKanbanFilter, + softDeleteTask, + restoreTask, + fetchDeletedTasks, + deletedTasks, + launchParams, + messagesPanelMode, + messagesPanelWidth, + sidebarLogsHeight, + setMessagesPanelMode, + setMessagesPanelWidth, + setSidebarLogsHeight, + selectReviewFile, + pendingReviewRequest, + setPendingReviewRequest, + } = useStore( + useShallow((s) => ({ + projects: s.projects, + repositoryGroups: s.repositoryGroups, + initTabUIState: s.initTabUIState, + selectTeam: s.selectTeam, + updateKanban: s.updateKanban, + updateKanbanColumnOrder: s.updateKanbanColumnOrder, + updateTaskStatus: s.updateTaskStatus, + updateTaskOwner: s.updateTaskOwner, + sendTeamMessage: s.sendTeamMessage, + requestReview: s.requestReview, + createTeamTask: s.createTeamTask, + startTaskByUser: s.startTaskByUser, + deleteTeam: s.deleteTeam, + openTeamsTab: s.openTeamsTab, + closeTab: s.closeTab, + sendingMessage: s.sendingMessage, + sendMessageError: s.sendMessageError, + sendMessageWarning: s.sendMessageWarning, + sendMessageDebugDetails: s.sendMessageDebugDetails, + lastSendMessageResult: s.lastSendMessageResult, + reviewActionError: s.reviewActionError, + addMember: s.addMember, + restartMember: s.restartMember, + skipMemberForLaunch: s.skipMemberForLaunch, + removeMember: s.removeMember, + updateMemberRole: s.updateMemberRole, + launchTeam: s.launchTeam, + provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, + clearProvisioningError: s.clearProvisioningError, + isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, + data: s.selectedTeamName === teamName ? s.selectedTeamData : null, + members: selectResolvedMembersForTeamName(s, teamName), + loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, + error: s.selectedTeamName === teamName ? s.selectedTeamError : null, + refreshTeamData: s.refreshTeamData, + refreshTeamMessagesHead: s.refreshTeamMessagesHead, + refreshMemberActivityMeta: s.refreshMemberActivityMeta, + syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, + kanbanFilterQuery: s.kanbanFilterQuery, + clearKanbanFilter: s.clearKanbanFilter, + softDeleteTask: s.softDeleteTask, + restoreTask: s.restoreTask, + fetchDeletedTasks: s.fetchDeletedTasks, + deletedTasks: s.deletedTasks, + launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, + messagesPanelMode: s.messagesPanelMode, + messagesPanelWidth: s.messagesPanelWidth, + sidebarLogsHeight: s.sidebarLogsHeight, + setMessagesPanelMode: s.setMessagesPanelMode, + setMessagesPanelWidth: s.setMessagesPanelWidth, + setSidebarLogsHeight: s.setSidebarLogsHeight, + selectReviewFile: s.selectReviewFile, + pendingReviewRequest: s.pendingReviewRequest, + setPendingReviewRequest: s.setPendingReviewRequest, + })) + ); + + const tabId = useTabIdOptional(); + const activeTabId = useStore((s) => s.activeTabId); + const isThisTabActive = tabId ? activeTabId === tabId : false; + const wasInteractiveRef = useRef(false); + + // Messages panel resize + const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = + useResizablePanel({ + width: messagesPanelWidth, + onWidthChange: setMessagesPanelWidth, + minWidth: 280, + maxWidth: 600, + side: 'left', + }); + const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = useResizablePanel({ + height: sidebarLogsHeight, + onHeightChange: setSidebarLogsHeight, + minHeight: 120, + maxHeight: 520, + side: 'top', + }); + + const changeMessagesPanelMode = useCallback( + (mode: TeamMessagesPanelMode) => { + setMessagesPanelMode(mode); + }, + [setMessagesPanelMode] + ); + + useEffect(() => { + if (tabId) { + initTabUIState(tabId); + } + }, [tabId, initTabUIState]); + + useEffect(() => { + setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); + }, [teamName]); + + useEffect(() => { + setTeamPendingRepliesState(teamName, pendingRepliesByMember); + }, [pendingRepliesByMember, teamName]); + + useEffect(() => { + const wasProvisioning = wasProvisioningRef.current; + wasProvisioningRef.current = isTeamProvisioning; + if (!wasProvisioning && isTeamProvisioning) { + provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [isTeamProvisioning]); + + const [kanbanSearch, setKanbanSearch] = useState(''); + + // Open editor overlay when a file reveal is requested (e.g. from chip click) + const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); + useEffect(() => { + if (pendingRevealFile && data?.config.projectPath) { + setEditorOpen(true); + } + }, [pendingRevealFile, data?.config.projectPath]); + + useEffect(() => { + if (!teamName) { + return; + } + void selectTeam(teamName); + void fetchDeletedTasks(teamName); + }, [teamName, selectTeam, fetchDeletedTasks]); + + // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. + // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins + // and other tabs get stuck with mismatched data (permanent skeleton). + // Re-trigger selectTeam when this tab becomes active and store data is stale. + const storedTeamName = data?.teamName; + useEffect(() => { + if (!isThisTabActive || !teamName || loading) return; + if (storedTeamName != null && storedTeamName !== teamName) { + void selectTeam(teamName); + } + }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); + + useEffect(() => { + const isInteractive = isThisTabActive && isPaneFocused; + const justBecameInteractive = isInteractive && !wasInteractiveRef.current; + wasInteractiveRef.current = isInteractive; + if (!justBecameInteractive || !teamName) { + return; + } + + void (async () => { + try { + const headResult = await refreshTeamMessagesHead(teamName); + if (headResult.feedChanged) { + await refreshMemberActivityMeta(teamName); + } + } catch { + // Best-effort refresh on tab focus. + } + })(); + }, [ + isPaneFocused, + isThisTabActive, + refreshMemberActivityMeta, + refreshTeamMessagesHead, + teamName, + ]); + + // Fetch active teams when launch dialog opens (for conflict warning) + useEffect(() => { + if (!launchDialogOpen) return; + let cancelled = false; + const teamsSnapshot = useStore.getState().teams; + void (async () => { + try { + const aliveList = await api.teams.aliveList(); + if (cancelled) return; + const aliveSet = new Set(aliveList); + const refs = teamsSnapshot + .filter((t) => aliveSet.has(t.teamName) && t.projectPath) + .map((t) => ({ + teamName: t.teamName, + displayName: t.displayName, + projectPath: t.projectPath!, + })); + setActiveTeamsForLaunch(refs); + } catch { + // best-effort + } + })(); + return () => { + cancelled = true; + }; + }, [launchDialogOpen]); + + useEffect(() => { + if (kanbanFilterQuery) { + setKanbanSearch(kanbanFilterQuery); + clearKanbanFilter(); + } + }, [kanbanFilterQuery, clearKanbanFilter]); + + // Load sessions for the team's project + const projectId = useMemo( + () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), + [projects, repositoryGroups, data?.config.projectPath] + ); + + const leadSessionId = data?.config.leadSessionId ?? null; + const pendingReplyRefreshSourceId = useId(); + const sessionHistoryKey = useMemo( + () => (data?.config.sessionHistory ?? []).join('|'), + [data?.config.sessionHistory] + ); + + // Keep team message state fresh while we are explicitly waiting for a reply. + // This stays enabled even for hidden mounted tabs, because the waiting state + // is renderer-local and should keep its lightweight polling until resolved. + useEffect(() => { + const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; + syncTeamPendingReplyRefresh( + teamName, + pendingReplyRefreshSourceId, + Boolean(data?.isAlive) && hasPendingReplies, + TEAM_PENDING_REPLY_REFRESH_DELAY_MS ); - const [createTaskDialog, setCreateTaskDialog] = useState({ + + return () => { + syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); + }; + }, [ + data?.isAlive, + pendingRepliesByMember, + pendingReplyRefreshSourceId, + syncTeamPendingReplyRefresh, + teamName, + ]); + + useEffect(() => { + if (!projectId) return; + + let cancelled = false; + setSessionsLoading(true); + setSessionsError(null); + + void (async () => { + try { + const result = await api.getSessions(projectId); + if (!cancelled) { + setSessions(result); + } + } catch (e) { + if (!cancelled) { + setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); + } + } finally { + if (!cancelled) { + setSessionsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectId]); + + // Live git branch tracking for the lead project and member worktrees + const teamProjectPath = data?.config.projectPath?.trim() ?? null; + const leadProjectPath = useMemo(() => { + const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); + return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; + }, [members, teamProjectPath]); + const branchSyncPaths = useMemo(() => { + const uniquePaths = new Map(); + const addPath = (candidate: string | null | undefined): void => { + const trimmed = candidate?.trim(); + if (!trimmed) return; + const key = normalizePath(trimmed); + if (!key || uniquePaths.has(key)) return; + uniquePaths.set(key, trimmed); + }; + + addPath(leadProjectPath); + for (const member of members) { + addPath(member.cwd); + } + + return Array.from(uniquePaths.values()); + }, [members, leadProjectPath]); + useBranchSync(branchSyncPaths, { live: true }); + const trackedBranches = useStore( + useShallow((s) => + Object.fromEntries( + branchSyncPaths.map((projectPath) => { + const normalizedPath = normalizePath(projectPath); + return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; + }) + ) + ) + ); + const leadBranch = leadProjectPath + ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) + : null; + const membersWithLiveBranches = useMemo(() => { + if (!data) return []; + + return members.map((member) => { + const memberPath = member.cwd?.trim(); + const nextGitBranch = + memberPath && !isLeadMember(member) && leadBranch !== null + ? (() => { + const branch = trackedBranches[normalizePath(memberPath)] ?? null; + return branch && branch !== leadBranch ? branch : undefined; + })() + : undefined; + + if (member.gitBranch === nextGitBranch) { + return member; + } + + const nextMember: ResolvedTeamMember = { ...member }; + if (nextGitBranch) { + nextMember.gitBranch = nextGitBranch; + } else { + delete nextMember.gitBranch; + } + return nextMember; + }); + }, [leadBranch, members, trackedBranches]); + const resolvedMemberColorMap = useMemo( + () => buildMemberColorMap(membersWithLiveBranches), + [membersWithLiveBranches] + ); + + // Filter sessions to team-only using sessionHistory + leadSessionId + const teamSessionIds = useMemo(() => { + const sessionIds = new Set(); + if (data?.config.leadSessionId) { + sessionIds.add(data.config.leadSessionId); + } + if (data?.config.sessionHistory) { + for (const id of data.config.sessionHistory) { + sessionIds.add(id); + } + } + return sessionIds; + }, [data?.config.leadSessionId, data?.config.sessionHistory]); + + const teamSessions = useMemo(() => { + // If no session IDs known (backward compat), show all sessions + if (teamSessionIds.size === 0) return sessions; + return sessions.filter((s) => teamSessionIds.has(s.id)); + }, [sessions, teamSessionIds]); + + // Auto-reset session filter if the selected session is no longer in teamSessions + useEffect(() => { + if ( + kanbanFilter.sessionId !== null && + !teamSessions.some((s) => s.id === kanbanFilter.sessionId) + ) { + setKanbanFilter((prev) => ({ ...prev, sessionId: null })); + } + }, [kanbanFilter.sessionId, teamSessions]); + + // Compute time-window for session filtering + const timeWindow = useMemo(() => { + if (kanbanFilter.sessionId === null) return null; + + const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); + const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); + if (idx === -1) return null; + + const start = sorted[idx].createdAt; + const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; + return { start, end }; + }, [kanbanFilter.sessionId, teamSessions]); + + // Filter tasks by time-window and owner + const filteredTasks = useMemo(() => { + if (!data) return []; + let result = data.tasks; + + // Session time-window filter + if (timeWindow) { + result = result.filter((t) => { + if (!t.createdAt) return true; // legacy tasks always included + const ts = new Date(t.createdAt).getTime(); + return ts >= timeWindow.start && ts < timeWindow.end; + }); + } + + // Owner filter + if (kanbanFilter.selectedOwners.size > 0) { + result = result.filter((t) => + t.owner + ? kanbanFilter.selectedOwners.has(t.owner) + : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) + ); + } + + return result; + }, [data, timeWindow, kanbanFilter.selectedOwners]); + + const activeMembers = useStableActiveMembers(membersWithLiveBranches); + + const kanbanDisplayTasks = useMemo(() => { + const query = kanbanSearch.trim(); + if (!query) return filteredTasks; + return filterKanbanTasks(filteredTasks, query); + }, [filteredTasks, kanbanSearch]); + + const activeTeammateCount = useMemo( + () => activeMembers.filter((m) => !isLeadMember(m)).length, + [activeMembers] + ); + const leadProviderId = useMemo(() => { + const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; + if (activeLeadProviderId) return activeLeadProviderId; + const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; + if (configuredLeadProviderId) return configuredLeadProviderId; + return launchParams?.providerId; + }, [activeMembers, data?.config.members, launchParams?.providerId]); + const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); + + const taskMap = useMemo(() => new Map((data?.tasks ?? []).map((t) => [t.id, t])), [data?.tasks]); + const taskMapRef = useRef(taskMap); + taskMapRef.current = taskMap; + + const memberTaskCounts = useMemo(() => buildTaskCountsByOwner(data?.tasks ?? []), [data?.tasks]); + + const openCreateTaskDialog = useCallback( + (subject = '', description = '', owner = '', startImmediately?: boolean): void => { + setCreateTaskDialog({ + open: true, + defaultSubject: subject, + defaultDescription: description, + defaultOwner: owner, + defaultStartImmediately: startImmediately, + }); + }, + [] + ); + + const closeCreateTaskDialog = useCallback((): void => { + setCreateTaskDialog({ open: false, defaultSubject: '', defaultDescription: '', defaultOwner: '', + defaultStartImmediately: undefined, }); - const [creatingTask, setCreatingTask] = useState(false); - const [addMemberDialogOpen, setAddMemberDialogOpen] = useState(false); - const [addingMemberLoading, setAddingMemberLoading] = useState(false); - const [removeMemberConfirm, setRemoveMemberConfirm] = useState(null); - const [updatingRoleLoading, setUpdatingRoleLoading] = useState(false); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [launchDialogState, setLaunchDialogState] = useState<{ - open: boolean; - mode: TeamLaunchDialogMode; - }>({ - open: false, - mode: 'launch', - }); - const [editorOpen, setEditorOpen] = useState(false); - const [graphOpen, setGraphOpen] = useState(false); - const contentRef = useRef(null); - const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState( - null - ); - const provisioningBannerRef = useRef(null); - const wasProvisioningRef = useRef(false); - const handleOpenGraphTab = useCallback(() => { - const state = useStore.getState(); - const displayName = state.teamByName[teamName]?.displayName ?? teamName; - state.openTab({ - type: 'graph', - label: `${displayName} Graph`, + }, []); + + const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { + openCreateTaskDialog(subject, description); + }, []); + + const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { + setSendDialogRecipient(message.from); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); + setSendDialogOpen(true); + }, []); + + const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { + setLaunchDialogState({ open: true, mode }); + }, []); + + const closeLaunchDialog = useCallback(() => { + setLaunchDialogState((prev) => ({ ...prev, open: false })); + }, []); + + const handleRestartTeam = useCallback(() => { + openLaunchDialog('relaunch'); + }, [openLaunchDialog]); + + const handleLaunchDialogSubmit = useCallback( + async (request: TeamLaunchRequest): Promise => { + await launchTeam(request); + }, + [launchTeam] + ); + + const handleRelaunchDialogSubmit = useCallback( + async ( + request: TeamLaunchRequest, + nextMembers: TeamCreateRequest['members'] + ): Promise => { + await executeTeamRelaunch({ teamName, + isTeamAlive: data?.isAlive === true, + request, + members: nextMembers, + stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), + replaceMembers: (nextTeamName, nextRequest) => + api.teams.replaceMembers(nextTeamName, nextRequest), + launchTeam, }); - }, [teamName]); - const visualizeButtonStyle = useMemo( - () => - isLight - ? { - background: - 'linear-gradient(135deg, rgba(59,130,246,0.14) 0%, rgba(34,197,94,0.16) 100%)', - borderColor: 'rgba(59,130,246,0.30)', - color: '#0f172a', - boxShadow: '0 10px 24px rgba(59,130,246,0.12)', - } - : { - background: - 'linear-gradient(135deg, rgba(56,189,248,0.18) 0%, rgba(16,185,129,0.16) 100%)', - borderColor: 'rgba(56,189,248,0.34)', - color: 'rgba(236,253,255,0.96)', - boxShadow: '0 12px 28px rgba(8,145,178,0.22)', - }, - [isLight] - ); + }, + [data?.isAlive, launchTeam, teamName] + ); - // Set inert on background content when editor/graph overlay is open (a11y focus trap) - useEffect(() => { - const el = contentRef.current; - if (!el) return; - if (editorOpen || graphOpen) { - el.setAttribute('inert', ''); - } else { - el.removeAttribute('inert'); - } - }, [editorOpen, graphOpen]); + const handleChangeLeadRuntime = useCallback(() => { + setEditDialogOpen(false); + openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); + }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - // Listen for Cmd+Shift+G keyboard shortcut — opens graph tab - useEffect(() => { - const handler = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (detail?.teamName === teamName) { - handleOpenGraphTab(); - } - }; - window.addEventListener('toggle-team-graph', handler); - return () => window.removeEventListener('toggle-team-graph', handler); - }, [handleOpenGraphTab, teamName]); + const handleRestartMember = useCallback( + async (memberName: string): Promise => { + await restartMember(teamName, memberName); + }, + [restartMember, teamName] + ); - // Listen for graph tab actions (open task, send message) - useEffect(() => { - const onOpenTask = (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - if (task) setSelectedTask(task); - }; - const onSendMsg = (e: Event) => { - const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); + const handleSkipMemberForLaunch = useCallback( + async (memberName: string): Promise => { + await skipMemberForLaunch(teamName, memberName); + }, + [skipMemberForLaunch, teamName] + ); + + const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + setSelectedMember(member); + setSelectedMemberView(null); + }, []); + + const closeSelectedMemberDialog = useCallback(() => { + setSelectedMember(null); + setSelectedMemberView(null); + }, []); + + const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { + setSendDialogRecipient(member.name); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }, []); + + const handleAssignTaskToMember = useCallback( + (member: ResolvedTeamMember) => { + openCreateTaskDialog('', '', member.name); + }, + [openCreateTaskDialog] + ); + + const handleOpenTaskById = useCallback((taskId: string) => { + const task = taskMapRef.current.get(taskId); + if (task) { + setSelectedTask(task); + } + }, []); + + const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { + setSelectedTask(task); + }, []); + + const handleTaskIdClick = useCallback( + (taskId: string) => { + const task = + taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); + if (task) setSelectedTask(task); + }, + [taskMap, data?.tasks] + ); + + const handleEditorAction = useCallback( + (action: EditorSelectionAction) => { + const chip = createChipFromSelection(action, []) ?? undefined; + if (action.type === 'sendMessage') { + setSendDialogDefaultText(chip ? undefined : action.formattedContext); + setSendDialogDefaultChip(chip); + setSendDialogRecipient(undefined); + setReplyQuote(undefined); setSendDialogOpen(true); - }; - const onOpenProfile = (e: Event) => { - const { - teamName: tn, - memberName, - initialTab, - initialActivityFilter, - } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !data) return; - const member = members.find((m: { name: string }) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab, - initialActivityFilter, + } else if (action.type === 'createTask') { + if (chip) { + setCreateTaskDialog({ + open: true, + defaultSubject: '', + defaultDescription: '', + defaultOwner: '', + defaultStartImmediately: undefined, + defaultChip: chip, }); - } - }; - const onCreateTask = (e: Event) => { - const { teamName: tn, owner } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName) return; - openCreateTaskDialog('', '', owner ?? ''); - }; - window.addEventListener('graph:open-task', onOpenTask); - window.addEventListener('graph:send-message', onSendMsg); - window.addEventListener('graph:open-profile', onOpenProfile); - window.addEventListener('graph:create-task', onCreateTask); - - // Task action events from graph - const taskAction = (handler: (taskId: string) => void) => (e: Event) => { - const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {}; - if (tn !== teamName || !taskId) return; - handler(taskId); - }; - const onStartTask = taskAction((taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t: { id: string }) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } - } catch { - /* best-effort */ - } - } - } catch { - /* error via store */ - } - })(); - }); - const onCompleteTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onApproveTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' }); - } catch { - /* */ - } - })(); - }); - const onRequestReviewTask = taskAction((taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - /* */ - } - })(); - }); - const onRequestChangesTask = taskAction((taskId) => { - setRequestChangesTaskId(taskId); - }); - const onCancelTask = taskAction((taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'pending'); - } catch { - /* */ - } - })(); - }); - const onMoveBackToDoneTask = taskAction((taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - /* */ - } - })(); - }); - const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId)); - - window.addEventListener('graph:start-task', onStartTask); - window.addEventListener('graph:complete-task', onCompleteTask); - window.addEventListener('graph:approve-task', onApproveTask); - window.addEventListener('graph:request-review', onRequestReviewTask); - window.addEventListener('graph:request-changes', onRequestChangesTask); - window.addEventListener('graph:cancel-task', onCancelTask); - window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.addEventListener('graph:delete-task', onDeleteTaskGraph); - return () => { - window.removeEventListener('graph:open-task', onOpenTask); - window.removeEventListener('graph:send-message', onSendMsg); - window.removeEventListener('graph:open-profile', onOpenProfile); - window.removeEventListener('graph:create-task', onCreateTask); - window.removeEventListener('graph:start-task', onStartTask); - window.removeEventListener('graph:complete-task', onCompleteTask); - window.removeEventListener('graph:approve-task', onApproveTask); - window.removeEventListener('graph:request-review', onRequestReviewTask); - window.removeEventListener('graph:request-changes', onRequestChangesTask); - window.removeEventListener('graph:cancel-task', onCancelTask); - window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask); - window.removeEventListener('graph:delete-task', onDeleteTaskGraph); - }; - }); - - const [sendDialogOpen, setSendDialogOpen] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [stoppingTeam, setStoppingTeam] = useState(false); - const [trashOpen, setTrashOpen] = useState(false); - const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); - const [sendDialogDefaultText, setSendDialogDefaultText] = useState( - undefined - ); - const [sendDialogDefaultChip, setSendDialogDefaultChip] = useState( - undefined - ); - const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( - undefined - ); - const [reviewDialogState, setReviewDialogState] = useState<{ - open: boolean; - mode: 'agent' | 'task'; - memberName?: string; - taskId?: string; - initialFilePath?: string; - taskChangeRequestOptions?: TaskChangeRequestOptions; - }>({ open: false, mode: 'task' }); - - // Active teams for conflict warning in LaunchTeamDialog - const [activeTeamsForLaunch, setActiveTeamsForLaunch] = useState< - { teamName: string; displayName: string; projectPath: string }[] - >([]); - const launchDialogOpen = launchDialogState.open; - - // Session loading and filtering state - const [sessions, setSessions] = useState([]); - const [sessionsLoading, setSessionsLoading] = useState(false); - const [sessionsError, setSessionsError] = useState(null); - const [kanbanFilter, setKanbanFilter] = useState({ - sessionId: null, - selectedOwners: new Set(), - columns: new Set(), - }); - const [kanbanSort, setKanbanSort] = useState({ field: 'updatedAt' }); - - const { - data, - members, - loading, - error, - projects, - repositoryGroups, - initTabUIState, - selectTeam, - updateKanban, - updateKanbanColumnOrder, - updateTaskStatus, - updateTaskOwner, - sendTeamMessage, - requestReview, - createTeamTask, - startTaskByUser, - deleteTeam, - openTeamsTab, - closeTab, - sendingMessage, - sendMessageError, - sendMessageWarning, - sendMessageDebugDetails, - lastSendMessageResult, - reviewActionError, - addMember, - restartMember, - skipMemberForLaunch, - removeMember, - updateMemberRole, - launchTeam, - provisioningError, - clearProvisioningError, - isTeamProvisioning, - refreshTeamData, - refreshTeamMessagesHead, - refreshMemberActivityMeta, - syncTeamPendingReplyRefresh, - kanbanFilterQuery, - clearKanbanFilter, - softDeleteTask, - restoreTask, - fetchDeletedTasks, - deletedTasks, - launchParams, - messagesPanelMode, - messagesPanelWidth, - sidebarLogsHeight, - setMessagesPanelMode, - setMessagesPanelWidth, - setSidebarLogsHeight, - selectReviewFile, - pendingReviewRequest, - setPendingReviewRequest, - } = useStore( - useShallow((s) => ({ - projects: s.projects, - repositoryGroups: s.repositoryGroups, - initTabUIState: s.initTabUIState, - selectTeam: s.selectTeam, - updateKanban: s.updateKanban, - updateKanbanColumnOrder: s.updateKanbanColumnOrder, - updateTaskStatus: s.updateTaskStatus, - updateTaskOwner: s.updateTaskOwner, - sendTeamMessage: s.sendTeamMessage, - requestReview: s.requestReview, - createTeamTask: s.createTeamTask, - startTaskByUser: s.startTaskByUser, - deleteTeam: s.deleteTeam, - openTeamsTab: s.openTeamsTab, - closeTab: s.closeTab, - sendingMessage: s.sendingMessage, - sendMessageError: s.sendMessageError, - sendMessageWarning: s.sendMessageWarning, - sendMessageDebugDetails: s.sendMessageDebugDetails, - lastSendMessageResult: s.lastSendMessageResult, - reviewActionError: s.reviewActionError, - addMember: s.addMember, - restartMember: s.restartMember, - skipMemberForLaunch: s.skipMemberForLaunch, - removeMember: s.removeMember, - updateMemberRole: s.updateMemberRole, - launchTeam: s.launchTeam, - provisioningError: teamName ? (s.provisioningErrorByTeam[teamName] ?? null) : null, - clearProvisioningError: s.clearProvisioningError, - isTeamProvisioning: teamName ? isTeamProvisioningActive(s, teamName) : false, - data: s.selectedTeamName === teamName ? s.selectedTeamData : null, - members: selectResolvedMembersForTeamName(s, teamName), - loading: s.selectedTeamName === teamName ? s.selectedTeamLoading : false, - error: s.selectedTeamName === teamName ? s.selectedTeamError : null, - refreshTeamData: s.refreshTeamData, - refreshTeamMessagesHead: s.refreshTeamMessagesHead, - refreshMemberActivityMeta: s.refreshMemberActivityMeta, - syncTeamPendingReplyRefresh: s.syncTeamPendingReplyRefresh, - kanbanFilterQuery: s.kanbanFilterQuery, - clearKanbanFilter: s.clearKanbanFilter, - softDeleteTask: s.softDeleteTask, - restoreTask: s.restoreTask, - fetchDeletedTasks: s.fetchDeletedTasks, - deletedTasks: s.deletedTasks, - launchParams: teamName ? s.launchParamsByTeam[teamName] : undefined, - messagesPanelMode: s.messagesPanelMode, - messagesPanelWidth: s.messagesPanelWidth, - sidebarLogsHeight: s.sidebarLogsHeight, - setMessagesPanelMode: s.setMessagesPanelMode, - setMessagesPanelWidth: s.setMessagesPanelWidth, - setSidebarLogsHeight: s.setSidebarLogsHeight, - selectReviewFile: s.selectReviewFile, - pendingReviewRequest: s.pendingReviewRequest, - setPendingReviewRequest: s.setPendingReviewRequest, - })) - ); - - const tabId = useTabIdOptional(); - const activeTabId = useStore((s) => s.activeTabId); - const isThisTabActive = tabId ? activeTabId === tabId : false; - const wasInteractiveRef = useRef(false); - - // Messages panel resize - const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } = - useResizablePanel({ - width: messagesPanelWidth, - onWidthChange: setMessagesPanelWidth, - minWidth: 280, - maxWidth: 600, - side: 'left', - }); - const { isResizing: isLogsPanelResizing, handleProps: logsPanelHandleProps } = - useResizablePanel({ - height: sidebarLogsHeight, - onHeightChange: setSidebarLogsHeight, - minHeight: 120, - maxHeight: 520, - side: 'top', - }); - - const changeMessagesPanelMode = useCallback( - (mode: TeamMessagesPanelMode) => { - setMessagesPanelMode(mode); - }, - [setMessagesPanelMode] - ); - - useEffect(() => { - if (tabId) { - initTabUIState(tabId); - } - }, [tabId, initTabUIState]); - - useEffect(() => { - setPendingRepliesByMember(getTeamPendingRepliesState(teamName)); - }, [teamName]); - - useEffect(() => { - setTeamPendingRepliesState(teamName, pendingRepliesByMember); - }, [pendingRepliesByMember, teamName]); - - useEffect(() => { - const wasProvisioning = wasProvisioningRef.current; - wasProvisioningRef.current = isTeamProvisioning; - if (!wasProvisioning && isTeamProvisioning) { - provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [isTeamProvisioning]); - - const [kanbanSearch, setKanbanSearch] = useState(''); - - // Open editor overlay when a file reveal is requested (e.g. from chip click) - const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); - useEffect(() => { - if (pendingRevealFile && data?.config.projectPath) { - setEditorOpen(true); - } - }, [pendingRevealFile, data?.config.projectPath]); - - useEffect(() => { - if (!teamName) { - return; - } - void selectTeam(teamName); - void fetchDeletedTasks(teamName); - }, [teamName, selectTeam, fetchDeletedTasks]); - - // Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. - // With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins - // and other tabs get stuck with mismatched data (permanent skeleton). - // Re-trigger selectTeam when this tab becomes active and store data is stale. - const storedTeamName = data?.teamName; - useEffect(() => { - if (!isThisTabActive || !teamName || loading) return; - if (storedTeamName != null && storedTeamName !== teamName) { - void selectTeam(teamName); - } - }, [isThisTabActive, teamName, storedTeamName, loading, selectTeam]); - - useEffect(() => { - const isInteractive = isThisTabActive && isPaneFocused; - const justBecameInteractive = isInteractive && !wasInteractiveRef.current; - wasInteractiveRef.current = isInteractive; - if (!justBecameInteractive || !teamName) { - return; - } - - void (async () => { - try { - const headResult = await refreshTeamMessagesHead(teamName); - if (headResult.feedChanged) { - await refreshMemberActivityMeta(teamName); - } - } catch { - // Best-effort refresh on tab focus. - } - })(); - }, [ - isPaneFocused, - isThisTabActive, - refreshMemberActivityMeta, - refreshTeamMessagesHead, - teamName, - ]); - - // Fetch active teams when launch dialog opens (for conflict warning) - useEffect(() => { - if (!launchDialogOpen) return; - let cancelled = false; - const teamsSnapshot = useStore.getState().teams; - void (async () => { - try { - const aliveList = await api.teams.aliveList(); - if (cancelled) return; - const aliveSet = new Set(aliveList); - const refs = teamsSnapshot - .filter((t) => aliveSet.has(t.teamName) && t.projectPath) - .map((t) => ({ - teamName: t.teamName, - displayName: t.displayName, - projectPath: t.projectPath!, - })); - setActiveTeamsForLaunch(refs); - } catch { - // best-effort - } - })(); - return () => { - cancelled = true; - }; - }, [launchDialogOpen]); - - useEffect(() => { - if (kanbanFilterQuery) { - setKanbanSearch(kanbanFilterQuery); - clearKanbanFilter(); - } - }, [kanbanFilterQuery, clearKanbanFilter]); - - // Load sessions for the team's project - const projectId = useMemo( - () => resolveProjectIdByPath(data?.config.projectPath, projects, repositoryGroups), - [projects, repositoryGroups, data?.config.projectPath] - ); - - const leadSessionId = data?.config.leadSessionId ?? null; - const pendingReplyRefreshSourceId = useId(); - const sessionHistoryKey = useMemo( - () => (data?.config.sessionHistory ?? []).join('|'), - [data?.config.sessionHistory] - ); - - // Keep team message state fresh while we are explicitly waiting for a reply. - // This stays enabled even for hidden mounted tabs, because the waiting state - // is renderer-local and should keep its lightweight polling until resolved. - useEffect(() => { - const hasPendingReplies = Object.keys(pendingRepliesByMember).length > 0; - syncTeamPendingReplyRefresh( - teamName, - pendingReplyRefreshSourceId, - Boolean(data?.isAlive) && hasPendingReplies, - TEAM_PENDING_REPLY_REFRESH_DELAY_MS - ); - - return () => { - syncTeamPendingReplyRefresh(teamName, pendingReplyRefreshSourceId, false); - }; - }, [ - data?.isAlive, - pendingRepliesByMember, - pendingReplyRefreshSourceId, - syncTeamPendingReplyRefresh, - teamName, - ]); - - useEffect(() => { - if (!projectId) return; - - let cancelled = false; - setSessionsLoading(true); - setSessionsError(null); - - void (async () => { - try { - const result = await api.getSessions(projectId); - if (!cancelled) { - setSessions(result); - } - } catch (e) { - if (!cancelled) { - setSessionsError(e instanceof Error ? e.message : 'Failed to load sessions'); - } - } finally { - if (!cancelled) { - setSessionsLoading(false); - } - } - })(); - - return () => { - cancelled = true; - }; - }, [projectId]); - - // Live git branch tracking for the lead project and member worktrees - const teamProjectPath = data?.config.projectPath?.trim() ?? null; - const leadProjectPath = useMemo(() => { - const explicitLeadPath = members.find((member) => isLeadMember(member))?.cwd?.trim(); - return explicitLeadPath && explicitLeadPath.length > 0 ? explicitLeadPath : teamProjectPath; - }, [members, teamProjectPath]); - const branchSyncPaths = useMemo(() => { - const uniquePaths = new Map(); - const addPath = (candidate: string | null | undefined): void => { - const trimmed = candidate?.trim(); - if (!trimmed) return; - const key = normalizePath(trimmed); - if (!key || uniquePaths.has(key)) return; - uniquePaths.set(key, trimmed); - }; - - addPath(leadProjectPath); - for (const member of members) { - addPath(member.cwd); - } - - return Array.from(uniquePaths.values()); - }, [members, leadProjectPath]); - useBranchSync(branchSyncPaths, { live: true }); - const trackedBranches = useStore( - useShallow((s) => - Object.fromEntries( - branchSyncPaths.map((projectPath) => { - const normalizedPath = normalizePath(projectPath); - return [normalizedPath, s.branchByPath[normalizedPath] ?? null] as const; - }) - ) - ) - ); - const leadBranch = leadProjectPath - ? (trackedBranches[normalizePath(leadProjectPath)] ?? null) - : null; - const membersWithLiveBranches = useMemo(() => { - if (!data) return []; - - return members.map((member) => { - const memberPath = member.cwd?.trim(); - const nextGitBranch = - memberPath && !isLeadMember(member) && leadBranch !== null - ? (() => { - const branch = trackedBranches[normalizePath(memberPath)] ?? null; - return branch && branch !== leadBranch ? branch : undefined; - })() - : undefined; - - if (member.gitBranch === nextGitBranch) { - return member; - } - - const nextMember: ResolvedTeamMember = { ...member }; - if (nextGitBranch) { - nextMember.gitBranch = nextGitBranch; } else { - delete nextMember.gitBranch; - } - return nextMember; - }); - }, [leadBranch, members, trackedBranches]); - const resolvedMemberColorMap = useMemo( - () => buildMemberColorMap(membersWithLiveBranches), - [membersWithLiveBranches] - ); - - // Filter sessions to team-only using sessionHistory + leadSessionId - const teamSessionIds = useMemo(() => { - const sessionIds = new Set(); - if (data?.config.leadSessionId) { - sessionIds.add(data.config.leadSessionId); - } - if (data?.config.sessionHistory) { - for (const id of data.config.sessionHistory) { - sessionIds.add(id); + openCreateTaskDialog('', action.formattedContext); } } - return sessionIds; - }, [data?.config.leadSessionId, data?.config.sessionHistory]); + }, - const teamSessions = useMemo(() => { - // If no session IDs known (backward compat), show all sessions - if (teamSessionIds.size === 0) return sessions; - return sessions.filter((s) => teamSessionIds.has(s.id)); - }, [sessions, teamSessionIds]); + [] + ); - // Auto-reset session filter if the selected session is no longer in teamSessions - useEffect(() => { - if ( - kanbanFilter.sessionId !== null && - !teamSessions.some((s) => s.id === kanbanFilter.sessionId) - ) { - setKanbanFilter((prev) => ({ ...prev, sessionId: null })); - } - }, [kanbanFilter.sessionId, teamSessions]); + const handleStopTeam = useCallback(async (): Promise => { + setStoppingTeam(true); + try { + await api.teams.stop(teamName); + // Backend sends 'disconnected' progress which triggers store refresh, + // but refresh here too as a safety net (e.g. if progress event is missed). + await refreshTeamData(teamName); + } catch (err) { + console.error('Failed to stop team:', err); + } finally { + setStoppingTeam(false); + } + }, [teamName, refreshTeamData]); - // Compute time-window for session filtering - const timeWindow = useMemo(() => { - if (kanbanFilter.sessionId === null) return null; + // Pick up pending review request from GlobalTaskDetailDialog + useEffect(() => { + if (!pendingReviewRequest) return; + setReviewDialogState({ + open: true, + mode: 'task', + taskId: pendingReviewRequest.taskId, + initialFilePath: pendingReviewRequest.filePath, + taskChangeRequestOptions: pendingReviewRequest.requestOptions, + }); + if (pendingReviewRequest.filePath) { + selectReviewFile(pendingReviewRequest.filePath); + } + setPendingReviewRequest(null); + }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); - const sorted = [...teamSessions].sort((a, b) => a.createdAt - b.createdAt); - const idx = sorted.findIndex((s) => s.id === kanbanFilter.sessionId); - if (idx === -1) return null; - - const start = sorted[idx].createdAt; - const end = idx + 1 < sorted.length ? sorted[idx + 1].createdAt : Infinity; - return { start, end }; - }, [kanbanFilter.sessionId, teamSessions]); - - // Filter tasks by time-window and owner - const filteredTasks = useMemo(() => { - if (!data) return []; - let result = data.tasks; - - // Session time-window filter - if (timeWindow) { - result = result.filter((t) => { - if (!t.createdAt) return true; // legacy tasks always included - const ts = new Date(t.createdAt).getTime(); - return ts >= timeWindow.start && ts < timeWindow.end; - }); - } - - // Owner filter - if (kanbanFilter.selectedOwners.size > 0) { - result = result.filter((t) => - t.owner - ? kanbanFilter.selectedOwners.has(t.owner) - : kanbanFilter.selectedOwners.has(UNASSIGNED_OWNER) - ); - } - - return result; - }, [data, timeWindow, kanbanFilter.selectedOwners]); - - const activeMembers = useStableActiveMembers(membersWithLiveBranches); - - const kanbanDisplayTasks = useMemo(() => { - const query = kanbanSearch.trim(); - if (!query) return filteredTasks; - return filterKanbanTasks(filteredTasks, query); - }, [filteredTasks, kanbanSearch]); - - const activeTeammateCount = useMemo( - () => activeMembers.filter((m) => !isLeadMember(m)).length, - [activeMembers] - ); - const leadProviderId = useMemo(() => { - const activeLeadProviderId = activeMembers.find(isLeadMember)?.providerId; - if (activeLeadProviderId) return activeLeadProviderId; - const configuredLeadProviderId = data?.config.members?.find(isLeadMember)?.providerId; - if (configuredLeadProviderId) return configuredLeadProviderId; - return launchParams?.providerId; - }, [activeMembers, data?.config.members, launchParams?.providerId]); - const shouldShowLeadContextUi = canShowLeadContextUi(leadProviderId); - - const taskMap = useMemo( - () => new Map((data?.tasks ?? []).map((t) => [t.id, t])), - [data?.tasks] - ); - const taskMapRef = useRef(taskMap); - taskMapRef.current = taskMap; - - const memberTaskCounts = useMemo( - () => buildTaskCountsByOwner(data?.tasks ?? []), - [data?.tasks] - ); - - const openCreateTaskDialog = useCallback( - (subject = '', description = '', owner = '', startImmediately?: boolean): void => { - setCreateTaskDialog({ - open: true, - defaultSubject: subject, - defaultDescription: description, - defaultOwner: owner, - defaultStartImmediately: startImmediately, - }); - }, - [] - ); - - const closeCreateTaskDialog = useCallback((): void => { - setCreateTaskDialog({ - open: false, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - }); - }, []); - - const handleCreateTaskFromMessage = useCallback((subject: string, description: string) => { - openCreateTaskDialog(subject, description); - }, []); - - const handleReplyToMessage = useCallback((message: { from: string; text: string }) => { - setSendDialogRecipient(message.from); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote({ from: message.from, text: stripAgentBlocks(message.text) }); - setSendDialogOpen(true); - }, []); - - const openLaunchDialog = useCallback((mode: TeamLaunchDialogMode) => { - setLaunchDialogState({ open: true, mode }); - }, []); - - const closeLaunchDialog = useCallback(() => { - setLaunchDialogState((prev) => ({ ...prev, open: false })); - }, []); - - const handleRestartTeam = useCallback(() => { - openLaunchDialog('relaunch'); - }, [openLaunchDialog]); - - const handleLaunchDialogSubmit = useCallback( - async (request: TeamLaunchRequest): Promise => { - await launchTeam(request); - }, - [launchTeam] - ); - - const handleRelaunchDialogSubmit = useCallback( - async ( - request: TeamLaunchRequest, - nextMembers: TeamCreateRequest['members'] - ): Promise => { - await executeTeamRelaunch({ - teamName, - isTeamAlive: data?.isAlive === true, - request, - members: nextMembers, - stopTeam: (nextTeamName) => api.teams.stop(nextTeamName), - replaceMembers: (nextTeamName, nextRequest) => - api.teams.replaceMembers(nextTeamName, nextRequest), - launchTeam, - }); - }, - [data?.isAlive, launchTeam, teamName] - ); - - const handleChangeLeadRuntime = useCallback(() => { - setEditDialogOpen(false); - openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch'); - }, [data?.isAlive, isTeamProvisioning, openLaunchDialog]); - - const handleRestartMember = useCallback( - async (memberName: string): Promise => { - await restartMember(teamName, memberName); - }, - [restartMember, teamName] - ); - - const handleSkipMemberForLaunch = useCallback( - async (memberName: string): Promise => { - await skipMemberForLaunch(teamName, memberName); - }, - [skipMemberForLaunch, teamName] - ); - - const handleSelectMember = useCallback((member: ResolvedTeamMember) => { + // Pick up pending member profile request from MemberHoverCard + const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); + useEffect(() => { + if (!pendingMemberProfile || !data) return; + const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); + if (member) { setSelectedMember(member); setSelectedMemberView(null); - }, []); + } + useStore.getState().closeMemberProfile(); + }, [pendingMemberProfile, membersWithLiveBranches]); - const closeSelectedMemberDialog = useCallback(() => { - setSelectedMember(null); - setSelectedMemberView(null); - }, []); - - const handleSendMessageToMember = useCallback((member: ResolvedTeamMember) => { - setSendDialogRecipient(member.name); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }, []); - - const handleAssignTaskToMember = useCallback( - (member: ResolvedTeamMember) => { - openCreateTaskDialog('', '', member.name); - }, - [openCreateTaskDialog] - ); - - const handleOpenTaskById = useCallback((taskId: string) => { - const task = taskMapRef.current.get(taskId); - if (task) { - setSelectedTask(task); - } - }, []); - - const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { - setSelectedTask(task); - }, []); - - const handleTaskIdClick = useCallback( - (taskId: string) => { - const task = - taskMap.get(taskId) ?? data?.tasks.find((candidate) => candidate.displayId === taskId); - if (task) setSelectedTask(task); - }, - [taskMap, data?.tasks] - ); - - const handleEditorAction = useCallback( - (action: EditorSelectionAction) => { - const chip = createChipFromSelection(action, []) ?? undefined; - if (action.type === 'sendMessage') { - setSendDialogDefaultText(chip ? undefined : action.formattedContext); - setSendDialogDefaultChip(chip); - setSendDialogRecipient(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - } else if (action.type === 'createTask') { - if (chip) { - setCreateTaskDialog({ - open: true, - defaultSubject: '', - defaultDescription: '', - defaultOwner: '', - defaultStartImmediately: undefined, - defaultChip: chip, - }); - } else { - openCreateTaskDialog('', action.formattedContext); + const handleDeleteTask = useCallback( + (taskId: string) => { + void (async () => { + const confirmed = await confirm({ + title: 'Delete task', + message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + variant: 'danger', + }); + if (confirmed) { + try { + await softDeleteTask(teamName, taskId); + } catch { + // error via store } } - }, + })(); + }, + [teamName, softDeleteTask] + ); - [] - ); - - const handleStopTeam = useCallback(async (): Promise => { - setStoppingTeam(true); - try { - await api.teams.stop(teamName); - // Backend sends 'disconnected' progress which triggers store refresh, - // but refresh here too as a safety net (e.g. if progress event is missed). - await refreshTeamData(teamName); - } catch (err) { - console.error('Failed to stop team:', err); - } finally { - setStoppingTeam(false); - } - }, [teamName, refreshTeamData]); - - // Pick up pending review request from GlobalTaskDetailDialog - useEffect(() => { - if (!pendingReviewRequest) return; + const handleViewChanges = useCallback( + (taskId: string) => { + const task = taskMap.get(taskId); setReviewDialogState({ open: true, mode: 'task', - taskId: pendingReviewRequest.taskId, - initialFilePath: pendingReviewRequest.filePath, - taskChangeRequestOptions: pendingReviewRequest.requestOptions, + taskId, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, }); - if (pendingReviewRequest.filePath) { - selectReviewFile(pendingReviewRequest.filePath); + }, + [taskMap] + ); + + const handleViewChangesForFile = useCallback( + (taskId: string, filePath?: string) => { + const task = taskMap.get(taskId); + setReviewDialogState({ + open: true, + mode: 'task', + taskId, + initialFilePath: filePath, + taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + }); + if (filePath) { + selectReviewFile(filePath); } - setPendingReviewRequest(null); - }, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); + }, + [selectReviewFile, taskMap] + ); - // Pick up pending member profile request from MemberHoverCard - const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); - useEffect(() => { - if (!pendingMemberProfile || !data) return; - const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); - if (member) { - setSelectedMember(member); - setSelectedMemberView(null); + const handleDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(true); + }, []); + + const confirmDeleteTeam = useCallback((): void => { + setDeleteConfirmOpen(false); + void (async () => { + try { + await deleteTeam(teamName); + if (tabId) closeTab(tabId); + openTeamsTab(); + } catch { + // error is shown via store } - useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches]); + })(); + }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); - const handleDeleteTask = useCallback( - (taskId: string) => { - void (async () => { - const confirmed = await confirm({ - title: 'Delete task', - message: `Move task #${deriveTaskDisplayId(taskId)} to trash?`, - confirmLabel: 'Delete', - cancelLabel: 'Cancel', - variant: 'danger', - }); - if (confirmed) { - try { - await softDeleteTask(teamName, taskId); - } catch { - // error via store - } - } - })(); - }, - [teamName, softDeleteTask] - ); - - const handleViewChanges = useCallback( - (taskId: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, + const handleCreateTask = ( + subject: string, + description: string, + owner?: string, + blockedBy?: string[], + related?: string[], + prompt?: string, + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] + ): void => { + setCreatingTask(true); + void (async () => { + try { + await createTeamTask(teamName, { + subject, + description: description || undefined, + owner, + blockedBy, + related, + prompt, + descriptionTaskRefs, + promptTaskRefs, + startImmediately, }); - }, - [taskMap] - ); - const handleViewChangesForFile = useCallback( - (taskId: string, filePath?: string) => { - const task = taskMap.get(taskId); - setReviewDialogState({ - open: true, - mode: 'task', - taskId, - initialFilePath: filePath, - taskChangeRequestOptions: task ? buildTaskChangeRequestOptions(task) : {}, - }); - if (filePath) { - selectReviewFile(filePath); - } - }, - [selectReviewFile, taskMap] - ); - - const handleDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(true); - }, []); - - const confirmDeleteTeam = useCallback((): void => { - setDeleteConfirmOpen(false); - void (async () => { - try { - await deleteTeam(teamName); - if (tabId) closeTab(tabId); - openTeamsTab(); - } catch { - // error is shown via store - } - })(); - }, [teamName, deleteTeam, openTeamsTab, closeTab, tabId]); - - const handleCreateTask = ( - subject: string, - description: string, - owner?: string, - blockedBy?: string[], - related?: string[], - prompt?: string, - startImmediately?: boolean, - descriptionTaskRefs?: TaskRef[], - promptTaskRefs?: TaskRef[] - ): void => { - setCreatingTask(true); - void (async () => { - try { - await createTeamTask(teamName, { - subject, - description: description || undefined, - owner, - blockedBy, - related, - prompt, - descriptionTaskRefs, - promptTaskRefs, - startImmediately, - }); - - if ( - prompt && - owner && - data?.isAlive && - !isTeamProvisioning && - startImmediately !== false - ) { - const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; - try { - await api.teams.processSend(teamName, msg); - } catch { - // best-effort - } + if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) { + const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; + try { + await api.teams.processSend(teamName, msg); + } catch { + // best-effort } - - closeCreateTaskDialog(); - } catch { - // error shown via store - } finally { - setCreatingTask(false); } - })(); - }; - const sharedMessagesPanelProps = useMemo( - () => ({ - teamName, - onPositionChange: changeMessagesPanelMode, - mountPoint: messagesPanelMountPoint, - members: activeMembers, - tasks: data?.tasks ?? [], - isTeamAlive: data?.isAlive, - timeWindow, - teamSessionIds, - currentLeadSessionId: data?.config.leadSessionId, - pendingRepliesByMember, - onPendingReplyChange: setPendingRepliesByMember, - onMemberClick: handleSelectMember, - onTaskClick: handleOpenTask, - onCreateTaskFromMessage: handleCreateTaskFromMessage, - onReplyToMessage: handleReplyToMessage, - onRestartTeam: handleRestartTeam, - onTaskIdClick: handleTaskIdClick, - inlineScrollContainerRef: contentRef, - }), - [ - activeMembers, - data?.config.leadSessionId, - data?.isAlive, - data?.tasks, - handleCreateTaskFromMessage, - handleOpenTask, - handleReplyToMessage, - handleRestartTeam, - handleSelectMember, - handleTaskIdClick, - messagesPanelMountPoint, - pendingRepliesByMember, - teamName, - teamSessionIds, - timeWindow, - changeMessagesPanelMode, - ] + closeCreateTaskDialog(); + } catch { + // error shown via store + } finally { + setCreatingTask(false); + } + })(); + }; + + const sharedMessagesPanelProps = useMemo( + () => ({ + teamName, + onPositionChange: changeMessagesPanelMode, + mountPoint: messagesPanelMountPoint, + members: activeMembers, + tasks: data?.tasks ?? [], + isTeamAlive: data?.isAlive, + timeWindow, + teamSessionIds, + currentLeadSessionId: data?.config.leadSessionId, + pendingRepliesByMember, + onPendingReplyChange: setPendingRepliesByMember, + onMemberClick: handleSelectMember, + onTaskClick: handleOpenTask, + onCreateTaskFromMessage: handleCreateTaskFromMessage, + onReplyToMessage: handleReplyToMessage, + onRestartTeam: handleRestartTeam, + onTaskIdClick: handleTaskIdClick, + inlineScrollContainerRef: contentRef, + }), + [ + activeMembers, + data?.config.leadSessionId, + data?.isAlive, + data?.tasks, + handleCreateTaskFromMessage, + handleOpenTask, + handleReplyToMessage, + handleRestartTeam, + handleSelectMember, + handleTaskIdClick, + messagesPanelMountPoint, + pendingRepliesByMember, + teamName, + teamSessionIds, + timeWindow, + changeMessagesPanelMode, + ] + ); + + if (!teamName) { + return ( +
+ Invalid team tab +
); + } - if (!teamName) { + const spawnStatusWatcher = ( + + ); + const teamAgentRuntimeWatcher = ( + + ); + const leadContextWatcher = shouldShowLeadContextUi ? ( + + ) : null; + + const renderBody = (): React.JSX.Element => { + if ((loading && !data) || (data && data.teamName !== teamName)) { return ( -
- Invalid team tab +
+
+
+ +
+
+
+
+
+
); } - const spawnStatusWatcher = ( - - ); - const teamAgentRuntimeWatcher = ( - - ); - const leadContextWatcher = shouldShowLeadContextUi ? ( - - ) : null; + if (error === 'TEAM_DRAFT') { + const draftTeamSummary = useStore.getState().teamByName[teamName]; + const draftDisplayName = draftTeamSummary?.displayName || teamName; + const draftMemberCount = draftTeamSummary?.memberCount ?? 0; - const renderBody = (): React.JSX.Element => { - if ((loading && !data) || (data && data.teamName !== teamName)) { - return ( -
-
+ return ( + <> +
-
-
-
-
-
-
- ); - } - - if (error === 'TEAM_DRAFT') { - const draftTeamSummary = useStore.getState().teamByName[teamName]; - const draftDisplayName = draftTeamSummary?.displayName || teamName; - const draftMemberCount = draftTeamSummary?.memberCount ?? 0; - - return ( - <> -
-
- -
-
-
-

Team not launched yet

-

- This is a draft team - {draftDisplayName} has been configured - with {draftMemberCount} member - {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. - Click Launch to select a model and start the team. -

-
- - -
+
+
+

Team not launched yet

+

+ This is a draft team - {draftDisplayName} has been configured + with {draftMemberCount} member + {draftMemberCount === 1 ? '' : 's'} but hasn't been provisioned by CLI yet. + Click Launch to select a model and start the team. +

+
+ +
+
+ {launchDialogOpen && ( - - ); - } - - if (error) { - return ( -
-
-

Failed to load team

-

{error}

-
-
- ); - } - - if (!data) { - return ( -
-
- -
-
- Team data will appear once provisioning completes -
-
- ); - } - - const headerColorSet = data.config.color - ? getTeamColorSet(data.config.color) - : nameColorSet(data.config.name); + )} + + ); + } + if (error) { return ( - <> -
- +
+
+

Failed to load team

+

{error}

+
+
+ ); + } - {/* Messages sidebar (left, after context panel) */} - +
+ +
+
+ Team data will appear once provisioning completes +
+
+ ); + } + + const headerColorSet = data.config.color + ? getTeamColorSet(data.config.color) + : nameColorSet(data.config.name); + + return ( + <> +
+ + + {/* Messages sidebar (left, after context panel) */} + + - - - - + messagesPanelProps={sharedMessagesPanelProps} + isResizing={isMessagesPanelResizing} + onResizeMouseDown={messagesPanelHandleProps.onMouseDown} + logsHeight={sidebarLogsHeight} + isLogsResizing={isLogsPanelResizing} + onLogsResizeMouseDown={logsPanelHandleProps.onMouseDown} + /> + + -
-
-
- {headerColorSet ? ( -
- ) : null} +
+
+
+ {headerColorSet ? (
-
-
-

- {data.config.name} -

- {data.isAlive && ( - - - Running - - )} - {!data.isAlive && isTeamProvisioning && ( - - - Launching... - - )} -
-
-
+ className="pointer-events-none absolute inset-0 z-0" + style={{ backgroundColor: getThemedBadge(headerColorSet, isLight) }} + /> + ) : null} +
+
+
+

+ {data.config.name} +

{data.isAlive && ( - - - - - Stop team - + + + Running + )} - - - - - - {isTeamProvisioning - ? 'Edit team is unavailable while provisioning is still in progress' - : 'Edit team'} - - + {!data.isAlive && isTeamProvisioning && ( + + + Launching... + + )} +
+
+
+ {data.isAlive && ( - Delete team + Stop team -
-
- {data.config.description && ( -

- {data.config.description} -

- )} -
-
- {data.config.projectPath && ( - - - - - - {data.config.projectPath - .replace(/\\/g, '/') - .split('/') - .filter(Boolean) - .pop() ?? data.config.projectPath} - - - - - {formatProjectPath(data.config.projectPath)} - - - - - - - - Open project in built-in editor - - - )} - {leadBranch && ( - - - {leadBranch} - - )} -
- Open team graph + + {isTeamProvisioning + ? 'Edit team is unavailable while provisioning is still in progress' + : 'Edit team'} + + + + + + + Delete team
- {(() => { - const currentPath = data.config.projectPath; - const history = data.config.projectPathHistory?.filter( - (p) => p !== currentPath - ); - if (!history || history.length === 0) return null; - return ( -
+ {data.config.description && ( +

+ {data.config.description} +

+ )} +
+
+ {data.config.projectPath && ( + + + + + + {data.config.projectPath + .replace(/\\/g, '/') + .split('/') + .filter(Boolean) + .pop() ?? data.config.projectPath} + + + + + {formatProjectPath(data.config.projectPath)} + + + + + + + + Open project in built-in editor + + + )} + {leadBranch && ( + - - - Previous: {history.map((p) => formatProjectPath(p)).join(', ')} - -
- ); - })()} -
- - {!data.isAlive && !isTeamProvisioning ? ( - openLaunchDialog('launch')} - /> - ) : null} - -
- -
- - {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( -
- Failed to fully load kanban. Displaying safe data. + + {leadBranch} + + )}
- ) : null} - {reviewActionError ? ( -
- {reviewActionError} -
- ) : null} - - } - badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} - defaultOpen - action={ -
+ + + + Open team graph + +
+ {(() => { + const currentPath = data.config.projectPath; + const history = data.config.projectPathHistory?.filter((p) => p !== currentPath); + if (!history || history.length === 0) return null; + return ( +
+ + + Previous: {history.map((p) => formatProjectPath(p)).join(', ')} +
- } - > - -
+ ); + })()} +
- } - defaultOpen={false} - > - - setKanbanFilter((prev) => ({ ...prev, sessionId: id })) - } - projectPath={data.config.projectPath} - /> - + {!data.isAlive && !isTeamProvisioning ? ( + openLaunchDialog('launch')} + /> + ) : null} - } - badge={filteredTasks.length} - defaultOpen - forceOpen={kanbanSearch.trim().length > 0} - action={ +
+ +
+ + {data.warnings?.some((warning) => warning.toLowerCase().includes('kanban')) ? ( +
+ Failed to fully load kanban. Displaying safe data. +
+ ) : null} + {reviewActionError ? ( +
+ {reviewActionError} +
+ ) : null} + + } + badge={activeTeammateCount === 0 ? 'Solo' : activeTeammateCount} + defaultOpen + action={ +
- } - > - - } - onRequestReview={(taskId) => { - void (async () => { - try { - await requestReview(teamName, taskId); - } catch { - // error via store - } - })(); - }} - onApprove={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { - op: 'set_column', - column: 'approved', - }); - } catch { - // error via store - } - })(); - }} - onRequestChanges={(taskId) => { - setRequestChangesTaskId(taskId); - }} - onMoveBackToDone={(taskId) => { - void (async () => { - try { - await updateKanban(teamName, taskId, { op: 'remove' }); - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onStartTask={(taskId) => { - void (async () => { - try { - const result = await startTaskByUser(teamName, taskId); - if (data?.isAlive) { - const task = data.tasks.find((t) => t.id === taskId); - try { - if (result.notifiedOwner && task?.owner) { - await api.teams.processSend( - teamName, - `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` - ); - } else if (!result.notifiedOwner) { - const desc = task?.description?.trim() - ? `\nDescription: ${task.description.trim()}` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` - ); - } - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onCompleteTask={(taskId) => { - void (async () => { - try { - await updateTaskStatus(teamName, taskId, 'completed'); - } catch { - // error via store - } - })(); - }} - onCancelTask={(taskId) => { - void (async () => { - try { - const task = data?.tasks.find((t) => t.id === taskId); - await updateTaskStatus(teamName, taskId, 'pending'); - - // Notify assignee directly via inbox — they'll see it immediately - if (task?.owner) { - try { - await api.teams.sendMessage(teamName, { - member: task.owner, - text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, - summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, - }); - } catch { - // best-effort - } - } - - // Also notify team lead so they can reassign/coordinate - if (data?.isAlive) { - try { - const ownerSuffix = task?.owner - ? ` ${task.owner} has been notified to stop.` - : ''; - await api.teams.processSend( - teamName, - `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` - ); - } catch { - // best-effort - } - } - } catch { - // error via store - } - })(); - }} - onColumnOrderChange={(columnId, orderedTaskIds) => { - void (async () => { - try { - await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); - } catch { - // error via store - } - })(); - }} - onScrollToTask={(taskId) => { - const el = document.querySelector(`[data-task-id="${taskId}"]`); - if (el) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - el.classList.remove('kanban-card-focus-pulse'); - void (el as HTMLElement).offsetWidth; - el.classList.add('kanban-card-focus-pulse'); - el.addEventListener( - 'animationend', - () => el.classList.remove('kanban-card-focus-pulse'), - { once: true } - ); - } - }} - onTaskClick={(task) => setSelectedTask(task)} - onViewChanges={handleViewChanges} - onAddTask={(startImmediately) => - openCreateTaskDialog('', '', '', startImmediately) - } - onDeleteTask={handleDeleteTask} - deletedTaskCount={deletedTasks.length} - onOpenTrash={() => setTrashOpen(true)} - /> - - - } - defaultOpen={false} - > - - - - {(data.processes?.length ?? 0) > 0 && ( - } - badge={data.processes.filter((p) => !p.stoppedAt).length} - headerExtra={ - data.processes.some((p) => !p.stoppedAt) ? ( - - - - - ) : null - } - defaultOpen - > - - - )} - - {messagesPanelMode !== 'sidebar' && } - - {messagesPanelMode === 'inline' && ( - - )} - - setRequestChangesTaskId(null)} - onSubmit={(comment, taskRefs) => { - if (!requestChangesTaskId) { - return; - } - void (async () => { - try { - await updateKanban(teamName, requestChangesTaskId, { - op: 'request_changes', - comment, - taskRefs, - }); - setRequestChangesTaskId(null); - } catch { - // error state is handled in the store and shown in the view - } - })(); - }} - /> - - + } + > + { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - setSendDialogRecipient(name || undefined); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setReplyQuote(undefined); - setSendDialogOpen(true); - }} - onAssignTask={() => { - const name = selectedMember?.name ?? ''; - closeSelectedMemberDialog(); - openCreateTaskDialog('', '', name); - }} + onMemberClick={handleSelectMember} + onSendMessage={handleSendMessageToMember} + onAssignTask={handleAssignTaskToMember} + onOpenTask={handleOpenTaskById} onRestartMember={handleRestartMember} - onTaskClick={(task) => { - closeSelectedMemberDialog(); - setSelectedTask(task); + onSkipMemberForLaunch={handleSkipMemberForLaunch} + /> + + + } + defaultOpen={false} + > + setKanbanFilter((prev) => ({ ...prev, sessionId: id }))} + projectPath={data.config.projectPath} + /> + + + } + badge={filteredTasks.length} + defaultOpen + forceOpen={kanbanSearch.trim().length > 0} + action={ + + } + > + + } + onRequestReview={(taskId) => { + void (async () => { + try { + await requestReview(teamName, taskId); + } catch { + // error via store + } + })(); }} - onUpdateRole={async (memberName, role) => { - setUpdatingRoleLoading(true); - try { - await updateMemberRole(teamName, memberName, role); - // Optimistically update local selectedMember to reflect new role - setSelectedMember((prev) => { - if (prev?.name !== memberName) return prev; - const normalized = - typeof role === 'string' && role.trim() ? role.trim() : undefined; - return { ...prev, role: normalized }; - }); - } finally { - setUpdatingRoleLoading(false); + onApprove={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { + op: 'set_column', + column: 'approved', + }); + } catch { + // error via store + } + })(); + }} + onRequestChanges={(taskId) => { + setRequestChangesTaskId(taskId); + }} + onMoveBackToDone={(taskId) => { + void (async () => { + try { + await updateKanban(teamName, taskId, { op: 'remove' }); + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onStartTask={(taskId) => { + void (async () => { + try { + const result = await startTaskByUser(teamName, taskId); + if (data?.isAlive) { + const task = data.tasks.find((t) => t.id === taskId); + try { + if (result.notifiedOwner && task?.owner) { + await api.teams.processSend( + teamName, + `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.` + ); + } else if (!result.notifiedOwner) { + const desc = task?.description?.trim() + ? `\nDescription: ${task.description.trim()}` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been moved to IN PROGRESS but has no assignee.${desc}\nPlease assign it to an available team member, or take it yourself if everyone is busy.` + ); + } + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onCompleteTask={(taskId) => { + void (async () => { + try { + await updateTaskStatus(teamName, taskId, 'completed'); + } catch { + // error via store + } + })(); + }} + onCancelTask={(taskId) => { + void (async () => { + try { + const task = data?.tasks.find((t) => t.id === taskId); + await updateTaskStatus(teamName, taskId, 'pending'); + + // Notify assignee directly via inbox — they'll see it immediately + if (task?.owner) { + try { + await api.teams.sendMessage(teamName, { + member: task.owner, + text: `Task ${formatTaskDisplayLabel(task)} "${task.subject}" has been CANCELLED by the user and moved back to TODO. Stop working on it immediately.`, + summary: `Task ${formatTaskDisplayLabel(task)} cancelled`, + }); + } catch { + // best-effort + } + } + + // Also notify team lead so they can reassign/coordinate + if (data?.isAlive) { + try { + const ownerSuffix = task?.owner + ? ` ${task.owner} has been notified to stop.` + : ''; + await api.teams.processSend( + teamName, + `Task #${deriveTaskDisplayId(taskId)} "${task?.subject ?? ''}" has been cancelled and moved back to TODO.${ownerSuffix}` + ); + } catch { + // best-effort + } + } + } catch { + // error via store + } + })(); + }} + onColumnOrderChange={(columnId, orderedTaskIds) => { + void (async () => { + try { + await updateKanbanColumnOrder(teamName, columnId, orderedTaskIds); + } catch { + // error via store + } + })(); + }} + onScrollToTask={(taskId) => { + const el = document.querySelector(`[data-task-id="${taskId}"]`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + el.classList.remove('kanban-card-focus-pulse'); + void (el as HTMLElement).offsetWidth; + el.classList.add('kanban-card-focus-pulse'); + el.addEventListener( + 'animationend', + () => el.classList.remove('kanban-card-focus-pulse'), + { once: true } + ); } }} - updatingRole={updatingRoleLoading} - onRemoveMember={() => { - const name = selectedMember?.name; - if (!name) return; - setRemoveMemberConfirm(name); - }} - onViewMemberChanges={(memberName, filePath) => { - closeSelectedMemberDialog(); - setReviewDialogState({ - open: true, - mode: 'agent', - memberName, - initialFilePath: filePath, - }); - }} + onTaskClick={(task) => setSelectedTask(task)} + onViewChanges={handleViewChanges} + onAddTask={(startImmediately) => + openCreateTaskDialog('', '', '', startImmediately) + } + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} /> + + } + defaultOpen={false} + > + + + + {(data.processes?.length ?? 0) > 0 && ( + } + badge={data.processes.filter((p) => !p.stoppedAt).length} + headerExtra={ + data.processes.some((p) => !p.stoppedAt) ? ( + + + + + ) : null + } + defaultOpen + > + + + )} + + {messagesPanelMode !== 'sidebar' && } + + {messagesPanelMode === 'inline' && ( + + )} + + setRequestChangesTaskId(null)} + onSubmit={(comment, taskRefs) => { + if (!requestChangesTaskId) { + return; + } + void (async () => { + try { + await updateKanban(teamName, requestChangesTaskId, { + op: 'request_changes', + comment, + taskRefs, + }); + setRequestChangesTaskId(null); + } catch { + // error state is handled in the store and shown in the view + } + })(); + }} + /> + + { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + setSendDialogRecipient(name || undefined); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setReplyQuote(undefined); + setSendDialogOpen(true); + }} + onAssignTask={() => { + const name = selectedMember?.name ?? ''; + closeSelectedMemberDialog(); + openCreateTaskDialog('', '', name); + }} + onRestartMember={handleRestartMember} + onTaskClick={(task) => { + closeSelectedMemberDialog(); + setSelectedTask(task); + }} + onUpdateRole={async (memberName, role) => { + setUpdatingRoleLoading(true); + try { + await updateMemberRole(teamName, memberName, role); + // Optimistically update local selectedMember to reflect new role + setSelectedMember((prev) => { + if (prev?.name !== memberName) return prev; + const normalized = + typeof role === 'string' && role.trim() ? role.trim() : undefined; + return { ...prev, role: normalized }; + }); + } finally { + setUpdatingRoleLoading(false); + } + }} + updatingRole={updatingRoleLoading} + onRemoveMember={() => { + const name = selectedMember?.name; + if (!name) return; + setRemoveMemberConfirm(name); + }} + onViewMemberChanges={(memberName, filePath) => { + closeSelectedMemberDialog(); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} + /> + + {createTaskDialog.open && ( + )} - !isLeadMember(m))} - leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} - resolvedMemberColorMap={resolvedMemberColorMap} - isTeamAlive={data.isAlive && !isTeamProvisioning} - isTeamProvisioning={isTeamProvisioning} - projectPath={data.config.projectPath} - onClose={() => setEditDialogOpen(false)} - onChangeLeadRuntime={handleChangeLeadRuntime} - onSaved={() => void selectTeam(teamName)} - /> + !isLeadMember(m))} + leadMember={membersWithLiveBranches.find((m) => isLeadMember(m)) ?? null} + resolvedMemberColorMap={resolvedMemberColorMap} + isTeamAlive={data.isAlive && !isTeamProvisioning} + isTeamProvisioning={isTeamProvisioning} + projectPath={data.config.projectPath} + onClose={() => setEditDialogOpen(false)} + onChangeLeadRuntime={handleChangeLeadRuntime} + onSaved={() => void selectTeam(teamName)} + /> - m.name)} - existingMembers={membersWithLiveBranches} - projectPath={data.config.projectPath} - adding={addingMemberLoading} - onClose={() => setAddMemberDialogOpen(false)} - onAdd={(entries: AddMemberEntry[]) => { - setAddingMemberLoading(true); - void (async () => { - try { - for (const entry of entries) { - await addMember(teamName, { - name: entry.name, - role: entry.role, - workflow: entry.workflow, - isolation: entry.isolation, - providerId: entry.providerId, - model: entry.model, - effort: entry.effort, - }); - } - setAddMemberDialogOpen(false); - } catch { - // error shown via store - } finally { - setAddingMemberLoading(false); + m.name)} + existingMembers={membersWithLiveBranches} + projectPath={data.config.projectPath} + adding={addingMemberLoading} + onClose={() => setAddMemberDialogOpen(false)} + onAdd={(entries: AddMemberEntry[]) => { + setAddingMemberLoading(true); + void (async () => { + try { + for (const entry of entries) { + await addMember(teamName, { + name: entry.name, + role: entry.role, + workflow: entry.workflow, + isolation: entry.isolation, + providerId: entry.providerId, + model: entry.model, + effort: entry.effort, + }); } - })(); - }} - /> + setAddMemberDialogOpen(false); + } catch { + // error shown via store + } finally { + setAddingMemberLoading(false); + } + })(); + }} + /> - { - if (!open) setRemoveMemberConfirm(null); - }} - > - - - Remove member - - Remove “{removeMemberConfirm}” from the team? Tasks and messages - will be preserved, but this name cannot be reused. - - - - - - - - + { + if (!open) setRemoveMemberConfirm(null); + }} + > + + + Remove member + + Remove “{removeMemberConfirm}” from the team? Tasks and messages + will be preserved, but this name cannot be reused. + + + + + + + + - - - - Delete team - - Delete team “{data.config.name}”? This action is irreversible. - All team data and tasks will be deleted. - - - - - - - - + + + + Delete team + + Delete team “{data.config.name}”? This action is irreversible. All + team data and tasks will be deleted. + + + + + + + + + {launchDialogOpen && ( + )} + {sendDialogOpen && ( + )} + {selectedTask !== null && ( + )} - setTrashOpen(false)} - onRestore={(taskId) => { - void (async () => { - try { - await restoreTask(teamName, taskId); - } catch { - // error via store - } - })(); - }} - /> + setTrashOpen(false)} + onRestore={(taskId) => { + void (async () => { + try { + await restoreTask(teamName, taskId); + } catch { + // error via store + } + })(); + }} + /> + {reviewDialogState.open && ( -
-
- {messagesPanelMode === 'bottom-sheet' && ( - )}
+
+ {messagesPanelMode === 'bottom-sheet' && ( + + )}
+
- {editorOpen && data.config.projectPath && ( - - setEditorOpen(false)} - onEditorAction={handleEditorAction} - /> - - )} + {editorOpen && data.config.projectPath && ( + + setEditorOpen(false)} + onEditorAction={handleEditorAction} + /> + + )} - {graphOpen && ( - - setGraphOpen(false)} - onPinAsTab={() => { - setGraphOpen(false); - useStore - .getState() - .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); - }} - onSendMessage={(memberName) => { - setSendDialogRecipient(memberName); - setSendDialogDefaultText(undefined); - setSendDialogDefaultChip(undefined); - setSendDialogOpen(true); - }} - onOpenTaskDetail={(taskId) => { - const task = data.tasks.find((t) => t.id === taskId); - if (task) setSelectedTask(task); - }} - onOpenMemberProfile={(memberName, options) => { - const member = members.find((m) => m.name === memberName); - if (member) { - setSelectedMember(member); - setSelectedMemberView({ - initialTab: options?.initialTab, - initialActivityFilter: options?.initialActivityFilter, - }); - } - }} - /> - - )} - - ); - }; - - return ( - <> - {spawnStatusWatcher} - {teamAgentRuntimeWatcher} - {leadContextWatcher} - {renderBody()} + {graphOpen && ( + + setGraphOpen(false)} + onPinAsTab={() => { + setGraphOpen(false); + useStore + .getState() + .openTab({ type: 'graph', label: `${data.config.name} Graph`, teamName }); + }} + onSendMessage={(memberName) => { + setSendDialogRecipient(memberName); + setSendDialogDefaultText(undefined); + setSendDialogDefaultChip(undefined); + setSendDialogOpen(true); + }} + onOpenTaskDetail={(taskId) => { + const task = data.tasks.find((t) => t.id === taskId); + if (task) setSelectedTask(task); + }} + onOpenMemberProfile={(memberName, options) => { + const member = members.find((m) => m.name === memberName); + if (member) { + setSelectedMember(member); + setSelectedMemberView({ + initialTab: options?.initialTab, + initialActivityFilter: options?.initialActivityFilter, + }); + } + }} + /> + + )} ); - } -); + }; + + return ( + <> + {spawnStatusWatcher} + {teamAgentRuntimeWatcher} + {leadContextWatcher} + {renderBody()} + + ); +}); diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index df540b6e..5e597278 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -45,7 +45,6 @@ import { } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; -import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import { TeamEmptyState } from './TeamEmptyState'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { @@ -54,13 +53,7 @@ import { teamMatchesProjectSelection, } from './teamProjectSelection'; -const CreateTeamDialog = lazy(() => - import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) -); -const LaunchTeamDialog = lazy(() => - import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) -); - +import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { @@ -72,6 +65,13 @@ import type { TeamSummaryMember, } from '@shared/types'; +const CreateTeamDialog = lazy(() => + import('./dialogs/CreateTeamDialog').then((m) => ({ default: m.CreateTeamDialog })) +); +const LaunchTeamDialog = lazy(() => + import('./dialogs/LaunchTeamDialog').then((m) => ({ default: m.LaunchTeamDialog })) +); + function generateUniqueName(sourceName: string, existingNames: string[]): string { const base = sourceName.replace(/-\d+$/, ''); const existing = new Set(existingNames); @@ -238,7 +238,7 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { } }; -export const TeamListView = memo((): React.JSX.Element => { +export const TeamListView = memo(function TeamListView(): React.JSX.Element { const { isLight } = useTheme(); const electronMode = isElectronMode(); const [showCreateDialog, setShowCreateDialog] = useState(false); diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 056f0014..e5241390 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -311,123 +311,119 @@ const SortableKanbanTaskCard = ({ ); }; -export const KanbanBoard = memo( - ({ - tasks, - teamName, - kanbanState, - filter, - sort, - sessions, - leadSessionId, - members, - onFilterChange, - onSortChange, - onRequestReview, - onApprove, - onRequestChanges, - onMoveBackToDone, - onStartTask, - onCompleteTask, - onCancelTask, - onScrollToTask, - onTaskClick, - onViewChanges, - onColumnOrderChange, - toolbarLeft, - onAddTask, - onDeleteTask, - deletedTaskCount, - onOpenTrash, - }: KanbanBoardProps): React.JSX.Element => { - const boardRef = useRef(null); - const scrollRestoreTimeoutsRef = useRef([]); - const [viewMode, setViewMode] = useState('grid'); - const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); - const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); - const hasReviewers = kanbanState.reviewers.length > 0; - const enableTaskSorting = - viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; +export const KanbanBoard = memo(function KanbanBoard({ + tasks, + teamName, + kanbanState, + filter, + sort, + sessions, + leadSessionId, + members, + onFilterChange, + onSortChange, + onRequestReview, + onApprove, + onRequestChanges, + onMoveBackToDone, + onStartTask, + onCompleteTask, + onCancelTask, + onScrollToTask, + onTaskClick, + onViewChanges, + onColumnOrderChange, + toolbarLeft, + onAddTask, + onDeleteTask, + deletedTaskCount, + onOpenTrash, +}: KanbanBoardProps): React.JSX.Element { + const boardRef = useRef(null); + const scrollRestoreTimeoutsRef = useRef([]); + const [viewMode, setViewMode] = useState('grid'); + const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState(null); + const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); + const hasReviewers = kanbanState.reviewers.length > 0; + const enableTaskSorting = + viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; - const stableTaskMapRef = useRef<{ - signatures: string[]; - map: Map; - } | null>(null); - const taskMap = useMemo(() => { - const signatures = tasks.map( - (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` - ); - const previous = stableTaskMapRef.current; - if ( - previous?.signatures.length === signatures.length && - previous.signatures.every((signature, index) => signature === signatures[index]) - ) { - return previous.map; - } - - const next = new Map(tasks.map((task) => [task.id, task])); - stableTaskMapRef.current = { signatures, map: next }; - return next; - }, [tasks]); - const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); - const grouped = useMemo(() => { - const result = new Map( - COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) - ); - for (const task of tasks) { - const column = getTaskColumn(task, kanbanState); - if (!column) { - continue; - } - result.get(column)?.push(task); - } - return result; - }, [tasks, kanbanState]); - - const groupedOrdered = useMemo(() => { - const result = new Map(); - for (const column of COLUMNS) { - const columnTasks = grouped.get(column.id) ?? []; - const order = kanbanState.columnOrder?.[column.id]; - result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); - } - return result; - }, [grouped, kanbanState.columnOrder, sort.field]); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }) + const stableTaskMapRef = useRef<{ + signatures: string[]; + map: Map; + } | null>(null); + const taskMap = useMemo(() => { + const signatures = tasks.map( + (task) => `${task.id}\0${task.displayId ?? ''}\0${task.subject}\0${task.status}` ); + const previous = stableTaskMapRef.current; + if ( + previous?.signatures.length === signatures.length && + previous.signatures.every((signature, index) => signature === signatures[index]) + ) { + return previous.map; + } - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!onColumnOrderChange || !over || active.id === over.id) { - return; - } - const activeData = active.data.current; - if (activeData?.type !== 'kanban-task') { - return; - } - const columnId = activeData.columnId as KanbanColumnId; - const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; - const oldIndex = orderedIds.indexOf(active.id as string); - const newIndex = orderedIds.indexOf(over.id as string); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { - return; - } - const newOrder = arrayMove(orderedIds, oldIndex, newIndex); - onColumnOrderChange(columnId, newOrder); - }, - [onColumnOrderChange, groupedOrdered] + const next = new Map(tasks.map((task) => [task.id, task])); + stableTaskMapRef.current = { signatures, map: next }; + return next; + }, [tasks]); + const memberColorMap = useMemo(() => buildMemberColorMap(members), [members]); + const grouped = useMemo(() => { + const result = new Map( + COLUMNS.map(({ id }) => [id, [] as TeamTask[]]) ); + for (const task of tasks) { + const column = getTaskColumn(task, kanbanState); + if (!column) { + continue; + } + result.get(column)?.push(task); + } + return result; + }, [tasks, kanbanState]); - const renderCards = ( - columnId: KanbanColumnId, - columnTasks: TeamTask[], - compact?: boolean - ): React.JSX.Element => { + const groupedOrdered = useMemo(() => { + const result = new Map(); + for (const column of COLUMNS) { + const columnTasks = grouped.get(column.id) ?? []; + const order = kanbanState.columnOrder?.[column.id]; + result.set(column.id, sortColumnTasksByField(columnTasks, sort.field, order)); + } + return result; + }, [grouped, kanbanState.columnOrder, sort.field]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!onColumnOrderChange || !over || active.id === over.id) { + return; + } + const activeData = active.data.current; + if (activeData?.type !== 'kanban-task') { + return; + } + const columnId = activeData.columnId as KanbanColumnId; + const orderedIds = groupedOrdered.get(columnId)?.map((t) => t.id) ?? []; + const oldIndex = orderedIds.indexOf(active.id as string); + const newIndex = orderedIds.indexOf(over.id as string); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) { + return; + } + const newOrder = arrayMove(orderedIds, oldIndex, newIndex); + onColumnOrderChange(columnId, newOrder); + }, + [onColumnOrderChange, groupedOrdered] + ); + + const renderCards = useCallback( + (columnId: KanbanColumnId, columnTasks: TeamTask[], compact?: boolean): React.JSX.Element => { const addHandler = onAddTask && columnId === 'todo' ? () => onAddTask(false) @@ -517,248 +513,266 @@ export const KanbanBoard = memo( {addButton} ); - }; + }, + [ + enableTaskSorting, + hasReviewers, + kanbanState, + memberColorMap, + onAddTask, + onApprove, + onCancelTask, + onCompleteTask, + onDeleteTask, + onMoveBackToDone, + onRequestChanges, + onRequestReview, + onScrollToTask, + onStartTask, + onTaskClick, + onViewChanges, + taskMap, + teamName, + ] + ); - const visibleColumns = useMemo( - () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), - [filter.columns] - ); - const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; + const visibleColumns = useMemo( + () => (filter.columns.size > 0 ? COLUMNS.filter((c) => filter.columns.has(c.id)) : COLUMNS), + [filter.columns] + ); + const primaryVisibleColumnId = visibleColumns[0]?.id ?? null; - const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); - const { widths: columnWidths, getHandleProps } = useResizableColumns({ - storageKey: teamName, - columnIds: resizableColumnIds, - }); - const columnModeSearchWidth = - primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; - const toolbarLeftWidth = - viewMode === 'grid' - ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) - : columnModeSearchWidth; + const resizableColumnIds = useMemo(() => visibleColumns.map((c) => c.id), [visibleColumns]); + const { widths: columnWidths, getHandleProps } = useResizableColumns({ + storageKey: teamName, + columnIds: resizableColumnIds, + }); + const columnModeSearchWidth = + primaryVisibleColumnId != null ? (columnWidths.get(primaryVisibleColumnId) ?? 256) : 256; + const toolbarLeftWidth = + viewMode === 'grid' ? (gridPrimaryColumnWidth ?? columnModeSearchWidth) : columnModeSearchWidth; - const clearScheduledScrollRestore = useCallback(() => { - for (const timeoutId of scrollRestoreTimeoutsRef.current) { - window.clearTimeout(timeoutId); + const clearScheduledScrollRestore = useCallback(() => { + for (const timeoutId of scrollRestoreTimeoutsRef.current) { + window.clearTimeout(timeoutId); + } + scrollRestoreTimeoutsRef.current = []; + }, []); + + useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); + + const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { + let current = startNode?.parentElement ?? null; + while (current) { + const { overflowY } = window.getComputedStyle(current); + if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { + return current; } - scrollRestoreTimeoutsRef.current = []; - }, []); + current = current.parentElement; + } + return null; + }, []); - useEffect(() => clearScheduledScrollRestore, [clearScheduledScrollRestore]); - - const findScrollContainer = useCallback((startNode: HTMLElement | null): HTMLElement | null => { - let current = startNode?.parentElement ?? null; - while (current) { - const { overflowY } = window.getComputedStyle(current); - if (SCROLLABLE_OVERFLOW_VALUES.has(overflowY)) { - return current; - } - current = current.parentElement; + const scheduleScrollRestore = useCallback( + (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { + const container = findScrollContainer(boardRef.current); + if (!container) { + return; } - return null; - }, []); - const scheduleScrollRestore = useCallback( - (nextViewMode: KanbanViewMode, skeletonDelayMs: number) => { - const container = findScrollContainer(boardRef.current); - if (!container) { - return; - } + const savedScrollTop = container.scrollTop; + clearScheduledScrollRestore(); - const savedScrollTop = container.scrollTop; - clearScheduledScrollRestore(); + const restore = (): void => { + container.scrollTop = savedScrollTop; + }; - const restore = (): void => { - container.scrollTop = savedScrollTop; + const delays = + nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; + + scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); + }, + [clearScheduledScrollRestore, findScrollContainer] + ); + + const switchViewMode = useCallback( + (nextViewMode: KanbanViewMode) => { + const nextSkeletonDelayMs = + nextViewMode === 'grid' && viewMode === 'columns' + ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH + : SKELETON_HIDE_DELAY_MS; + + setGridSkeletonDelayMs(nextSkeletonDelayMs); + scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); + setViewMode(nextViewMode); + }, + [scheduleScrollRestore, viewMode] + ); + + const gridColumns = useMemo( + () => + visibleColumns.map((column) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + return { + id: column.id, + title: column.title, + count: columnTasks.length, + icon: accent.icon, + headerBg: accent.headerBg, + bodyBg: accent.bodyBg, + content: renderCards(column.id, columnTasks), + showAddButton: columnSupportsAddButton(column.id, onAddTask), + skeletonCards: columnTasks.map((task) => ({ + key: task.id, + height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), + })), }; + }), + [visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers] + ); - const delays = - nextViewMode === 'grid' ? [skeletonDelayMs + 40, skeletonDelayMs + 220] : [120]; - - scrollRestoreTimeoutsRef.current = delays.map((delay) => window.setTimeout(restore, delay)); - }, - [clearScheduledScrollRestore, findScrollContainer] - ); - - const switchViewMode = useCallback( - (nextViewMode: KanbanViewMode) => { - const nextSkeletonDelayMs = - nextViewMode === 'grid' && viewMode === 'columns' - ? SKELETON_HIDE_DELAY_MS_ON_MODE_SWITCH - : SKELETON_HIDE_DELAY_MS; - - setGridSkeletonDelayMs(nextSkeletonDelayMs); - scheduleScrollRestore(nextViewMode, nextSkeletonDelayMs); - setViewMode(nextViewMode); - }, - [scheduleScrollRestore, viewMode] - ); - - const boardContent = ( -
-
- {toolbarLeft != null && ( -
- {toolbarLeft} -
- )} -
-
- -
- -
- {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( - - - - - Trash - - ) : null} -
- - - - - Grid view - - - - - - Columns view - -
-
-
- - {viewMode === 'grid' ? ( - column.id)} - primaryColumnId={primaryVisibleColumnId} - onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} - skeletonDelayMs={gridSkeletonDelayMs} - columns={visibleColumns.map((column) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - - return { - id: column.id, - title: column.title, - count: columnTasks.length, - icon: accent.icon, - headerBg: accent.headerBg, - bodyBg: accent.bodyBg, - content: renderCards(column.id, columnTasks), - showAddButton: columnSupportsAddButton(column.id, onAddTask), - skeletonCards: columnTasks.map((task) => ({ - key: task.id, - height: estimateGridSkeletonCardHeight( - task, - column.id, - kanbanState, - hasReviewers - ), - })), - }; - })} - /> - ) : ( -
-
- {visibleColumns.map((column, index) => { - const columnTasks = groupedOrdered.get(column.id) ?? []; - const accent = COLUMN_ACCENTS[column.id]; - const width = columnWidths.get(column.id) ?? 256; - const handleProps = getHandleProps(column.id); - return ( -
-
- - {renderCards(column.id, columnTasks, true)} - -
- {index < visibleColumns.length - 1 ? ( -
-
-
- ) : null} -
- ); - })} -
+ const boardContent = ( +
+
+ {toolbarLeft != null && ( +
+ {toolbarLeft}
)} +
+
+ +
+ +
+ {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( + + + + + Trash + + ) : null} +
+ + + + + Grid view + + + + + + Columns view + +
+
+ + {viewMode === 'grid' ? ( + column.id)} + primaryColumnId={primaryVisibleColumnId} + onPrimaryColumnWidthChange={setGridPrimaryColumnWidth} + skeletonDelayMs={gridSkeletonDelayMs} + columns={gridColumns} + /> + ) : ( +
+
+ {visibleColumns.map((column, index) => { + const columnTasks = groupedOrdered.get(column.id) ?? []; + const accent = COLUMN_ACCENTS[column.id]; + const width = columnWidths.get(column.id) ?? 256; + const handleProps = getHandleProps(column.id); + return ( +
+
+ + {renderCards(column.id, columnTasks, true)} + +
+ {index < visibleColumns.length - 1 ? ( +
+
+
+ ) : null} +
+ ); + })} +
+
+ )} +
+ ); + + if (enableTaskSorting) { + return ( + + {boardContent} + ); - - if (enableTaskSorting) { - return ( - - {boardContent} - - ); - } - - return boardContent; } -); + + return boardContent; +}); diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index dbfe9c34..70396058 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -91,632 +91,622 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): { }; } -export const MemberCard = memo( - ({ +export const MemberCard = memo(function MemberCard({ + member, + memberColor, + runtimeSummary, + runtimeEntry, + runtimeRunId, + taskCounts, + isTeamAlive, + isTeamProvisioning, + leadActivity, + currentTask, + reviewTask, + isAwaitingReply, + isRemoved, + spawnStatus, + spawnEntry, + spawnError, + spawnLivenessSource, + spawnLaunchState, + spawnRuntimeAlive, + isLaunchSettling, + onOpenTask, + onOpenReviewTask, + onClick, + onSendMessage, + onAssignTask, + onRestartMember, + onSkipMemberForLaunch, +}: MemberCardProps): React.JSX.Element { + // NOTE: lead context display disabled — usage formula is inaccurate + // const teamName = useStore((s) => s.selectedTeamName); + // const leadContext = useStore((s) => + // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined + // ); + const selectedTeamName = useStore((s) => s.selectedTeamName); + const [retryingLaunch, setRetryingLaunch] = useState(false); + const [retryLaunchError, setRetryLaunchError] = useState(null); + const [skippingLaunch, setSkippingLaunch] = useState(false); + const [skipLaunchError, setSkipLaunchError] = useState(null); + const teamMembers = useStore((s) => + selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] + ); + const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); + const launchPresentation = buildMemberLaunchPresentation({ member, - memberColor, - runtimeSummary, + spawnStatus, + spawnLaunchState, + spawnLivenessSource, + spawnRuntimeAlive, runtimeEntry, - runtimeRunId, - taskCounts, + runtimeAdvisory: member.runtimeAdvisory, + isLaunchSettling, isTeamAlive, isTeamProvisioning, leadActivity, - currentTask, - reviewTask, - isAwaitingReply, - isRemoved, - spawnStatus, - spawnEntry, - spawnError, - spawnLivenessSource, - spawnLaunchState, - spawnRuntimeAlive, - isLaunchSettling, - onOpenTask, - onOpenReviewTask, - onClick, - onSendMessage, - onAssignTask, - onRestartMember, - onSkipMemberForLaunch, - }: MemberCardProps): React.JSX.Element => { - // NOTE: lead context display disabled — usage formula is inaccurate - // const teamName = useStore((s) => s.selectedTeamName); - // const leadContext = useStore((s) => - // member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined - // ); - const selectedTeamName = useStore((s) => s.selectedTeamName); - const [retryingLaunch, setRetryingLaunch] = useState(false); - const [retryLaunchError, setRetryLaunchError] = useState(null); - const [skippingLaunch, setSkippingLaunch] = useState(false); - const [skipLaunchError, setSkipLaunchError] = useState(null); - const teamMembers = useStore((s) => - selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : [] - ); - const avatarMap = useMemo(() => buildMemberAvatarMap(teamMembers), [teamMembers]); - const launchPresentation = buildMemberLaunchPresentation({ - member, - spawnStatus, - spawnLaunchState, - spawnLivenessSource, - spawnRuntimeAlive, - runtimeEntry, - runtimeAdvisory: member.runtimeAdvisory, - isLaunchSettling, - isTeamAlive, - isTeamProvisioning, - leadActivity, - }); - const dotClass = launchPresentation.dotClass; - const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; - const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; - const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; - const presenceLabel = launchPresentation.presenceLabel; - const spawnCardClass = launchPresentation.cardClass; - const launchVisualState = launchPresentation.launchVisualState; - const launchStatusLabel = launchPresentation.launchStatusLabel; - const displayPresenceLabel = + }); + const dotClass = launchPresentation.dotClass; + const runtimeAdvisoryLabel = launchPresentation.runtimeAdvisoryLabel; + const runtimeAdvisoryTitle = launchPresentation.runtimeAdvisoryTitle; + const runtimeAdvisoryTone = launchPresentation.runtimeAdvisoryTone; + const presenceLabel = launchPresentation.presenceLabel; + const spawnCardClass = launchPresentation.cardClass; + const launchVisualState = launchPresentation.launchVisualState; + const launchStatusLabel = launchPresentation.launchStatusLabel; + const displayPresenceLabel = + launchVisualState === 'queued' || + launchVisualState === 'runtime_pending' || + launchVisualState === 'permission_pending' || + launchVisualState === 'shell_only' || + launchVisualState === 'runtime_candidate' || + launchVisualState === 'registered_only' || + launchVisualState === 'stale_runtime' + ? (launchStatusLabel ?? presenceLabel) + : presenceLabel; + const colors = getTeamColorSet(memberColor); + const { isLight } = useTheme(); + const pending = taskCounts?.pending ?? 0; + const inProgress = taskCounts?.inProgress ?? 0; + const completed = taskCounts?.completed ?? 0; + const totalTasks = pending + inProgress + completed; + const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; + const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); + const { summary: runtimeSummaryText, memory: memoryLabel } = + splitRuntimeSummaryMemory(runtimeSummary); + const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); + const isLead = isLeadMember(member); + const workspacePath = member.cwd?.trim(); + const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; + const workspaceTooltipLines = [ + 'Worktree isolation is enabled.', + workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', + member.gitBranch ? `Branch: ${member.gitBranch}` : null, + ].filter((line): line is string => Boolean(line)); + const activityTask = currentTask ?? reviewTask ?? null; + const activityTitle = currentTask + ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` + : reviewTask + ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` + : undefined; + const showStartingSkeleton = + !isRemoved && + presenceLabel === 'starting' && + spawnLaunchState !== 'failed_to_start' && + !activityTask && + !runtimeSummary; + const showLaunchBadge = + !isRemoved && + !runtimeAdvisoryLabel && + (presenceLabel === 'starting' || + presenceLabel === 'connecting' || launchVisualState === 'queued' || launchVisualState === 'runtime_pending' || - launchVisualState === 'permission_pending' || launchVisualState === 'shell_only' || launchVisualState === 'runtime_candidate' || launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime' - ? (launchStatusLabel ?? presenceLabel) - : presenceLabel; - const colors = getTeamColorSet(memberColor); - const { isLight } = useTheme(); - const pending = taskCounts?.pending ?? 0; - const inProgress = taskCounts?.inProgress ?? 0; - const completed = taskCounts?.completed ?? 0; - const totalTasks = pending + inProgress + completed; - const progressPercent = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 0; - const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); - const { summary: runtimeSummaryText, memory: memoryLabel } = - splitRuntimeSummaryMemory(runtimeSummary); - const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); - const isLead = isLeadMember(member); - const workspacePath = member.cwd?.trim(); - const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree'; - const workspaceTooltipLines = [ - 'Worktree isolation is enabled.', - workspacePath ? `Path: ${workspacePath}` : 'Path is not available yet.', - member.gitBranch ? `Branch: ${member.gitBranch}` : null, - ].filter((line): line is string => Boolean(line)); - const activityTask = currentTask ?? reviewTask ?? null; - const activityTitle = currentTask - ? `Current task: #${deriveTaskDisplayId(currentTask.id)}` - : reviewTask - ? `Reviewing task: #${deriveTaskDisplayId(reviewTask.id)}` - : undefined; - const showStartingSkeleton = - !isRemoved && - presenceLabel === 'starting' && - spawnLaunchState !== 'failed_to_start' && - !activityTask && - !runtimeSummary; - const showLaunchBadge = - !isRemoved && - !runtimeAdvisoryLabel && - (presenceLabel === 'starting' || - presenceLabel === 'connecting' || - launchVisualState === 'queued' || - launchVisualState === 'runtime_pending' || - launchVisualState === 'shell_only' || - launchVisualState === 'runtime_candidate' || - launchVisualState === 'registered_only' || - launchVisualState === 'stale_runtime'); - const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; - const launchDiagnosticsPayload = useMemo( - () => - buildMemberLaunchDiagnosticsPayload({ - teamName: selectedTeamName, - runId: runtimeRunId, - memberName: member.name, - spawnStatus, - launchState: spawnLaunchState, - livenessSource: spawnLivenessSource, - spawnEntry, - runtimeEntry, - }), - [ - member.name, - runtimeEntry, - runtimeRunId, - selectedTeamName, - spawnEntry, - spawnLaunchState, - spawnLivenessSource, + launchVisualState === 'stale_runtime'); + const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; + const launchDiagnosticsPayload = useMemo( + () => + buildMemberLaunchDiagnosticsPayload({ + teamName: selectedTeamName, + runId: runtimeRunId, + memberName: member.name, spawnStatus, - ] - ); - const showCopyDiagnostics = - !isRemoved && - hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && - hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); - const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; - const isSkippedLaunch = - spawnStatus === 'skipped' || - spawnLaunchState === 'skipped_for_launch' || - spawnEntry?.skippedForLaunch === true; - const showFailedLaunchBadge = !isRemoved && isFailedLaunch; - const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; - const hasLiveLaunchControls = - isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; - const hasRestartMemberControl = - !isRemoved && - !isLeadMember(member) && - Boolean(onRestartMember) && - hasLiveLaunchControls && - runtimeEntry?.restartable !== false; - const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ - member, - spawnEntry, + launchState: spawnLaunchState, + livenessSource: spawnLivenessSource, + spawnEntry, + runtimeEntry, + }), + [ + member.name, runtimeEntry, - }); - const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; - const canRetryLaunch = - (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; - const canSkipFailedLaunch = - showFailedLaunchBadge && - !isLeadMember(member) && - Boolean(onSkipMemberForLaunch) && - hasLiveLaunchControls; - const showRuntimeAdvisoryBadge = - !isRemoved && - Boolean(runtimeAdvisoryLabel) && - !showLaunchBadge && - !isFailedLaunch && - !isSkippedLaunch && - (Boolean(activityTask) || !isAwaitingReply); - const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; - const restartActionBusyLabel = canRelaunchOpenCode - ? 'Relaunching OpenCode teammate' - : 'Retrying teammate'; - const restartActionErrorFallback = canRelaunchOpenCode - ? 'Failed to relaunch OpenCode teammate' - : 'Failed to retry teammate'; - const handleRestartMember = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onRestartMember || retryingLaunch) { - return; - } - setRetryLaunchError(null); - setRetryingLaunch(true); - try { - await onRestartMember(member.name); - } catch (error) { - setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); - } finally { - setRetryingLaunch(false); - } - }; - const handleSkipFailedLaunch = async ( - event: React.MouseEvent - ): Promise => { - event.preventDefault(); - event.stopPropagation(); - if (!onSkipMemberForLaunch || skippingLaunch) { - return; - } - setSkipLaunchError(null); - setSkippingLaunch(true); - try { - await onSkipMemberForLaunch(member.name); - } catch (error) { - setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); - } finally { - setSkippingLaunch(false); - } - }; + runtimeRunId, + selectedTeamName, + spawnEntry, + spawnLaunchState, + spawnLivenessSource, + spawnStatus, + ] + ); + const showCopyDiagnostics = + !isRemoved && + hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) && + hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload); + const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start'; + const isSkippedLaunch = + spawnStatus === 'skipped' || + spawnLaunchState === 'skipped_for_launch' || + spawnEntry?.skippedForLaunch === true; + const showFailedLaunchBadge = !isRemoved && isFailedLaunch; + const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; + const hasLiveLaunchControls = + isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; + const hasRestartMemberControl = + !isRemoved && + !isLeadMember(member) && + Boolean(onRestartMember) && + hasLiveLaunchControls && + runtimeEntry?.restartable !== false; + const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ + member, + spawnEntry, + runtimeEntry, + }); + const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; + const canSkipFailedLaunch = + showFailedLaunchBadge && + !isLeadMember(member) && + Boolean(onSkipMemberForLaunch) && + hasLiveLaunchControls; + const showRuntimeAdvisoryBadge = + !isRemoved && + Boolean(runtimeAdvisoryLabel) && + !showLaunchBadge && + !isFailedLaunch && + !isSkippedLaunch && + (Boolean(activityTask) || !isAwaitingReply); + const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; + const restartActionBusyLabel = canRelaunchOpenCode + ? 'Relaunching OpenCode teammate' + : 'Retrying teammate'; + const restartActionErrorFallback = canRelaunchOpenCode + ? 'Failed to relaunch OpenCode teammate' + : 'Failed to retry teammate'; + const handleRestartMember = async (event: React.MouseEvent): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onRestartMember || retryingLaunch) { + return; + } + setRetryLaunchError(null); + setRetryingLaunch(true); + try { + await onRestartMember(member.name); + } catch (error) { + setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); + } finally { + setRetryingLaunch(false); + } + }; + const handleSkipFailedLaunch = async ( + event: React.MouseEvent + ): Promise => { + event.preventDefault(); + event.stopPropagation(); + if (!onSkipMemberForLaunch || skippingLaunch) { + return; + } + setSkipLaunchError(null); + setSkippingLaunch(true); + try { + await onSkipMemberForLaunch(member.name); + } catch (error) { + setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate'); + } finally { + setSkippingLaunch(false); + } + }; - return ( + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick?.(); + } + }} > -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onClick?.(); - } - }} - > -
-
-
-
- {member.name} -
- +
+
+
+
+ {member.name}
-
-
- - {displayMemberName(member.name)} + +
+
+
+ + {displayMemberName(member.name)} + + {member.gitBranch && !showWorkspaceBadge ? ( + + + {member.gitBranch} - {member.gitBranch && !showWorkspaceBadge ? ( - - - {member.gitBranch} - - ) : null} - {showWorkspaceBadge ? ( - - - - worktree - - - -
- {workspaceTooltipLines.map((line) => ( -

- {line} -

- ))} -
-
-
- ) : null} - {currentTask ? ( - - ) : null} - {reviewTask ? ( - - ) : null} - {!activityTask && isAwaitingReply ? ( - <> - {runtimeAdvisoryTone === 'error' ? ( - - ) : ( - - )} - - {runtimeAdvisoryLabel ?? 'awaiting reply'} - - - ) : null} -
- {showStartingSkeleton ? ( - + {showStartingSkeleton ? ( + ); }; diff --git a/src/renderer/components/team/TeamListView.tsx b/src/renderer/components/team/TeamListView.tsx index 5e597278..b3ab27f7 100644 --- a/src/renderer/components/team/TeamListView.tsx +++ b/src/renderer/components/team/TeamListView.tsx @@ -736,7 +736,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element { ); } - const createDialogElement = ( + const createDialogElement = showCreateDialog && ( ); - const launchDialogElement = ( + const launchDialogElement = launchDialogOpen && ( - - + {dialogOpen && ( + + + + )}
); }; From bb03a12a482ce432040d17251c073a05fe66da03 Mon Sep 17 00:00:00 2001 From: Mike Date: Sun, 3 May 2026 14:53:03 +0500 Subject: [PATCH 26/51] fix(perf): suppress Show raw toggle in bare mode MarkdownViewer --- src/renderer/components/chat/viewers/MarkdownViewer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 6adeacfb..90faaf38 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1183,8 +1183,8 @@ export const MarkdownViewer: React.FC = React.memo(function
)} - {/* Show raw toggle for no-label path */} - {!label && ( + {/* Show raw toggle for no-label path (skip in bare mode) */} + {!label && !bare && (
Date: Sun, 3 May 2026 11:23:45 +0300 Subject: [PATCH 27/51] chore(team): instrument refresh fanout diagnostics --- src/renderer/store/index.ts | 197 +++++++++++++++++- src/renderer/store/slices/teamSlice.ts | 40 ++++ .../store/teamRefreshFanoutDiagnostics.ts | 148 +++++++++++++ .../renderer/store/teamChangeThrottle.test.ts | 49 +++++ .../teamRefreshFanoutDiagnostics.test.ts | 139 ++++++++++++ test/renderer/store/teamSlice.test.ts | 57 +++++ 6 files changed, 619 insertions(+), 11 deletions(-) create mode 100644 src/renderer/store/teamRefreshFanoutDiagnostics.ts create mode 100644 test/renderer/store/teamRefreshFanoutDiagnostics.test.ts diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 75751dbc..ee029f91 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -47,6 +47,10 @@ import { isTeamDataRefreshPending, selectTeamDataForName, } from './slices/teamSlice'; +import { + noteTeamRefreshFanout, + type TeamRefreshFanoutOperation, +} from './teamRefreshFanoutDiagnostics'; import { createUISlice } from './slices/uiSlice'; import { createUpdateSlice } from './slices/updateSlice'; @@ -250,6 +254,8 @@ export function initializeNotificationListeners(): () => void { let teamListRefreshTimer: ReturnType | null = null; let globalTasksRefreshTimer: ReturnType | null = null; + const pendingTeamListRefreshDiagnostics = new Map>(); + const pendingGlobalTasksRefreshDiagnostics = new Map>(); const SESSION_REFRESH_DEBOUNCE_MS = 150; const PROJECT_REFRESH_DEBOUNCE_MS = 300; const TEAM_REFRESH_THROTTLE_MS = 800; @@ -257,6 +263,53 @@ export function initializeNotificationListeners(): () => void { const TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS = 500; const TEAM_LIST_REFRESH_THROTTLE_MS = 2000; const GLOBAL_TASKS_REFRESH_THROTTLE_MS = 500; + const buildTeamChangeFanoutReason = (eventType: string): string => `event:${eventType}`; + const addPendingGlobalRefreshDiagnostic = ( + pending: Map>, + teamName: string, + reason: string + ): void => { + const reasons = pending.get(teamName) ?? new Set(); + reasons.add(reason); + pending.set(teamName, reasons); + }; + const drainPendingGlobalRefreshDiagnostics = ( + pending: Map>, + operation: TeamRefreshFanoutOperation + ): void => { + const entries = Array.from(pending.entries()); + pending.clear(); + for (const [teamName, reasons] of entries) { + for (const reason of reasons) { + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason, + operation, + }); + } + } + }; + const noteGlobalRefreshScheduled = ( + pending: Map>, + teamName: string | null | undefined, + reason: string, + operation: TeamRefreshFanoutOperation, + coalesced: boolean + ): void => { + if (!teamName) { + return; + } + addPendingGlobalRefreshDiagnostic(pending, teamName, reason); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: coalesced ? 'coalesced' : 'scheduled', + reason, + operation, + }); + }; const refreshTrackedTeamMessages = async (teamName: string): Promise => { if (!teamName || !shouldRefreshTeamMessages(teamName)) { return; @@ -278,11 +331,26 @@ export function initializeNotificationListeners(): () => void { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; } + const existingTimer = memberSpawnRefreshTimers.get(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason: 'event:member-spawn', + operation: 'fetchMemberSpawnStatuses', + }); if (memberSpawnRefreshTimers.has(teamName)) { return; } const timer = setTimeout(() => { memberSpawnRefreshTimers.delete(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: 'event:member-spawn', + operation: 'fetchMemberSpawnStatuses', + }); void useStore.getState().fetchMemberSpawnStatuses(teamName); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); memberSpawnRefreshTimers.set(teamName, timer); @@ -291,24 +359,57 @@ export function initializeNotificationListeners(): () => void { if (!teamName || !isTeamVisibleInAnyPane(teamName)) { return; } + const existingTimer = teamAgentRuntimeRefreshTimers.get(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason: 'event:member-spawn', + operation: 'fetchTeamAgentRuntime', + }); if (teamAgentRuntimeRefreshTimers.has(teamName)) { return; } const timer = setTimeout(() => { teamAgentRuntimeRefreshTimers.delete(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: 'event:member-spawn', + operation: 'fetchTeamAgentRuntime', + }); void useStore.getState().fetchTeamAgentRuntime(teamName); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); teamAgentRuntimeRefreshTimers.set(teamName, timer); }; - const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => { + const scheduleTrackedTeamMessageRefresh = ( + teamName: string | null | undefined, + reason: 'event:inbox' | 'event:lead-message' + ): void => { if (!teamName || !shouldRefreshTeamMessages(teamName)) { return; } + const existingTimer = teamMessageRefreshTimers.get(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason, + operation: 'fetchTeamMessageHead', + }); if (teamMessageRefreshTimers.has(teamName)) { return; } const timer = setTimeout(() => { teamMessageRefreshTimers.delete(teamName); + noteTeamRefreshFanout({ + teamName, + surface: 'team-change-listener', + phase: 'executed', + reason, + operation: 'fetchTeamMessageHead', + }); void refreshTrackedTeamMessages(teamName); }, TEAM_REFRESH_THROTTLE_MS); teamMessageRefreshTimers.set(teamName, timer); @@ -700,7 +801,16 @@ export function initializeNotificationListeners(): () => void { teamMessageFallbackPollInFlight = true; try { await Promise.allSettled( - Array.from(teamNames, (teamName) => refreshTrackedTeamMessages(teamName)) + Array.from(teamNames, (teamName) => { + noteTeamRefreshFanout({ + teamName, + surface: 'pending-reply-fallback', + phase: 'executed', + reason: 'pending-reply:fallback-poll', + operation: 'fetchTeamMessageHead', + }); + return refreshTrackedTeamMessages(teamName); + }) ); } finally { teamMessageFallbackPollInFlight = false; @@ -1213,7 +1323,7 @@ export function initializeNotificationListeners(): () => void { } if (event.type === 'inbox') { - scheduleTrackedTeamMessageRefresh(event.teamName); + scheduleTrackedTeamMessageRefresh(event.teamName, 'event:inbox'); return; } @@ -1224,7 +1334,7 @@ export function initializeNotificationListeners(): () => void { return; } seedCurrentRunIdIfMissing(); - scheduleTrackedTeamMessageRefresh(event.teamName); + scheduleTrackedTeamMessageRefresh(event.teamName, 'event:lead-message'); return; } @@ -1232,22 +1342,47 @@ export function initializeNotificationListeners(): () => void { if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { return; } - if (teamPresenceRefreshTimers.has(event.teamName)) { + const existingTimer = teamPresenceRefreshTimers.get(event.teamName); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: existingTimer ? 'coalesced' : 'scheduled', + reason: 'event:log-source-change', + operation: 'refreshTaskChangePresence', + }); + if (existingTimer) { return; } const timer = setTimeout(() => { teamPresenceRefreshTimers.delete(event.teamName); const current = useStore.getState(); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: 'event:log-source-change', + operation: 'refreshTaskChangePresence', + }); void current.refreshTeamChangePresence(event.teamName); }, TEAM_PRESENCE_REFRESH_THROTTLE_MS); teamPresenceRefreshTimers.set(event.teamName, timer); return; } + const eventReason = buildTeamChangeFanoutReason(event.type); + // Throttled refresh of summary list (keeps TeamListView current without flooding). + noteGlobalRefreshScheduled( + pendingTeamListRefreshDiagnostics, + event.teamName, + eventReason, + 'fetchTeams', + teamListRefreshTimer != null + ); if (!teamListRefreshTimer) { teamListRefreshTimer = setTimeout(() => { teamListRefreshTimer = null; + drainPendingGlobalRefreshDiagnostics(pendingTeamListRefreshDiagnostics, 'fetchTeams'); void useStore.getState().fetchTeams(); }, TEAM_LIST_REFRESH_THROTTLE_MS); } @@ -1255,11 +1390,24 @@ export function initializeNotificationListeners(): () => void { const shouldRefreshGlobalTasks = event.type === 'task' || event.type === 'config'; // Throttled refresh of global tasks list for sidebar. - if (shouldRefreshGlobalTasks && !globalTasksRefreshTimer) { - globalTasksRefreshTimer = setTimeout(() => { - globalTasksRefreshTimer = null; - void useStore.getState().fetchAllTasks(); - }, GLOBAL_TASKS_REFRESH_THROTTLE_MS); + if (shouldRefreshGlobalTasks) { + noteGlobalRefreshScheduled( + pendingGlobalTasksRefreshDiagnostics, + event.teamName, + eventReason, + 'fetchAllTasks', + globalTasksRefreshTimer != null + ); + if (!globalTasksRefreshTimer) { + globalTasksRefreshTimer = setTimeout(() => { + globalTasksRefreshTimer = null; + drainPendingGlobalRefreshDiagnostics( + pendingGlobalTasksRefreshDiagnostics, + 'fetchAllTasks' + ); + void useStore.getState().fetchAllTasks(); + }, GLOBAL_TASKS_REFRESH_THROTTLE_MS); + } } if (!event?.teamName || !isTeamVisibleInAnyPane(event.teamName)) { @@ -1268,13 +1416,38 @@ export function initializeNotificationListeners(): () => void { // Per-team throttle (not debounce): keep at most one pending detail refresh per team. // Debounce would delay indefinitely while inbox messages keep arriving. - if (teamRefreshTimers.has(event.teamName)) { + const selectedForRefresh = useStore.getState().selectedTeamName === event.teamName; + const activeTabForRefresh = getFocusedVisibleTeamName() === event.teamName; + const existingDetailTimer = teamRefreshTimers.get(event.teamName); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: existingDetailTimer ? 'coalesced' : 'scheduled', + reason: eventReason, + operation: 'refreshTeamData', + eventType: event.type, + selected: selectedForRefresh, + visible: true, + activeTab: activeTabForRefresh, + }); + if (existingDetailTimer) { return; } const timer = setTimeout(() => { teamRefreshTimers.delete(event.teamName); const current = useStore.getState(); + noteTeamRefreshFanout({ + teamName: event.teamName, + surface: 'team-change-listener', + phase: 'executed', + reason: eventReason, + operation: 'refreshTeamData', + eventType: event.type, + selected: current.selectedTeamName === event.teamName, + visible: isTeamVisibleInAnyPane(event.teamName), + activeTab: getFocusedVisibleTeamName() === event.teamName, + }); void current.refreshTeamData(event.teamName, { withDedup: true }); }, TEAM_REFRESH_THROTTLE_MS); teamRefreshTimers.set(event.teamName, timer); @@ -1301,10 +1474,12 @@ export function initializeNotificationListeners(): () => void { clearTimeout(teamListRefreshTimer); teamListRefreshTimer = null; } + pendingTeamListRefreshDiagnostics.clear(); if (globalTasksRefreshTimer) { clearTimeout(globalTasksRefreshTimer); globalTasksRefreshTimer = null; } + pendingGlobalTasksRefreshDiagnostics.clear(); }); } } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b9adaedf..c676ec27 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -20,6 +20,7 @@ import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; +import { noteTeamRefreshFanout } from '../teamRefreshFanoutDiagnostics'; import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; @@ -4887,6 +4888,17 @@ export const createTeamSlice: StateCreator = (set, if (isCanonicalRun && becameConfigReady) { const state = get(); if (isVisibleInActiveTeamSurface(state, progress.teamName)) { + const willSelectTeam = + state.selectedTeamName === progress.teamName && state.selectedTeamData == null; + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'scheduled', + reason: 'provisioning:config-ready', + operation: willSelectTeam ? 'selectTeam' : 'refreshTeamData', + selected: state.selectedTeamName === progress.teamName, + visible: true, + }); if (state.selectedTeamName === progress.teamName && state.selectedTeamData == null) { void state.selectTeam(progress.teamName, { allowReloadWhileProvisioning: true }); } else { @@ -4939,8 +4951,27 @@ export const createTeamSlice: StateCreator = (set, } if (isCanonicalRun && (progress.state === 'ready' || progress.state === 'disconnected')) { + const terminalReason = + progress.state === 'ready' + ? 'provisioning:terminal-ready' + : 'provisioning:terminal-disconnected'; + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'scheduled', + reason: terminalReason, + operation: 'fetchTeams', + }); void get().fetchTeams(); if (hydratedVisibleTeam) { + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'skipped', + reason: 'provisioning:already-hydrated-visible-team', + operation: 'refreshTeamData', + visible: true, + }); return; } @@ -4951,6 +4982,15 @@ export const createTeamSlice: StateCreator = (set, // If the user already opened the team tab, reload team data now that // config.json is guaranteed to exist. + noteTeamRefreshFanout({ + teamName: progress.teamName, + surface: 'provisioning-progress', + phase: 'scheduled', + reason: terminalReason, + operation: state.selectedTeamName === progress.teamName ? 'selectTeam' : 'refreshTeamData', + selected: state.selectedTeamName === progress.teamName, + visible: true, + }); if (state.selectedTeamName === progress.teamName) { void state.selectTeam(progress.teamName); } else { diff --git a/src/renderer/store/teamRefreshFanoutDiagnostics.ts b/src/renderer/store/teamRefreshFanoutDiagnostics.ts new file mode 100644 index 00000000..e4db20a3 --- /dev/null +++ b/src/renderer/store/teamRefreshFanoutDiagnostics.ts @@ -0,0 +1,148 @@ +export type TeamRefreshFanoutSurface = + | 'team-change-listener' + | 'provisioning-progress' + | 'pending-reply-fallback' + | 'manual-refresh'; + +export type TeamRefreshFanoutPhase = 'scheduled' | 'coalesced' | 'executed' | 'skipped'; + +export type TeamRefreshFanoutOperation = + | 'fetchTeams' + | 'fetchAllTasks' + | 'refreshTeamData' + | 'selectTeam' + | 'fetchTeamMessageHead' + | 'fetchMemberSpawnStatuses' + | 'fetchTeamAgentRuntime' + | 'refreshTaskChangePresence'; + +export interface TeamRefreshFanoutNote { + teamName: string; + surface: TeamRefreshFanoutSurface; + phase: TeamRefreshFanoutPhase; + reason: string; + operation: TeamRefreshFanoutOperation; + eventType?: string; + tabId?: string; + selected?: boolean; + visible?: boolean; + activeTab?: boolean; +} + +export interface TeamRefreshFanoutRecentNote { + at: number; + surface: TeamRefreshFanoutSurface; + phase: TeamRefreshFanoutPhase; + reason: string; + operation: TeamRefreshFanoutOperation; + eventType?: string; + tabId?: string; + selected?: boolean; + visible?: boolean; + activeTab?: boolean; +} + +export interface TeamRefreshFanoutSnapshot { + counts: Record; + recent: TeamRefreshFanoutRecentNote[]; + lastAt: number; +} + +interface TeamRefreshFanoutBucket { + counts: Record; + recent: TeamRefreshFanoutRecentNote[]; + lastAt: number; +} + +export const MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS = 100; +export const MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES = 50; + +const buckets = new Map(); + +function createEmptyBucket(): TeamRefreshFanoutBucket { + return { + counts: {}, + recent: [], + lastAt: 0, + }; +} + +function ensureTeamBucket(teamName: string): TeamRefreshFanoutBucket { + if (!buckets.has(teamName) && buckets.size >= MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS) { + const oldestKey = buckets.keys().next().value as string | undefined; + if (oldestKey) { + buckets.delete(oldestKey); + } + } + + let bucket = buckets.get(teamName); + if (!bucket) { + bucket = createEmptyBucket(); + buckets.set(teamName, bucket); + } + + return bucket; +} + +function cloneBucket( + bucket: TeamRefreshFanoutBucket | undefined +): TeamRefreshFanoutSnapshot | null { + if (!bucket) { + return null; + } + + return { + counts: { ...bucket.counts }, + recent: bucket.recent.map((note) => ({ ...note })), + lastAt: bucket.lastAt, + }; +} + +export function buildTeamRefreshFanoutCountKey(note: TeamRefreshFanoutNote): string { + return `${note.surface}:${note.reason}:${note.operation}:${note.phase}`; +} + +export function noteTeamRefreshFanout(note: TeamRefreshFanoutNote): void { + if (!note.teamName || !note.reason || !note.operation) { + return; + } + + const bucket = ensureTeamBucket(note.teamName); + const key = buildTeamRefreshFanoutCountKey(note); + const now = Date.now(); + + bucket.counts[key] = (bucket.counts[key] ?? 0) + 1; + bucket.lastAt = now; + bucket.recent.push({ + at: now, + surface: note.surface, + phase: note.phase, + reason: note.reason, + operation: note.operation, + eventType: note.eventType, + tabId: note.tabId, + selected: note.selected, + visible: note.visible, + activeTab: note.activeTab, + }); + + if (bucket.recent.length > MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES) { + bucket.recent.splice(0, bucket.recent.length - MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES); + } +} + +export function getTeamRefreshFanoutSnapshotForTests( + teamName?: string +): TeamRefreshFanoutSnapshot | Record | null { + if (teamName) { + return cloneBucket(buckets.get(teamName)); + } + + return Object.fromEntries( + Array.from(buckets.entries(), ([key, bucket]) => [key, cloneBucket(bucket)]) + ) as Record; +} + +export function __resetTeamRefreshFanoutDiagnosticsForTests(): void { + buckets.clear(); +} diff --git a/test/renderer/store/teamChangeThrottle.test.ts b/test/renderer/store/teamChangeThrottle.test.ts index 47fd6c9c..491ce366 100644 --- a/test/renderer/store/teamChangeThrottle.test.ts +++ b/test/renderer/store/teamChangeThrottle.test.ts @@ -63,6 +63,11 @@ vi.mock('@renderer/api', () => ({ import { initializeNotificationListeners, useStore } from '../../../src/renderer/store'; import { __resetTeamSliceModuleStateForTests } from '../../../src/renderer/store/slices/teamSlice'; +import { + __resetTeamRefreshFanoutDiagnosticsForTests, + getTeamRefreshFanoutSnapshotForTests, + type TeamRefreshFanoutSnapshot, +} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; import { api } from '@renderer/api'; describe('team change throttling', () => { @@ -71,6 +76,7 @@ describe('team change throttling', () => { beforeEach(async () => { vi.useFakeTimers(); __resetTeamSliceModuleStateForTests(); + __resetTeamRefreshFanoutDiagnosticsForTests(); const fetchTeams = vi.fn(async () => undefined); const fetchMemberSpawnStatuses = vi.fn(async () => undefined); const refreshTeamData = vi.fn(async () => undefined); @@ -117,6 +123,7 @@ describe('team change throttling', () => { cleanup?.(); cleanup = null; __resetTeamSliceModuleStateForTests(); + __resetTeamRefreshFanoutDiagnosticsForTests(); vi.mocked(console.warn).mockClear(); vi.useRealTimers(); }); @@ -163,6 +170,48 @@ describe('team change throttling', () => { expect(refreshTeamDataSpy).toHaveBeenCalledTimes(2); }); + it('keeps process events on the existing structural refresh path and records fanout', async () => { + const state = useStore.getState(); + const refreshTeamDataSpy = vi.spyOn(state, 'refreshTeamData'); + + hoisted.onTeamChangeCb?.({}, { type: 'process', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(800); + + expect(refreshTeamDataSpy).toHaveBeenCalledTimes(1); + expect(refreshTeamDataSpy).toHaveBeenCalledWith('my-team', { withDedup: true }); + + const snapshot = getTeamRefreshFanoutSnapshotForTests( + 'my-team' + ) as TeamRefreshFanoutSnapshot | null; + expect( + snapshot?.counts['team-change-listener:event:process:refreshTeamData:scheduled'] + ).toBe(1); + expect(snapshot?.counts['team-change-listener:event:process:refreshTeamData:executed']).toBe( + 1 + ); + }); + + it('keeps task and config events on the existing global task refresh path', async () => { + const fetchAllTasksSpy = vi.fn(async () => undefined); + useStore.setState({ fetchAllTasks: fetchAllTasksSpy } as never); + + hoisted.onTeamChangeCb?.({}, { type: 'task', teamName: 'my-team' }); + hoisted.onTeamChangeCb?.({}, { type: 'config', teamName: 'my-team' }); + + await vi.advanceTimersByTimeAsync(500); + + expect(fetchAllTasksSpy).toHaveBeenCalledTimes(1); + + const snapshot = getTeamRefreshFanoutSnapshotForTests( + 'my-team' + ) as TeamRefreshFanoutSnapshot | null; + expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:scheduled']).toBe(1); + expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:coalesced']).toBe(1); + expect(snapshot?.counts['team-change-listener:event:task:fetchAllTasks:executed']).toBe(1); + expect(snapshot?.counts['team-change-listener:event:config:fetchAllTasks:executed']).toBe(1); + }); + it('lead-message refreshes message head only, not team list, tasks, or structural detail', async () => { const state = useStore.getState(); const fetchTeamsSpy = vi.spyOn(state, 'fetchTeams'); diff --git a/test/renderer/store/teamRefreshFanoutDiagnostics.test.ts b/test/renderer/store/teamRefreshFanoutDiagnostics.test.ts new file mode 100644 index 00000000..88ad5c7b --- /dev/null +++ b/test/renderer/store/teamRefreshFanoutDiagnostics.test.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + __resetTeamRefreshFanoutDiagnosticsForTests, + buildTeamRefreshFanoutCountKey, + getTeamRefreshFanoutSnapshotForTests, + MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES, + MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS, + noteTeamRefreshFanout, + type TeamRefreshFanoutSnapshot, +} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; + +function snapshotFor(teamName: string): TeamRefreshFanoutSnapshot { + const snapshot = getTeamRefreshFanoutSnapshotForTests(teamName); + expect(snapshot).not.toBeNull(); + return snapshot as TeamRefreshFanoutSnapshot; +} + +describe('teamRefreshFanoutDiagnostics', () => { + beforeEach(() => { + vi.useFakeTimers(); + __resetTeamRefreshFanoutDiagnosticsForTests(); + }); + + afterEach(() => { + __resetTeamRefreshFanoutDiagnosticsForTests(); + vi.useRealTimers(); + }); + + it('records scheduled and executed fanout counts separately', () => { + const scheduled = { + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + } as const; + const executed = { + ...scheduled, + phase: 'executed', + } as const; + + noteTeamRefreshFanout(scheduled); + noteTeamRefreshFanout(executed); + + const snapshot = snapshotFor('team-a'); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(scheduled)]).toBe(1); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(executed)]).toBe(1); + }); + + it('records coalesced notes separately from scheduled notes', () => { + const scheduled = { + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:member-spawn', + operation: 'fetchMemberSpawnStatuses', + } as const; + const coalesced = { + ...scheduled, + phase: 'coalesced', + } as const; + + noteTeamRefreshFanout(scheduled); + noteTeamRefreshFanout(coalesced); + noteTeamRefreshFanout(coalesced); + + const snapshot = snapshotFor('team-a'); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(scheduled)]).toBe(1); + expect(snapshot.counts[buildTeamRefreshFanoutCountKey(coalesced)]).toBe(2); + }); + + it('caps recent notes per team', () => { + for (let index = 0; index < MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES + 5; index += 1) { + noteTeamRefreshFanout({ + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: `event:${index}`, + operation: 'refreshTeamData', + }); + } + + const snapshot = snapshotFor('team-a'); + expect(snapshot.recent).toHaveLength(MAX_TEAM_REFRESH_DIAGNOSTIC_RECENT_NOTES); + expect(snapshot.recent[0]?.reason).toBe('event:5'); + }); + + it('caps team buckets by evicting the oldest bucket', () => { + for (let index = 0; index < MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS + 1; index += 1) { + noteTeamRefreshFanout({ + teamName: `team-${index}`, + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + }); + } + + expect(getTeamRefreshFanoutSnapshotForTests('team-0')).toBeNull(); + expect( + getTeamRefreshFanoutSnapshotForTests(`team-${MAX_TEAM_REFRESH_DIAGNOSTIC_TEAMS}`) + ).not.toBeNull(); + }); + + it('reset clears all diagnostic state', () => { + noteTeamRefreshFanout({ + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + }); + + __resetTeamRefreshFanoutDiagnosticsForTests(); + + expect(getTeamRefreshFanoutSnapshotForTests('team-a')).toBeNull(); + expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({}); + }); + + it('ignores invalid empty team or reason values', () => { + noteTeamRefreshFanout({ + teamName: '', + surface: 'team-change-listener', + phase: 'scheduled', + reason: 'event:process', + operation: 'refreshTeamData', + }); + noteTeamRefreshFanout({ + teamName: 'team-a', + surface: 'team-change-listener', + phase: 'scheduled', + reason: '', + operation: 'refreshTeamData', + }); + + expect(getTeamRefreshFanoutSnapshotForTests()).toEqual({}); + }); +}); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index b2abffb9..8991ade7 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -12,6 +12,11 @@ import { selectResolvedMemberForTeamName, selectResolvedMembersForTeamName, } from '../../../src/renderer/store/slices/teamSlice'; +import { + __resetTeamRefreshFanoutDiagnosticsForTests, + getTeamRefreshFanoutSnapshotForTests, + type TeamRefreshFanoutSnapshot, +} from '../../../src/renderer/store/teamRefreshFanoutDiagnostics'; const hoisted = vi.hoisted(() => ({ list: vi.fn(), @@ -198,6 +203,7 @@ describe('teamSlice actions', () => { beforeEach(() => { vi.clearAllMocks(); __resetTeamSliceModuleStateForTests(); + __resetTeamRefreshFanoutDiagnosticsForTests(); hoisted.list.mockResolvedValue([]); hoisted.getData.mockResolvedValue(createTeamSnapshot()); hoisted.getMessagesPage.mockResolvedValue({ @@ -238,6 +244,57 @@ describe('teamSlice actions', () => { hoisted.skipMemberForLaunch.mockResolvedValue(undefined); }); + it('records terminal provisioning fanout diagnostics without changing visible graph hydrate behavior', () => { + const store = createSliceStore(); + const fetchTeams = vi.fn(async () => undefined); + const refreshTeamData = vi.fn(async () => undefined); + store.setState({ + fetchTeams, + refreshTeamData, + selectedTeamName: 'other-team', + selectedTeamData: createTeamSnapshot({ + teamName: 'other-team', + config: { name: 'Other Team' }, + }), + paneLayout: { + focusedPaneId: 'pane-default', + panes: [ + { + id: 'pane-default', + widthFraction: 1, + tabs: [{ id: 'graph-my-team', type: 'graph', teamName: 'my-team', label: 'Graph' }], + activeTabId: 'graph-my-team', + }, + ], + }, + }); + + store.getState().onProvisioningProgress({ + runId: 'run-ready', + teamName: 'my-team', + state: 'ready', + message: 'Ready', + startedAt: '2026-03-12T10:00:00.000Z', + updatedAt: '2026-03-12T10:00:01.000Z', + } as never); + + expect(fetchTeams).toHaveBeenCalledTimes(1); + expect(refreshTeamData).toHaveBeenCalledTimes(1); + expect(refreshTeamData).toHaveBeenCalledWith('my-team', { withDedup: true }); + + const snapshot = getTeamRefreshFanoutSnapshotForTests( + 'my-team' + ) as TeamRefreshFanoutSnapshot | null; + expect( + snapshot?.counts['provisioning-progress:provisioning:terminal-ready:fetchTeams:scheduled'] + ).toBe(1); + expect( + snapshot?.counts[ + 'provisioning-progress:provisioning:terminal-ready:refreshTeamData:scheduled' + ] + ).toBe(1); + }); + it('maps inbox verify failure to user-friendly text', async () => { const store = createSliceStore(); hoisted.sendMessage.mockRejectedValue(new Error('Failed to verify inbox write')); From e3c62eb620802e9c7e478451916b1a8d2eaca5d4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 3 May 2026 13:06:33 +0300 Subject: [PATCH 28/51] fix(team): harden runtime status and opencode bootstrap --- package.json | 4 + src/main/index.ts | 4 + src/main/ipc/teams.ts | 3 + .../services/error/ErrorMessageBuilder.ts | 4 +- .../infrastructure/NotificationManager.ts | 446 +++++++++++++++++- .../services/team/TeamLogSourceTracker.ts | 35 +- .../services/team/TeamProvisioningService.ts | 221 ++++++++- .../team/TeamRuntimeLivenessResolver.ts | 23 +- src/main/utils/teamNotificationBuilder.ts | 16 +- .../team/LiveRuntimeStatusBridge.tsx | 77 +++ .../team/LiveRuntimeStatusSection.tsx | 98 ++++ .../components/team/TeamDetailView.tsx | 18 +- .../team/dialogs/GlobalTaskDetailDialog.tsx | 2 + .../team/dialogs/TaskCommentsSection.tsx | 35 +- .../team/dialogs/TaskDetailDialog.tsx | 3 + .../components/team/members/MemberList.tsx | 23 +- .../components/team/teamRuntimeDisplayRows.ts | 317 +++++++++++++ src/renderer/store/index.ts | 278 ++++++++++- .../store/slices/notificationSlice.ts | 87 +++- src/renderer/store/slices/teamSlice.ts | 58 ++- src/renderer/store/teamProcessFanoutDryRun.ts | 70 +++ .../store/teamRefreshFanoutDebugBridge.ts | 49 ++ .../store/teamRefreshFanoutDiagnostics.ts | 82 +++- src/shared/types/notifications.ts | 22 + src/shared/types/team.ts | 10 +- .../TeamAgentLaunchMatrix.safe-e2e.test.ts | 146 +++++- .../team/TeamLogSourceTracker.test.ts | 75 ++- .../team/TeamProvisioningService.test.ts | 308 ++++++++++-- .../team/TeamRuntimeLivenessResolver.test.ts | 30 ++ .../team/LiveRuntimeStatusSection.test.tsx | 53 +++ .../team/teamRuntimeDisplayRows.test.ts | 190 ++++++++ .../renderer/store/teamChangeThrottle.test.ts | 437 ++++++++++++++++- .../store/teamProcessFanoutDryRun.test.ts | 112 +++++ .../teamRefreshFanoutDebugBridge.test.ts | 81 ++++ .../teamRefreshFanoutDiagnostics.test.ts | 79 ++++ test/renderer/store/teamSlice.test.ts | 30 ++ 36 files changed, 3378 insertions(+), 148 deletions(-) create mode 100644 src/renderer/components/team/LiveRuntimeStatusBridge.tsx create mode 100644 src/renderer/components/team/LiveRuntimeStatusSection.tsx create mode 100644 src/renderer/components/team/teamRuntimeDisplayRows.ts create mode 100644 src/renderer/store/teamProcessFanoutDryRun.ts create mode 100644 src/renderer/store/teamRefreshFanoutDebugBridge.ts create mode 100644 test/renderer/components/team/LiveRuntimeStatusSection.test.tsx create mode 100644 test/renderer/components/team/teamRuntimeDisplayRows.test.ts create mode 100644 test/renderer/store/teamProcessFanoutDryRun.test.ts create mode 100644 test/renderer/store/teamRefreshFanoutDebugBridge.test.ts diff --git a/package.json b/package.json index ffb89ac9..0c0df825 100644 --- a/package.json +++ b/package.json @@ -247,6 +247,10 @@ "from": "resources/runtime", "to": "runtime" }, + { + "from": "src/renderer/assets/participant-avatars", + "to": "participant-avatars" + }, { "from": "mcp-server/dist/index.js", "to": "mcp-server/index.js" diff --git a/src/main/index.ts b/src/main/index.ts index 1bf686f0..01ddf9d7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -498,6 +498,9 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise summary, body: extracted.body, dedupeKey: `inbox:${teamName}:${memberName}:${msgId}`, + target: isCrossTeam + ? { kind: 'team', teamName, section: 'messages' } + : { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' }, suppressToast: effectiveSuppressToast, }) .catch(() => undefined); @@ -557,6 +560,7 @@ async function notifyNewSentMessages(teamName: string): Promise { summary, body: extracted.body, dedupeKey: `sent:${teamName}:${msg.timestamp ?? String(prevCount + i)}`, + target: { kind: 'member', teamName, memberName: fromLabel, focus: 'messages' }, suppressToast, }) .catch(() => undefined); diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 49002931..32132aaf 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -415,6 +415,7 @@ function checkRateLimitMessages( summary: `Rate limit: ${msg.from}`, body: msg.text.slice(0, 200), dedupeKey, + target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, projectPath, }) .catch(() => undefined); @@ -489,6 +490,7 @@ function checkApiErrorMessages( summary: `API Error ${statusCode}: ${msg.from}`, body: msg.text.slice(0, 400), dedupeKey, + target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' }, projectPath, }) .catch(() => undefined); @@ -4444,6 +4446,7 @@ async function handleShowMessageNotification( summary: d.summary ?? `${d.from} → ${d.to ?? 'team'}`, body: d.body, dedupeKey, + target: d.target, suppressToast: d.suppressToast, }) .catch(() => undefined); diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 1fdfe40b..b8f1c3f3 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'crypto'; import { type ExtractedToolResult } from '../analysis/ToolResultExtractor'; import type { TriggerColor } from '@shared/constants/triggerColors'; -import type { TeamEventType } from '@shared/types/notifications'; +import type { NotificationTarget, TeamEventType } from '@shared/types/notifications'; // ============================================================================= // Types @@ -54,6 +54,8 @@ export interface DetectedError { category?: 'error' | 'team'; /** For team notifications: specific event sub-type */ teamEventType?: TeamEventType; + /** Structured destination for notification clicks. */ + target?: NotificationTarget; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ dedupeKey?: string; /** Additional context about the error */ diff --git a/src/main/services/infrastructure/NotificationManager.ts b/src/main/services/infrastructure/NotificationManager.ts index 3ebc49e6..0491e5dc 100644 --- a/src/main/services/infrastructure/NotificationManager.ts +++ b/src/main/services/infrastructure/NotificationManager.ts @@ -16,15 +16,19 @@ */ import { getAppIconPath } from '@main/utils/appIcon'; -import { getHomeDir } from '@main/utils/pathDecoder'; +import { getAppDataPath, getHomeDir, getTeamsBasePath } from '@main/utils/pathDecoder'; import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { getMemberColorByName, MEMBER_COLOR_HUE } from '@shared/constants/memberColors'; +import { isLeadMember } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; -import { Notification as ElectronNotification } from 'electron'; +import { Notification as ElectronNotification, nativeImage } from 'electron'; import { EventEmitter } from 'events'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import * as fsp from 'fs/promises'; import * as path from 'path'; +import { pathToFileURL } from 'url'; import { type DetectedError } from '../error/ErrorMessageBuilder'; @@ -101,6 +105,16 @@ const LEGACY_NOTIFICATION_FILENAMES = [ const LEGACY_NOTIFICATION_PATHS = LEGACY_NOTIFICATION_FILENAMES.map((filename) => path.join(getHomeDir(), '.claude', filename) ); +const SENDER_ICON_CACHE = new Map(); +const WINDOWS_TOAST_AVATAR_CACHE = new Map(); +const PARTICIPANT_AVATAR_COUNT = 13; +const LEAD_PARTICIPANT_AVATAR_NUMBER = 1; + +interface TeamNotificationAvatarMember { + name: string; + removedAt?: number | string | null; + agentType?: string; +} interface LegacyNotificationData { path: string; @@ -123,6 +137,385 @@ function getNotificationClass(): NotificationClass | null { return (ElectronNotification as NotificationClass | undefined) ?? null; } +function getNativeImage(): typeof nativeImage | null { + return nativeImage && typeof nativeImage.createFromPath === 'function' ? nativeImage : null; +} + +function hashStringToIndex(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +function getParticipantAvatarNumberByIndex(index: number): number { + const normalized = + ((Math.trunc(index) % PARTICIPANT_AVATAR_COUNT) + PARTICIPANT_AVATAR_COUNT) % + PARTICIPANT_AVATAR_COUNT; + return normalized + 1; +} + +function getFallbackParticipantAvatarNumber(name: string): number { + const normalized = name.trim().toLowerCase(); + if (normalized === 'team-lead' || normalized === 'lead') { + return LEAD_PARTICIPANT_AVATAR_NUMBER; + } + return getParticipantAvatarNumberByIndex(hashStringToIndex(normalized)); +} + +function getParticipantAvatarNumber( + sender: string, + members: readonly TeamNotificationAvatarMember[] +): number { + const senderName = sender.trim(); + if (!senderName) return getFallbackParticipantAvatarNumber(sender); + + const map = new Map(); + const activeMembers = members.filter((member) => !member.removedAt); + const leadMembers = activeMembers.filter((member) => isLeadMember(member)); + const teammateMembers = activeMembers.filter((member) => !isLeadMember(member)); + + for (const [index, member] of leadMembers.entries()) { + map.set( + member.name, + index === 0 ? LEAD_PARTICIPANT_AVATAR_NUMBER : getFallbackParticipantAvatarNumber(member.name) + ); + } + + for (const [index, member] of teammateMembers.entries()) { + map.set(member.name, 2 + (index % (PARTICIPANT_AVATAR_COUNT - 1))); + } + + for (const member of members) { + if (!map.has(member.name)) { + map.set( + member.name, + isLeadMember(member) + ? LEAD_PARTICIPANT_AVATAR_NUMBER + : getFallbackParticipantAvatarNumber(member.name) + ); + } + } + + map.set('user', getFallbackParticipantAvatarNumber('user')); + map.set('system', getFallbackParticipantAvatarNumber('system')); + + return map.get(senderName) ?? getFallbackParticipantAvatarNumber(senderName); +} + +function readTeamNotificationMembers(teamName: string): TeamNotificationAvatarMember[] { + try { + const configPath = path.join(getTeamsBasePath(), teamName, 'config.json'); + if (!existsSync(configPath)) return []; + + const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as { + members?: unknown; + }; + if (!Array.isArray(parsed.members)) return []; + + return parsed.members + .map((member): TeamNotificationAvatarMember | null => { + if (!member || typeof member !== 'object') return null; + const record = member as Record; + const name = typeof record.name === 'string' ? record.name.trim() : ''; + if (!name) return null; + return { + name, + removedAt: + typeof record.removedAt === 'number' || typeof record.removedAt === 'string' + ? record.removedAt + : null, + agentType: typeof record.agentType === 'string' ? record.agentType : undefined, + }; + }) + .filter((member): member is TeamNotificationAvatarMember => Boolean(member)); + } catch (error) { + logger.debug(`[team-toast] failed to read team members for avatar: ${String(error)}`); + return []; + } +} + +function resolveParticipantAvatarPath(avatarNumber: number): string | undefined { + const filename = `${String(avatarNumber).padStart(2, '0')}.png`; + const resourceRoot = + typeof process.resourcesPath === 'string' && process.resourcesPath.length > 0 + ? process.resourcesPath + : null; + const candidates = [ + path.join(process.cwd(), 'src/renderer/assets/participant-avatars', filename), + ...(resourceRoot ? [path.join(resourceRoot, 'participant-avatars', filename)] : []), + ]; + + return candidates.find((candidate) => existsSync(candidate)); +} + +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function escapeXmlText(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function formatSenderLabel(sender: string): string | null { + const trimmed = sender.trim(); + if (!trimmed) return null; + if (trimmed.toLowerCase() === 'system') return 'System'; + return trimmed.startsWith('@') ? trimmed : `@${trimmed}`; +} + +function cleanNotificationText(value: string): string { + return stripMarkdown(stripAgentBlocks(value)).replace(/\s+/g, ' ').trim(); +} + +function truncateNotificationText(value: string, maxLength: number): string { + if (value.length <= maxLength) return value; + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...`; +} + +function extractTaskRef(summary: string): string | null { + const match = summary.match(/#([A-Za-z0-9][A-Za-z0-9-]*)/); + return match ? `#${match[1]}` : null; +} + +function extractTaskSubject(summary: string): string { + return summary + .replace(/^Comment on\s+#[^:]+:\s*/i, '') + .replace(/^Comment on\s+#[^\s]+/i, '') + .replace(/^Clarification needed\s+-\s+Task\s+#[^:]+:\s*/i, '') + .replace(/^Clarification needed\s+-\s+Task\s+#[^\s]+/i, '') + .replace(/^New task\s+#[^:]+:\s*/i, '') + .replace(/^New task\s+#[^\s]+/i, '') + .replace(/^Task\s+#[^:]+:\s*/i, '') + .trim(); +} + +function getTeamNotificationAction( + payload: TeamNotificationPayload, + taskRef: string | null +): string { + switch (payload.teamEventType) { + case 'task_comment': + return taskRef ? `commented on ${taskRef}` : 'commented on a task'; + case 'task_clarification': + return taskRef ? `needs clarification on ${taskRef}` : 'needs clarification'; + case 'task_status_change': + return taskRef ? `changed ${taskRef}` : 'changed task status'; + case 'task_created': + return taskRef ? `created ${taskRef}` : 'created a task'; + case 'all_tasks_completed': + return 'completed all tasks'; + case 'lead_inbox': + case 'user_inbox': + return 'sent a message'; + case 'cross_team_message': + return 'sent a cross-team message'; + case 'rate_limit': + return /api error/i.test(`${payload.summary} ${payload.body}`) + ? 'hit an API error' + : 'hit rate limit'; + case 'schedule_completed': + return 'completed a schedule'; + case 'schedule_failed': + return 'schedule failed'; + case 'team_launched': + return 'launched a team'; + default: + return 'sent an update'; + } +} + +function getTeamNotificationWhere( + payload: TeamNotificationPayload, + taskRef: string | null +): string { + const team = cleanNotificationText(payload.teamDisplayName) || payload.teamDisplayName; + const summary = cleanNotificationText(payload.summary); + + if (payload.teamEventType.startsWith('task_')) { + const subject = extractTaskSubject(summary); + const taskContext = subject || taskRef; + return taskContext ? `${taskContext} - ${team}` : team; + } + + return team; +} + +function buildTeamNotificationPresentation( + payload: TeamNotificationPayload, + body: string +): { title: string; where: string; body: string } { + const who = formatSenderLabel(payload.from) ?? cleanNotificationText(payload.teamDisplayName); + const summary = cleanNotificationText(payload.summary); + const taskRef = extractTaskRef(summary); + const action = getTeamNotificationAction(payload, taskRef); + const where = getTeamNotificationWhere(payload, taskRef); + const normalizedBody = cleanNotificationText(body); + + return { + title: truncateNotificationText(`${who} ${action}`.trim(), 96), + where: truncateNotificationText(where, 120), + body: truncateNotificationText(normalizedBody || summary, 300), + }; +} + +function getSenderInitials(sender: string): string { + const trimmed = sender.trim().replace(/^@+/, ''); + if (!trimmed) return '?'; + + const parts = trimmed.split(/[\s._:-]+/).filter(Boolean); + const initials = + parts.length >= 2 + ? `${parts[0]?.[0] ?? ''}${parts[1]?.[0] ?? ''}` + : trimmed.replace(/[\s._:-]+/g, '').slice(0, 2); + + return initials.toLocaleUpperCase() || '?'; +} + +function resolveSenderParticipantAvatarPath( + sender: string, + teamName: string, + members: readonly TeamNotificationAvatarMember[] | undefined +): string | undefined { + const senderLabel = sender.trim(); + if (!senderLabel || senderLabel.toLowerCase() === 'system') return undefined; + + const roster = members && members.length > 0 ? members : readTeamNotificationMembers(teamName); + const avatarNumber = getParticipantAvatarNumber(senderLabel, roster); + return resolveParticipantAvatarPath(avatarNumber); +} + +function getWindowsToastAvatarPath(avatarPath: string): string { + const cached = WINDOWS_TOAST_AVATAR_CACHE.get(avatarPath); + if (cached) return cached; + + const NativeImage = getNativeImage(); + if (!NativeImage) { + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } + + try { + const source = NativeImage.createFromPath(avatarPath); + if (source.isEmpty()) { + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } + + const resized = source.resize({ width: 96, height: 96 }); + if (resized.isEmpty()) { + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } + + const cacheDir = path.join(getAppDataPath(), 'notification-avatars'); + mkdirSync(cacheDir, { recursive: true }); + + const parsed = path.parse(avatarPath); + const outPath = path.join(cacheDir, `${parsed.name}-96.png`); + writeFileSync(outPath, resized.toPNG()); + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, outPath); + return outPath; + } catch (error) { + logger.debug(`[team-toast] failed to prepare Windows toast avatar: ${String(error)}`); + WINDOWS_TOAST_AVATAR_CACHE.set(avatarPath, avatarPath); + return avatarPath; + } +} + +function buildSenderNotificationIcon( + sender: string, + teamName: string, + members: readonly TeamNotificationAvatarMember[] | undefined +): NotificationConstructorOptions['icon'] { + const senderLabel = sender.trim(); + if (!senderLabel || senderLabel.toLowerCase() === 'system') return getAppIconPath(); + + const senderAvatarPath = resolveSenderParticipantAvatarPath(senderLabel, teamName, members); + const cacheKey = `${teamName}:${senderLabel}:${senderAvatarPath ?? 'generated'}`.toLowerCase(); + if (SENDER_ICON_CACHE.has(cacheKey)) { + return SENDER_ICON_CACHE.get(cacheKey); + } + + try { + if (senderAvatarPath) { + const NativeImage = getNativeImage(); + if (NativeImage) { + const avatarIcon = NativeImage.createFromPath(senderAvatarPath); + if (!avatarIcon.isEmpty()) { + SENDER_ICON_CACHE.set(cacheKey, avatarIcon); + return avatarIcon; + } + } + } + + const colorName = getMemberColorByName(senderLabel); + const hue = MEMBER_COLOR_HUE[colorName] ?? 210; + const initials = escapeXmlAttribute(getSenderInitials(senderLabel)); + const svg = [ + '', + ``, + ``, + ``, + ``, + `${initials}`, + '', + ].join(''); + const NativeImage = getNativeImage(); + const icon = NativeImage?.createFromDataURL( + `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` + ); + const resolvedIcon = icon && !icon.isEmpty() ? icon : getAppIconPath(); + SENDER_ICON_CACHE.set(cacheKey, resolvedIcon); + return resolvedIcon; + } catch (error) { + logger.debug(`[team-toast] sender icon fallback for "${senderLabel}": ${String(error)}`); + const fallbackIcon = getAppIconPath(); + SENDER_ICON_CACHE.set(cacheKey, fallbackIcon); + return fallbackIcon; + } +} + +function buildWindowsTeamToastXml(input: { + title: string; + summary?: string; + body: string; + sender: string; + avatarPath?: string; + silent: boolean; +}): string { + const textRows = [ + `${escapeXmlText(input.title)}`, + input.summary ? `${escapeXmlText(input.summary)}` : null, + input.body ? `${escapeXmlText(input.body)}` : null, + ].filter(Boolean); + + const avatarRow = input.avatarPath + ? `${escapeXmlAttribute(`${input.sender} avatar`)}` + : null; + + return [ + '', + '', + '', + ...textRows, + avatarRow, + '', + '', + input.silent ? '', + ] + .filter(Boolean) + .join(''); +} + async function migrateLegacyNotificationPath(): Promise { try { await fsp.readFile(NOTIFICATIONS_PATH, 'utf8'); @@ -603,7 +996,7 @@ export class NotificationManager extends EventEmitter { /** * Shows a native notification for a team event. - * Uses team-specific formatting (title = team name, subtitle = summary). + * Uses a consistent who + what + where presentation for all team events. */ private showTeamNativeNotification( stored: StoredNotification, @@ -618,20 +1011,45 @@ export class NotificationManager extends EventEmitter { try { const config = this.configManager.getConfig(); const isMac = process.platform === 'darwin'; - const truncatedBody = stripMarkdown(stripAgentBlocks(payload.body)).slice(0, 300); - const iconPath = isMac ? undefined : getAppIconPath(); + const presentation = buildTeamNotificationPresentation(payload, payload.body); + const senderAvatarPath = resolveSenderParticipantAvatarPath( + payload.from, + payload.teamName, + payload.members + ); + const toastXml = + process.platform === 'win32' && senderAvatarPath + ? buildWindowsTeamToastXml({ + title: presentation.title, + summary: presentation.where, + body: presentation.body, + sender: payload.from, + avatarPath: getWindowsToastAvatarPath(senderAvatarPath), + silent: !config.notifications.soundEnabled, + }) + : undefined; + const senderIcon = toastXml + ? undefined + : buildSenderNotificationIcon(payload.from, payload.teamName, payload.members); logger.debug( - `[team-toast] creating: title="${payload.teamDisplayName}" summary="${payload.summary ?? ''}" bodyLen=${truncatedBody.length}` + `[team-toast] creating: title="${presentation.title}" where="${presentation.where}" bodyLen=${presentation.body.length}` ); - const notification = new NotificationClass({ - title: payload.teamDisplayName, - ...(isMac ? { subtitle: payload.summary } : {}), - body: !isMac && payload.summary ? `${payload.summary}\n${truncatedBody}` : truncatedBody, - sound: config.notifications.soundEnabled ? 'default' : undefined, - ...(iconPath ? { icon: iconPath } : {}), - }); + const notificationOptions: NotificationConstructorOptions = toastXml + ? { toastXml } + : { + title: presentation.title, + ...(isMac ? { subtitle: presentation.where } : {}), + body: + !isMac && presentation.where + ? `${presentation.where}\n${presentation.body}` + : presentation.body, + sound: config.notifications.soundEnabled ? 'default' : undefined, + ...(senderIcon ? { icon: senderIcon } : {}), + }; + + const notification = new NotificationClass(notificationOptions); // Hold a strong reference to prevent GC from collecting the notification this.activeNotifications.add(notification); @@ -647,7 +1065,7 @@ export class NotificationManager extends EventEmitter { notification.on('show', () => { logger.debug( - `[team-toast] OS confirmed show: "${payload.teamDisplayName}" — ${payload.summary ?? ''}` + `[team-toast] OS confirmed show: "${presentation.title}" - ${presentation.where}` ); }); notification.on('failed', (_, error) => { diff --git a/src/main/services/team/TeamLogSourceTracker.ts b/src/main/services/team/TeamLogSourceTracker.ts index 781dea22..c2d84055 100644 --- a/src/main/services/team/TeamLogSourceTracker.ts +++ b/src/main/services/team/TeamLogSourceTracker.ts @@ -76,7 +76,10 @@ function isOpaqueSafeTaskIdSegment(segment: string): boolean { export function shouldIgnoreLogSourceWatcherPath( projectDir: string, watchedPath: string, - _scope?: { scopedSessionIds?: ReadonlySet } + scope?: { + scopedSessionIds?: ReadonlySet; + pendingRootSessionIds?: ReadonlySet; + } ): boolean { const parts = getRelativeLogSourceParts(projectDir, watchedPath); if (!parts) { @@ -90,6 +93,31 @@ export function shouldIgnoreLogSourceWatcherPath( if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME) return false; if (first === BOARD_TASK_CHANGE_FRESHNESS_DIRNAME) return false; + const scopedSessionIds = scope?.scopedSessionIds; + if (scopedSessionIds) { + if (parts.length === 1) { + if (first.endsWith('.jsonl')) { + const sessionId = normalizeLogSourceSessionId(first.slice(0, -'.jsonl'.length)); + return ( + !sessionId || + (!scopedSessionIds.has(sessionId) && !scope?.pendingRootSessionIds?.has(sessionId)) + ); + } + return !scopedSessionIds.has(first); + } + + if (!scopedSessionIds.has(first)) { + return true; + } + + if (parts[1] === 'subagents') { + if (parts.length === 2) return false; + if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]); + } + + return true; + } + if (parts.length >= 2 && parts[1] === 'subagents') { if (parts.length === 2) return false; if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]); @@ -360,7 +388,10 @@ export class TeamLogSourceTracker { followSymlinks: false, depth: 0, ignored: (watchedPath) => - shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { scopedSessionIds }), + shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { + scopedSessionIds, + pendingRootSessionIds: new Set(this.getPendingUnknownSessionIds(state)), + }), awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index a824e498..29307f3c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1820,6 +1820,123 @@ function isDefinitiveOpenCodePreLaunchFailure( ); } +const OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC = + 'opencode_bootstrap_pending_after_materialized_session'; + +function isMaterializedOpenCodeSessionId(sessionId: unknown): boolean { + if (typeof sessionId !== 'string') { + return false; + } + const trimmed = sessionId.trim(); + return trimmed.length > 0 && !trimmed.startsWith('failed:'); +} + +function hasMaterializedOpenCodeRuntimeForBootstrap( + member: TeamRuntimeMemberLaunchEvidence | undefined +): member is TeamRuntimeMemberLaunchEvidence { + if (!member) { + return false; + } + if (isMaterializedOpenCodeSessionId(member.sessionId)) { + return true; + } + return ( + hasOpenCodeRuntimeLivenessMarker(member) && + typeof member.runtimePid === 'number' && + Number.isFinite(member.runtimePid) && + member.runtimePid > 0 + ); +} + +function hasRecoverableOpenCodeBootstrapDiagnostic(diagnostics: readonly string[]): boolean { + const text = diagnostics.join('\n').toLowerCase(); + if (!text) { + return false; + } + if (hasRealOpenCodeFailureDiagnostic(text)) { + return false; + } + return ( + text.includes('runtime_bootstrap_checkin') || + text.includes('member_briefing') || + text.includes('bootstrap mcp') || + text.includes('member_session_recorded') || + text.includes('not connected') || + text.includes('mcp not connected') || + text.includes('member_launch_reconcile_pending') || + text.includes('member_launch_preview_timeout') + ); +} + +function isRecoverableOpenCodeBootstrapPendingLaunchResult( + result: TeamRuntimeLaunchResult, + memberName: string +): boolean { + const member = result.members[memberName]; + if (!hasMaterializedOpenCodeRuntimeForBootstrap(member)) { + return false; + } + if (member.bootstrapConfirmed || member.launchState === 'confirmed_alive') { + return false; + } + if ((member.pendingPermissionRequestIds?.length ?? 0) > 0) { + return false; + } + return hasRecoverableOpenCodeBootstrapDiagnostic( + collectRuntimeLaunchFailureDiagnostics(result, memberName) + ); +} + +function normalizeRecoverableOpenCodeBootstrapPendingLaunchResult( + result: TeamRuntimeLaunchResult, + memberName: string, + diagnostics: readonly string[] +): TeamRuntimeLaunchResult { + const member = result.members[memberName]; + if (!member) { + return result; + } + const memberDiagnostics = Array.from( + new Set([ + ...(member.diagnostics ?? []), + OPENCODE_BOOTSTRAP_PENDING_DIAGNOSTIC, + 'OpenCode runtime session materialized; waiting for runtime_bootstrap_checkin.', + ...diagnostics, + ]) + ); + const normalizedMember: TeamRuntimeMemberLaunchEvidence = { + ...member, + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + pendingPermissionRequestIds: undefined, + livenessKind: + member.livenessKind === 'confirmed_bootstrap' + ? 'runtime_process' + : (member.livenessKind ?? 'runtime_process'), + runtimeDiagnostic: + member.runtimeDiagnostic ?? + 'OpenCode runtime process detected; waiting for bootstrap check-in.', + runtimeDiagnosticSeverity: member.runtimeDiagnosticSeverity ?? 'info', + diagnostics: memberDiagnostics, + }; + const members = { + ...result.members, + [memberName]: normalizedMember, + }; + const teamLaunchState = summarizeRuntimeLaunchResultMembers(members); + return { + ...result, + launchPhase: teamLaunchState === 'clean_success' ? result.launchPhase : 'active', + teamLaunchState, + members, + diagnostics: Array.from(new Set([...result.diagnostics, ...memberDiagnostics])), + }; +} + const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC = 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'; @@ -2064,7 +2181,7 @@ function promoteOpenCodeSecondaryMemberFromCommittedBootstrapEvidence(input: { 'opencode_bootstrap_evidence_committed', ]), ]; - const runtimeAlive = input.current.runtimeAlive === true; + const runtimeAlive = true; return { ...input.previous, ...input.current, @@ -6574,6 +6691,7 @@ export class TeamProvisioningService { let liveSecondaryLaneRunId: string | null = null; let trackedSecondaryLanePresent = false; let trackedSecondaryLaneSnapshotKnown = false; + let trackedSecondaryLaneBootstrapConfirmed: boolean | null = null; if ( trackedRun && laneIdentity.laneKind === 'secondary' && @@ -6588,6 +6706,15 @@ export class TeamProvisioningService { ); trackedSecondaryLanePresent = liveLane != null; liveSecondaryLaneRunId = liveLane ? trackedRunId : null; + const liveLaneMember = liveLane + ? (liveLane.result?.members?.[canonicalMemberName] ?? + liveLane.result?.members?.[liveLane.member.name]) + : null; + if (liveLaneMember) { + trackedSecondaryLaneBootstrapConfirmed = + liveLaneMember.bootstrapConfirmed === true || + liveLaneMember.launchState === 'confirmed_alive'; + } if (!liveLane && trackedSecondaryLaneSnapshotKnown) { return { delivered: false, reason: 'opencode_runtime_not_active' }; } @@ -6681,6 +6808,26 @@ export class TeamProvisioningService { return { delivered: false, reason: 'opencode_runtime_not_active' }; } + if (laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode') { + const bootstrapReady = + trackedSecondaryLaneBootstrapConfirmed === true || + (await this.hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence({ + teamName, + runId: runtimeRunId, + laneId: laneIdentity.laneId, + memberName: canonicalMemberName, + })); + if (!bootstrapReady) { + return { + delivered: false, + reason: 'opencode_runtime_not_active', + diagnostics: [ + `OpenCode runtime bootstrap is not confirmed for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`, + ], + }; + } + } + if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { const result = await adapter.sendMessageToMember({ ...(runtimeRunId ? { runId: runtimeRunId } : {}), @@ -8928,6 +9075,31 @@ export class TeamProvisioningService { ); } + private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: { + teamName: string; + runId: string | null; + laneId: string; + memberName: string; + }): Promise { + const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({ + teamsBasePath: getTeamsBasePath(), + teamName: input.teamName, + laneId: input.laneId, + }).catch(() => null); + if (!evidence?.committed) { + return false; + } + const activeRunId = evidence.activeRunId?.trim() || null; + if (activeRunId !== input.runId) { + return false; + } + return evidence.sessions.some( + (session) => + session.runId === input.runId && + namesMatchCaseInsensitive(session.memberName, input.memberName) + ); + } + private async readOpenCodeRuntimeSessionStore( filePath: string ): Promise[]> { @@ -19656,25 +19828,49 @@ export class TeamProvisioningService { }, } : result; - lane.result = resultWithTiming; - lane.warnings = [...resultWithTiming.warnings]; + const baseFailureDiagnostics = appendDiagnosticOnce( + [...requestedDiagnostics, ...migration.diagnostics], + timingDiagnostic + ); + const recoverableBootstrapPending = isRecoverableOpenCodeBootstrapPendingLaunchResult( + resultWithTiming, + lane.member.name + ); + const normalizedResult = recoverableBootstrapPending + ? normalizeRecoverableOpenCodeBootstrapPendingLaunchResult( + resultWithTiming, + lane.member.name, + baseFailureDiagnostics + ) + : resultWithTiming; + lane.result = normalizedResult; + lane.warnings = [...normalizedResult.warnings]; const launchDiagnostics = appendDiagnosticOnce( - [...requestedDiagnostics, ...migration.diagnostics, ...resultWithTiming.diagnostics], + [...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics], timingDiagnostic ); lane.diagnostics = launchDiagnostics; - if ( - isDefinitiveOpenCodePreLaunchFailure(resultWithTiming, lane.member.name) || - resultWithTiming.teamLaunchState === 'partial_failure' + if (recoverableBootstrapPending) { + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: run.teamName, + laneId: lane.laneId, + state: 'active', + diagnostics: collectOpenCodeSecondaryLaneFailureDiagnostics( + normalizedResult, + lane.member.name, + baseFailureDiagnostics + ), + }).catch(() => undefined); + } else if ( + isDefinitiveOpenCodePreLaunchFailure(normalizedResult, lane.member.name) || + normalizedResult.teamLaunchState === 'partial_failure' ) { const diagnostics = collectOpenCodeSecondaryLaneFailureDiagnostics( - resultWithTiming, + normalizedResult, lane.member.name, - appendDiagnosticOnce( - [...requestedDiagnostics, ...migration.diagnostics], - timingDiagnostic - ) + baseFailureDiagnostics ); await upsertOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: getTeamsBasePath(), @@ -24078,6 +24274,7 @@ export class TeamProvisioningService { summary: run.isLaunch ? 'Team launched' : 'Team provisioned', body, dedupeKey: `team_launched:${run.teamName}:${run.runId}`, + target: { kind: 'team', teamName: run.teamName, section: 'overview' }, projectPath: run.request.cwd, suppressToast, }); diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 429d73e7..558ffb0c 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -267,17 +267,24 @@ export function resolveTeamMemberRuntimeLiveness( }); } return result({ - alive: false, - livenessKind: 'runtime_process_candidate', - pidSource: 'opencode_bridge', - pid: runtimePidRow.pid, + alive: hasConfirmedBootstrap, + livenessKind: hasConfirmedBootstrap ? 'confirmed_bootstrap' : 'runtime_process_candidate', + pidSource: hasConfirmedBootstrap ? 'runtime_bootstrap' : 'opencode_bridge', + pid: hasConfirmedBootstrap ? undefined : runtimePidRow.pid, runtimeSessionId, - processCommand, - runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified', - runtimeDiagnosticSeverity: 'warning', + processCommand: hasConfirmedBootstrap ? undefined : processCommand, + runtimeLastSeenAt: hasConfirmedBootstrap + ? (tracked?.lastHeartbeatAt ?? tracked?.updatedAt) + : undefined, + runtimeDiagnostic: hasConfirmedBootstrap + ? 'bootstrap confirmed; runtime pid currently points to a different process' + : 'OpenCode runtime pid is alive, but process identity is unverified', + runtimeDiagnosticSeverity: hasConfirmedBootstrap ? 'info' : 'warning', diagnostics: [ ...diagnostics, - 'matched OpenCode runtime pid without OpenCode process identity', + hasConfirmedBootstrap + ? 'bootstrap confirmed despite runtime pid identity mismatch' + : 'matched OpenCode runtime pid without OpenCode process identity', ], }); } diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index 40b5e810..c6649e4e 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -10,7 +10,7 @@ import { randomUUID } from 'crypto'; import type { DetectedError } from '../services/error/ErrorMessageBuilder'; import type { TriggerColor } from '@shared/constants/triggerColors'; -import type { TeamEventType } from '@shared/types/notifications'; +import type { NotificationTarget, TeamEventType } from '@shared/types/notifications'; // Re-export for callers that import TeamEventType from this module export type { TeamEventType } from '@shared/types/notifications'; @@ -29,10 +29,18 @@ export interface TeamNotificationPayload { teamDisplayName: string; from: string; to?: string; + /** Optional team roster for resolving the same participant avatar shown in the UI. */ + members?: readonly { + name: string; + removedAt?: number | string | null; + agentType?: string; + }[]; summary: string; body: string; /** Stable key for storage deduplication. REQUIRED — no fallback to Date.now(). */ dedupeKey: string; + /** Structured destination used by notification click handling. */ + target?: NotificationTarget; projectPath?: string; /** * When true, the notification is stored in-app but no native OS toast is shown. @@ -76,6 +84,9 @@ const TEAM_NOTIFICATION_CONFIG: Record = */ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): DetectedError { const config = TEAM_NOTIFICATION_CONFIG[payload.teamEventType]; + const summary = stripAgentBlocks(payload.summary).replace(/\s+/g, ' ').trim(); + const body = stripAgentBlocks(payload.body).replace(/\s+/g, ' ').trim(); + const preview = summary && body ? `${summary}: ${body}` : summary || body; return { id: randomUUID(), @@ -84,9 +95,10 @@ export function buildDetectedErrorFromTeam(payload: TeamNotificationPayload): De projectId: payload.teamName, filePath: '', source: payload.teamEventType, - message: `[${payload.from}] ${stripAgentBlocks(payload.body).trim().slice(0, 300)}`, + message: `[${payload.from}] ${preview.slice(0, 300)}`, category: 'team', teamEventType: payload.teamEventType, + target: payload.target, dedupeKey: payload.dedupeKey, triggerColor: config.triggerColor, triggerName: config.triggerName, diff --git a/src/renderer/components/team/LiveRuntimeStatusBridge.tsx b/src/renderer/components/team/LiveRuntimeStatusBridge.tsx new file mode 100644 index 00000000..b646dcf2 --- /dev/null +++ b/src/renderer/components/team/LiveRuntimeStatusBridge.tsx @@ -0,0 +1,77 @@ +import { memo, useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { Activity } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; + +import { CollapsibleTeamSection } from './CollapsibleTeamSection'; +import { LiveRuntimeStatusSection } from './LiveRuntimeStatusSection'; +import { + buildTeamRuntimeDisplayRows, + type TeamRuntimeDisplayMember, +} from './teamRuntimeDisplayRows'; + +export const TEAM_RUNTIME_UI_DECOUPLING_STORAGE_KEY = 'teamRuntimeUiDecouplingEnabled'; + +interface LiveRuntimeStatusBridgeProps { + teamName: string; + members: readonly TeamRuntimeDisplayMember[]; +} + +export const LiveRuntimeStatusBridge = memo(function LiveRuntimeStatusBridge({ + teamName, + members, +}: LiveRuntimeStatusBridgeProps): React.JSX.Element | null { + if (!isTeamRuntimeUiDecouplingEnabled()) return null; + + return ; +}); + +const LiveRuntimeStatusStoreBridge = memo(function LiveRuntimeStatusStoreBridge({ + teamName, + members, +}: LiveRuntimeStatusBridgeProps): React.JSX.Element | null { + const { runtimeSnapshot, spawnStatuses } = useStore( + useShallow((s) => ({ + runtimeSnapshot: s.teamAgentRuntimeByTeam[teamName], + spawnStatuses: s.memberSpawnStatusesByTeam[teamName], + })) + ); + const rows = useMemo( + () => + buildTeamRuntimeDisplayRows({ + members, + runtimeSnapshot, + spawnStatuses, + }), + [members, runtimeSnapshot, spawnStatuses] + ); + + if (rows.length === 0) return null; + + const liveCount = rows.filter((row) => row.state === 'running').length; + const attentionCount = rows.filter((row) => row.state === 'degraded').length; + const badge = attentionCount > 0 ? attentionCount : liveCount > 0 ? liveCount : undefined; + + return ( + } + badge={badge} + defaultOpen={false} + > + + + ); +}); + +export function isTeamRuntimeUiDecouplingEnabled(): boolean { + if (typeof window === 'undefined') return false; + + try { + return window.localStorage.getItem(TEAM_RUNTIME_UI_DECOUPLING_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} diff --git a/src/renderer/components/team/LiveRuntimeStatusSection.tsx b/src/renderer/components/team/LiveRuntimeStatusSection.tsx new file mode 100644 index 00000000..b0ccd897 --- /dev/null +++ b/src/renderer/components/team/LiveRuntimeStatusSection.tsx @@ -0,0 +1,98 @@ +import { memo } from 'react'; + +import type { RuntimeDisplayState, TeamRuntimeDisplayRow } from './teamRuntimeDisplayRows'; + +interface LiveRuntimeStatusSectionProps { + rows: readonly TeamRuntimeDisplayRow[]; +} + +const STATE_LABELS: Record = { + running: 'Running', + starting: 'Starting', + waiting: 'Waiting', + degraded: 'Needs attention', + stopped: 'Stopped', + unknown: 'Unknown', +}; + +const STATE_CLASS_NAMES: Record = { + running: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300', + starting: 'border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300', + waiting: 'border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300', + degraded: 'border-rose-500/25 bg-rose-500/10 text-rose-700 dark:text-rose-300', + stopped: 'border-zinc-500/25 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300', + unknown: 'border-zinc-500/20 bg-zinc-500/5 text-zinc-600 dark:text-zinc-400', +}; + +export const LiveRuntimeStatusSection = memo(function LiveRuntimeStatusSection({ + rows, +}: LiveRuntimeStatusSectionProps): React.JSX.Element | null { + if (rows.length === 0) return null; + + return ( +
+ Live runtime status +

+ Display-only heartbeat and launch state. Process controls remain below. +

+
+ {rows.map((row) => ( +
+
+
+
{row.memberName}
+
+ {row.stateReason} +
+
+ + {STATE_LABELS[row.state]} + +
+ +
+ source: {row.source} + {row.runtimeModel ? ( + {row.runtimeModel} + ) : null} + {row.laneKind ? ( + {row.laneKind} lane + ) : null} + {row.pidLabel ? ( + + {row.pidLabel} + + ) : null} + {row.updatedAt ? ( + + updated {formatRuntimeUpdatedAt(row.updatedAt)} + + ) : null} +
+
+ ))} +
+
+ ); +}); + +function formatRuntimeUpdatedAt(value: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + + const secondsAgo = Math.max(0, Math.round((Date.now() - timestamp) / 1000)); + if (secondsAgo < 60) return `${secondsAgo}s ago`; + + const minutesAgo = Math.round(secondsAgo / 60); + if (minutesAgo < 60) return `${minutesAgo}m ago`; + + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 341485f5..e2331837 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -133,6 +133,7 @@ import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from './provis import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { deriveLeadContextButtonLabel } from './leadContextLoadGuards'; import { LeadSessionDetailGate } from './LeadSessionDetailGate'; +import { LiveRuntimeStatusBridge } from './LiveRuntimeStatusBridge'; import { loadTeamSessionMetadata } from './teamSessionFetchGuards'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -1847,13 +1848,22 @@ export const TeamDetailView = memo(function TeamDetailView({ const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); useEffect(() => { if (!pendingMemberProfile || !data) return; - const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile); + if (pendingMemberProfile.teamName && pendingMemberProfile.teamName !== teamName) return; + + const member = membersWithLiveBranches.find((m) => m.name === pendingMemberProfile.memberName); if (member) { setSelectedMember(member); - setSelectedMemberView(null); + setSelectedMemberView({ + initialTab: + pendingMemberProfile.focus === 'logs' + ? 'logs' + : pendingMemberProfile.focus === 'messages' + ? 'activity' + : undefined, + }); } useStore.getState().closeMemberProfile(); - }, [pendingMemberProfile, membersWithLiveBranches]); + }, [pendingMemberProfile, membersWithLiveBranches, teamName, data]); const handleDeleteTask = useCallback( (taskId: string) => { @@ -2638,6 +2648,8 @@ export const TeamDetailView = memo(function TeamDetailView({ + + {(data.processes?.length ?? 0) > 0 && ( { const teamName = globalTaskDetail?.teamName ?? ''; const taskId = globalTaskDetail?.taskId ?? ''; + const commentId = globalTaskDetail?.commentId; const hasTargetTeamData = hasSelectedTargetTeamData( teamName, selectedTeamName, @@ -150,6 +151,7 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { onClose={closeGlobalTaskDetail} onOwnerChange={undefined} onViewChanges={isFullTeamLoaded ? handleViewChanges : undefined} + focusCommentId={commentId} headerExtra={ +
+ ) : null; const block = ( ); - if (!presentation.isFailed) { + if (!presentation.isFailed && !retryOpenCodeAction) { return block; } return (
-
-

- {presentation.progress.message} -

- {dismissible ? ( - - ) : null} -
+ {presentation.isFailed ? ( +
+

+ {presentation.progress.message} +

+ {dismissible ? ( + + ) : null} +
+ ) : null} {block} + {retryOpenCodeAction}
); }); diff --git a/src/renderer/components/team/useTeamProvisioningPresentation.ts b/src/renderer/components/team/useTeamProvisioningPresentation.ts index 0cdf27ec..970cc9f8 100644 --- a/src/renderer/components/team/useTeamProvisioningPresentation.ts +++ b/src/renderer/components/team/useTeamProvisioningPresentation.ts @@ -9,22 +9,33 @@ import { buildTeamProvisioningPresentation } from '@renderer/utils/teamProvision import { useShallow } from 'zustand/react/shallow'; import type { TeamProvisioningPresentation } from '@renderer/utils/teamProvisioningPresentation'; +import type { RetryFailedOpenCodeSecondaryLanesResult } from '@shared/types'; export function useTeamProvisioningPresentation(teamName: string): { presentation: TeamProvisioningPresentation | null; cancelProvisioning: ((runId: string) => Promise) | null; + retryFailedOpenCodeSecondaryLanes: + | ((teamName: string) => Promise) + | null; runInstanceKey: string | null; } { - const { progress, cancelProvisioning, teamMembers, memberSpawnStatuses, memberSpawnSnapshot } = - useStore( - useShallow((s) => ({ - progress: getCurrentProvisioningProgressForTeam(s, teamName), - cancelProvisioning: s.cancelProvisioning, - teamMembers: selectTeamMemberSnapshotsForName(s, teamName), - memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], - memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], - })) - ); + const { + progress, + cancelProvisioning, + retryFailedOpenCodeSecondaryLanes, + teamMembers, + memberSpawnStatuses, + memberSpawnSnapshot, + } = useStore( + useShallow((s) => ({ + progress: getCurrentProvisioningProgressForTeam(s, teamName), + cancelProvisioning: s.cancelProvisioning, + retryFailedOpenCodeSecondaryLanes: s.retryFailedOpenCodeSecondaryLanes, + teamMembers: selectTeamMemberSnapshotsForName(s, teamName), + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + })) + ); const presentation = useMemo( () => @@ -40,6 +51,7 @@ export function useTeamProvisioningPresentation(teamName: string): { return { presentation, cancelProvisioning, + retryFailedOpenCodeSecondaryLanes: retryFailedOpenCodeSecondaryLanes ?? null, runInstanceKey: progress ? `${teamName}:${progress.runId}:${progress.startedAt}` : null, }; } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index f1cd371d..1c0effeb 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -46,6 +46,7 @@ import type { NotificationTarget, PersistedTeamLaunchSummary, ResolvedTeamMember, + RetryFailedOpenCodeSecondaryLanesResult, SendMessageRequest, SendMessageResult, TaskChangePresenceState, @@ -2500,6 +2501,9 @@ export interface TeamSlice { memberName: string, role: string | undefined ) => Promise; + retryFailedOpenCodeSecondaryLanes: ( + teamName: string + ) => Promise; addTaskRelationship: ( teamName: string, taskId: string, @@ -4679,6 +4683,19 @@ export const createTeamSlice: StateCreator = (set, } }, + retryFailedOpenCodeSecondaryLanes: async (teamName: string) => { + try { + return await unwrapIpc('team:retryFailedOpenCodeSecondaryLanes', () => + api.teams.retryFailedOpenCodeSecondaryLanes(teamName) + ); + } finally { + await Promise.allSettled([ + get().fetchMemberSpawnStatuses(teamName), + get().fetchTeamAgentRuntime(teamName), + ]); + } + }, + skipMemberForLaunch: async (teamName: string, memberName: string) => { try { await unwrapIpc('team:skipMemberForLaunch', () => diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 58b22116..f1b3c2ed 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -10,6 +10,7 @@ import type { MemberSpawnStatusesSnapshot, TeamProvisioningProgress, } from '@shared/types'; +import { isLeadMember } from '@shared/utils/leadDetection'; type MemberSpawnStatusCollection = | Record @@ -69,6 +70,42 @@ function isSkippedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean return entry?.launchState === 'skipped_for_launch' || entry?.skippedForLaunch === true; } +function isOpenCodeSecondaryRetryCandidate(params: { + member: ProvisioningMemberLike | undefined; + entry: MemberSpawnStatusEntry | undefined; +}): boolean { + const { member, entry } = params; + if (!member || !entry) { + return false; + } + if (member.providerId !== 'opencode' || member.removedAt) { + return false; + } + if (isLeadMember({ name: member.name, agentType: member.agentType })) { + return false; + } + if (member.laneKind && member.laneKind !== 'secondary') { + return false; + } + if (member.laneOwnerProviderId && member.laneOwnerProviderId !== 'opencode') { + return false; + } + if ( + entry.launchState === 'skipped_for_launch' || + entry.skippedForLaunch === true || + entry.launchState === 'runtime_pending_permission' || + entry.launchState === 'runtime_pending_bootstrap' || + (entry.pendingPermissionRequestIds?.length ?? 0) > 0 || + entry.launchState === 'starting' || + entry.status === 'spawning' || + entry.launchState === 'confirmed_alive' || + entry.bootstrapConfirmed === true + ) { + return false; + } + return entry.launchState === 'failed_to_start' || entry.status === 'error'; +} + function shouldPreferSnapshotEntryOverLive(params: { liveEntry: MemberSpawnStatusEntry | undefined; snapshotEntry: MemberSpawnStatusEntry | undefined; @@ -480,6 +517,51 @@ function getSkippedSpawnDetails(params: { .sort((left, right) => left.name.localeCompare(right.name)); } +function getRetryableOpenCodeSecondaryFailedNames(params: { + members: readonly ProvisioningMemberLike[]; + memberSpawnStatuses: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; +}): string[] { + const membersByName = new Map( + params.members + .map((member) => [member.name.trim(), member] as const) + .filter(([name]) => name.length > 0) + ); + const names = new Set(membersByName.keys()); + if (params.memberSpawnStatuses instanceof Map) { + for (const name of params.memberSpawnStatuses.keys()) { + names.add(name); + } + } else if (params.memberSpawnStatuses) { + for (const name of Object.keys(params.memberSpawnStatuses)) { + names.add(name); + } + } + for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) { + names.add(name); + } + + return [...names] + .filter((name) => { + const liveEntry = + params.memberSpawnStatuses instanceof Map + ? params.memberSpawnStatuses.get(name) + : params.memberSpawnStatuses?.[name]; + const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name]; + const entry = getPreferredSpawnEntry({ + liveEntry, + snapshotEntry, + snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt, + }); + return isOpenCodeSecondaryRetryCandidate({ + member: membersByName.get(name), + entry, + }); + }) + .sort((left, right) => left.localeCompare(right)); +} + function normalizeFailureReason(reason: string): string { return reason.replace(/\s+/g, ' ').trim(); } @@ -581,6 +663,8 @@ export interface TeamProvisioningPresentation { allTeammatesConfirmedAlive: boolean; hasMembersStillJoining: boolean; remainingJoinCount: number; + retryableOpenCodeSecondaryFailedCount: number; + retryableOpenCodeSecondaryFailedNames: string[]; panelTitle: string; panelMessage?: string | null; panelMessageSeverity?: 'error' | 'warning' | 'info'; @@ -674,6 +758,13 @@ export function buildTeamProvisioningPresentation({ memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, }); + const retryableOpenCodeSecondaryFailedNames = getRetryableOpenCodeSecondaryFailedNames({ + members, + memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, + }); + const retryableOpenCodeSecondaryFailedCount = retryableOpenCodeSecondaryFailedNames.length; const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = getLaunchJoinState({ @@ -712,6 +803,8 @@ export function buildTeamProvisioningPresentation({ allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, + retryableOpenCodeSecondaryFailedCount, + retryableOpenCodeSecondaryFailedNames, panelTitle: 'Launch failed', panelMessage: progress.error ?? failedSpawnPanelMessage ?? genericFailedSpawnPanelMessage, panelTone: 'error', @@ -800,6 +893,8 @@ export function buildTeamProvisioningPresentation({ allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, + retryableOpenCodeSecondaryFailedCount, + retryableOpenCodeSecondaryFailedNames, panelTitle: 'Launch details', panelMessage: failedSpawnCount > 0 || skippedSpawnCount > 0 || hasMembersStillJoining @@ -875,6 +970,8 @@ export function buildTeamProvisioningPresentation({ allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount, + retryableOpenCodeSecondaryFailedCount, + retryableOpenCodeSecondaryFailedNames, panelTitle: openCodeSecondaryWaitPhrase ? 'Core team ready' : 'Launching team', panelMessage: failedSpawnCount > 0 diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index d679956b..42a19156 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -60,6 +60,7 @@ import type { MessagesPage, ProjectBranchChangeEvent, ReplaceMembersRequest, + RetryFailedOpenCodeSecondaryLanesResult, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -555,6 +556,9 @@ export interface TeamsAPI { getLeadContext: (teamName: string) => Promise; getMemberSpawnStatuses: (teamName: string) => Promise; getTeamAgentRuntime: (teamName: string) => Promise; + retryFailedOpenCodeSecondaryLanes: ( + teamName: string + ) => Promise; restartMember: (teamName: string, memberName: string) => Promise; skipMemberForLaunch: (teamName: string, memberName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 0ebede0a..a9224eae 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -1079,6 +1079,14 @@ export interface MemberSpawnStatusesSnapshot { source?: 'live' | 'persisted' | 'merged'; } +export interface RetryFailedOpenCodeSecondaryLanesResult { + attempted: string[]; + confirmed: string[]; + pending: string[]; + failed: Array<{ memberName: string; error: string }>; + skipped: Array<{ memberName: string; reason: string }>; +} + export type MemberSpawnLivenessSource = 'heartbeat' | 'process'; export type TeamAgentRuntimeBackendType = 'lead' | 'tmux' | 'iterm2' | 'in-process' | 'process'; diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index daced49e..5f597bf6 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -16195,4 +16195,64 @@ describe('TeamProvisioningService', () => { }); expect(run.expectedMembers).toEqual(['alice', 'jack']); }); + + it('bulk retries failed OpenCode secondary lanes sequentially and classifies outcomes', async () => { + const svc = new TeamProvisioningService(); + const run = createMemberSpawnRun({ + teamName: 'mixed-retry-team', + runId: 'run-mixed-retry', + expectedMembers: ['alice', 'tom', 'nova'], + }); + run.isLaunch = true; + run.provisioningComplete = true; + + (svc as any).runs.set(run.runId, run); + (svc as any).aliveRunByTeam.set(run.teamName, run.runId); + + vi.spyOn(svc as any, 'collectFailedOpenCodeSecondaryRetryCandidates').mockResolvedValue([ + { memberName: 'alice', laneId: 'secondary:opencode:alice' }, + { memberName: 'tom', laneId: 'secondary:opencode:tom' }, + { memberName: 'nova', laneId: 'secondary:opencode:nova' }, + ]); + const reattach = vi + .spyOn(svc as any, 'reattachOpenCodeOwnedMemberLane') + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('OpenCode bridge crashed')); + vi.spyOn(svc as any, 'readOpenCodeSecondaryRetryOutcome') + .mockResolvedValueOnce({ launchState: 'confirmed_alive' }) + .mockResolvedValueOnce({ + launchState: 'failed_to_start', + reason: 'Latest assistant message reported OpenRouter credits exhausted', + }); + const notify = vi + .spyOn(svc as any, 'notifyLeadAboutConfirmedOpenCodeRetries') + .mockResolvedValue(undefined); + + const result = await svc.retryFailedOpenCodeSecondaryLanes(run.teamName); + + expect(reattach).toHaveBeenNthCalledWith(1, run.teamName, 'alice', { + reason: 'manual_restart', + }); + expect(reattach).toHaveBeenNthCalledWith(2, run.teamName, 'tom', { + reason: 'manual_restart', + }); + expect(reattach).toHaveBeenNthCalledWith(3, run.teamName, 'nova', { + reason: 'manual_restart', + }); + expect(result).toEqual({ + attempted: ['alice', 'tom'], + confirmed: ['alice'], + pending: [], + failed: [ + { + memberName: 'tom', + error: 'Latest assistant message reported OpenRouter credits exhausted', + }, + { memberName: 'nova', error: 'OpenCode bridge crashed' }, + ], + skipped: [], + }); + expect(notify).toHaveBeenCalledWith(run, result); + }); }); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index a9af3989..72e42e9e 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const storeState = { progress: null as Record | null, cancelProvisioning: vi.fn(), + retryFailedOpenCodeSecondaryLanes: vi.fn(), selectedTeamName: 'northstar-core', selectedTeamData: { members: [ @@ -106,6 +107,13 @@ describe('TeamProvisioningBanner launch-step alignment', () => { cliLogsTail: '', assistantOutput: '', }; + storeState.retryFailedOpenCodeSecondaryLanes.mockResolvedValue({ + attempted: [], + confirmed: [], + pending: [], + failed: [], + skipped: [], + }); storeState.memberSpawnStatusesByTeam['northstar-core'] = {}; storeState.selectedTeamData.members = [ { name: 'team-lead', agentType: 'team-lead' }, @@ -408,6 +416,51 @@ describe('TeamProvisioningBanner launch-step alignment', () => { }); }); + it('renders a bulk retry action for failed OpenCode secondary teammates', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.selectedTeamData.members = [ + { name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }, + { + name: 'alice', + agentType: 'developer', + providerId: 'opencode', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }, + ]; + storeState.teamDataCacheByName['northstar-core'] = { + members: [...storeState.selectedTeamData.members], + }; + storeState.memberSpawnStatusesByTeam['northstar-core'] = { + alice: { + status: 'error', + launchState: 'failed_to_start', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenRouter credits exhausted', + agentToolAccepted: false, + }, + } as Record; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Retry failed OpenCode teammates'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('uses info severity while runtimes are online but teammate contact is still pending', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 670c550b..12ad5420 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -34,6 +34,7 @@ const hoisted = vi.hoisted(() => ({ restoreTeam: vi.fn(), permanentlyDeleteTeam: vi.fn(), sendMessage: vi.fn(), + retryFailedOpenCodeSecondaryLanes: vi.fn(), restartMember: vi.fn(), skipMemberForLaunch: vi.fn(), requestReview: vi.fn(), @@ -69,6 +70,7 @@ vi.mock('@renderer/api', () => ({ restoreTeam: hoisted.restoreTeam, permanentlyDeleteTeam: hoisted.permanentlyDeleteTeam, sendMessage: hoisted.sendMessage, + retryFailedOpenCodeSecondaryLanes: hoisted.retryFailedOpenCodeSecondaryLanes, restartMember: hoisted.restartMember, skipMemberForLaunch: hoisted.skipMemberForLaunch, requestReview: hoisted.requestReview, @@ -328,6 +330,13 @@ describe('teamSlice actions', () => { hoisted.deleteTeam.mockResolvedValue(undefined); hoisted.restoreTeam.mockResolvedValue(undefined); hoisted.permanentlyDeleteTeam.mockResolvedValue(undefined); + hoisted.retryFailedOpenCodeSecondaryLanes.mockResolvedValue({ + attempted: [], + confirmed: [], + pending: [], + failed: [], + skipped: [], + }); hoisted.restartMember.mockResolvedValue(undefined); hoisted.skipMemberForLaunch.mockResolvedValue(undefined); }); @@ -3394,6 +3403,38 @@ describe('teamSlice actions', () => { expect(store.getState().teamAgentRuntimeByTeam['my-team']).toEqual(createRuntimeSnapshot()); }); + it('retryFailedOpenCodeSecondaryLanes refreshes only spawn statuses and runtime snapshot', async () => { + const store = createSliceStore(); + const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined); + const refreshRuntimeSnapshot = vi.fn(async (_teamName: string) => undefined); + const refreshTeamData = vi.fn(async (_teamName: string) => undefined); + const fetchTeams = vi.fn(async () => undefined); + store.setState({ + fetchMemberSpawnStatuses: refreshSpawnStatuses, + fetchTeamAgentRuntime: refreshRuntimeSnapshot, + refreshTeamData, + fetchTeams, + }); + hoisted.retryFailedOpenCodeSecondaryLanes.mockResolvedValueOnce({ + attempted: ['alice'], + confirmed: [], + pending: [], + failed: [{ memberName: 'alice', error: 'OpenRouter credits exhausted' }], + skipped: [], + }); + + const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team'); + + expect(result.failed).toEqual([ + { memberName: 'alice', error: 'OpenRouter credits exhausted' }, + ]); + expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team'); + expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team'); + expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team'); + expect(refreshTeamData).not.toHaveBeenCalled(); + expect(fetchTeams).not.toHaveBeenCalled(); + }); + it('restartMember refreshes spawn statuses and runtime snapshot even when restart fails', async () => { const store = createSliceStore(); const refreshSpawnStatuses = vi.fn(async (_teamName: string) => undefined); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index 38f03e30..569fd476 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -91,6 +91,128 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.defaultLiveOutputOpen).toBe(false); }); + it('counts retryable failed OpenCode secondary teammates conservatively', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-opencode-retry', + teamName: 'mixed-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed with teammate errors', + messageSeverity: 'warning', + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'anthropic', + }, + { + name: 'alice', + agentType: 'developer', + providerId: 'opencode', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }, + { + name: 'bob', + agentType: 'developer', + providerId: 'anthropic', + laneKind: 'primary', + }, + ], + memberSpawnStatuses: { + alice: { + status: 'error', + launchState: 'failed_to_start', + hardFailureReason: 'OpenRouter credits exhausted', + updatedAt: '2026-04-13T10:00:05.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: false, + }, + bob: { + status: 'error', + launchState: 'failed_to_start', + hardFailureReason: 'Primary lane failed', + updatedAt: '2026-04-13T10:00:05.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: false, + }, + }, + memberSpawnSnapshot: undefined, + }); + + expect(presentation?.retryableOpenCodeSecondaryFailedNames).toEqual(['alice']); + expect(presentation?.retryableOpenCodeSecondaryFailedCount).toBe(1); + }); + + it('does not count skipped or permission-blocked OpenCode failures as bulk retry candidates', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-opencode-no-retry', + teamName: 'mixed-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Launch completed with teammate errors', + messageSeverity: 'warning', + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'alice', + agentType: 'developer', + providerId: 'opencode', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }, + { + name: 'tom', + agentType: 'developer', + providerId: 'opencode', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + }, + ], + memberSpawnStatuses: { + alice: { + status: 'skipped', + launchState: 'skipped_for_launch', + skippedForLaunch: true, + updatedAt: '2026-04-13T10:00:05.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + }, + tom: { + status: 'waiting', + launchState: 'runtime_pending_permission', + pendingPermissionRequestIds: ['perm-1'], + updatedAt: '2026-04-13T10:00:05.000Z', + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: true, + }, + }, + memberSpawnSnapshot: undefined, + }); + + expect(presentation?.retryableOpenCodeSecondaryFailedNames).toEqual([]); + expect(presentation?.retryableOpenCodeSecondaryFailedCount).toBe(0); + }); + it('does not truncate long failed teammate reasons in the panel message', () => { const reason = 'You are bootstrapping into team "relay-works-10" as member "alice". Your first action is to call the MCP tool member_briefing on the agent-teams server with teamName="relay-works-10" and memberName="alice". If tool search shows only the prefixed MCP name, use mcp__agent-teams__member_briefing.'; From d20fe2a53864bd12d62e8c73d266a4aba4f254bf Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 4 May 2026 17:21:05 +0300 Subject: [PATCH 42/51] feat: docs + optmizitation + improve launch --- landing/assets/styles/brand-tokens.css | 136 ++ landing/assets/styles/main.scss | 8 +- landing/components/PageBackground.vue | 22 +- landing/components/layout/AppFooter.vue | 21 +- landing/components/layout/AppHeader.vue | 43 +- landing/nuxt.config.ts | 4 + landing/package-lock.json | 2011 ++++++++++++++++- landing/package.json | 12 +- landing/plugins/vuetify.ts | 25 +- landing/product-docs/.vitepress/config.ts | 212 ++ .../.vitepress/theme/DocsCardGrid.vue | 117 + .../.vitepress/theme/DocsHeroVisual.vue | 80 + .../.vitepress/theme/DocsLayout.vue | 89 + .../.vitepress/theme/InstallBlock.vue | 76 + .../.vitepress/theme/ZoomImage.vue | 39 + .../product-docs/.vitepress/theme/custom.css | 400 ++++ .../product-docs/.vitepress/theme/index.ts | 28 + landing/product-docs/guide/agent-workflow.md | 35 + landing/product-docs/guide/code-review.md | 35 + landing/product-docs/guide/create-team.md | 51 + landing/product-docs/guide/installation.md | 45 + landing/product-docs/guide/quickstart.md | 50 + landing/product-docs/guide/runtime-setup.md | 33 + landing/product-docs/guide/troubleshooting.md | 40 + landing/product-docs/index.md | 67 + landing/product-docs/reference/concepts.md | 32 + landing/product-docs/reference/faq.md | 29 + .../reference/privacy-local-data.md | 30 + .../reference/providers-runtimes.md | 40 + .../product-docs/ru/guide/agent-workflow.md | 35 + landing/product-docs/ru/guide/code-review.md | 35 + landing/product-docs/ru/guide/create-team.md | 51 + landing/product-docs/ru/guide/installation.md | 45 + landing/product-docs/ru/guide/quickstart.md | 50 + .../product-docs/ru/guide/runtime-setup.md | 33 + .../product-docs/ru/guide/troubleshooting.md | 40 + landing/product-docs/ru/index.md | 67 + landing/product-docs/ru/reference/concepts.md | 32 + landing/product-docs/ru/reference/faq.md | 29 + .../ru/reference/privacy-local-data.md | 30 + .../ru/reference/providers-runtimes.md | 40 + landing/server/routes/robots.txt.ts | 1 + pnpm-lock.yaml | 1292 +++++++---- .../renderer/ui/GraphProvisioningHud.tsx | 1 + src/main/services/discovery/ProjectScanner.ts | 242 +- .../discovery/SessionMetadataIndex.ts | 511 +++++ .../services/team/TeamProvisioningService.ts | 181 +- .../team/ProvisioningProgressBlock.tsx | 12 +- .../components/team/StepProgressBar.tsx | 35 +- .../components/team/members/MemberCard.tsx | 55 + src/renderer/utils/linkifiedText.tsx | 105 + .../utils/teamProvisioningPresentation.ts | 15 +- .../ProjectScanner.sessionIndex.test.ts | 727 ++++++ .../team/TeamProvisioningService.test.ts | 107 + .../team/ProvisioningProgressBlock.test.tsx | 69 +- .../components/team/StepProgressBar.test.tsx | 151 ++ .../team/members/MemberCard.test.ts | 65 + .../agent-graph/GraphProvisioningHud.test.ts | 94 +- .../teamProvisioningPresentation.test.ts | 71 +- 59 files changed, 7371 insertions(+), 660 deletions(-) create mode 100644 landing/assets/styles/brand-tokens.css create mode 100644 landing/product-docs/.vitepress/config.ts create mode 100644 landing/product-docs/.vitepress/theme/DocsCardGrid.vue create mode 100644 landing/product-docs/.vitepress/theme/DocsHeroVisual.vue create mode 100644 landing/product-docs/.vitepress/theme/DocsLayout.vue create mode 100644 landing/product-docs/.vitepress/theme/InstallBlock.vue create mode 100644 landing/product-docs/.vitepress/theme/ZoomImage.vue create mode 100644 landing/product-docs/.vitepress/theme/custom.css create mode 100644 landing/product-docs/.vitepress/theme/index.ts create mode 100644 landing/product-docs/guide/agent-workflow.md create mode 100644 landing/product-docs/guide/code-review.md create mode 100644 landing/product-docs/guide/create-team.md create mode 100644 landing/product-docs/guide/installation.md create mode 100644 landing/product-docs/guide/quickstart.md create mode 100644 landing/product-docs/guide/runtime-setup.md create mode 100644 landing/product-docs/guide/troubleshooting.md create mode 100644 landing/product-docs/index.md create mode 100644 landing/product-docs/reference/concepts.md create mode 100644 landing/product-docs/reference/faq.md create mode 100644 landing/product-docs/reference/privacy-local-data.md create mode 100644 landing/product-docs/reference/providers-runtimes.md create mode 100644 landing/product-docs/ru/guide/agent-workflow.md create mode 100644 landing/product-docs/ru/guide/code-review.md create mode 100644 landing/product-docs/ru/guide/create-team.md create mode 100644 landing/product-docs/ru/guide/installation.md create mode 100644 landing/product-docs/ru/guide/quickstart.md create mode 100644 landing/product-docs/ru/guide/runtime-setup.md create mode 100644 landing/product-docs/ru/guide/troubleshooting.md create mode 100644 landing/product-docs/ru/index.md create mode 100644 landing/product-docs/ru/reference/concepts.md create mode 100644 landing/product-docs/ru/reference/faq.md create mode 100644 landing/product-docs/ru/reference/privacy-local-data.md create mode 100644 landing/product-docs/ru/reference/providers-runtimes.md create mode 100644 src/main/services/discovery/SessionMetadataIndex.ts create mode 100644 src/renderer/utils/linkifiedText.tsx create mode 100644 test/main/services/discovery/ProjectScanner.sessionIndex.test.ts create mode 100644 test/renderer/components/team/StepProgressBar.test.tsx diff --git a/landing/assets/styles/brand-tokens.css b/landing/assets/styles/brand-tokens.css new file mode 100644 index 00000000..ee4ec4da --- /dev/null +++ b/landing/assets/styles/brand-tokens.css @@ -0,0 +1,136 @@ +:root { + color-scheme: light dark; + + --at-font-sans: "Inter", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + --at-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace; + + --at-c-cyan: #00f0ff; + --at-c-cyan-strong: #00d4e6; + --at-c-cyan-deep: #0891b2; + --at-c-magenta: #ff00ff; + --at-c-green: #39ff14; + --at-c-gold: #ffd700; + --at-c-red: #ff4757; + + --at-c-dark-0: #05070b; + --at-c-dark-1: #0a0a0f; + --at-c-dark-2: #12121a; + --at-c-dark-3: #1e293b; + --at-c-light-0: #ffffff; + --at-c-light-1: #f8fafc; + --at-c-light-2: #f0f2f5; + + --at-c-text-dark-1: #e0e6ff; + --at-c-text-dark-2: #c8d6e5; + --at-c-text-dark-3: #a0a8c0; + --at-c-text-dark-muted: #8892b0; + --at-c-text-light-1: #1e293b; + --at-c-text-light-2: #475569; + --at-c-text-light-3: #64748b; + + --at-c-bg: var(--at-c-dark-1); + --at-c-bg-soft: rgba(10, 10, 15, 0.8); + --at-c-surface: var(--at-c-dark-2); + --at-c-surface-soft: rgba(10, 10, 15, 0.6); + --at-c-surface-raised: rgba(30, 41, 59, 0.78); + --at-c-text: var(--at-c-text-dark-1); + --at-c-text-soft: var(--at-c-text-dark-2); + --at-c-text-muted: var(--at-c-text-dark-muted); + --at-c-border: rgba(0, 240, 255, 0.12); + --at-c-border-strong: rgba(0, 240, 255, 0.28); + --at-c-focus: rgba(0, 240, 255, 0.55); + + --at-gradient-brand: linear-gradient(135deg, var(--at-c-cyan), var(--at-c-magenta)); + --at-gradient-brand-text: linear-gradient(135deg, #e0e6ff 0%, var(--at-c-cyan) 50%, var(--at-c-magenta) 100%); + --at-gradient-success: linear-gradient(135deg, var(--at-c-cyan), var(--at-c-green)); + --at-gradient-cyan-text: linear-gradient(135deg, #e0e6ff 0%, var(--at-c-cyan) 100%); + --at-gradient-panel: linear-gradient(135deg, rgba(0, 240, 255, 0.06), rgba(255, 0, 255, 0.035)); + + --at-radius-xs: 6px; + --at-radius-sm: 8px; + --at-radius-md: 10px; + --at-radius-lg: 12px; + --at-radius-xl: 16px; + --at-radius-2xl: 20px; + --at-radius-preview: 22px; + --at-radius-pill: 999px; + + --at-shadow-cyan-sm: 0 4px 20px rgba(0, 240, 255, 0.3); + --at-shadow-cyan-md: 0 8px 32px rgba(0, 240, 255, 0.08); + --at-shadow-cyan-lg: 0 20px 60px rgba(0, 0, 0, 0.55), 0 0 30px rgba(0, 240, 255, 0.06); + --at-shadow-card: 0 16px 32px -10px rgba(0, 0, 0, 0.35); + + --at-blur-sm: 8px; + --at-blur-md: 12px; + --at-blur-lg: 20px; + --at-glass-bg: rgba(10, 10, 15, 0.78); + --at-glass-bg-hover: rgba(10, 10, 15, 0.9); + --at-glass-border: 1px solid var(--at-c-border); + --at-glass-border-strong: 1px solid var(--at-c-border-strong); + + --at-grid-line: rgba(0, 240, 255, 0.03); + --at-scanline: rgba(0, 240, 255, 0.008); + --at-transition-fast: 0.15s ease; + --at-transition-base: 0.25s ease; + --at-transition-smooth: 0.35s cubic-bezier(0.4, 0, 0.2, 1); + + --at-z-header: 1000; +} + +.v-theme--light, +:root:not(.dark) { + --at-c-bg: var(--at-c-light-2); + --at-c-bg-soft: rgba(255, 255, 255, 0.82); + --at-c-surface: var(--at-c-light-0); + --at-c-surface-soft: rgba(255, 255, 255, 0.78); + --at-c-surface-raised: rgba(255, 255, 255, 0.92); + --at-c-text: var(--at-c-text-light-1); + --at-c-text-soft: var(--at-c-text-light-2); + --at-c-text-muted: var(--at-c-text-light-3); + --at-c-border: rgba(0, 0, 0, 0.08); + --at-c-border-strong: rgba(0, 139, 178, 0.3); + --at-c-focus: rgba(8, 145, 178, 0.5); + --at-glass-bg: rgba(255, 255, 255, 0.78); + --at-glass-bg-hover: rgba(255, 255, 255, 0.92); + --at-glass-border: 1px solid rgba(0, 0, 0, 0.08); + --at-glass-border-strong: 1px solid rgba(0, 139, 178, 0.25); + --at-shadow-card: 0 16px 32px -10px rgba(0, 0, 0, 0.12); + --at-shadow-cyan-lg: 0 20px 60px rgba(0, 180, 200, 0.12); + --at-grid-line: rgba(8, 145, 178, 0.045); +} + +.v-theme--dark, +.dark { + --at-c-bg: var(--at-c-dark-1); + --at-c-bg-soft: rgba(10, 10, 15, 0.8); + --at-c-surface: var(--at-c-dark-2); + --at-c-surface-soft: rgba(10, 10, 15, 0.6); + --at-c-surface-raised: rgba(30, 41, 59, 0.78); + --at-c-text: var(--at-c-text-dark-1); + --at-c-text-soft: var(--at-c-text-dark-2); + --at-c-text-muted: var(--at-c-text-dark-muted); + --at-c-border: rgba(0, 240, 255, 0.12); + --at-c-border-strong: rgba(0, 240, 255, 0.28); + --at-glass-bg: rgba(10, 10, 15, 0.78); + --at-glass-bg-hover: rgba(10, 10, 15, 0.92); +} + +.at-gradient-text { + background: var(--at-gradient-brand-text); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.at-glass { + background: var(--at-glass-bg); + border: var(--at-glass-border); + backdrop-filter: blur(var(--at-blur-md)); + -webkit-backdrop-filter: blur(var(--at-blur-md)); + box-shadow: var(--at-shadow-cyan-md); +} + +.at-focus-ring:focus-visible { + outline: 2px solid var(--at-c-focus); + outline-offset: 3px; +} diff --git a/landing/assets/styles/main.scss b/landing/assets/styles/main.scss index 4f1ead04..85428476 100644 --- a/landing/assets/styles/main.scss +++ b/landing/assets/styles/main.scss @@ -1,10 +1,12 @@ +@import "./brand-tokens.css"; + :root { color-scheme: light dark; } body { margin: 0; - font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; + font-family: var(--at-font-sans); background: rgb(var(--v-theme-background)); color: rgb(var(--v-theme-on-background)); } @@ -32,7 +34,7 @@ body { /* Monospace accent font for technical elements */ .mono { - font-family: "JetBrains Mono", "Fira Code", monospace; + font-family: var(--at-font-mono); } @media (max-width: 960px) { @@ -72,5 +74,5 @@ body { } .app-header { - backdrop-filter: blur(10px); + backdrop-filter: blur(var(--at-blur-md)); } diff --git a/landing/components/PageBackground.vue b/landing/components/PageBackground.vue index 6fa18bdc..2071bb88 100644 --- a/landing/components/PageBackground.vue +++ b/landing/components/PageBackground.vue @@ -26,8 +26,8 @@ position: absolute; inset: 0; background-image: - linear-gradient(rgba(0, 240, 255, 0.03) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px); + linear-gradient(var(--at-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--at-grid-line) 1px, transparent 1px); background-size: 60px 60px; z-index: 1; } @@ -40,8 +40,8 @@ 0deg, transparent, transparent 2px, - rgba(0, 240, 255, 0.008) 2px, - rgba(0, 240, 255, 0.008) 4px + var(--at-scanline) 2px, + var(--at-scanline) 4px ); z-index: 2; } @@ -56,7 +56,7 @@ .page-bg__orb--1 { width: 900px; height: 900px; - background: #00f0ff; + background: var(--at-c-cyan); top: -200px; right: -150px; animation: orbDrift1 20s ease-in-out infinite; @@ -65,7 +65,7 @@ .page-bg__orb--2 { width: 700px; height: 700px; - background: #ff00ff; + background: var(--at-c-magenta); top: 300px; left: -200px; animation: orbDrift2 25s ease-in-out infinite; @@ -74,7 +74,7 @@ .page-bg__orb--3 { width: 800px; height: 800px; - background: #39ff14; + background: var(--at-c-green); top: 1200px; right: -100px; opacity: 0.05; @@ -84,7 +84,7 @@ .page-bg__orb--4 { width: 700px; height: 700px; - background: #00f0ff; + background: var(--at-c-cyan); top: 2100px; left: -150px; opacity: 0.06; @@ -94,7 +94,7 @@ .page-bg__orb--5 { width: 750px; height: 750px; - background: #ff00ff; + background: var(--at-c-magenta); top: 2900px; right: -120px; opacity: 0.05; @@ -104,7 +104,7 @@ .page-bg__orb--6 { width: 700px; height: 700px; - background: #ffd700; + background: var(--at-c-gold); top: 3600px; left: -100px; opacity: 0.04; @@ -114,7 +114,7 @@ .page-bg__orb--7 { width: 650px; height: 650px; - background: #00f0ff; + background: var(--at-c-cyan); top: 4300px; right: -80px; opacity: 0.05; diff --git a/landing/components/layout/AppFooter.vue b/landing/components/layout/AppFooter.vue index 74cb7a7d..76c83732 100644 --- a/landing/components/layout/AppFooter.vue +++ b/landing/components/layout/AppFooter.vue @@ -1,7 +1,12 @@