From 946ccb692c3b3c7909fc796ab8984611d0d1e402 Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 11 Mar 2026 21:37:08 +0200 Subject: [PATCH] feat: persist safe task change summaries Restore terminal task-change badges quickly across restarts without trusting stale snapshots, and keep kanban card review actions compact so important controls fit reliably on one row. Made-with: Cursor --- src/main/ipc/review.ts | 28 + .../services/team/ChangeExtractorService.ts | 566 +++++- .../JsonTaskChangeSummaryCacheRepository.ts | 183 ++ .../cache/TaskChangeSummaryCacheRepository.ts | 11 + .../cache/taskChangeSummaryCacheSchema.ts | 159 ++ .../team/cache/taskChangeSummaryCacheTypes.ts | 27 + src/main/utils/pathDecoder.ts | 4 + src/preload/constants/ipcChannels.ts | 3 + src/preload/index.ts | 6 + src/renderer/api/httpClient.ts | 5 + .../components/team/UnreadCommentsBadge.tsx | 30 +- .../team/dialogs/TaskDetailDialog.tsx | 70 +- .../components/team/kanban/KanbanTaskCard.tsx | 229 +-- .../store/slices/changeReviewSlice.ts | 1747 +++++++++-------- src/renderer/store/slices/teamSlice.ts | 78 +- src/renderer/utils/taskChangeRequest.ts | 37 +- src/shared/types/api.ts | 5 + src/shared/utils/taskChangeState.ts | 48 + .../team/ChangeExtractorService.test.ts | 386 +++- ...onTaskChangeSummaryCacheRepository.test.ts | 135 ++ test/renderer/store/changeReviewSlice.test.ts | 222 +++ test/renderer/store/teamSlice.test.ts | 114 ++ test/renderer/utils/taskChangeRequest.test.ts | 5 + 23 files changed, 2984 insertions(+), 1114 deletions(-) create mode 100644 src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository.ts create mode 100644 src/main/services/team/cache/TaskChangeSummaryCacheRepository.ts create mode 100644 src/main/services/team/cache/taskChangeSummaryCacheSchema.ts create mode 100644 src/main/services/team/cache/taskChangeSummaryCacheTypes.ts create mode 100644 src/shared/utils/taskChangeState.ts create mode 100644 test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index eb33ca92..1bba8f36 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -16,6 +16,7 @@ import { REVIEW_GET_FILE_CONTENT, REVIEW_GET_GIT_FILE_LOG, REVIEW_GET_TASK_CHANGES, + REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, REVIEW_LOAD_DECISIONS, REVIEW_PREVIEW_REJECT, REVIEW_REJECT_FILE, @@ -91,6 +92,7 @@ export function registerReviewHandlers(ipcMain: IpcMain): void { // Phase 1 ipcMain.handle(REVIEW_GET_AGENT_CHANGES, handleGetAgentChanges); ipcMain.handle(REVIEW_GET_TASK_CHANGES, handleGetTaskChanges); + ipcMain.handle(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, handleInvalidateTaskChangeSummaries); ipcMain.handle(REVIEW_GET_CHANGE_STATS, handleGetChangeStats); // Phase 2 ipcMain.handle(REVIEW_CHECK_CONFLICT, handleCheckConflict); @@ -113,6 +115,7 @@ export function removeReviewHandlers(ipcMain: IpcMain): void { // Phase 1 ipcMain.removeHandler(REVIEW_GET_AGENT_CHANGES); ipcMain.removeHandler(REVIEW_GET_TASK_CHANGES); + ipcMain.removeHandler(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES); ipcMain.removeHandler(REVIEW_GET_CHANGE_STATS); // Phase 2 ipcMain.removeHandler(REVIEW_CHECK_CONFLICT); @@ -174,7 +177,19 @@ async function handleGetTaskChanges( typeof (i as Record).completedAt === 'string') ) as { startedAt: string; completedAt?: string }[]) : undefined, + stateBucket: + (options as Record).stateBucket === 'approved' || + (options as Record).stateBucket === 'review' || + (options as Record).stateBucket === 'completed' || + (options as Record).stateBucket === 'active' + ? ((options as Record).stateBucket as + | 'approved' + | 'review' + | 'completed' + | 'active') + : undefined, summaryOnly: (options as Record).summaryOnly === true, + forceFresh: (options as Record).forceFresh === true, } : undefined; @@ -183,6 +198,19 @@ async function handleGetTaskChanges( ); } +async function handleInvalidateTaskChangeSummaries( + _event: IpcMainInvokeEvent, + teamName: string, + taskIds: string[] +): Promise> { + return wrapReviewHandler('invalidateTaskChangeSummaries', async () => { + await getChangeExtractor().invalidateTaskChangeSummaries( + teamName, + Array.isArray(taskIds) ? taskIds.filter((taskId) => typeof taskId === 'string') : [] + ); + }); +} + async function handleGetChangeStats( _event: IpcMainInvokeEvent, teamName: string, diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index e7d9d90a..16020b77 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -1,10 +1,17 @@ import { getTasksBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; +import { + getTaskChangeStateBucket, + isTaskChangeSummaryCacheable, + type TaskChangeStateBucket, +} from '@shared/utils/taskChangeState'; +import { createHash } from 'crypto'; import { createReadStream } from 'fs'; import { readFile, stat } from 'fs/promises'; import * as path from 'path'; import * as readline from 'readline'; +import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; import { TeamConfigReader } from './TeamConfigReader'; import { countLineChanges } from './UnifiedLineCounter'; @@ -31,7 +38,7 @@ interface CacheEntry { expiresAt: number; } -interface TaskChangeCacheEntry { +interface TaskChangeSummaryCacheEntry { data: TaskChangeSetV2; expiresAt: number; } @@ -50,16 +57,25 @@ interface LogFileRef { export class ChangeExtractorService { private cache = new Map(); - private taskChangeCache = new Map(); + private taskChangeSummaryCache = new Map(); + private taskChangeSummaryInFlight = new Map>(); + private taskChangeSummaryVersionByTask = new Map(); + private taskChangeSummaryValidationInFlight = new Set(); private parsedSnippetsCache = new Map(); private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk - private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes + private readonly taskChangeSummaryCacheTtl = 60 * 1000; + private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000; + private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000; + private readonly maxTaskChangeSummaryCacheEntries = 200; private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets + private readonly isPersistedTaskChangeCacheEnabled = + process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0'; constructor( private readonly logsFinder: TeamMemberLogsFinder, private readonly boundaryParser: TaskBoundaryParser, - private readonly configReader: TeamConfigReader = new TeamConfigReader() + private readonly configReader: TeamConfigReader = new TeamConfigReader(), + private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository() ) {} /** Получить все изменения агента */ @@ -128,7 +144,9 @@ export class ChangeExtractorService { status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; + stateBucket?: TaskChangeStateBucket; summaryOnly?: boolean; + forceFresh?: boolean; } ): Promise { const includeDetails = options?.summaryOnly !== true; @@ -139,28 +157,130 @@ export class ChangeExtractorService { intervals: options?.intervals ?? taskMeta?.intervals, since: options?.since, }; - const cacheKey = this.buildTaskChangeCacheKey( + const effectiveStateBucket = taskMeta + ? getTaskChangeStateBucket({ + status: effectiveOptions.status, + reviewState: taskMeta.reviewState, + historyEvents: taskMeta.historyEvents, + kanbanColumn: taskMeta.kanbanColumn, + }) + : (options?.stateBucket ?? + getTaskChangeStateBucket({ + status: effectiveOptions.status, + })); + const summaryCacheableState = isTaskChangeSummaryCacheable(effectiveStateBucket); + const shouldUseSummaryCache = !includeDetails && summaryCacheableState; + + if (!summaryCacheableState || options?.forceFresh === true) { + await this.invalidateTaskChangeSummaries(teamName, [taskId], { + deletePersisted: true, + }); + } + + if (!shouldUseSummaryCache) { + return this.computeTaskChanges(teamName, taskId, effectiveOptions, includeDetails); + } + + const cacheKey = this.buildTaskChangeSummaryCacheKey( teamName, taskId, effectiveOptions, - includeDetails + effectiveStateBucket ); - const cached = includeDetails ? this.taskChangeCache.get(cacheKey) : undefined; - if (cached && cached.expiresAt > Date.now()) { - return cached.data; + const version = this.getTaskChangeSummaryVersion(teamName, taskId); + + if (options?.forceFresh !== true) { + const cached = this.taskChangeSummaryCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + this.taskChangeSummaryCache.delete(cacheKey); + + const inFlight = this.taskChangeSummaryInFlight.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const persisted = await this.readPersistedTaskChangeSummary( + teamName, + taskId, + effectiveOptions, + effectiveStateBucket, + taskMeta + ); + if (persisted) { + this.setTaskChangeSummaryCache(cacheKey, persisted); + return persisted; + } } + const promise = this.computeTaskChanges(teamName, taskId, effectiveOptions, false) + .then(async (result) => { + if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) { + return result; + } + + this.setTaskChangeSummaryCache(cacheKey, result); + await this.persistTaskChangeSummary( + teamName, + taskId, + effectiveOptions, + effectiveStateBucket, + result, + version + ); + return result; + }) + .finally(() => { + this.taskChangeSummaryInFlight.delete(cacheKey); + }); + + this.taskChangeSummaryInFlight.set(cacheKey, promise); + return promise; + } + + async invalidateTaskChangeSummaries( + teamName: string, + taskIds: string[], + options?: { deletePersisted?: boolean } + ): Promise { + const uniqueTaskIds = [...new Set(taskIds.filter((taskId) => taskId.length > 0))]; + await Promise.all( + uniqueTaskIds.map(async (taskId) => { + this.bumpTaskChangeSummaryVersion(teamName, taskId); + for (const key of [...this.taskChangeSummaryCache.keys()]) { + if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) { + this.taskChangeSummaryCache.delete(key); + } + } + for (const key of [...this.taskChangeSummaryInFlight.keys()]) { + if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) { + this.taskChangeSummaryInFlight.delete(key); + } + } + if (options?.deletePersisted !== false && this.isPersistedTaskChangeCacheEnabled) { + await this.taskChangeSummaryRepository.delete(teamName, taskId); + } + }) + ); + } + + private async computeTaskChanges( + teamName: string, + taskId: string, + effectiveOptions: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + }, + includeDetails: boolean + ): Promise { + const taskMeta = await this.readTaskMeta(teamName, taskId); const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions); const logRefs = await this.resolveLogFileRefs(teamName, logs); if (logRefs.length === 0) { - const empty = this.emptyTaskChangeSet(teamName, taskId); - if (includeDetails) { - this.taskChangeCache.set(cacheKey, { - data: empty, - expiresAt: Date.now() + this.taskChangeCacheTtl, - }); - } - return empty; + return this.emptyTaskChangeSet(teamName, taskId); } const projectPath = await this.resolveProjectPath(teamName); @@ -182,23 +302,7 @@ export class ChangeExtractorService { const { files, toolUseIds, startTimestamp, endTimestamp } = await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails); - const intervalScope: TaskChangeScope = { - taskId, - memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '', - startLine: 0, - endLine: 0, - startTimestamp, - endTimestamp, - toolUseIds, - filePaths: files.map((f) => f.filePath), - confidence: { - tier: 2, - label: 'medium', - reason: 'Scoped by persisted task workIntervals (timestamp-based)', - }, - }; - - const intervalResult: TaskChangeSetV2 = { + return { teamName, taskId, files, @@ -207,39 +311,32 @@ export class ChangeExtractorService { totalFiles: files.length, confidence: 'medium', computedAt: new Date().toISOString(), - scope: intervalScope, + scope: { + taskId, + memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '', + startLine: 0, + endLine: 0, + startTimestamp, + endTimestamp, + toolUseIds, + filePaths: files.map((f) => f.filePath), + confidence: { + tier: 2, + label: 'medium', + reason: 'Scoped by persisted task workIntervals (timestamp-based)', + }, + }, warnings: files.length === 0 ? ['No file edits found within persisted workIntervals.'] : ['Task boundaries missing — scoped by workIntervals timestamps.'], }; - if (includeDetails) { - this.taskChangeCache.set(cacheKey, { - data: intervalResult, - expiresAt: Date.now() + this.taskChangeCacheTtl, - }); - } - return intervalResult; } - const fallbackResult = await this.fallbackSingleTaskScope( - teamName, - taskId, - logRefs, - projectPath, - includeDetails - ); - if (includeDetails) { - this.taskChangeCache.set(cacheKey, { - data: fallbackResult, - expiresAt: Date.now() + this.taskChangeCacheTtl, - }); - } - return fallbackResult; + return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath, includeDetails); } - // Фильтруем snippets по tool_use IDs из scope - const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds)); + const allowedToolUseIds = new Set(allScopes.flatMap((scope) => scope.toolUseIds)); const files = await this.extractFilteredChanges( logRefs, allowedToolUseIds, @@ -247,31 +344,19 @@ export class ChangeExtractorService { includeDetails ); - const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier)); - const warnings: string[] = []; - if (worstTier >= 3) { - warnings.push('Some task boundaries could not be precisely determined.'); - } - - const result: TaskChangeSetV2 = { + const worstTier = Math.max(...allScopes.map((scope) => scope.confidence.tier)); + return { teamName, taskId, files, - totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0), - totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0), + totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0), totalFiles: files.length, confidence: worstTier <= 1 ? 'high' : worstTier <= 2 ? 'medium' : 'low', computedAt: new Date().toISOString(), scope: allScopes[0], - warnings, + warnings: worstTier >= 3 ? ['Some task boundaries could not be precisely determined.'] : [], }; - if (includeDetails) { - this.taskChangeCache.set(cacheKey, { - data: result, - expiresAt: Date.now() + this.taskChangeCacheTtl, - }); - } - return result; } /** Получить краткую статистику */ @@ -294,6 +379,9 @@ export class ChangeExtractorService { owner?: string; status?: string; intervals?: { startedAt: string; completedAt?: string }[]; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; } | null> { try { const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`); @@ -350,6 +438,17 @@ export class ChangeExtractorService { owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, status: typeof parsed.status === 'string' ? parsed.status : undefined, intervals: derivedIntervals, + reviewState: + parsed.reviewState === 'review' || + parsed.reviewState === 'needsFix' || + parsed.reviewState === 'approved' + ? parsed.reviewState + : 'none', + historyEvents: Array.isArray(parsed.historyEvents) ? parsed.historyEvents : undefined, + kanbanColumn: + parsed.kanbanColumn === 'review' || parsed.kanbanColumn === 'approved' + ? parsed.kanbanColumn + : undefined, }; } catch (error) { logger.debug(`Failed to read task meta for ${teamName}/${taskId}: ${String(error)}`); @@ -927,7 +1026,7 @@ export class ChangeExtractorService { }; } - private buildTaskChangeCacheKey( + private buildTaskChangeSummaryCacheKey( teamName: string, taskId: string, options: { @@ -936,7 +1035,24 @@ export class ChangeExtractorService { intervals?: { startedAt: string; completedAt?: string }[]; since?: string; }, - includeDetails: boolean + stateBucket: TaskChangeStateBucket + ): string { + return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`; + } + + private normalizeFilePathKey(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); + } + + private buildTaskSignature( + options: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + }, + stateBucket: TaskChangeStateBucket ): string { const owner = typeof options.owner === 'string' ? options.owner.trim() : ''; const status = typeof options.status === 'string' ? options.status.trim() : ''; @@ -947,21 +1063,295 @@ export class ChangeExtractorService { completedAt: interval.completedAt ?? '', })) : []; + return JSON.stringify({ owner, status, since, stateBucket, intervals }); + } - return JSON.stringify({ - type: 'task', + private setTaskChangeSummaryCache(cacheKey: string, result: TaskChangeSetV2): void { + this.pruneExpiredTaskChangeSummaryCache(); + this.taskChangeSummaryCache.set(cacheKey, { + data: result, + expiresAt: + Date.now() + + (result.files.length > 0 + ? this.taskChangeSummaryCacheTtl + : this.emptyTaskChangeSummaryCacheTtl), + }); + while (this.taskChangeSummaryCache.size > this.maxTaskChangeSummaryCacheEntries) { + const oldestKey = this.taskChangeSummaryCache.keys().next().value; + if (!oldestKey) break; + this.taskChangeSummaryCache.delete(oldestKey); + } + } + + private pruneExpiredTaskChangeSummaryCache(): void { + const now = Date.now(); + for (const [key, entry] of this.taskChangeSummaryCache.entries()) { + if (entry.expiresAt <= now) { + this.taskChangeSummaryCache.delete(key); + } + } + } + + private getTaskChangeSummaryVersionKey(teamName: string, taskId: string): string { + return `${teamName}:${taskId}`; + } + + private getTaskChangeSummaryVersion(teamName: string, taskId: string): number { + return ( + this.taskChangeSummaryVersionByTask.get( + this.getTaskChangeSummaryVersionKey(teamName, taskId) + ) ?? 0 + ); + } + + private bumpTaskChangeSummaryVersion(teamName: string, taskId: string): void { + const key = this.getTaskChangeSummaryVersionKey(teamName, taskId); + this.taskChangeSummaryVersionByTask.set( + key, + this.getTaskChangeSummaryVersion(teamName, taskId) + 1 + ); + } + + private isTaskChangeSummaryCacheKeyForTask( + cacheKey: string, + teamName: string, + taskId: string + ): boolean { + return cacheKey.startsWith(`${teamName}:${taskId}:`); + } + + private async readPersistedTaskChangeSummary( + teamName: string, + taskId: string, + effectiveOptions: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + }, + stateBucket: TaskChangeStateBucket, + taskMeta: { + status?: string; + reviewState?: 'review' | 'needsFix' | 'approved' | 'none'; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved'; + } | null + ): Promise { + if (!this.isPersistedTaskChangeCacheEnabled) { + return null; + } + if (!taskMeta || !isTaskChangeSummaryCacheable(stateBucket)) { + await this.taskChangeSummaryRepository.delete(teamName, taskId); + return null; + } + + const currentBucket = getTaskChangeStateBucket({ + status: taskMeta.status, + reviewState: taskMeta.reviewState, + historyEvents: taskMeta.historyEvents, + kanbanColumn: taskMeta.kanbanColumn, + }); + if (!isTaskChangeSummaryCacheable(currentBucket)) { + await this.taskChangeSummaryRepository.delete(teamName, taskId); + return null; + } + + const entry = await this.taskChangeSummaryRepository.load(teamName, taskId); + if (!entry) { + return null; + } + + const projectFingerprint = await this.computeProjectFingerprint(teamName); + const taskSignature = this.buildTaskSignature(effectiveOptions, currentBucket); + + if ( + !projectFingerprint || + entry.taskSignature !== taskSignature || + entry.projectFingerprint !== projectFingerprint || + entry.stateBucket !== currentBucket + ) { + logger.debug(`Rejecting persisted task-change summary for ${teamName}/${taskId}`); + await this.taskChangeSummaryRepository.delete(teamName, taskId); + return null; + } + + this.schedulePersistedTaskChangeSummaryValidation( teamName, taskId, - includeDetails, - owner, - status, - since, - intervals, - }); + effectiveOptions, + currentBucket, + entry.sourceFingerprint + ); + + return entry.summary; } - private normalizeFilePathKey(filePath: string): string { - const normalized = filePath.replace(/\\/g, '/'); - return normalized.replace(/^[A-Z]:/, (drive) => drive.toLowerCase()); + private schedulePersistedTaskChangeSummaryValidation( + teamName: string, + taskId: string, + effectiveOptions: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + }, + expectedBucket: TaskChangeStateBucket, + expectedSourceFingerprint: string + ): void { + const validationKey = `${teamName}:${taskId}`; + if (this.taskChangeSummaryValidationInFlight.has(validationKey)) { + return; + } + + const version = this.getTaskChangeSummaryVersion(teamName, taskId); + this.taskChangeSummaryValidationInFlight.add(validationKey); + + setTimeout(() => { + void this.validatePersistedTaskChangeSummary( + teamName, + taskId, + effectiveOptions, + expectedBucket, + expectedSourceFingerprint, + version + ) + .catch((error) => { + logger.debug( + `Background persisted summary validation failed for ${teamName}/${taskId}: ${String(error)}` + ); + }) + .finally(() => { + this.taskChangeSummaryValidationInFlight.delete(validationKey); + }); + }, 0); + } + + private async validatePersistedTaskChangeSummary( + teamName: string, + taskId: string, + effectiveOptions: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + }, + expectedBucket: TaskChangeStateBucket, + expectedSourceFingerprint: string, + version: number + ): Promise { + if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) { + return; + } + + const taskMeta = await this.readTaskMeta(teamName, taskId); + if (!taskMeta) { + await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true }); + return; + } + + const currentBucket = getTaskChangeStateBucket({ + status: taskMeta.status ?? effectiveOptions.status, + reviewState: taskMeta.reviewState, + historyEvents: taskMeta.historyEvents, + kanbanColumn: taskMeta.kanbanColumn, + }); + if (!isTaskChangeSummaryCacheable(currentBucket) || currentBucket !== expectedBucket) { + await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true }); + return; + } + + const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions); + const logRefs = await this.resolveLogFileRefs(teamName, logs); + const sourceFingerprint = await this.computeSourceFingerprint(logRefs); + if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) { + await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true }); + } + } + + private async persistTaskChangeSummary( + teamName: string, + taskId: string, + effectiveOptions: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + }, + stateBucket: TaskChangeStateBucket, + result: TaskChangeSetV2, + generation: number + ): Promise { + if (!this.isPersistedTaskChangeCacheEnabled) return; + if (!isTaskChangeSummaryCacheable(stateBucket)) return; + if (result.files.length === 0) return; + if (result.confidence !== 'high' && result.confidence !== 'medium') { + await this.taskChangeSummaryRepository.delete(teamName, taskId); + return; + } + if (this.getTaskChangeSummaryVersion(teamName, taskId) !== generation) { + return; + } + const currentTaskMeta = await this.readTaskMeta(teamName, taskId); + if (!currentTaskMeta) return; + const currentBucket = getTaskChangeStateBucket({ + status: currentTaskMeta.status ?? effectiveOptions.status, + reviewState: currentTaskMeta.reviewState, + historyEvents: currentTaskMeta.historyEvents, + kanbanColumn: currentTaskMeta.kanbanColumn, + }); + if (!isTaskChangeSummaryCacheable(currentBucket)) { + await this.taskChangeSummaryRepository.delete(teamName, taskId); + return; + } + + const logs = await this.logsFinder.findLogsForTask(teamName, taskId, effectiveOptions); + const logRefs = await this.resolveLogFileRefs(teamName, logs); + const sourceFingerprint = await this.computeSourceFingerprint(logRefs); + const projectFingerprint = await this.computeProjectFingerprint(teamName); + if (!sourceFingerprint || !projectFingerprint) { + return; + } + + const expiresAt = new Date(Date.now() + this.persistedTaskChangeSummaryTtl).toISOString(); + await this.taskChangeSummaryRepository.save( + { + version: 1, + teamName, + taskId, + stateBucket: currentBucket === 'approved' ? 'approved' : 'completed', + taskSignature: this.buildTaskSignature(effectiveOptions, currentBucket), + sourceFingerprint, + projectFingerprint, + writtenAt: new Date().toISOString(), + expiresAt, + extractorConfidence: result.confidence, + summary: result, + debugMeta: { + sourceCount: logRefs.length, + projectPathHash: projectFingerprint, + }, + }, + { generation } + ); + } + + private async computeSourceFingerprint(logRefs: LogFileRef[]): Promise { + if (logRefs.length === 0) return null; + const parts: string[] = []; + for (const ref of [...logRefs].sort((a, b) => a.filePath.localeCompare(b.filePath))) { + try { + const stats = await stat(ref.filePath); + parts.push(`${this.normalizeFilePathKey(ref.filePath)}:${stats.size}:${stats.mtimeMs}`); + } catch { + return null; + } + } + return createHash('sha1').update(parts.join('|')).digest('hex'); + } + + private async computeProjectFingerprint(teamName: string): Promise { + const projectPath = await this.resolveProjectPath(teamName); + if (!projectPath) return null; + return createHash('sha1').update(this.normalizeFilePathKey(projectPath)).digest('hex'); } } diff --git a/src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository.ts b/src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository.ts new file mode 100644 index 00000000..921ee0b9 --- /dev/null +++ b/src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository.ts @@ -0,0 +1,183 @@ +import { getTaskChangeSummariesBasePath } from '@main/utils/pathDecoder'; +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { + normalizePersistedTaskChangeSummaryEntry, + toPersistedSummary, +} from './taskChangeSummaryCacheSchema'; + +import type { TaskChangeSummaryCacheRepository } from './TaskChangeSummaryCacheRepository'; +import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes'; + +const logger = createLogger('Service:JsonTaskChangeSummaryCacheRepository'); + +const READ_TIMEOUT_MS = 5_000; +const MAX_ENTRY_BYTES = 512 * 1024; +const MAX_CACHE_FILES = 1_000; + +function encodeFileSegment(value: string): string { + return encodeURIComponent(value); +} + +export class JsonTaskChangeSummaryCacheRepository implements TaskChangeSummaryCacheRepository { + private readonly latestGenerationByKey = new Map(); + private readonly writeChains = new Map>(); + + private get basePath(): string { + return getTaskChangeSummariesBasePath(); + } + + private teamDir(teamName: string): string { + return path.join(this.basePath, encodeFileSegment(teamName)); + } + + private filePath(teamName: string, taskId: string): string { + return path.join(this.teamDir(teamName), `${encodeFileSegment(taskId)}.json`); + } + + async load(teamName: string, taskId: string): Promise { + const filePath = this.filePath(teamName, taskId); + let content: string; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS); + try { + content = await fs.promises.readFile(filePath, { + encoding: 'utf8', + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + logger.warn(`Failed to read persisted task-change summary ${filePath}: ${String(error)}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(content) as unknown; + } catch (error) { + logger.warn(`Corrupted persisted task-change summary ${filePath}: ${String(error)}`); + await this.delete(teamName, taskId); + return null; + } + + const normalized = normalizePersistedTaskChangeSummaryEntry(parsed); + if (!normalized) { + await this.delete(teamName, taskId); + return null; + } + + if (new Date(normalized.expiresAt).getTime() <= Date.now()) { + await this.delete(teamName, taskId); + return null; + } + + return normalized; + } + + async save( + entry: PersistedTaskChangeSummaryEntry, + options?: { generation?: number } + ): Promise<{ written: boolean }> { + const cacheKey = `${entry.teamName}:${entry.taskId}`; + const generation = options?.generation; + const currentGeneration = this.latestGenerationByKey.get(cacheKey); + if ( + generation !== undefined && + currentGeneration !== undefined && + generation < currentGeneration + ) { + return { written: false }; + } + + if (generation !== undefined) { + this.latestGenerationByKey.set(cacheKey, generation); + } + + const write = async (): Promise<{ written: boolean }> => { + const normalized = toPersistedSummary(entry); + const payload = JSON.stringify(normalized, null, 2); + if (Buffer.byteLength(payload, 'utf8') > MAX_ENTRY_BYTES) { + logger.warn(`Skipping oversized persisted task-change summary for ${cacheKey}`); + return { written: false }; + } + + await atomicWriteAsync(this.filePath(entry.teamName, entry.taskId), payload); + await this.prune(); + return { written: true }; + }; + + const previous = this.writeChains.get(cacheKey) ?? Promise.resolve(); + let result: { written: boolean } = { written: false }; + const next = previous + .catch(() => undefined) + .then(async () => { + result = await write(); + }) + .finally(() => { + if (this.writeChains.get(cacheKey) === next) { + this.writeChains.delete(cacheKey); + } + }); + this.writeChains.set(cacheKey, next); + await next; + return result; + } + + async delete(teamName: string, taskId: string): Promise { + const cacheKey = `${teamName}:${taskId}`; + this.latestGenerationByKey.delete(cacheKey); + await fs.promises.unlink(this.filePath(teamName, taskId)).catch(() => undefined); + } + + async prune(): Promise { + let teamDirs: string[] = []; + try { + teamDirs = await fs.promises.readdir(this.basePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return 0; + } + logger.warn(`Failed to read persisted summary cache dir: ${String(error)}`); + return 0; + } + + const files: { path: string; mtimeMs: number }[] = []; + for (const dirName of teamDirs) { + const teamPath = path.join(this.basePath, dirName); + let taskFiles: string[] = []; + try { + taskFiles = await fs.promises.readdir(teamPath); + } catch { + continue; + } + for (const taskFile of taskFiles) { + const fullPath = path.join(teamPath, taskFile); + try { + const stats = await fs.promises.stat(fullPath); + files.push({ path: fullPath, mtimeMs: stats.mtimeMs }); + } catch { + // best effort + } + } + } + + if (files.length <= MAX_CACHE_FILES) { + return 0; + } + + files.sort((a, b) => a.mtimeMs - b.mtimeMs); + const toDelete = files.slice(0, files.length - MAX_CACHE_FILES); + await Promise.all(toDelete.map((file) => fs.promises.unlink(file.path).catch(() => undefined))); + return toDelete.length; + } +} diff --git a/src/main/services/team/cache/TaskChangeSummaryCacheRepository.ts b/src/main/services/team/cache/TaskChangeSummaryCacheRepository.ts new file mode 100644 index 00000000..a8041242 --- /dev/null +++ b/src/main/services/team/cache/TaskChangeSummaryCacheRepository.ts @@ -0,0 +1,11 @@ +import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes'; + +export interface TaskChangeSummaryCacheRepository { + load(teamName: string, taskId: string): Promise; + save( + entry: PersistedTaskChangeSummaryEntry, + options?: { generation?: number } + ): Promise<{ written: boolean }>; + delete(teamName: string, taskId: string): Promise; + prune(): Promise; +} diff --git a/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts b/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts new file mode 100644 index 00000000..579ad738 --- /dev/null +++ b/src/main/services/team/cache/taskChangeSummaryCacheSchema.ts @@ -0,0 +1,159 @@ +import { TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION } from './taskChangeSummaryCacheTypes'; + +import type { FileChangeSummary, TaskChangeSetV2 } from '@shared/types'; +import type { PersistedTaskChangeSummaryEntry } from './taskChangeSummaryCacheTypes'; + +function normalizeIsoString(value: unknown): string | null { + if (typeof value !== 'string' || value.trim() === '') return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString(); +} + +function normalizeString(value: unknown): string | null { + return typeof value === 'string' && value.trim() !== '' ? value : null; +} + +function normalizeFileSummary(value: unknown): FileChangeSummary | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial; + if (typeof candidate.filePath !== 'string' || typeof candidate.relativePath !== 'string') { + return null; + } + + return { + filePath: candidate.filePath, + relativePath: candidate.relativePath, + snippets: [], + linesAdded: Number.isFinite(candidate.linesAdded) ? Number(candidate.linesAdded) : 0, + linesRemoved: Number.isFinite(candidate.linesRemoved) ? Number(candidate.linesRemoved) : 0, + isNewFile: candidate.isNewFile === true, + }; +} + +function normalizeSummary( + value: unknown, + teamName: string, + taskId: string +): TaskChangeSetV2 | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial; + const files = Array.isArray(candidate.files) + ? candidate.files + .map(normalizeFileSummary) + .filter((file): file is FileChangeSummary => file !== null) + : null; + const confidence = + candidate.confidence === 'high' || candidate.confidence === 'medium' + ? candidate.confidence + : null; + const computedAt = normalizeIsoString(candidate.computedAt); + if ( + !files || + !confidence || + !computedAt || + !candidate.scope || + !Array.isArray(candidate.warnings) + ) { + return null; + } + + return { + teamName, + taskId, + files, + totalFiles: Number.isFinite(candidate.totalFiles) ? Number(candidate.totalFiles) : files.length, + totalLinesAdded: Number.isFinite(candidate.totalLinesAdded) + ? Number(candidate.totalLinesAdded) + : files.reduce((sum, file) => sum + file.linesAdded, 0), + totalLinesRemoved: Number.isFinite(candidate.totalLinesRemoved) + ? Number(candidate.totalLinesRemoved) + : files.reduce((sum, file) => sum + file.linesRemoved, 0), + confidence, + computedAt, + scope: candidate.scope, + warnings: candidate.warnings.filter( + (warning): warning is string => typeof warning === 'string' + ), + }; +} + +export function toPersistedSummary( + entry: PersistedTaskChangeSummaryEntry +): PersistedTaskChangeSummaryEntry { + return { + ...entry, + version: TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION, + summary: { + ...entry.summary, + files: entry.summary.files.map((file) => ({ + ...file, + snippets: [], + timeline: undefined, + })), + }, + }; +} + +export function normalizePersistedTaskChangeSummaryEntry( + value: unknown +): PersistedTaskChangeSummaryEntry | null { + if (!value || typeof value !== 'object') return null; + const candidate = value as Partial; + if (candidate.version !== TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION) { + return null; + } + + const teamName = normalizeString(candidate.teamName); + const taskId = normalizeString(candidate.taskId); + const taskSignature = normalizeString(candidate.taskSignature); + const sourceFingerprint = normalizeString(candidate.sourceFingerprint); + const projectFingerprint = normalizeString(candidate.projectFingerprint); + const writtenAt = normalizeIsoString(candidate.writtenAt); + const expiresAt = normalizeIsoString(candidate.expiresAt); + const stateBucket = + candidate.stateBucket === 'approved' || candidate.stateBucket === 'completed' + ? candidate.stateBucket + : null; + const extractorConfidence = + candidate.extractorConfidence === 'high' || candidate.extractorConfidence === 'medium' + ? candidate.extractorConfidence + : null; + + if ( + !teamName || + !taskId || + !taskSignature || + !sourceFingerprint || + !projectFingerprint || + !writtenAt || + !expiresAt || + !stateBucket || + !extractorConfidence + ) { + return null; + } + + const summary = normalizeSummary(candidate.summary, teamName, taskId); + if (!summary) { + return null; + } + + return { + version: TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION, + teamName, + taskId, + stateBucket, + taskSignature, + sourceFingerprint, + projectFingerprint, + writtenAt, + expiresAt, + extractorConfidence, + summary, + debugMeta: + candidate.debugMeta && typeof candidate.debugMeta === 'object' + ? candidate.debugMeta + : undefined, + }; +} diff --git a/src/main/services/team/cache/taskChangeSummaryCacheTypes.ts b/src/main/services/team/cache/taskChangeSummaryCacheTypes.ts new file mode 100644 index 00000000..ee878902 --- /dev/null +++ b/src/main/services/team/cache/taskChangeSummaryCacheTypes.ts @@ -0,0 +1,27 @@ +import type { TaskChangeSetV2 } from '@shared/types'; +import type { TaskChangeStateBucket } from '@shared/utils/taskChangeState'; + +export const TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION = 1; + +export type PersistedTaskChangeExtractorConfidence = Exclude< + TaskChangeSetV2['confidence'], + 'low' | 'fallback' +>; + +export interface PersistedTaskChangeSummaryEntry { + version: typeof TASK_CHANGE_SUMMARY_CACHE_SCHEMA_VERSION; + teamName: string; + taskId: string; + stateBucket: Extract; + taskSignature: string; + sourceFingerprint: string; + projectFingerprint: string; + writtenAt: string; + expiresAt: string; + extractorConfidence: PersistedTaskChangeExtractorConfidence; + summary: TaskChangeSetV2; + debugMeta?: { + sourceCount?: number; + projectPathHash?: string; + }; +} diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 0556ba07..b95d426b 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -368,3 +368,7 @@ export function getToolsBasePath(): string { export function getSchedulesBasePath(): string { return path.join(getClaudeBasePath(), 'claude-devtools-schedules'); } + +export function getTaskChangeSummariesBasePath(): string { + return path.join(getClaudeBasePath(), 'task-change-summaries'); +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 5cad249e..cf0156fc 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -431,6 +431,9 @@ export const REVIEW_GET_AGENT_CHANGES = 'review:getAgentChanges'; /** Получить изменения задачи */ export const REVIEW_GET_TASK_CHANGES = 'review:getTaskChanges'; +/** Инвалидировать persisted/in-memory summary cache для задач */ +export const REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES = 'review:invalidateTaskChangeSummaries'; + /** Получить краткую статистику изменений */ export const REVIEW_GET_CHANGE_STATS = 'review:getChangeStats'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 2b49b8ad..2224065f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -57,6 +57,7 @@ import { REVIEW_GET_FILE_CONTENT, REVIEW_GET_GIT_FILE_LOG, REVIEW_GET_TASK_CHANGES, + REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, REVIEW_LOAD_DECISIONS, REVIEW_PREVIEW_REJECT, REVIEW_REJECT_FILE, @@ -1103,7 +1104,9 @@ const electronAPI: ElectronAPI = { status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; + stateBucket?: 'approved' | 'review' | 'completed' | 'active'; summaryOnly?: boolean; + forceFresh?: boolean; } ) => { return invokeIpcWithResult( @@ -1113,6 +1116,9 @@ const electronAPI: ElectronAPI = { options ); }, + invalidateTaskChangeSummaries: async (teamName: string, taskIds: string[]) => { + return invokeIpcWithResult(REVIEW_INVALIDATE_TASK_CHANGE_SUMMARIES, teamName, taskIds); + }, getChangeStats: async (teamName: string, memberName: string) => { return invokeIpcWithResult(REVIEW_GET_CHANGE_STATS, teamName, memberName); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b54cfac9..d158d84f 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -942,11 +942,16 @@ export class HttpAPIClient implements ElectronAPI { status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; + stateBucket?: 'approved' | 'review' | 'completed' | 'active'; summaryOnly?: boolean; + forceFresh?: boolean; } ): Promise => { throw new Error('Review is not available in browser mode'); }, + invalidateTaskChangeSummaries: async (): Promise => { + throw new Error('Review is not available in browser mode'); + }, getChangeStats: async (_teamName: string, _memberName: string): Promise => { throw new Error('Review is not available in browser mode'); }, diff --git a/src/renderer/components/team/UnreadCommentsBadge.tsx b/src/renderer/components/team/UnreadCommentsBadge.tsx index 9df735a2..68fdeb29 100644 --- a/src/renderer/components/team/UnreadCommentsBadge.tsx +++ b/src/renderer/components/team/UnreadCommentsBadge.tsx @@ -1,3 +1,4 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { MessageSquare } from 'lucide-react'; interface UnreadCommentsBadgeProps { @@ -12,16 +13,25 @@ export const UnreadCommentsBadge = ({ if (totalCount === 0) return null; return ( - 0 ? 'mr-1 pl-1.5 pr-2' : 'px-1.5'}`} - > - - {totalCount} - {unreadCount > 0 && ( - - {unreadCount} + + + + + + {totalCount} + + {unreadCount > 0 ? ( + + {unreadCount} + + ) : null} - )} - + + + {unreadCount > 0 + ? `${unreadCount} unread comments, ${totalCount} total` + : `${totalCount} comments`} + + ); }; diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index d9b015f4..1464765f 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -36,6 +36,7 @@ import { } from '@renderer/utils/memberHelpers'; import { buildTaskChangeRequestOptions, deriveTaskSince } from '@renderer/utils/taskChangeRequest'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; +import { isTaskChangeSummaryCacheable } from '@shared/utils/taskChangeState'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatDistanceToNow } from 'date-fns'; import { @@ -265,8 +266,8 @@ export const TaskDetailDialog = ({ return result; }, [currentTask?.comments]); - // Lazy-load task changes when dialog is open and task is completed - const isTaskCompleted = currentTask?.status === 'completed'; + // Lazy-load task changes only for terminal, cacheable states. + const canShowTaskChanges = currentTask ? isTaskChangeSummaryCacheable(currentTask) : false; const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]); const taskChangeRequestOptions = useMemo( () => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null), @@ -284,27 +285,30 @@ export const TaskDetailDialog = ({ ); const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification); - const loadTaskChangeSummary = useCallback(async (): Promise => { - if ( - !currentTask || - !taskChangeSummaryOptions || - variant !== 'team' || - !isTaskCompleted || - !onViewChanges - ) { - return null; - } - const data = await api.review.getTaskChanges( - teamName, - currentTask.id, - taskChangeSummaryOptions - ); - return data.files; - }, [currentTask, isTaskCompleted, onViewChanges, taskChangeSummaryOptions, teamName, variant]); + const loadTaskChangeSummary = useCallback( + async (forceFresh = false): Promise => { + if ( + !currentTask || + !taskChangeSummaryOptions || + variant !== 'team' || + !canShowTaskChanges || + !onViewChanges + ) { + return null; + } + const data = await api.review.getTaskChanges(teamName, currentTask.id, { + ...taskChangeSummaryOptions, + forceFresh, + }); + return data.files; + }, + [canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant] + ); useEffect(() => { if (variant !== 'team') return; - if (!open || !currentTask || !isTaskCompleted || !onViewChanges || !changesSectionOpen) return; + if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) + return; let cancelled = false; setTaskChangesLoading(true); @@ -335,6 +339,22 @@ export const TaskDetailDialog = ({ if (!cancelled) setTaskChangesLoading(false); }); + void loadTaskChangeSummary(true) + .then((files) => { + if (!cancelled && files) { + setTaskChangesFiles(files); + if (currentTask && taskChangeRequestOptions) { + recordTaskHasChanges( + teamName, + currentTask.id, + taskChangeRequestOptions, + files.length > 0 + ); + } + } + }) + .catch(() => undefined); + return () => { cancelled = true; }; @@ -342,7 +362,7 @@ export const TaskDetailDialog = ({ changesSectionOpen, open, currentTask, - isTaskCompleted, + canShowTaskChanges, teamName, onViewChanges, taskSince, @@ -351,10 +371,10 @@ export const TaskDetailDialog = ({ ]); const handleRefreshChanges = useCallback(() => { - if (!currentTask || variant !== 'team' || !isTaskCompleted || !onViewChanges) return; + if (!currentTask || variant !== 'team' || !canShowTaskChanges || !onViewChanges) return; setTaskChangesLoading(true); setTaskChangesError(null); - void loadTaskChangeSummary() + void loadTaskChangeSummary(true) .then((files) => { setTaskChangesFiles(files ?? null); if (currentTask && taskChangeRequestOptions) { @@ -370,7 +390,7 @@ export const TaskDetailDialog = ({ .finally(() => setTaskChangesLoading(false)); }, [ currentTask, - isTaskCompleted, + canShowTaskChanges, onViewChanges, loadTaskChangeSummary, recordTaskHasChanges, @@ -810,7 +830,7 @@ export const TaskDetailDialog = ({ {/* Changes */} - {variant === 'team' && isTaskCompleted && onViewChanges ? ( + {variant === 'team' && canShowTaskChanges && onViewChanges ? ( - - - + + + + + + + Cancel + ) => void; + className: string; + variant?: 'outline' | 'ghost' | 'destructive'; +} + +const TaskActionIconButton = ({ + label, + icon, + onClick, + className, + variant = 'outline', +}: TaskActionIconButtonProps): React.JSX.Element => ( + + + + + {label} + +); + export const KanbanTaskCard = ({ task, teamName, @@ -205,6 +244,10 @@ export const KanbanTaskCard = ({ const showChangesColumn = (columnId === 'done' || columnId === 'review' || columnId === 'approved') && !!onViewChanges; const taskChangeRequestOptions = useMemo(() => buildTaskChangeRequestOptions(task), [task]); + const useTerminalSummaryCache = useMemo( + () => isTaskSummaryCacheableForOptions(taskChangeRequestOptions), + [taskChangeRequestOptions] + ); const cacheKey = useMemo( () => buildTaskChangePresenceKey(teamName, task.id, taskChangeRequestOptions), [teamName, task.id, taskChangeRequestOptions] @@ -213,12 +256,12 @@ export const KanbanTaskCard = ({ const checkTaskHasChanges = useStore((s) => s.checkTaskHasChanges); useEffect(() => { - if (showChangesColumn && task.status === 'completed' && taskHasChanges !== true) { + if (showChangesColumn && useTerminalSummaryCache && taskHasChanges !== true) { void checkTaskHasChanges(teamName, task.id, taskChangeRequestOptions); } }, [ showChangesColumn, - task.status, + useTerminalSummaryCache, task.id, teamName, taskHasChanges, @@ -227,41 +270,32 @@ export const KanbanTaskCard = ({ ]); const isReviewManual = columnId === 'review' && !hasReviewers; - const multiButton = - compact || - columnId === 'todo' || - columnId === 'in_progress' || - columnId === 'done' || - columnId === 'review'; - const metaActions = ( <> {showChangesColumn && taskHasChanges === true ? ( - + /> ) : null} {onDeleteTask ? ( - + /> ) : null} ); @@ -348,143 +382,118 @@ export const KanbanTaskCard = ({ ) : null} -
-
+
+
{columnId === 'todo' ? ( <> - - + /> ) : null} {columnId === 'in_progress' ? ( <> - + /> ) : null} {columnId === 'done' ? ( <> - - + /> ) : null} {columnId === 'review' ? ( -
+
{isReviewManual ? ( -
-

Manual review

-
{metaActions}
+
+ Manual review
) : null} -
- - + />
+ {isReviewManual ? ( +
{metaActions}
+ ) : null}
) : null} {columnId === 'approved' ? ( - + /> ) : null}
{!isReviewManual ? ( -
- {metaActions} -
+
{metaActions}
) : null}
diff --git a/src/renderer/store/slices/changeReviewSlice.ts b/src/renderer/store/slices/changeReviewSlice.ts index 3ba525ce..48a8152b 100644 --- a/src/renderer/store/slices/changeReviewSlice.ts +++ b/src/renderer/store/slices/changeReviewSlice.ts @@ -1,6 +1,7 @@ import { api } from '@renderer/api'; import { buildTaskChangePresenceKey, + isTaskSummaryCacheableForOptions, type TaskChangeRequestOptions, } from '@renderer/utils/taskChangeRequest'; import { computeDiffContextHash } from '@shared/utils/diffContextHash'; @@ -9,9 +10,12 @@ import { structuredPatch } from 'diff'; /** Tracks in-flight checkTaskHasChanges calls to avoid duplicate requests */ const taskChangesCheckInFlight = new Set(); +/** Tracks background presence revalidation for optimistic terminal summary hits */ +const taskChangesPresenceRevalidationInFlight = new Set(); /** Negative results cached with timestamp — recheck after 30s */ const taskChangesNegativeCache = new Map(); const NEGATIVE_CACHE_TTL = 30_000; +const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now(); let latestTaskChangesRequestToken = 0; /** Debounce timer for persisting decisions to disk */ @@ -57,9 +61,18 @@ function mapReviewError(error: unknown): string { return message || 'Failed to apply review changes'; } +function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean { + const computedAtMs = Date.parse(data.computedAt); + if (!Number.isFinite(computedAtMs)) { + return true; + } + return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME; +} + export interface ChangeReviewSlice { // Phase 1 state activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null; + activeTaskChangeRequestOptions: TaskChangeRequestOptions | null; changeSetLoading: boolean; changeSetError: string | null; selectedReviewFilePath: string | null; @@ -165,6 +178,10 @@ export interface ChangeReviewSlice { taskId: string, options: TaskChangeRequestOptions ) => Promise; + warmTaskChangeSummaries: ( + requests: { teamName: string; taskId: string; options: TaskChangeRequestOptions }[] + ) => Promise; + invalidateTaskChangePresence: (cacheKeys: string[]) => void; } /** @@ -256,823 +273,955 @@ function buildHunkContextHashesForFile( export const createChangeReviewSlice: StateCreator = ( set, get -) => ({ - // Phase 1 initial state - activeChangeSet: null, - changeSetLoading: false, - changeSetError: null, - selectedReviewFilePath: null, - changeStatsCache: {}, - - // Phase 2 initial state - hunkDecisions: {}, - fileDecisions: {}, - fileChunkCounts: {}, - reviewUndoStack: [], - hunkContextHashesByFile: {}, - fileContents: {}, - fileContentsLoading: {}, - collapseUnchanged: true, - applyError: null, - applying: false, - - // Editable diff initial state - editedContents: {}, - - taskHasChanges: {}, - - fetchAgentChanges: async (teamName: string, memberName: string) => { - set({ changeSetLoading: true, changeSetError: null }); - try { - const data = await api.review.getAgentChanges(teamName, memberName); - set({ - activeChangeSet: data, - changeSetLoading: false, - selectedReviewFilePath: data.files[0]?.filePath ?? null, - }); - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to fetch agent changes'; - logger.error('fetchAgentChanges error:', message); - set({ changeSetError: message, changeSetLoading: false }); - } - }, - - recordTaskHasChanges: ( - teamName: string, - taskId: string, - options: TaskChangeRequestOptions, - hasChanges: boolean - ) => { - const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: hasChanges }, - })); - if (hasChanges) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } - }, - - fetchTaskChanges: async (teamName: string, taskId: string, options: TaskChangeRequestOptions) => { - const requestToken = ++latestTaskChangesRequestToken; - set({ changeSetLoading: true, changeSetError: null }); - try { - const data = await api.review.getTaskChanges(teamName, taskId, options); - if (requestToken !== latestTaskChangesRequestToken) return; - const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); - set((s) => ({ - activeChangeSet: data, - changeSetLoading: false, - selectedReviewFilePath: data.files[0]?.filePath ?? null, - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, - })); - if (data.files.length > 0) { - taskChangesNegativeCache.delete(cacheKey); - } else { - taskChangesNegativeCache.set(cacheKey, Date.now()); - } - } catch (error) { - if (requestToken !== latestTaskChangesRequestToken) return; - const message = error instanceof Error ? error.message : 'Failed to fetch task changes'; - logger.error('fetchTaskChanges error:', message); - set({ changeSetError: message, changeSetLoading: false }); - } - }, - - selectReviewFile: (filePath: string | null) => { - set({ selectedReviewFilePath: filePath }); - }, - - clearChangeReview: () => { - latestTaskChangesRequestToken++; - set({ - activeChangeSet: null, - changeSetLoading: false, - changeSetError: null, - selectedReviewFilePath: null, - hunkDecisions: {}, - fileDecisions: {}, - fileChunkCounts: {}, - reviewUndoStack: [], - hunkContextHashesByFile: {}, - fileContents: {}, - fileContentsLoading: {}, - applyError: null, - applying: false, - editedContents: {}, - }); - }, - - clearChangeReviewCache: () => { - latestTaskChangesRequestToken++; - set({ - activeChangeSet: null, - changeSetLoading: false, - changeSetError: null, - selectedReviewFilePath: null, - fileChunkCounts: {}, - reviewUndoStack: [], - hunkContextHashesByFile: {}, - fileContents: {}, - fileContentsLoading: {}, - applyError: null, - applying: false, - editedContents: {}, - }); - }, - - resetAllReviewState: () => { - latestTaskChangesRequestToken++; - set({ - activeChangeSet: null, - changeSetLoading: false, - changeSetError: null, - selectedReviewFilePath: null, - hunkDecisions: {}, - fileDecisions: {}, - fileChunkCounts: {}, - reviewUndoStack: [], - hunkContextHashesByFile: {}, - fileContents: {}, - fileContentsLoading: {}, - applyError: null, - applying: false, - editedContents: {}, - }); - }, - - // ── Decision persistence ── - - loadDecisionsFromDisk: async (teamName: string, scopeKey: string) => { - try { - const data = await api.review.loadDecisions(teamName, scopeKey); - // Always set decisions — even to empty if no saved file exists. - // This prevents stale decisions from a previous scope leaking through. - set({ - hunkDecisions: data?.hunkDecisions ?? {}, - fileDecisions: data?.fileDecisions ?? {}, - hunkContextHashesByFile: data?.hunkContextHashesByFile ?? {}, - }); - } catch (error) { - logger.error('loadDecisionsFromDisk error:', error); - } - }, - - persistDecisions: (teamName: string, scopeKey: string) => { - if (persistDebounceTimer) { - clearTimeout(persistDebounceTimer); - } - persistDebounceTimer = setTimeout(() => { - const { - hunkDecisions, - fileDecisions, - hunkContextHashesByFile, - activeChangeSet, - fileContents, - fileChunkCounts, - } = get(); - - const computed: Record> = {}; - for (const file of activeChangeSet?.files ?? []) { - const fp = file.filePath; - const content = fileContents[fp]; - if (!content) continue; - const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts); - const hashes = buildHunkContextHashesForFile( - content.originalFullContent, - content.modifiedFullContent, - expected - ); - if (hashes) computed[fp] = hashes; - } - - // Prune to only files in the current scope. This avoids persisting stale file paths - // (e.g. from older sessions) that could confuse future replays. - const mergedHashes: Record> = {}; - for (const file of activeChangeSet?.files ?? []) { - const fp = file.filePath; - mergedHashes[fp] = computed[fp] ?? hunkContextHashesByFile[fp] ?? {}; - } - // Keep store in sync so replay can use hashes without reload. - set({ hunkContextHashesByFile: mergedHashes }); - - void api.review.saveDecisions(teamName, scopeKey, hunkDecisions, fileDecisions, mergedHashes); - }, PERSIST_DEBOUNCE_MS); - }, - - clearDecisionsFromDisk: async (teamName: string, scopeKey: string) => { - try { - await api.review.clearDecisions(teamName, scopeKey); - } catch (error) { - logger.error('clearDecisionsFromDisk error:', error); - } - }, - - fetchChangeStats: async (teamName: string, memberName: string) => { - try { - const stats = await api.review.getChangeStats(teamName, memberName); - const key = `${teamName}:${memberName}`; - set((state) => ({ - changeStatsCache: { ...state.changeStatsCache, [key]: stats }, - })); - } catch (error) { - logger.error('fetchChangeStats error:', error); - } - }, - - // ── Phase 2 actions ── - - setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => { - const state = get(); - const totalChunks = state.fileChunkCounts[filePath] ?? 0; - // Map current chunk index to original: after accept/reject, chunks shift in CM. - // We need the original index to keep decisions stable across shifts. - const originalIndex = - totalChunks > 0 - ? mapCurrentToOriginalIndex(filePath, hunkIndex, state.hunkDecisions, totalChunks) - : hunkIndex; - const key = `${filePath}:${originalIndex}`; - set((s) => ({ - hunkDecisions: { ...s.hunkDecisions, [key]: decision }, - })); - return originalIndex; - }, - - clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => { - const key = `${filePath}:${originalIndex}`; - set((s) => { - if (!(key in s.hunkDecisions)) return s; - const next = { ...s.hunkDecisions }; - delete next[key]; - return { hunkDecisions: next }; - }); - }, - - setFileDecision: (filePath: string, decision: HunkDecision) => { - set((state) => ({ - fileDecisions: { ...state.fileDecisions, [filePath]: decision }, - })); - }, - - setFileChunkCount: (filePath: string, count: number) => { - set((s) => ({ - fileChunkCounts: { ...s.fileChunkCounts, [filePath]: count }, - })); - }, - - pushReviewUndoSnapshot: () => { - const state = get(); - const snapshot: DecisionSnapshot = { - hunkDecisions: { ...state.hunkDecisions }, - fileDecisions: { ...state.fileDecisions }, - }; - const stack = [...state.reviewUndoStack, snapshot]; - if (stack.length > MAX_REVIEW_UNDO_DEPTH) { - stack.shift(); - } - set({ reviewUndoStack: stack }); - }, - - undoBulkReview: () => { - const state = get(); - if (state.reviewUndoStack.length === 0) return false; - const stack = [...state.reviewUndoStack]; - const snapshot = stack.pop()!; - set({ - hunkDecisions: snapshot.hunkDecisions, - fileDecisions: snapshot.fileDecisions, - reviewUndoStack: stack, - }); - return true; - }, - - acceptAllFile: (filePath: string) => { - const state = get(); - const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); - if (!file) return; - - const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); - const newHunkDecisions = { ...state.hunkDecisions }; - for (let i = 0; i < count; i++) { - newHunkDecisions[`${filePath}:${i}`] = 'accepted'; - } - set({ - hunkDecisions: newHunkDecisions, - fileDecisions: { ...state.fileDecisions, [filePath]: 'accepted' }, - }); - }, - - rejectAllFile: (filePath: string) => { - const state = get(); - const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); - if (!file) return; - - const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); - const newHunkDecisions = { ...state.hunkDecisions }; - for (let i = 0; i < count; i++) { - newHunkDecisions[`${filePath}:${i}`] = 'rejected'; - } - set({ - hunkDecisions: newHunkDecisions, - fileDecisions: { ...state.fileDecisions, [filePath]: 'rejected' }, - }); - }, - - acceptAll: () => { - const state = get(); - if (!state.activeChangeSet) return; - - const newHunkDecisions: Record = {}; - const newFileDecisions: Record = {}; - - for (const file of state.activeChangeSet.files) { - newFileDecisions[file.filePath] = 'accepted'; - const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); - for (let i = 0; i < count; i++) { - newHunkDecisions[`${file.filePath}:${i}`] = 'accepted'; - } - } - set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); - }, - - rejectAll: () => { - const state = get(); - if (!state.activeChangeSet) return; - - const newHunkDecisions: Record = {}; - const newFileDecisions: Record = {}; - - for (const file of state.activeChangeSet.files) { - newFileDecisions[file.filePath] = 'rejected'; - const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); - for (let i = 0; i < count; i++) { - newHunkDecisions[`${file.filePath}:${i}`] = 'rejected'; - } - } - set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); - }, - - setCollapseUnchanged: (collapse: boolean) => { - set({ collapseUnchanged: collapse }); - }, - - fetchFileContent: async (teamName: string, memberName: string | undefined, filePath: string) => { - const state = get(); - // Skip if already loaded or loading - if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return; - - set((s) => ({ - fileContentsLoading: { ...s.fileContentsLoading, [filePath]: true }, - })); - - try { - // Lookup snippets from activeChangeSet so backend can use them for reconstruction - const activeChangeSet = get().activeChangeSet; - const fileEntry = activeChangeSet?.files.find((f) => f.filePath === filePath); - const snippets = fileEntry?.snippets ?? []; - - const content = await api.review.getFileContent(teamName, memberName, filePath, snippets); - set((s) => { - const result: Partial = { - fileContents: { ...s.fileContents, [filePath]: content }, - fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false }, - }; - - // Update activeChangeSet stats if original was successfully resolved - if ( - content.contentSource !== 'unavailable' && - content.contentSource !== 'disk-current' && - s.activeChangeSet - ) { - const updatedFiles = s.activeChangeSet.files.map((f) => - f.filePath === filePath - ? { ...f, linesAdded: content.linesAdded, linesRemoved: content.linesRemoved } - : f - ); - const totalLinesAdded = updatedFiles.reduce((sum, f) => sum + f.linesAdded, 0); - const totalLinesRemoved = updatedFiles.reduce((sum, f) => sum + f.linesRemoved, 0); - result.activeChangeSet = { - ...s.activeChangeSet, - files: updatedFiles, - totalLinesAdded, - totalLinesRemoved, - }; - } - - return result; - }); - } catch (error) { - logger.error('fetchFileContent error:', error); - set((s) => ({ - fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false }, - })); - } - }, - - applyReview: async (teamName: string, taskId?: string, memberName?: string) => { - set({ applying: true, applyError: null }); - - try { - // Stale check: re-fetch changes and compare content fingerprint - const state = get(); - const current = state.activeChangeSet; - // Fingerprint uses file count + file paths only (not line counts) - // because line counts may be corrected by lazy-loaded content resolution - const fingerprint = (cs: { totalFiles: number; files: { filePath: string }[] }): string => - `${cs.totalFiles}:${cs.files.map((f) => f.filePath).join(',')}`; - - if (memberName && current) { - const fresh = await api.review.getAgentChanges(teamName, memberName); - if (fingerprint(fresh) !== fingerprint(current)) { - set({ - activeChangeSet: fresh, - applying: false, - applyError: 'Changes have been updated since you started reviewing. Please re-review.', - }); - return; - } - } else if (taskId && current) { - const fresh = await api.review.getTaskChanges(teamName, taskId); - if (fingerprint(fresh) !== fingerprint(current)) { - set({ - activeChangeSet: fresh, - applying: false, - applyError: 'Changes have been updated since you started reviewing. Please re-review.', - }); - return; - } - } - - // Build FileReviewDecision[] from hunkDecisions/fileDecisions - const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet, fileContents } = - get(); - if (!activeChangeSet) { - set({ applying: false }); - return; - } - - const decisions: FileReviewDecision[] = []; - - for (const file of activeChangeSet.files) { - const fileDecision = fileDecisions[file.filePath] ?? 'pending'; - const hunkDecs: Record = {}; - - const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); - const maxIdx = getMaxDecisionIndexForFile(file.filePath, hunkDecisions); - const count = Math.max(baseCount, maxIdx + 1); - for (let i = 0; i < count; i++) { - const key = `${file.filePath}:${i}`; - hunkDecs[i] = hunkDecisions[key] ?? 'pending'; - } - - // Only include files that have at least one rejected hunk - const hasRejected = - fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected'); - if (hasRejected) { - const content = fileContents[file.filePath]; - const hunkContextHashes = - maxIdx < baseCount - ? buildHunkContextHashesForFile( - content?.originalFullContent, - content?.modifiedFullContent, - baseCount - ) - : undefined; - decisions.push({ - filePath: file.filePath, - fileDecision, - hunkDecisions: hunkDecs, - hunkContextHashes, - // Provide optional context so main can apply without re-resolving. - // If full contents are missing (lazy not loaded yet), still pass snippets. - snippets: content?.snippets ?? file.snippets, - originalFullContent: content?.originalFullContent, - modifiedFullContent: content?.modifiedFullContent, - isNewFile: content?.isNewFile ?? file.isNewFile, - }); - } - } - - if (decisions.length === 0) { - set({ applying: false }); - return; - } - - const request: ApplyReviewRequest = { - teamName, - taskId, - memberName, - decisions, - }; - - await api.review.applyDecisions(request); - - set({ applying: false }); - } catch (error) { - logger.error('applyReview error:', error); - set({ - applying: false, - applyError: mapReviewError(error), - }); - } - }, - - applySingleFileDecision: async ( - teamName: string, - filePath: string, - taskId?: string, - memberName?: string - ) => { - const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet, fileContents } = get(); - if (!activeChangeSet) return null; - - const file = activeChangeSet.files.find((f) => f.filePath === filePath); - if (!file) return null; - - const fileDecision = fileDecisions[filePath] ?? 'pending'; - const hunkDecs: Record = {}; - const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts); - const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions); - const count = Math.max(baseCount, maxIdx + 1); - for (let i = 0; i < count; i++) { - hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending'; - } - - const hasRejected = - fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected'); - if (!hasRejected) return null; - - try { - const content = fileContents[filePath]; - const innerBaseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts); - const innerMaxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions); - const hunkContextHashes = - innerMaxIdx < innerBaseCount - ? buildHunkContextHashesForFile( - content?.originalFullContent, - content?.modifiedFullContent, - innerBaseCount - ) - : undefined; - const result = await api.review.applyDecisions({ - teamName, - taskId, - memberName, - decisions: [ - { - filePath, - fileDecision, - hunkDecisions: hunkDecs, - hunkContextHashes, - snippets: content?.snippets ?? file.snippets, - originalFullContent: content?.originalFullContent, - modifiedFullContent: content?.modifiedFullContent, - isNewFile: content?.isNewFile ?? file.isNewFile, - }, - ], - }); - return result; - } catch (error) { - logger.error('applySingleFileDecision error:', error); - set({ applyError: mapReviewError(error) }); - return null; - } - }, - - removeReviewFile: (filePath: string) => { - set((s) => { - if (!s.activeChangeSet) return s; - const existing = s.activeChangeSet.files.find((f) => f.filePath === filePath); - if (!existing) return s; - - const nextFiles = s.activeChangeSet.files.filter((f) => f.filePath !== filePath); - const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0); - const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0); - - const nextHunkDecisions = { ...s.hunkDecisions }; - const prefix = `${filePath}:`; - for (const key of Object.keys(nextHunkDecisions)) { - if (key.startsWith(prefix)) delete nextHunkDecisions[key]; - } - - const nextFileDecisions = { ...s.fileDecisions }; - delete nextFileDecisions[filePath]; - - const nextFileChunkCounts = { ...s.fileChunkCounts }; - delete nextFileChunkCounts[filePath]; - - const nextFileContents = { ...s.fileContents }; - delete nextFileContents[filePath]; - - const nextFileContentsLoading = { ...s.fileContentsLoading }; - delete nextFileContentsLoading[filePath]; - - const nextEditedContents = { ...s.editedContents }; - delete nextEditedContents[filePath]; - - const nextHashes = { ...s.hunkContextHashesByFile }; - delete nextHashes[filePath]; - - const nextSelected = - s.selectedReviewFilePath === filePath - ? (nextFiles[0]?.filePath ?? null) - : s.selectedReviewFilePath; - - return { - activeChangeSet: { - ...s.activeChangeSet, - files: nextFiles, - totalFiles: nextFiles.length, - totalLinesAdded, - totalLinesRemoved, - }, - selectedReviewFilePath: nextSelected, - hunkDecisions: nextHunkDecisions, - fileDecisions: nextFileDecisions, - fileChunkCounts: nextFileChunkCounts, - fileContents: nextFileContents, - fileContentsLoading: nextFileContentsLoading, - editedContents: nextEditedContents, - hunkContextHashesByFile: nextHashes, - }; - }); - }, - - addReviewFile: ( - file: FileChangeSummary, - options?: { index?: number; content?: FileChangeWithContent } - ) => { - set((s) => { - if (!s.activeChangeSet) return s; - if (s.activeChangeSet.files.some((f) => f.filePath === file.filePath)) return s; - - const idxRaw = options?.index; - const idx = - typeof idxRaw === 'number' && Number.isFinite(idxRaw) - ? Math.max(0, Math.min(idxRaw, s.activeChangeSet.files.length)) - : s.activeChangeSet.files.length; - - const nextFiles = [...s.activeChangeSet.files]; - nextFiles.splice(idx, 0, file); - const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0); - const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0); - - const nextFileContents = options?.content - ? { ...s.fileContents, [file.filePath]: options.content } - : s.fileContents; - - const nextFileContentsLoading = options?.content - ? { ...s.fileContentsLoading, [file.filePath]: false } - : s.fileContentsLoading; - - return { - activeChangeSet: { - ...s.activeChangeSet, - files: nextFiles, - totalFiles: nextFiles.length, - totalLinesAdded, - totalLinesRemoved, - }, - selectedReviewFilePath: s.selectedReviewFilePath ?? file.filePath, - fileContents: nextFileContents, - fileContentsLoading: nextFileContentsLoading, - }; - }); - }, - - clearReviewStateForFile: (filePath: string) => { - set((s) => { - const nextHunkDecisions = { ...s.hunkDecisions }; - const prefix = `${filePath}:`; - for (const key of Object.keys(nextHunkDecisions)) { - if (key.startsWith(prefix) && nextHunkDecisions[key] === 'rejected') { - delete nextHunkDecisions[key]; - } - } - - const nextFileDecisions = { ...s.fileDecisions }; - if (nextFileDecisions[filePath] === 'rejected') { - delete nextFileDecisions[filePath]; - } - - const nextFileChunkCounts = { ...s.fileChunkCounts }; - delete nextFileChunkCounts[filePath]; - - const nextFileContents = { ...s.fileContents }; - delete nextFileContents[filePath]; - - const nextFileContentsLoading = { ...s.fileContentsLoading }; - delete nextFileContentsLoading[filePath]; - - const nextEditedContents = { ...s.editedContents }; - delete nextEditedContents[filePath]; - - return { - hunkDecisions: nextHunkDecisions, - fileDecisions: nextFileDecisions, - fileChunkCounts: nextFileChunkCounts, - fileContents: nextFileContents, - fileContentsLoading: nextFileContentsLoading, - editedContents: nextEditedContents, - }; - }); - }, - - // ── Editable diff actions ── - - updateEditedContent: (filePath: string, content: string) => { - set((s) => ({ - editedContents: { ...s.editedContents, [filePath]: content }, - })); - }, - - discardFileEdits: (filePath: string) => { - set((s) => { - const next = { ...s.editedContents }; - delete next[filePath]; - return { editedContents: next }; - }); - }, - - discardAllEdits: () => set({ editedContents: {} }), - - saveEditedFile: async (filePath: string, projectPath?: string) => { - const content = get().editedContents[filePath]; - if (!(filePath in get().editedContents)) return; - set({ applying: true, applyError: null }); - try { - await api.review.saveEditedFile(filePath, content, projectPath); - set((s) => { - const nextEdited = { ...s.editedContents }; - delete nextEdited[filePath]; - // Update cached content in-place to avoid skeleton flash. - // Replace modifiedFullContent with saved version so CodeMirror - // reflects the new baseline without a full re-fetch cycle. - const nextContents = { ...s.fileContents }; - const existing = nextContents[filePath]; - if (existing) { - nextContents[filePath] = { - ...existing, - modifiedFullContent: content, - contentSource: 'disk-current', - }; - } - return { editedContents: nextEdited, fileContents: nextContents, applying: false }; - }); - } catch (error) { - set({ applying: false, applyError: mapReviewError(error) }); - } - }, - - checkTaskHasChanges: async ( +) => { + const revalidateTaskChangePresence = async ( teamName: string, taskId: string, options: TaskChangeRequestOptions - ) => { + ): Promise => { const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); - // Positive results are final — no need to recheck - if (get().taskHasChanges[cacheKey] === true) return; - // Prevent duplicate in-flight requests - if (taskChangesCheckInFlight.has(cacheKey)) return; - // Negative results cached with TTL — avoid API spam for tasks that truly have no changes - const negativeTs = taskChangesNegativeCache.get(cacheKey); - if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return; + if ( + !isTaskSummaryCacheableForOptions(options) || + taskChangesPresenceRevalidationInFlight.has(cacheKey) + ) { + return; + } - taskChangesCheckInFlight.add(cacheKey); + taskChangesPresenceRevalidationInFlight.add(cacheKey); try { const data = await api.review.getTaskChanges(teamName, taskId, { ...options, summaryOnly: true, + forceFresh: true, }); + set((state) => ({ + taskHasChanges: { ...state.taskHasChanges, [cacheKey]: data.files.length > 0 }, + })); if (data.files.length > 0) { - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, - })); taskChangesNegativeCache.delete(cacheKey); } else { - set((s) => ({ - taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false }, - })); taskChangesNegativeCache.set(cacheKey, Date.now()); } } catch { - // Allow immediate retry after transient failures (race, file lock, late logs). + // Best-effort background revalidation; keep optimistic state on transient failure. } finally { - taskChangesCheckInFlight.delete(cacheKey); + taskChangesPresenceRevalidationInFlight.delete(cacheKey); } - }, + }; - invalidateChangeStats: (teamName: string) => { - set((state) => { - const newCache = { ...state.changeStatsCache }; - // Remove all entries for this team - for (const key of Object.keys(newCache)) { - if (key.startsWith(`${teamName}:`)) { - delete newCache[key]; + return { + // Phase 1 initial state + activeChangeSet: null, + activeTaskChangeRequestOptions: null, + changeSetLoading: false, + changeSetError: null, + selectedReviewFilePath: null, + changeStatsCache: {}, + + // Phase 2 initial state + hunkDecisions: {}, + fileDecisions: {}, + fileChunkCounts: {}, + reviewUndoStack: [], + hunkContextHashesByFile: {}, + fileContents: {}, + fileContentsLoading: {}, + collapseUnchanged: true, + applyError: null, + applying: false, + + // Editable diff initial state + editedContents: {}, + + taskHasChanges: {}, + + fetchAgentChanges: async (teamName: string, memberName: string) => { + set({ changeSetLoading: true, changeSetError: null }); + try { + const data = await api.review.getAgentChanges(teamName, memberName); + set({ + activeChangeSet: data, + changeSetLoading: false, + selectedReviewFilePath: data.files[0]?.filePath ?? null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to fetch agent changes'; + logger.error('fetchAgentChanges error:', message); + set({ changeSetError: message, changeSetLoading: false }); + } + }, + + recordTaskHasChanges: ( + teamName: string, + taskId: string, + options: TaskChangeRequestOptions, + hasChanges: boolean + ) => { + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: hasChanges }, + })); + if (hasChanges) { + taskChangesNegativeCache.delete(cacheKey); + } else { + taskChangesNegativeCache.set(cacheKey, Date.now()); + } + }, + + fetchTaskChanges: async ( + teamName: string, + taskId: string, + options: TaskChangeRequestOptions + ) => { + const requestToken = ++latestTaskChangesRequestToken; + set({ changeSetLoading: true, changeSetError: null }); + try { + const data = await api.review.getTaskChanges(teamName, taskId, options); + if (requestToken !== latestTaskChangesRequestToken) return; + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); + set((s) => ({ + activeChangeSet: data, + activeTaskChangeRequestOptions: options, + changeSetLoading: false, + selectedReviewFilePath: data.files[0]?.filePath ?? null, + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, + })); + if (data.files.length > 0) { + taskChangesNegativeCache.delete(cacheKey); + } else { + taskChangesNegativeCache.set(cacheKey, Date.now()); + } + } catch (error) { + if (requestToken !== latestTaskChangesRequestToken) return; + const message = error instanceof Error ? error.message : 'Failed to fetch task changes'; + logger.error('fetchTaskChanges error:', message); + set({ changeSetError: message, changeSetLoading: false }); + } + }, + + selectReviewFile: (filePath: string | null) => { + set({ selectedReviewFilePath: filePath }); + }, + + clearChangeReview: () => { + latestTaskChangesRequestToken++; + set({ + activeChangeSet: null, + changeSetLoading: false, + changeSetError: null, + selectedReviewFilePath: null, + activeTaskChangeRequestOptions: null, + hunkDecisions: {}, + fileDecisions: {}, + fileChunkCounts: {}, + reviewUndoStack: [], + hunkContextHashesByFile: {}, + fileContents: {}, + fileContentsLoading: {}, + applyError: null, + applying: false, + editedContents: {}, + }); + }, + + clearChangeReviewCache: () => { + latestTaskChangesRequestToken++; + set({ + activeChangeSet: null, + changeSetLoading: false, + changeSetError: null, + selectedReviewFilePath: null, + activeTaskChangeRequestOptions: null, + fileChunkCounts: {}, + reviewUndoStack: [], + hunkContextHashesByFile: {}, + fileContents: {}, + fileContentsLoading: {}, + applyError: null, + applying: false, + editedContents: {}, + }); + }, + + resetAllReviewState: () => { + latestTaskChangesRequestToken++; + set({ + activeChangeSet: null, + changeSetLoading: false, + changeSetError: null, + selectedReviewFilePath: null, + activeTaskChangeRequestOptions: null, + hunkDecisions: {}, + fileDecisions: {}, + fileChunkCounts: {}, + reviewUndoStack: [], + hunkContextHashesByFile: {}, + fileContents: {}, + fileContentsLoading: {}, + applyError: null, + applying: false, + editedContents: {}, + }); + }, + + // ── Decision persistence ── + + loadDecisionsFromDisk: async (teamName: string, scopeKey: string) => { + try { + const data = await api.review.loadDecisions(teamName, scopeKey); + // Always set decisions — even to empty if no saved file exists. + // This prevents stale decisions from a previous scope leaking through. + set({ + hunkDecisions: data?.hunkDecisions ?? {}, + fileDecisions: data?.fileDecisions ?? {}, + hunkContextHashesByFile: data?.hunkContextHashesByFile ?? {}, + }); + } catch (error) { + logger.error('loadDecisionsFromDisk error:', error); + } + }, + + persistDecisions: (teamName: string, scopeKey: string) => { + if (persistDebounceTimer) { + clearTimeout(persistDebounceTimer); + } + persistDebounceTimer = setTimeout(() => { + const { + hunkDecisions, + fileDecisions, + hunkContextHashesByFile, + activeChangeSet, + fileContents, + fileChunkCounts, + } = get(); + + const computed: Record> = {}; + for (const file of activeChangeSet?.files ?? []) { + const fp = file.filePath; + const content = fileContents[fp]; + if (!content) continue; + const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts); + const hashes = buildHunkContextHashesForFile( + content.originalFullContent, + content.modifiedFullContent, + expected + ); + if (hashes) computed[fp] = hashes; + } + + // Prune to only files in the current scope. This avoids persisting stale file paths + // (e.g. from older sessions) that could confuse future replays. + const mergedHashes: Record> = {}; + for (const file of activeChangeSet?.files ?? []) { + const fp = file.filePath; + mergedHashes[fp] = computed[fp] ?? hunkContextHashesByFile[fp] ?? {}; + } + // Keep store in sync so replay can use hashes without reload. + set({ hunkContextHashesByFile: mergedHashes }); + + void api.review.saveDecisions( + teamName, + scopeKey, + hunkDecisions, + fileDecisions, + mergedHashes + ); + }, PERSIST_DEBOUNCE_MS); + }, + + clearDecisionsFromDisk: async (teamName: string, scopeKey: string) => { + try { + await api.review.clearDecisions(teamName, scopeKey); + } catch (error) { + logger.error('clearDecisionsFromDisk error:', error); + } + }, + + fetchChangeStats: async (teamName: string, memberName: string) => { + try { + const stats = await api.review.getChangeStats(teamName, memberName); + const key = `${teamName}:${memberName}`; + set((state) => ({ + changeStatsCache: { ...state.changeStatsCache, [key]: stats }, + })); + } catch (error) { + logger.error('fetchChangeStats error:', error); + } + }, + + // ── Phase 2 actions ── + + setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => { + const state = get(); + const totalChunks = state.fileChunkCounts[filePath] ?? 0; + // Map current chunk index to original: after accept/reject, chunks shift in CM. + // We need the original index to keep decisions stable across shifts. + const originalIndex = + totalChunks > 0 + ? mapCurrentToOriginalIndex(filePath, hunkIndex, state.hunkDecisions, totalChunks) + : hunkIndex; + const key = `${filePath}:${originalIndex}`; + set((s) => ({ + hunkDecisions: { ...s.hunkDecisions, [key]: decision }, + })); + return originalIndex; + }, + + clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => { + const key = `${filePath}:${originalIndex}`; + set((s) => { + if (!(key in s.hunkDecisions)) return s; + const next = { ...s.hunkDecisions }; + delete next[key]; + return { hunkDecisions: next }; + }); + }, + + setFileDecision: (filePath: string, decision: HunkDecision) => { + set((state) => ({ + fileDecisions: { ...state.fileDecisions, [filePath]: decision }, + })); + }, + + setFileChunkCount: (filePath: string, count: number) => { + set((s) => ({ + fileChunkCounts: { ...s.fileChunkCounts, [filePath]: count }, + })); + }, + + pushReviewUndoSnapshot: () => { + const state = get(); + const snapshot: DecisionSnapshot = { + hunkDecisions: { ...state.hunkDecisions }, + fileDecisions: { ...state.fileDecisions }, + }; + const stack = [...state.reviewUndoStack, snapshot]; + if (stack.length > MAX_REVIEW_UNDO_DEPTH) { + stack.shift(); + } + set({ reviewUndoStack: stack }); + }, + + undoBulkReview: () => { + const state = get(); + if (state.reviewUndoStack.length === 0) return false; + const stack = [...state.reviewUndoStack]; + const snapshot = stack.pop()!; + set({ + hunkDecisions: snapshot.hunkDecisions, + fileDecisions: snapshot.fileDecisions, + reviewUndoStack: stack, + }); + return true; + }, + + acceptAllFile: (filePath: string) => { + const state = get(); + const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); + if (!file) return; + + const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); + const newHunkDecisions = { ...state.hunkDecisions }; + for (let i = 0; i < count; i++) { + newHunkDecisions[`${filePath}:${i}`] = 'accepted'; + } + set({ + hunkDecisions: newHunkDecisions, + fileDecisions: { ...state.fileDecisions, [filePath]: 'accepted' }, + }); + }, + + rejectAllFile: (filePath: string) => { + const state = get(); + const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath); + if (!file) return; + + const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts); + const newHunkDecisions = { ...state.hunkDecisions }; + for (let i = 0; i < count; i++) { + newHunkDecisions[`${filePath}:${i}`] = 'rejected'; + } + set({ + hunkDecisions: newHunkDecisions, + fileDecisions: { ...state.fileDecisions, [filePath]: 'rejected' }, + }); + }, + + acceptAll: () => { + const state = get(); + if (!state.activeChangeSet) return; + + const newHunkDecisions: Record = {}; + const newFileDecisions: Record = {}; + + for (const file of state.activeChangeSet.files) { + newFileDecisions[file.filePath] = 'accepted'; + const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); + for (let i = 0; i < count; i++) { + newHunkDecisions[`${file.filePath}:${i}`] = 'accepted'; } } - return { changeStatsCache: newCache }; - }); - }, -}); + set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); + }, + + rejectAll: () => { + const state = get(); + if (!state.activeChangeSet) return; + + const newHunkDecisions: Record = {}; + const newFileDecisions: Record = {}; + + for (const file of state.activeChangeSet.files) { + newFileDecisions[file.filePath] = 'rejected'; + const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts); + for (let i = 0; i < count; i++) { + newHunkDecisions[`${file.filePath}:${i}`] = 'rejected'; + } + } + set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions }); + }, + + setCollapseUnchanged: (collapse: boolean) => { + set({ collapseUnchanged: collapse }); + }, + + fetchFileContent: async ( + teamName: string, + memberName: string | undefined, + filePath: string + ) => { + const state = get(); + // Skip if already loaded or loading + if (state.fileContents[filePath] || state.fileContentsLoading[filePath]) return; + + set((s) => ({ + fileContentsLoading: { ...s.fileContentsLoading, [filePath]: true }, + })); + + try { + // Lookup snippets from activeChangeSet so backend can use them for reconstruction + const activeChangeSet = get().activeChangeSet; + const fileEntry = activeChangeSet?.files.find((f) => f.filePath === filePath); + const snippets = fileEntry?.snippets ?? []; + + const content = await api.review.getFileContent(teamName, memberName, filePath, snippets); + set((s) => { + const result: Partial = { + fileContents: { ...s.fileContents, [filePath]: content }, + fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false }, + }; + + // Update activeChangeSet stats if original was successfully resolved + if ( + content.contentSource !== 'unavailable' && + content.contentSource !== 'disk-current' && + s.activeChangeSet + ) { + const updatedFiles = s.activeChangeSet.files.map((f) => + f.filePath === filePath + ? { ...f, linesAdded: content.linesAdded, linesRemoved: content.linesRemoved } + : f + ); + const totalLinesAdded = updatedFiles.reduce((sum, f) => sum + f.linesAdded, 0); + const totalLinesRemoved = updatedFiles.reduce((sum, f) => sum + f.linesRemoved, 0); + result.activeChangeSet = { + ...s.activeChangeSet, + files: updatedFiles, + totalLinesAdded, + totalLinesRemoved, + }; + } + + return result; + }); + } catch (error) { + logger.error('fetchFileContent error:', error); + set((s) => ({ + fileContentsLoading: { ...s.fileContentsLoading, [filePath]: false }, + })); + } + }, + + applyReview: async (teamName: string, taskId?: string, memberName?: string) => { + set({ applying: true, applyError: null }); + + try { + // Stale check: re-fetch changes and compare content fingerprint + const state = get(); + const current = state.activeChangeSet; + // Fingerprint uses file count + file paths only (not line counts) + // because line counts may be corrected by lazy-loaded content resolution + const fingerprint = (cs: { totalFiles: number; files: { filePath: string }[] }): string => + `${cs.totalFiles}:${cs.files.map((f) => f.filePath).join(',')}`; + + if (memberName && current) { + const fresh = await api.review.getAgentChanges(teamName, memberName); + if (fingerprint(fresh) !== fingerprint(current)) { + set({ + activeChangeSet: fresh, + applying: false, + applyError: + 'Changes have been updated since you started reviewing. Please re-review.', + }); + return; + } + } else if (taskId && current) { + const fresh = await api.review.getTaskChanges(teamName, taskId, { + ...(state.activeTaskChangeRequestOptions ?? {}), + forceFresh: true, + }); + if (fingerprint(fresh) !== fingerprint(current)) { + set({ + activeChangeSet: fresh, + applying: false, + applyError: + 'Changes have been updated since you started reviewing. Please re-review.', + }); + return; + } + } + + // Build FileReviewDecision[] from hunkDecisions/fileDecisions + const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet, fileContents } = + get(); + if (!activeChangeSet) { + set({ applying: false }); + return; + } + + const decisions: FileReviewDecision[] = []; + + for (const file of activeChangeSet.files) { + const fileDecision = fileDecisions[file.filePath] ?? 'pending'; + const hunkDecs: Record = {}; + + const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts); + const maxIdx = getMaxDecisionIndexForFile(file.filePath, hunkDecisions); + const count = Math.max(baseCount, maxIdx + 1); + for (let i = 0; i < count; i++) { + const key = `${file.filePath}:${i}`; + hunkDecs[i] = hunkDecisions[key] ?? 'pending'; + } + + // Only include files that have at least one rejected hunk + const hasRejected = + fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected'); + if (hasRejected) { + const content = fileContents[file.filePath]; + const hunkContextHashes = + maxIdx < baseCount + ? buildHunkContextHashesForFile( + content?.originalFullContent, + content?.modifiedFullContent, + baseCount + ) + : undefined; + decisions.push({ + filePath: file.filePath, + fileDecision, + hunkDecisions: hunkDecs, + hunkContextHashes, + // Provide optional context so main can apply without re-resolving. + // If full contents are missing (lazy not loaded yet), still pass snippets. + snippets: content?.snippets ?? file.snippets, + originalFullContent: content?.originalFullContent, + modifiedFullContent: content?.modifiedFullContent, + isNewFile: content?.isNewFile ?? file.isNewFile, + }); + } + } + + if (decisions.length === 0) { + set({ applying: false }); + return; + } + + const request: ApplyReviewRequest = { + teamName, + taskId, + memberName, + decisions, + }; + + await api.review.applyDecisions(request); + + set({ applying: false }); + } catch (error) { + logger.error('applyReview error:', error); + set({ + applying: false, + applyError: mapReviewError(error), + }); + } + }, + + applySingleFileDecision: async ( + teamName: string, + filePath: string, + taskId?: string, + memberName?: string + ) => { + const { hunkDecisions, fileDecisions, fileChunkCounts, activeChangeSet, fileContents } = + get(); + if (!activeChangeSet) return null; + + const file = activeChangeSet.files.find((f) => f.filePath === filePath); + if (!file) return null; + + const fileDecision = fileDecisions[filePath] ?? 'pending'; + const hunkDecs: Record = {}; + const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts); + const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions); + const count = Math.max(baseCount, maxIdx + 1); + for (let i = 0; i < count; i++) { + hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending'; + } + + const hasRejected = + fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected'); + if (!hasRejected) return null; + + try { + const content = fileContents[filePath]; + const innerBaseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts); + const innerMaxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions); + const hunkContextHashes = + innerMaxIdx < innerBaseCount + ? buildHunkContextHashesForFile( + content?.originalFullContent, + content?.modifiedFullContent, + innerBaseCount + ) + : undefined; + const result = await api.review.applyDecisions({ + teamName, + taskId, + memberName, + decisions: [ + { + filePath, + fileDecision, + hunkDecisions: hunkDecs, + hunkContextHashes, + snippets: content?.snippets ?? file.snippets, + originalFullContent: content?.originalFullContent, + modifiedFullContent: content?.modifiedFullContent, + isNewFile: content?.isNewFile ?? file.isNewFile, + }, + ], + }); + return result; + } catch (error) { + logger.error('applySingleFileDecision error:', error); + set({ applyError: mapReviewError(error) }); + return null; + } + }, + + removeReviewFile: (filePath: string) => { + set((s) => { + if (!s.activeChangeSet) return s; + const existing = s.activeChangeSet.files.find((f) => f.filePath === filePath); + if (!existing) return s; + + const nextFiles = s.activeChangeSet.files.filter((f) => f.filePath !== filePath); + const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0); + const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0); + + const nextHunkDecisions = { ...s.hunkDecisions }; + const prefix = `${filePath}:`; + for (const key of Object.keys(nextHunkDecisions)) { + if (key.startsWith(prefix)) delete nextHunkDecisions[key]; + } + + const nextFileDecisions = { ...s.fileDecisions }; + delete nextFileDecisions[filePath]; + + const nextFileChunkCounts = { ...s.fileChunkCounts }; + delete nextFileChunkCounts[filePath]; + + const nextFileContents = { ...s.fileContents }; + delete nextFileContents[filePath]; + + const nextFileContentsLoading = { ...s.fileContentsLoading }; + delete nextFileContentsLoading[filePath]; + + const nextEditedContents = { ...s.editedContents }; + delete nextEditedContents[filePath]; + + const nextHashes = { ...s.hunkContextHashesByFile }; + delete nextHashes[filePath]; + + const nextSelected = + s.selectedReviewFilePath === filePath + ? (nextFiles[0]?.filePath ?? null) + : s.selectedReviewFilePath; + + return { + activeChangeSet: { + ...s.activeChangeSet, + files: nextFiles, + totalFiles: nextFiles.length, + totalLinesAdded, + totalLinesRemoved, + }, + selectedReviewFilePath: nextSelected, + hunkDecisions: nextHunkDecisions, + fileDecisions: nextFileDecisions, + fileChunkCounts: nextFileChunkCounts, + fileContents: nextFileContents, + fileContentsLoading: nextFileContentsLoading, + editedContents: nextEditedContents, + hunkContextHashesByFile: nextHashes, + }; + }); + }, + + addReviewFile: ( + file: FileChangeSummary, + options?: { index?: number; content?: FileChangeWithContent } + ) => { + set((s) => { + if (!s.activeChangeSet) return s; + if (s.activeChangeSet.files.some((f) => f.filePath === file.filePath)) return s; + + const idxRaw = options?.index; + const idx = + typeof idxRaw === 'number' && Number.isFinite(idxRaw) + ? Math.max(0, Math.min(idxRaw, s.activeChangeSet.files.length)) + : s.activeChangeSet.files.length; + + const nextFiles = [...s.activeChangeSet.files]; + nextFiles.splice(idx, 0, file); + const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0); + const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0); + + const nextFileContents = options?.content + ? { ...s.fileContents, [file.filePath]: options.content } + : s.fileContents; + + const nextFileContentsLoading = options?.content + ? { ...s.fileContentsLoading, [file.filePath]: false } + : s.fileContentsLoading; + + return { + activeChangeSet: { + ...s.activeChangeSet, + files: nextFiles, + totalFiles: nextFiles.length, + totalLinesAdded, + totalLinesRemoved, + }, + selectedReviewFilePath: s.selectedReviewFilePath ?? file.filePath, + fileContents: nextFileContents, + fileContentsLoading: nextFileContentsLoading, + }; + }); + }, + + clearReviewStateForFile: (filePath: string) => { + set((s) => { + const nextHunkDecisions = { ...s.hunkDecisions }; + const prefix = `${filePath}:`; + for (const key of Object.keys(nextHunkDecisions)) { + if (key.startsWith(prefix) && nextHunkDecisions[key] === 'rejected') { + delete nextHunkDecisions[key]; + } + } + + const nextFileDecisions = { ...s.fileDecisions }; + if (nextFileDecisions[filePath] === 'rejected') { + delete nextFileDecisions[filePath]; + } + + const nextFileChunkCounts = { ...s.fileChunkCounts }; + delete nextFileChunkCounts[filePath]; + + const nextFileContents = { ...s.fileContents }; + delete nextFileContents[filePath]; + + const nextFileContentsLoading = { ...s.fileContentsLoading }; + delete nextFileContentsLoading[filePath]; + + const nextEditedContents = { ...s.editedContents }; + delete nextEditedContents[filePath]; + + return { + hunkDecisions: nextHunkDecisions, + fileDecisions: nextFileDecisions, + fileChunkCounts: nextFileChunkCounts, + fileContents: nextFileContents, + fileContentsLoading: nextFileContentsLoading, + editedContents: nextEditedContents, + }; + }); + }, + + // ── Editable diff actions ── + + updateEditedContent: (filePath: string, content: string) => { + set((s) => ({ + editedContents: { ...s.editedContents, [filePath]: content }, + })); + }, + + discardFileEdits: (filePath: string) => { + set((s) => { + const next = { ...s.editedContents }; + delete next[filePath]; + return { editedContents: next }; + }); + }, + + discardAllEdits: () => set({ editedContents: {} }), + + saveEditedFile: async (filePath: string, projectPath?: string) => { + const content = get().editedContents[filePath]; + if (!(filePath in get().editedContents)) return; + set({ applying: true, applyError: null }); + try { + await api.review.saveEditedFile(filePath, content, projectPath); + set((s) => { + const nextEdited = { ...s.editedContents }; + delete nextEdited[filePath]; + // Update cached content in-place to avoid skeleton flash. + // Replace modifiedFullContent with saved version so CodeMirror + // reflects the new baseline without a full re-fetch cycle. + const nextContents = { ...s.fileContents }; + const existing = nextContents[filePath]; + if (existing) { + nextContents[filePath] = { + ...existing, + modifiedFullContent: content, + contentSource: 'disk-current', + }; + } + return { editedContents: nextEdited, fileContents: nextContents, applying: false }; + }); + } catch (error) { + set({ applying: false, applyError: mapReviewError(error) }); + } + }, + + checkTaskHasChanges: async ( + teamName: string, + taskId: string, + options: TaskChangeRequestOptions + ) => { + const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options); + const summaryCacheable = isTaskSummaryCacheableForOptions(options); + if (summaryCacheable && get().taskHasChanges[cacheKey] === true) return; + if (taskChangesCheckInFlight.has(cacheKey)) return; + if (summaryCacheable) { + const negativeTs = taskChangesNegativeCache.get(cacheKey); + if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL) return; + } + + taskChangesCheckInFlight.add(cacheKey); + try { + const data = await api.review.getTaskChanges(teamName, taskId, { + ...options, + summaryOnly: true, + }); + if (data.files.length > 0) { + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true }, + })); + taskChangesNegativeCache.delete(cacheKey); + if (wasRestoredBeforeCurrentSession(data)) { + void revalidateTaskChangePresence(teamName, taskId, options); + } + } else { + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false }, + })); + taskChangesNegativeCache.set(cacheKey, Date.now()); + } + } catch { + // Allow immediate retry after transient failures (race, file lock, late logs). + } finally { + taskChangesCheckInFlight.delete(cacheKey); + } + }, + + warmTaskChangeSummaries: async (requests) => { + const uniqueRequests = new Map< + string, + { teamName: string; taskId: string; options: TaskChangeRequestOptions } + >(); + for (const request of requests) { + if (!isTaskSummaryCacheableForOptions(request.options)) continue; + const cacheKey = buildTaskChangePresenceKey( + request.teamName, + request.taskId, + request.options + ); + uniqueRequests.set(cacheKey, request); + } + + await Promise.all( + [...uniqueRequests.entries()].map(async ([cacheKey, request]) => { + if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) + return; + + taskChangesCheckInFlight.add(cacheKey); + try { + const data = await api.review.getTaskChanges(request.teamName, request.taskId, { + ...request.options, + summaryOnly: true, + }); + set((s) => ({ + taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 }, + })); + if (data.files.length > 0) { + taskChangesNegativeCache.delete(cacheKey); + if (wasRestoredBeforeCurrentSession(data)) { + void revalidateTaskChangePresence( + request.teamName, + request.taskId, + request.options + ); + } + } else { + taskChangesNegativeCache.set(cacheKey, Date.now()); + } + } catch { + // Best-effort warm path. + } finally { + taskChangesCheckInFlight.delete(cacheKey); + } + }) + ); + }, + + invalidateTaskChangePresence: (cacheKeys) => { + if (cacheKeys.length === 0) return; + const keySet = new Set(cacheKeys); + set((state) => { + const nextTaskHasChanges = { ...state.taskHasChanges }; + let changed = false; + for (const key of keySet) { + if (key in nextTaskHasChanges) { + delete nextTaskHasChanges[key]; + changed = true; + } + taskChangesNegativeCache.delete(key); + } + return changed ? { taskHasChanges: nextTaskHasChanges } : {}; + }); + }, + + invalidateChangeStats: (teamName: string) => { + set((state) => { + const newCache = { ...state.changeStatsCache }; + // Remove all entries for this team + for (const key of Object.keys(newCache)) { + if (key.startsWith(`${teamName}:`)) { + delete newCache[key]; + } + } + return { changeStatsCache: newCache }; + }); + }, + }; +}; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index f38c5f7a..35f771bd 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1,5 +1,10 @@ import { api } from '@renderer/api'; -import type { TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest'; +import { + buildTaskChangePresenceKey, + buildTaskChangeRequestOptions, + isTaskSummaryCacheableForOptions, + type TaskChangeRequestOptions, +} from '@renderer/utils/taskChangeRequest'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; import { createLogger } from '@shared/utils/logger'; @@ -226,6 +231,48 @@ function fireStatusChangeNotification( .catch(() => undefined); } +function collectTaskChangeInvalidationState( + teamName: string, + prevTasks: TeamData['tasks'], + nextTasks: TeamData['tasks'] +): { cacheKeys: string[]; taskIds: string[] } { + const nextKeys = new Set( + nextTasks.map((task) => + buildTaskChangePresenceKey(teamName, task.id, buildTaskChangeRequestOptions(task)) + ) + ); + const invalidationKeys: string[] = []; + const invalidationTaskIds = new Set(); + for (const task of prevTasks) { + const previousKey = buildTaskChangePresenceKey( + teamName, + task.id, + buildTaskChangeRequestOptions(task) + ); + if (!nextKeys.has(previousKey)) { + invalidationKeys.push(previousKey); + invalidationTaskIds.add(task.id); + } + } + return { + cacheKeys: invalidationKeys, + taskIds: [...invalidationTaskIds], + }; +} + +function buildTaskChangeWarmRequests( + teamName: string, + tasks: TeamData['tasks'] +): { teamName: string; taskId: string; options: TaskChangeRequestOptions }[] { + return tasks.flatMap((task) => { + const options = buildTaskChangeRequestOptions(task); + if (!isTaskSummaryCacheableForOptions(options)) { + return []; + } + return [{ teamName, taskId: task.id, options }]; + }); +} + function mapSendMessageError(error: unknown): string { const message = error instanceof IpcError ? error.message : error instanceof Error ? error.message : ''; @@ -770,6 +817,8 @@ export const createTeamSlice: StateCreator = (set, if (get().selectedTeamLoading && get().selectedTeamName === teamName) { return; } + const previousSelectedTeamName = get().selectedTeamName; + const previousData = previousSelectedTeamName === teamName ? get().selectedTeamData : null; // Stale-while-revalidate: keep previous data visible while loading new team. // Skeleton only shows on first load (when data is null). @@ -817,6 +866,19 @@ export const createTeamSlice: StateCreator = (set, selectedTeamLoading: false, selectedTeamError: null, }); + const invalidationState = previousData + ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) + : { cacheKeys: [], taskIds: [] }; + if (invalidationState.cacheKeys.length > 0) { + get().invalidateTaskChangePresence(invalidationState.cacheKeys); + } + if (invalidationState.taskIds.length > 0) { + await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); + } + const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); + if (warmRequests.length > 0) { + void get().warmTaskChangeSummaries(warmRequests); + } // Sync tab label with the team's display name from config const displayName = data.config.name || teamName; @@ -902,6 +964,7 @@ export const createTeamSlice: StateCreator = (set, // Silent refresh — update data without showing loading skeleton. // Only selectTeam() sets loading: true (for initial load). try { + const previousData = get().selectedTeamData; const data = await withTimeout( unwrapIpc('team:getData', () => api.teams.getData(teamName)), TEAM_GET_DATA_TIMEOUT_MS, @@ -915,6 +978,19 @@ export const createTeamSlice: StateCreator = (set, selectedTeamData: data, selectedTeamError: null, }); + const invalidationState = previousData + ? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks) + : { cacheKeys: [], taskIds: [] }; + if (invalidationState.cacheKeys.length > 0) { + get().invalidateTaskChangePresence(invalidationState.cacheKeys); + } + if (invalidationState.taskIds.length > 0) { + await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds); + } + const warmRequests = buildTaskChangeWarmRequests(teamName, data.tasks); + if (warmRequests.length > 0) { + void get().warmTaskChangeSummaries(warmRequests); + } } catch (error) { if (get().selectedTeamName !== teamName) { return; diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts index e41660b2..6976e635 100644 --- a/src/renderer/utils/taskChangeRequest.ts +++ b/src/renderer/utils/taskChangeRequest.ts @@ -1,5 +1,10 @@ import type { ReviewAPI } from '@shared/types/api'; import type { TeamTaskWithKanban } from '@shared/types/team'; +import { + getTaskChangeStateBucket, + isTaskChangeSummaryCacheable, + type TaskChangeStateBucket, +} from '@shared/utils/taskChangeState'; const TASK_SINCE_GRACE_MS = 2 * 60 * 1000; @@ -13,7 +18,15 @@ export interface TaskChangeContext { type TaskChangeTaskLike = Pick< TeamTaskWithKanban, - 'id' | 'owner' | 'status' | 'createdAt' | 'updatedAt' | 'workIntervals' | 'historyEvents' + | 'id' + | 'owner' + | 'status' + | 'createdAt' + | 'updatedAt' + | 'workIntervals' + | 'historyEvents' + | 'reviewState' + | 'kanbanColumn' >; export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undefined { @@ -48,6 +61,7 @@ export function buildTaskChangeRequestOptions( status: task.status, intervals: task.workIntervals, since: deriveTaskSince(task), + stateBucket: getTaskChangeStateBucket(task), }; return { @@ -73,6 +87,7 @@ export function buildTaskChangeSignature(options: TaskChangeRequestOptions): str const owner = typeof options.owner === 'string' ? options.owner.trim() : ''; const status = typeof options.status === 'string' ? options.status.trim() : ''; const since = typeof options.since === 'string' ? options.since : ''; + const stateBucket = typeof options.stateBucket === 'string' ? options.stateBucket : 'active'; const intervals = Array.isArray(options.intervals) ? options.intervals.map((interval) => ({ startedAt: interval.startedAt, @@ -84,6 +99,7 @@ export function buildTaskChangeSignature(options: TaskChangeRequestOptions): str owner, status, since, + stateBucket, intervals, }); } @@ -95,3 +111,22 @@ export function buildTaskChangePresenceKey( ): string { return `${teamName}:${taskId}:${buildTaskChangeSignature(options)}`; } + +export function getTaskChangeStateBucketFromOptions( + options: TaskChangeRequestOptions | null | undefined +): TaskChangeStateBucket { + switch (options?.stateBucket) { + case 'approved': + case 'review': + case 'completed': + return options.stateBucket; + default: + return 'active'; + } +} + +export function isTaskSummaryCacheableForOptions( + options: TaskChangeRequestOptions | null | undefined +): boolean { + return isTaskChangeSummaryCacheable(getTaskChangeStateBucketFromOptions(options)); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index f987861f..92744461 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -595,10 +595,15 @@ export interface ReviewAPI { intervals?: { startedAt: string; completedAt?: string }[]; /** Back-compat: single since timestamp (deprecated). */ since?: string; + /** Derived task lifecycle bucket used for safe summary caching. */ + stateBucket?: 'approved' | 'review' | 'completed' | 'active'; /** Lightweight response for summary UIs; skips snippets/timeline details. */ summaryOnly?: boolean; + /** Force a fresh recompute and overwrite any cache snapshot. */ + forceFresh?: boolean; } ) => Promise; + invalidateTaskChangeSummaries: (teamName: string, taskIds: string[]) => Promise; getChangeStats: (teamName: string, memberName: string) => Promise; getFileContent: ( teamName: string, diff --git a/src/shared/utils/taskChangeState.ts b/src/shared/utils/taskChangeState.ts new file mode 100644 index 00000000..5e196e2e --- /dev/null +++ b/src/shared/utils/taskChangeState.ts @@ -0,0 +1,48 @@ +import type { TaskHistoryEvent, TeamReviewState } from '@shared/types'; + +import { getDerivedReviewState } from './taskHistory'; + +export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active'; + +interface TaskChangeStateLike { + status?: string | null; + reviewState?: TeamReviewState | null; + historyEvents?: unknown[]; + kanbanColumn?: 'review' | 'approved' | null; +} + +function normalizeReviewState(value: unknown): TeamReviewState { + return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none'; +} + +function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState { + if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) { + return getDerivedReviewState({ historyEvents: task.historyEvents as TaskHistoryEvent[] }); + } + + const explicit = normalizeReviewState(task.reviewState); + if (explicit !== 'none') { + return explicit; + } + + if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') { + return task.kanbanColumn; + } + + return 'none'; +} + +export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket { + const reviewState = getEffectiveReviewState(task); + if (reviewState === 'approved') return 'approved'; + if (reviewState === 'review') return 'review'; + return task.status === 'completed' ? 'completed' : 'active'; +} + +export function isTaskChangeSummaryCacheable( + taskOrBucket: TaskChangeStateLike | TaskChangeStateBucket +): boolean { + const bucket = + typeof taskOrBucket === 'string' ? taskOrBucket : getTaskChangeStateBucket(taskOrBucket); + return bucket === 'completed' || bucket === 'approved'; +} diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index 4162fd81..fc89b777 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -7,6 +7,97 @@ import * as fs from 'fs/promises'; import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; +const TEAM_NAME = 'team-a'; +const TASK_ID = '1'; +const PROJECT_PATH = '/repo'; +const SUMMARY_OPTIONS = { + owner: 'alice', + status: 'completed', + stateBucket: 'completed' as const, + summaryOnly: true, +}; + +function buildAssistantWriteEntry(toolUseId: string, filePath: string, content: string, timestamp: string) { + return { + timestamp, + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: toolUseId, + name: 'Write', + input: { file_path: filePath, content }, + }, + ], + }, + }; +} + +async function writeJsonl(filePath: string, entries: object[]): Promise { + await fs.writeFile(filePath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8'); +} + +async function writeTaskFile( + baseDir: string, + overrides?: Record +): Promise { + const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${TASK_ID}.json`); + await fs.mkdir(path.dirname(taskPath), { recursive: true }); + await fs.writeFile( + taskPath, + JSON.stringify( + { + id: TASK_ID, + owner: 'alice', + status: 'completed', + createdAt: '2026-03-01T09:55:00.000Z', + updatedAt: '2026-03-01T10:10:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }], + historyEvents: [], + ...overrides, + }, + null, + 2 + ), + 'utf8' + ); + return taskPath; +} + +function persistedEntryPath(baseDir: string): string { + return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`); +} + +function createService(params: { + logPaths: string[]; + projectPath?: string; + findLogsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise; +}) { + const findLogsForTask = + params.findLogsForTask ?? + vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' }))); + return { + findLogsForTask, + service: new ChangeExtractorService( + { + findLogsForTask, + findMemberLogPaths: vi.fn(async () => []), + } as any, + { + parseBoundaries: vi.fn(async () => ({ + boundaries: [], + scopes: [], + isSingleTaskSession: true, + detectedMechanism: 'none' as const, + })), + } as any, + { getConfig: vi.fn(async () => ({ projectPath: params.projectPath ?? PROJECT_PATH })) } as any + ), + }; +} + describe('ChangeExtractorService', () => { let tmpDir: string | null = null; @@ -23,46 +114,17 @@ describe('ChangeExtractorService', () => { setClaudeBasePathOverride(tmpDir); const aliceLogPath = path.join(tmpDir, 'alice.jsonl'); - await fs.writeFile( - aliceLogPath, - JSON.stringify({ - timestamp: '2026-03-01T10:00:00.000Z', - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool-1', - name: 'Write', - input: { file_path: '/repo/src/file.ts', content: 'export const value = 1;\n' }, - }, - ], - }, - }) + '\n', - 'utf8' - ); + await writeJsonl(aliceLogPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); const findLogsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) => options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : [] ); - const parseBoundaries = vi.fn(async () => ({ - boundaries: [], - scopes: [], - isSingleTaskSession: true, - detectedMechanism: 'none' as const, - })); - const service = new ChangeExtractorService( - { - findLogsForTask, - findMemberLogPaths: vi.fn(async () => []), - } as any, - { parseBoundaries } as any, - { getConfig: vi.fn(async () => ({ projectPath: '/repo' })) } as any - ); + const service = createService({ logPaths: [aliceLogPath], findLogsForTask }).service; - const empty = await service.getTaskChanges('team-a', '1', { owner: 'bob', status: 'completed' }); - const populated = await service.getTaskChanges('team-a', '1', { + const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' }); + const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); @@ -72,71 +134,235 @@ describe('ChangeExtractorService', () => { expect(findLogsForTask).toHaveBeenCalledTimes(2); }); - it('merges fallback changes for the same Windows file across slash variants', async () => { + it('caches terminal summary requests in memory but keeps detailed requests fresh', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); setClaudeBasePathOverride(tmpDir); - const firstLogPath = path.join(tmpDir, 'first.jsonl'); - const secondLogPath = path.join(tmpDir, 'second.jsonl'); - await fs.writeFile( - firstLogPath, - JSON.stringify({ - timestamp: '2026-03-01T10:00:00.000Z', - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool-1', - name: 'Write', - input: { file_path: 'C:\\repo\\src\\same.ts', content: 'first\n' }, - }, - ], - }, - }) + '\n', - 'utf8' + const logPath = path.join(tmpDir, 'alice-summary.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const { service, findLogsForTask } = createService({ logPaths: [logPath] }); + + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + stateBucket: 'completed', + }); + await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + stateBucket: 'completed', + }); + + expect(findLogsForTask).toHaveBeenCalledTimes(3); + }); + + it('restores a persisted terminal summary after a simulated restart', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-restart.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const first = createService({ logPaths: [logPath] }); + const initial = await first.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + const second = createService({ logPaths: [logPath] }); + const restored = await second.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(initial.files).toHaveLength(1); + expect(restored.files).toHaveLength(1); + expect(await fs.readFile(persistedEntryPath(tmpDir), 'utf8')).toContain('"taskId": "1"'); + expect((second.findLogsForTask as any).mock.calls).toHaveLength(0); + }); + + it('forceFresh overwrites the persisted terminal summary snapshot', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-refresh.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const { service } = createService({ logPaths: [logPath] }); + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'), + buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'), + ]); + + const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, { + ...SUMMARY_OPTIONS, + forceFresh: true, + }); + const after = await createService({ logPaths: [logPath] }).service.getTaskChanges( + TEAM_NAME, + TASK_ID, + SUMMARY_OPTIONS ); - await fs.writeFile( - secondLogPath, - JSON.stringify({ - timestamp: '2026-03-01T10:01:00.000Z', - type: 'assistant', - message: { - role: 'assistant', - content: [ - { - type: 'tool_use', - id: 'tool-2', - name: 'Write', - input: { file_path: 'C:/repo/src/same.ts', content: 'second\n' }, - }, - ], + + expect(refreshed.totalFiles).toBe(2); + expect(after.totalFiles).toBe(2); + }); + + it('invalidates old terminal summaries when the task moves into review', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-review.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + const { service } = createService({ logPaths: [logPath] }); + await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await writeTaskFile(tmpDir, { + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + to: 'review', + timestamp: '2026-03-01T11:00:00.000Z', }, - }) + '\n', - 'utf8' + ], + }); + + await service.getTaskChanges(TEAM_NAME, TASK_ID, { + owner: 'alice', + status: 'completed', + stateBucket: 'review', + summaryOnly: true, + }); + + await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rejects persisted summaries after project/worktree drift', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-project-drift.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges( + TEAM_NAME, + TASK_ID, + SUMMARY_OPTIONS ); + const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' }); + await drifted.service.getTaskChanges( + TEAM_NAME, + TASK_ID, + SUMMARY_OPTIONS + ); + + expect((drifted.findLogsForTask as any).mock.calls.length).toBeGreaterThan(1); + }); + + it('rejects persisted summaries when the task file is missing on restart', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + const taskPath = await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-missing-task.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + await fs.unlink(taskPath); + await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('falls back safely when the persisted summary file is corrupted', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir); + + const logPath = path.join(tmpDir, 'alice-corrupt.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); + + await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8'); + + const restored = await createService({ logPaths: [logPath] }).service.getTaskChanges( + TEAM_NAME, + TASK_ID, + SUMMARY_OPTIONS + ); + + expect(restored.files).toHaveLength(1); + }); + + it('does not persist low-confidence fallback summaries', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + await writeTaskFile(tmpDir, { workIntervals: [], historyEvents: [] }); + + const logPath = path.join(tmpDir, 'alice-fallback.jsonl'); + await writeJsonl(logPath, [ + buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'), + ]); const service = new ChangeExtractorService( { - findLogsForTask: vi.fn(async () => [ - { filePath: firstLogPath, memberName: 'alice' }, - { filePath: secondLogPath, memberName: 'alice' }, - ]), + findLogsForTask: vi.fn(async () => [{ filePath: logPath, memberName: 'alice' }]), findMemberLogPaths: vi.fn(async () => []), } as any, { parseBoundaries: vi.fn(async () => ({ boundaries: [], scopes: [], - isSingleTaskSession: true, + isSingleTaskSession: false, detectedMechanism: 'none' as const, })), } as any, - { getConfig: vi.fn(async () => ({ projectPath: 'C:\\repo' })) } as any + { getConfig: vi.fn(async () => ({ projectPath: PROJECT_PATH })) } as any ); - const result = await service.getTaskChanges('team-a', '1', { + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); + + expect(result.confidence).toBe('fallback'); + await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('merges fallback changes for the same Windows file across slash variants', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); + setClaudeBasePathOverride(tmpDir); + + const firstLogPath = path.join(tmpDir, 'first.jsonl'); + const secondLogPath = path.join(tmpDir, 'second.jsonl'); + await writeJsonl(firstLogPath, [ + buildAssistantWriteEntry('tool-1', 'C:\\repo\\src\\same.ts', 'first\n', '2026-03-01T10:00:00.000Z'), + ]); + await writeJsonl(secondLogPath, [ + buildAssistantWriteEntry('tool-2', 'C:/repo/src/same.ts', 'second\n', '2026-03-01T10:01:00.000Z'), + ]); + + const service = createService({ + logPaths: [firstLogPath, secondLogPath], + projectPath: 'C:\\repo', + }).service; + + const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'alice', status: 'completed', }); diff --git a/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts b/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts new file mode 100644 index 00000000..70f8dfdb --- /dev/null +++ b/test/main/services/team/JsonTaskChangeSummaryCacheRepository.test.ts @@ -0,0 +1,135 @@ +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import * as fs from 'fs/promises'; + +import { JsonTaskChangeSummaryCacheRepository } from '../../../../src/main/services/team/cache/JsonTaskChangeSummaryCacheRepository'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +import type { PersistedTaskChangeSummaryEntry } from '../../../../src/main/services/team/cache/taskChangeSummaryCacheTypes'; + +function buildEntry(overrides?: Partial): PersistedTaskChangeSummaryEntry { + return { + version: 1, + teamName: 'team-a', + taskId: '1', + stateBucket: 'completed', + taskSignature: '{"owner":"alice"}', + sourceFingerprint: 'source-fingerprint', + projectFingerprint: 'project-fingerprint', + writtenAt: '2026-03-01T10:00:00.000Z', + expiresAt: '2099-03-01T10:00:00.000Z', + extractorConfidence: 'high', + summary: { + teamName: 'team-a', + taskId: '1', + files: [ + { + filePath: '/repo/src/file.ts', + relativePath: 'src/file.ts', + snippets: [ + { + toolUseId: 'tool-1', + filePath: '/repo/src/file.ts', + toolName: 'Write', + type: 'write-new', + oldString: '', + newString: 'x', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + }, + ], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ], + totalFiles: 1, + totalLinesAdded: 1, + totalLinesRemoved: 0, + confidence: 'high', + computedAt: '2026-03-01T10:00:00.000Z', + scope: { + taskId: '1', + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: ['/repo/src/file.ts'], + confidence: { tier: 1, label: 'high', reason: 'test' }, + }, + warnings: [], + }, + ...overrides, + }; +} + +describe('JsonTaskChangeSummaryCacheRepository', () => { + let tmpDir: string | null = null; + + afterEach(async () => { + setClaudeBasePathOverride(null); + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + tmpDir = null; + } + }); + + it('saves and loads normalized per-task entries', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-')); + setClaudeBasePathOverride(tmpDir); + const repo = new JsonTaskChangeSummaryCacheRepository(); + + await repo.save(buildEntry()); + const loaded = await repo.load('team-a', '1'); + + expect(loaded?.summary.files[0]?.snippets).toEqual([]); + expect( + await fs.readFile( + path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json'), + 'utf8' + ) + ).toContain('"teamName": "team-a"'); + }); + + it('treats expired entries as cache misses', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-')); + setClaudeBasePathOverride(tmpDir); + const repo = new JsonTaskChangeSummaryCacheRepository(); + + await repo.save(buildEntry({ expiresAt: '2000-03-01T10:00:00.000Z' })); + + expect(await repo.load('team-a', '1')).toBeNull(); + }); + + it('ignores malformed entries and deletes them best-effort', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-')); + setClaudeBasePathOverride(tmpDir); + const repo = new JsonTaskChangeSummaryCacheRepository(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const filePath = path.join(tmpDir, 'task-change-summaries', encodeURIComponent('team-a'), '1.json'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, '{bad-json', 'utf8'); + + expect(await repo.load('team-a', '1')).toBeNull(); + await expect(fs.stat(filePath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not let older generations overwrite newer ones', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-summary-repo-')); + setClaudeBasePathOverride(tmpDir); + const repo = new JsonTaskChangeSummaryCacheRepository(); + + const newer = await repo.save(buildEntry({ taskSignature: 'newer' }), { generation: 2 }); + const older = await repo.save(buildEntry({ taskSignature: 'older' }), { generation: 1 }); + const loaded = await repo.load('team-a', '1'); + + expect(newer.written).toBe(true); + expect(older.written).toBe(false); + expect(loaded?.taskSignature).toBe('newer'); + }); +}); diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 0df5a8ae..ce7b8dec 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { create } from 'zustand'; import { createChangeReviewSlice } from '../../../src/renderer/store/slices/changeReviewSlice'; +import { buildTaskChangePresenceKey } from '../../../src/renderer/utils/taskChangeRequest'; const hoisted = vi.hoisted(() => ({ getTaskChanges: vi.fn(), @@ -49,11 +50,17 @@ function deferred() { return { promise, resolve, reject }; } +async function flushAsyncWork(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + const OPTIONS_A = { owner: 'alice', status: 'completed', intervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], since: '2026-03-01T09:58:00.000Z', + stateBucket: 'completed' as const, }; const OPTIONS_B = { @@ -61,6 +68,15 @@ const OPTIONS_B = { status: 'completed', intervals: [{ startedAt: '2026-03-01T11:00:00.000Z' }], since: '2026-03-01T10:58:00.000Z', + stateBucket: 'completed' as const, +}; + +const REVIEW_OPTIONS = { + owner: 'alice', + status: 'completed', + intervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }], + since: '2026-03-01T09:58:00.000Z', + stateBucket: 'review' as const, }; describe('changeReviewSlice task changes', () => { @@ -170,4 +186,210 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().activeChangeSet?.taskId).toBe('2'); expect(store.getState().selectedReviewFilePath).toBe('/repo/new.ts'); }); + + it('does not treat review-state summaries as permanently cacheable', async () => { + const store = createSliceStore(); + hoisted.getTaskChanges.mockResolvedValue({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName: 'team-a', + taskId: '1', + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: '1', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS); + await store.getState().checkTaskHasChanges('team-a', '1', REVIEW_OPTIONS); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2); + }); + + it('re-warms terminal summaries after an earlier empty result', async () => { + const store = createSliceStore(); + const teamName = 'team-warm'; + const taskId = 'late-log-task'; + hoisted.getTaskChanges + .mockResolvedValueOnce({ + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + teamName, + taskId, + confidence: 'fallback', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: '1', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: [], + }) + .mockResolvedValueOnce({ + teamName, + taskId, + files: [ + { + filePath: '/repo/new.ts', + relativePath: 'new.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ], + totalFiles: 1, + totalLinesAdded: 1, + totalLinesRemoved: 0, + confidence: 'fallback', + computedAt: '2026-03-01T12:01:00.000Z', + scope: { + taskId: '1', + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: ['/repo/new.ts'], + confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, + }, + warnings: [], + }) + .mockResolvedValueOnce({ + teamName, + taskId, + files: [ + { + filePath: '/repo/new.ts', + relativePath: 'new.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ], + totalFiles: 1, + totalLinesAdded: 1, + totalLinesRemoved: 0, + confidence: 'fallback', + computedAt: '2026-03-01T12:01:01.000Z', + scope: { + taskId: '1', + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: ['/repo/new.ts'], + confidence: { tier: 4, label: 'fallback', reason: 'No task boundaries found in JSONL' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); + await store + .getState() + .warmTaskChangeSummaries([{ teamName, taskId, options: OPTIONS_A }]); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(3); + expect( + store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)] + ).toBe(true); + }); + + it('clears optimistic terminal presence after background forceFresh revalidation', async () => { + const store = createSliceStore(); + const teamName = 'team-revalidate'; + const taskId = 'persisted-hit'; + hoisted.getTaskChanges + .mockResolvedValueOnce({ + teamName, + taskId, + files: [ + { + filePath: '/repo/persisted.ts', + relativePath: 'persisted.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: true, + }, + ], + totalFiles: 1, + totalLinesAdded: 1, + totalLinesRemoved: 0, + confidence: 'medium', + computedAt: '2026-03-01T12:00:00.000Z', + scope: { + taskId: '1', + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: ['/repo/persisted.ts'], + confidence: { tier: 2, label: 'medium', reason: 'Persisted summary' }, + }, + warnings: [], + }) + .mockResolvedValueOnce({ + teamName, + taskId, + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + confidence: 'fallback', + computedAt: '2026-03-01T12:01:00.000Z', + scope: { + taskId: '1', + memberName: '', + startLine: 0, + endLine: 0, + startTimestamp: '', + endTimestamp: '', + toolUseIds: [], + filePaths: [], + confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, + }, + warnings: [], + }); + + await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A); + await flushAsyncWork(); + + expect(hoisted.getTaskChanges).toHaveBeenNthCalledWith(1, teamName, taskId, { + ...OPTIONS_A, + summaryOnly: true, + }); + expect(hoisted.getTaskChanges).toHaveBeenNthCalledWith(2, teamName, taskId, { + ...OPTIONS_A, + summaryOnly: true, + forceFresh: true, + }); + expect(store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)]).toBe(false); + }); }); diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 58a91192..219e2e5f 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -12,6 +12,7 @@ const hoisted = vi.hoisted(() => ({ sendMessage: vi.fn(), requestReview: vi.fn(), updateKanban: vi.fn(), + invalidateTaskChangeSummaries: vi.fn(), onProvisioningProgress: vi.fn(() => () => undefined), })); @@ -28,6 +29,9 @@ vi.mock('@renderer/api', () => ({ updateKanban: hoisted.updateKanban, onProvisioningProgress: hoisted.onProvisioningProgress, }, + review: { + invalidateTaskChangeSummaries: hoisted.invalidateTaskChangeSummaries, + }, }, })); @@ -63,6 +67,8 @@ function createSliceStore() { openTab: vi.fn(), setActiveTab: vi.fn(), getAllPaneTabs: vi.fn(() => []), + warmTaskChangeSummaries: vi.fn(async () => undefined), + invalidateTaskChangePresence: vi.fn(), })); } @@ -82,6 +88,7 @@ describe('teamSlice actions', () => { hoisted.requestReview.mockResolvedValue(undefined); hoisted.updateKanban.mockResolvedValue(undefined); hoisted.createTeam.mockResolvedValue({ runId: 'run-1' }); + hoisted.invalidateTaskChangeSummaries.mockResolvedValue(undefined); hoisted.getProvisioningStatus.mockResolvedValue({ runId: 'run-1', teamName: 'my-team', @@ -251,5 +258,112 @@ describe('teamSlice actions', () => { // No previous data — error should be shown expect(store.getState().selectedTeamError).toBe('Team not found'); }); + + it('invalidates changed task summaries and warms only cacheable terminal tasks', async () => { + const store = createSliceStore(); + const invalidateTaskChangePresence = vi.fn(); + const warmTaskChangeSummaries = vi.fn(async () => undefined); + store.setState({ + selectedTeamName: 'my-team', + invalidateTaskChangePresence, + warmTaskChangeSummaries, + selectedTeamData: { + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [ + { + id: 'task-1', + subject: 'Old completed', + status: 'completed', + owner: 'alice', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [], + comments: [], + attachments: [], + }, + { + id: 'task-2', + subject: 'Still approved', + status: 'completed', + owner: 'bob', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [ + { + id: 'evt-approved', + type: 'review_approved', + to: 'approved', + timestamp: '2026-03-01T10:10:00.000Z', + }, + ], + comments: [], + attachments: [], + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }, + }); + + hoisted.getData.mockResolvedValue({ + teamName: 'my-team', + config: { name: 'My Team' }, + tasks: [ + { + id: 'task-1', + subject: 'Moved to review', + status: 'completed', + owner: 'alice', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T11:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [ + { + id: 'evt-review', + type: 'review_requested', + to: 'review', + timestamp: '2026-03-01T11:00:00.000Z', + }, + ], + comments: [], + attachments: [], + }, + { + id: 'task-2', + subject: 'Still approved', + status: 'completed', + owner: 'bob', + createdAt: '2026-03-01T10:00:00.000Z', + updatedAt: '2026-03-01T10:00:00.000Z', + workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }], + historyEvents: [ + { + id: 'evt-approved', + type: 'review_approved', + to: 'approved', + timestamp: '2026-03-01T10:10:00.000Z', + }, + ], + comments: [], + attachments: [], + }, + ], + members: [], + messages: [], + kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} }, + }); + + await store.getState().refreshTeamData('my-team'); + + expect(hoisted.invalidateTaskChangeSummaries).toHaveBeenCalledWith('my-team', ['task-1']); + expect(invalidateTaskChangePresence).toHaveBeenCalledTimes(1); + expect(warmTaskChangeSummaries).toHaveBeenCalledWith([ + expect.objectContaining({ teamName: 'my-team', taskId: 'task-2' }), + ]); + }); }); }); diff --git a/test/renderer/utils/taskChangeRequest.test.ts b/test/renderer/utils/taskChangeRequest.test.ts index 041ac310..913c4219 100644 --- a/test/renderer/utils/taskChangeRequest.test.ts +++ b/test/renderer/utils/taskChangeRequest.test.ts @@ -48,6 +48,7 @@ describe('taskChangeRequest', () => { status: 'completed', intervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }], since: '2026-03-01T10:03:00.000Z', + stateBucket: 'completed', summaryOnly: true, }); }); @@ -58,10 +59,14 @@ describe('taskChangeRequest', () => { status: 'completed', intervals: [{ startedAt: '2026-03-01T10:10:00.000Z' }], since: '2026-03-01T10:03:00.000Z', + stateBucket: 'completed' as const, }; expect(buildTaskChangePresenceKey('team-a', '1', base)).not.toBe( buildTaskChangePresenceKey('team-a', '1', { ...base, owner: 'bob' }) ); + expect(buildTaskChangePresenceKey('team-a', '1', base)).not.toBe( + buildTaskChangePresenceKey('team-a', '1', { ...base, stateBucket: 'review' }) + ); }); });