agent-ecosystem/src/renderer/store/slices/changeReviewSlice.ts
2026-04-24 20:52:26 +03:00

1694 lines
61 KiB
TypeScript

import { api } from '@renderer/api';
import {
getReviewChangeSetIdentityToken,
type ReviewChangeSetLike,
} from '@renderer/utils/reviewDecisionScope';
import {
buildHunkDecisionKey,
getFileReviewKey,
getReviewKeyForFilePath,
normalizePersistedReviewState,
} from '@renderer/utils/reviewKey';
import {
resolveTaskChangePresenceFromResult,
shouldBackgroundRevalidateTaskPresence,
} from '@renderer/utils/taskChangePresence';
import {
buildTaskChangePresenceKey,
isTaskSummaryCacheableForOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { createLogger } from '@shared/utils/logger';
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
import { structuredPatch } from 'diff';
/** Tracks in-flight checkTaskHasChanges calls to avoid duplicate requests */
const taskChangesCheckInFlight = new Set<string>();
/** Tracks background presence revalidation for optimistic terminal summary hits */
const taskChangesPresenceRevalidationInFlight = new Set<string>();
/** Negative results cached with timestamp — recheck after 30s */
const taskChangesNegativeCache = new Map<string, number>();
const NEGATIVE_CACHE_TTL = 30_000;
const TASK_CHANGE_WARM_CONCURRENCY = 4;
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
let latestAgentChangesRequestToken = 0;
let latestTaskChangesRequestToken = 0;
let latestDecisionLoadRequestToken = 0;
/** Debounce timer for persisting decisions to disk */
const persistDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const PERSIST_DEBOUNCE_MS = 500;
import type { AppState } from '../types';
import type {
AgentChangeSet,
ApplyReviewRequest,
ApplyReviewResult,
ChangeStats,
FileChangeSummary,
FileChangeWithContent,
FileReviewDecision,
HunkDecision,
SnippetDiff,
TaskChangePresenceState,
TaskChangeSet,
TaskChangeSetV2,
} from '@shared/types';
import type { StateCreator } from 'zustand';
const logger = createLogger('changeReviewSlice');
function reviewPathsEqual(left: string, right: string): boolean {
const caseInsensitive = isWindowsReviewPath(left) || isWindowsReviewPath(right);
return (
normalizeReviewPathForComparison(left, caseInsensitive) ===
normalizeReviewPathForComparison(right, caseInsensitive)
);
}
function normalizeReviewPathForComparison(filePath: string, caseInsensitive: boolean): string {
const normalized = normalizePathForComparison(filePath);
return caseInsensitive ? normalized.toLowerCase() : normalized;
}
function isWindowsReviewPath(filePath: string): boolean {
return isWindowsishPath(filePath) || filePath.includes('\\');
}
function findReviewFileByPath(
files: readonly FileChangeSummary[] | null | undefined,
filePath: string
): FileChangeSummary | undefined {
return files?.find((file) => reviewPathsEqual(file.filePath, filePath));
}
/** Snapshot of review decisions for undo support */
interface DecisionSnapshot {
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
}
export interface ReviewExternalChange {
type: 'change' | 'add' | 'unlink';
}
const MAX_REVIEW_UNDO_DEPTH = 10;
/**
* When true, rejected hunks are immediately applied to disk (no need for "Apply All Changes").
* When false, decisions are batched and applied manually via "Apply All Changes" button.
*/
export const REVIEW_INSTANT_APPLY = true;
function mapReviewError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (message.includes('conflict')) return 'File has been modified since agent changes.';
if (message.includes('ENOENT')) return 'File no longer exists on disk.';
if (message.includes('EACCES') || message.includes('Permission')) return 'Permission denied.';
return message || 'Failed to apply review changes';
}
function clearPersistDecisionTimer(scopeStorageKey: string): void {
const timer = persistDebounceTimers.get(scopeStorageKey);
if (!timer) return;
clearTimeout(timer);
persistDebounceTimers.delete(scopeStorageKey);
}
function buildPersistDecisionScopeKey(
teamName: string,
scopeKey: string,
scopeToken?: string
): string {
return scopeToken ? `${teamName}:${scopeKey}:${scopeToken}` : `${teamName}:${scopeKey}`;
}
function clearAllPersistDecisionTimers(): void {
for (const timer of persistDebounceTimers.values()) {
clearTimeout(timer);
}
persistDebounceTimers.clear();
}
function applyTaskChangePresenceCacheUpdate(
taskChangePresenceByKey: Record<string, Exclude<TaskChangePresenceState, 'unknown'>>,
cacheKey: string,
presence: TaskChangePresenceState | null
): Record<string, Exclude<TaskChangePresenceState, 'unknown'>> {
const nextTaskChangePresenceByKey = { ...taskChangePresenceByKey };
if (presence && presence !== 'unknown') {
nextTaskChangePresenceByKey[cacheKey] = presence;
} else {
delete nextTaskChangePresenceByKey[cacheKey];
}
return nextTaskChangePresenceByKey;
}
function syncTaskChangeNegativeCache(
cacheKey: string,
presence: TaskChangePresenceState | null
): void {
if (presence === 'has_changes' || presence === 'needs_attention') {
taskChangesNegativeCache.delete(cacheKey);
} else if (presence === 'no_changes') {
taskChangesNegativeCache.set(cacheKey, Date.now());
} else {
taskChangesNegativeCache.delete(cacheKey);
}
}
export interface ChangeReviewSlice {
// Phase 1 state
activeChangeSet: AgentChangeSet | TaskChangeSet | TaskChangeSetV2 | null;
activeTaskChangeRequestOptions: TaskChangeRequestOptions | null;
changeSetLoading: boolean;
changeSetError: string | null;
selectedReviewFilePath: string | null;
changeStatsCache: Record<string, ChangeStats>;
// Phase 2 state
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
/** Actual CodeMirror chunk count per file (may differ from snippets.length) */
fileChunkCounts: Record<string, number>;
/** Undo stack for bulk review operations (Accept All / Reject All) */
reviewUndoStack: DecisionSnapshot[];
/** filePath -> (hunkIndex -> contextHash), persisted for robust replay */
hunkContextHashesByFile: Record<string, Record<number, string>>;
fileContents: Record<string, FileChangeWithContent>;
fileContentsLoading: Record<string, boolean>;
changeSetEpoch: number;
fileContentVersionByPath: Record<string, number>;
reviewExternalChangesByFile: Record<string, ReviewExternalChange>;
collapseUnchanged: boolean;
applyError: string | null;
applying: boolean;
// Editable diff state
editedContents: Record<string, string>;
/** Cache: "teamName:taskId:signature" → resolved task change presence */
taskChangePresenceByKey: Record<string, Exclude<TaskChangePresenceState, 'unknown'>>;
// Phase 1 actions
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
fetchTaskChanges: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
) => Promise<void>;
recordTaskChangePresence: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions,
presence: TaskChangePresenceState | null
) => void;
selectReviewFile: (filePath: string | null) => void;
clearChangeReview: () => void;
clearChangeReviewCache: () => void;
resetAllReviewState: () => void;
fetchChangeStats: (teamName: string, memberName: string) => Promise<void>;
// Decision persistence actions
loadDecisionsFromDisk: (teamName: string, scopeKey: string, scopeToken: string) => Promise<void>;
persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => void;
clearDecisionsFromDisk: (
teamName: string,
scopeKey: string,
scopeToken?: string
) => Promise<void>;
// Phase 2 actions
/**
* Set decision for a hunk at the current (visible) CM index.
* Returns the stable/original hunk index used as the decision key.
*/
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => number;
/** Clear a persisted decision using the stable/original hunk index */
clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => void;
setFileDecision: (filePath: string, decision: HunkDecision) => void;
setFileChunkCount: (filePath: string, count: number) => void;
pushReviewUndoSnapshot: () => void;
undoBulkReview: () => boolean;
acceptAllFile: (filePath: string) => void;
rejectAllFile: (filePath: string) => void;
acceptAll: () => void;
rejectAll: () => void;
setCollapseUnchanged: (collapse: boolean) => void;
fetchFileContent: (
teamName: string,
memberName: string | undefined,
filePath: string
) => Promise<void>;
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
applySingleFileDecision: (
teamName: string,
filePath: string,
taskId?: string,
memberName?: string
) => Promise<ApplyReviewResult | null>;
/** Remove a file from the current review set (used for rejecting new files) */
removeReviewFile: (filePath: string) => void;
/** Re-add a file to the current review set (used for undoing new-file reject) */
addReviewFile: (
file: FileChangeSummary,
options?: { index?: number; content?: FileChangeWithContent }
) => void;
/**
* Clear in-memory review state for a single file after applying changes to disk.
* Prevents stale decisions from being re-applied later and forces fresh content resolve.
*/
clearReviewStateForFile: (filePath: string) => void;
invalidateResolvedFileContent: (filePath: string) => void;
markReviewFileExternallyChanged: (filePath: string, type: ReviewExternalChange['type']) => void;
clearReviewFileExternalChange: (filePath: string) => void;
reloadReviewFileFromDisk: (filePath: string) => void;
invalidateChangeStats: (teamName: string) => void;
// Editable diff actions
updateEditedContent: (filePath: string, content: string) => void;
discardFileEdits: (filePath: string) => void;
discardAllEdits: () => void;
saveEditedFile: (filePath: string, projectPath?: string) => Promise<void>;
// Task change availability
checkTaskHasChanges: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
) => Promise<void>;
warmTaskChangeSummaries: (
requests: { teamName: string; taskId: string; options: TaskChangeRequestOptions }[]
) => Promise<void>;
invalidateTaskChangePresence: (cacheKeys: string[]) => void;
}
/**
* Map a current CM chunk index to its original index, accounting for chunks
* that have been accepted/rejected (removed from CM view, causing index shifts).
*
* When chunk 0 is accepted, CM removes it — old chunk 1 becomes new chunk 0.
* This function reverses that shift so decisions are stored with stable indices.
*/
function mapCurrentToOriginalIndex(
reviewKey: string,
currentIdx: number,
hunkDecisions: Record<string, HunkDecision>,
totalChunks: number
): number {
const decided = new Set<number>();
for (let i = 0; i < totalChunks; i++) {
if (buildHunkDecisionKey(reviewKey, i) in hunkDecisions) {
decided.add(i);
}
}
// Walk original indices, skip already-decided, count undecided until currentIdx
let undecidedSeen = 0;
for (let orig = 0; orig < totalChunks; orig++) {
if (decided.has(orig)) continue;
if (undecidedSeen === currentIdx) return orig;
undecidedSeen++;
}
return currentIdx;
}
/** Get the hunk count for a file: prefer actual CM chunk count, fallback to snippet count */
export function getFileHunkCount(
filePath: string,
snippetsLength: number,
fileChunkCounts: Record<string, number>
): number {
return fileChunkCounts[filePath] ?? snippetsLength;
}
function getMaxDecisionIndexForFile(
reviewKey: string,
hunkDecisions: Record<string, HunkDecision>
): number {
let max = -1;
const prefix = `${reviewKey}:`;
for (const key of Object.keys(hunkDecisions)) {
if (!key.startsWith(prefix)) continue;
const raw = key.slice(prefix.length);
const idx = Number.parseInt(raw, 10);
if (!Number.isNaN(idx)) {
max = Math.max(max, idx);
}
}
return max;
}
function buildHunkContextHashesForFile(
original: string | null | undefined,
modified: string | null | undefined,
expectedHunkCount: number
): Record<number, string> | undefined {
if (original === null || original === undefined) return undefined;
if (modified === null || modified === undefined) return undefined;
const patch = structuredPatch('file', 'file', original, modified);
const hunks = patch.hunks ?? [];
if (hunks.length === 0) return undefined;
if (hunks.length !== expectedHunkCount) return undefined;
const out: Record<number, string> = {};
for (let i = 0; i < hunks.length; i++) {
const hunk = hunks[i];
const oldSideContent = hunk.lines
.filter((l) => !l.startsWith('+'))
.map((l) => l.slice(1))
.join('\n');
const newSideContent = hunk.lines
.filter((l) => !l.startsWith('-'))
.map((l) => l.slice(1))
.join('\n');
out[i] = computeDiffContextHash(oldSideContent, newSideContent);
}
return out;
}
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
set,
get
) => {
const addMatchingReviewPathAliases = (
aliases: Set<string>,
filePath: string,
canonicalFilePath: string,
record: Record<string, unknown>
): void => {
for (const key of Object.keys(record)) {
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
aliases.add(key);
}
}
};
const buildResolvedFileInvalidation = (
s: ChangeReviewSlice,
filePath: string
): Pick<
ChangeReviewSlice,
| 'fileChunkCounts'
| 'fileContents'
| 'fileContentsLoading'
| 'hunkContextHashesByFile'
| 'fileContentVersionByPath'
> => {
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
const canonicalFilePath = existing?.filePath ?? filePath;
const aliases = new Set([filePath, canonicalFilePath]);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentsLoading);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentVersionByPath);
const nextFileChunkCounts = { ...s.fileChunkCounts };
for (const alias of aliases) delete nextFileChunkCounts[alias];
const nextFileContents = { ...s.fileContents };
for (const alias of aliases) delete nextFileContents[alias];
const nextFileContentsLoading = { ...s.fileContentsLoading };
for (const alias of aliases) delete nextFileContentsLoading[alias];
const nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile };
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, filePath);
delete nextHunkContextHashesByFile[reviewKey];
for (const alias of aliases) delete nextHunkContextHashesByFile[alias];
const nextFileContentVersionByPath = { ...s.fileContentVersionByPath };
for (const alias of aliases) {
nextFileContentVersionByPath[alias] = (s.fileContentVersionByPath[alias] ?? 0) + 1;
}
return {
fileChunkCounts: nextFileChunkCounts,
fileContents: nextFileContents,
fileContentsLoading: nextFileContentsLoading,
hunkContextHashesByFile: nextHunkContextHashesByFile,
fileContentVersionByPath: nextFileContentVersionByPath,
};
};
const installActiveChangeSetForLoad = (
data: ReviewChangeSetLike,
extraState?: Partial<ChangeReviewSlice>
): void => {
set((s) => ({
activeChangeSet: data,
changeSetLoading: false,
selectedReviewFilePath: data.files[0]?.filePath ?? null,
hunkDecisions: {},
fileDecisions: {},
fileContents: {},
fileContentsLoading: {},
fileChunkCounts: {},
reviewUndoStack: [],
hunkContextHashesByFile: {},
applyError: null,
editedContents: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
...extraState,
}));
};
const replaceActiveChangeSetAfterStaleRefresh = (
fresh: ReviewChangeSetLike,
applyError: string
): void => {
set((s) => ({
activeChangeSet: fresh,
applying: false,
applyError,
selectedReviewFilePath: fresh.files[0]?.filePath ?? null,
hunkDecisions: {},
fileDecisions: {},
fileChunkCounts: {},
reviewUndoStack: [],
hunkContextHashesByFile: {},
fileContents: {},
fileContentsLoading: {},
editedContents: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
}));
};
const revalidateTaskChangePresence = async (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
): Promise<void> => {
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
if (
!isTaskSummaryCacheableForOptions(options) ||
taskChangesPresenceRevalidationInFlight.has(cacheKey)
) {
return;
}
taskChangesPresenceRevalidationInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(teamName, taskId, {
...options,
summaryOnly: true,
forceFresh: true,
});
const nextPresence = resolveTaskChangePresenceFromResult(data);
set((state) => ({
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
state.taskChangePresenceByKey,
cacheKey,
nextPresence
),
}));
syncTaskChangeNegativeCache(cacheKey, nextPresence);
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown');
} catch {
// Best-effort background revalidation; keep optimistic state on transient failure.
} finally {
taskChangesPresenceRevalidationInFlight.delete(cacheKey);
}
};
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: {},
changeSetEpoch: 0,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
collapseUnchanged: true,
applyError: null,
applying: false,
// Editable diff initial state
editedContents: {},
taskChangePresenceByKey: {},
fetchAgentChanges: async (teamName: string, memberName: string) => {
const requestToken = ++latestAgentChangesRequestToken;
set({ changeSetLoading: true, changeSetError: null });
try {
const data = await api.review.getAgentChanges(teamName, memberName);
if (requestToken !== latestAgentChangesRequestToken) return;
installActiveChangeSetForLoad(data, { activeTaskChangeRequestOptions: null });
} catch (error) {
if (requestToken !== latestAgentChangesRequestToken) return;
const message = error instanceof Error ? error.message : 'Failed to fetch agent changes';
logger.error('fetchAgentChanges error:', message);
set({ changeSetError: message, changeSetLoading: false });
}
},
recordTaskChangePresence: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions,
presence: TaskChangePresenceState | null
) => {
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
set((s) => {
return {
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
s.taskChangePresenceByKey,
cacheKey,
presence
),
};
});
syncTaskChangeNegativeCache(cacheKey, presence);
},
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);
const nextPresence = resolveTaskChangePresenceFromResult(data);
installActiveChangeSetForLoad(data, {
activeTaskChangeRequestOptions: options,
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
get().taskChangePresenceByKey,
cacheKey,
nextPresence
),
});
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown');
syncTaskChangeNegativeCache(cacheKey, nextPresence);
} 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: () => {
latestAgentChangesRequestToken++;
latestTaskChangesRequestToken++;
latestDecisionLoadRequestToken++;
clearAllPersistDecisionTimers();
set((s) => ({
activeChangeSet: null,
changeSetLoading: false,
changeSetError: null,
selectedReviewFilePath: null,
activeTaskChangeRequestOptions: null,
hunkDecisions: {},
fileDecisions: {},
fileChunkCounts: {},
reviewUndoStack: [],
hunkContextHashesByFile: {},
fileContents: {},
fileContentsLoading: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
applyError: null,
applying: false,
editedContents: {},
}));
},
clearChangeReviewCache: () => {
latestAgentChangesRequestToken++;
latestTaskChangesRequestToken++;
latestDecisionLoadRequestToken++;
clearAllPersistDecisionTimers();
set((s) => ({
activeChangeSet: null,
changeSetLoading: false,
changeSetError: null,
selectedReviewFilePath: null,
activeTaskChangeRequestOptions: null,
hunkDecisions: {},
fileDecisions: {},
fileChunkCounts: {},
reviewUndoStack: [],
hunkContextHashesByFile: {},
fileContents: {},
fileContentsLoading: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
applyError: null,
applying: false,
editedContents: {},
}));
},
resetAllReviewState: () => {
latestAgentChangesRequestToken++;
latestTaskChangesRequestToken++;
latestDecisionLoadRequestToken++;
clearAllPersistDecisionTimers();
set((s) => ({
activeChangeSet: null,
changeSetLoading: false,
changeSetError: null,
selectedReviewFilePath: null,
activeTaskChangeRequestOptions: null,
hunkDecisions: {},
fileDecisions: {},
fileChunkCounts: {},
reviewUndoStack: [],
hunkContextHashesByFile: {},
fileContents: {},
fileContentsLoading: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
applyError: null,
applying: false,
editedContents: {},
}));
},
// ── Decision persistence ──
loadDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken: string) => {
const requestToken = ++latestDecisionLoadRequestToken;
try {
const data = await api.review.loadDecisions(teamName, scopeKey, scopeToken);
if (requestToken !== latestDecisionLoadRequestToken) return;
const normalized = normalizePersistedReviewState(get().activeChangeSet?.files ?? [], {
hunkDecisions: data?.hunkDecisions,
fileDecisions: data?.fileDecisions,
hunkContextHashesByFile: data?.hunkContextHashesByFile,
});
// Always set decisions — even to empty if no saved file exists.
// This prevents stale decisions from a previous scope leaking through.
set({
hunkDecisions: normalized.hunkDecisions,
fileDecisions: normalized.fileDecisions,
hunkContextHashesByFile: normalized.hunkContextHashesByFile,
});
} catch (error) {
if (requestToken !== latestDecisionLoadRequestToken) return;
logger.error('loadDecisionsFromDisk error:', error);
set({
hunkDecisions: {},
fileDecisions: {},
hunkContextHashesByFile: {},
});
}
},
persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => {
const scopeStorageKey = buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken);
clearPersistDecisionTimer(scopeStorageKey);
const {
hunkDecisions,
fileDecisions,
hunkContextHashesByFile,
activeChangeSet,
fileContents,
fileChunkCounts,
} = get();
const computed: Record<string, Record<number, string>> = {};
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;
}
const mergedHashes: Record<string, Record<number, string>> = {};
for (const file of activeChangeSet?.files ?? []) {
const fp = file.filePath;
const reviewKey = getFileReviewKey(file);
mergedHashes[reviewKey] =
computed[fp] ?? hunkContextHashesByFile[reviewKey] ?? hunkContextHashesByFile[fp] ?? {};
}
set({ hunkContextHashesByFile: mergedHashes });
const persistedHunkDecisions = { ...hunkDecisions };
const persistedFileDecisions = { ...fileDecisions };
const persistedHashes = { ...mergedHashes };
const timer = setTimeout(() => {
persistDebounceTimers.delete(scopeStorageKey);
void api.review.saveDecisions(
teamName,
scopeKey,
scopeToken,
persistedHunkDecisions,
persistedFileDecisions,
persistedHashes
);
}, PERSIST_DEBOUNCE_MS);
persistDebounceTimers.set(scopeStorageKey, timer);
},
clearDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken?: string) => {
clearPersistDecisionTimer(buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken));
try {
await api.review.clearDecisions(teamName, scopeKey, scopeToken);
} 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;
const reviewKey = getReviewKeyForFilePath(state.activeChangeSet?.files, filePath);
// 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(reviewKey, hunkIndex, state.hunkDecisions, totalChunks)
: hunkIndex;
const key = buildHunkDecisionKey(reviewKey, originalIndex);
set((s) => ({
hunkDecisions: { ...s.hunkDecisions, [key]: decision },
}));
return originalIndex;
},
clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => {
const key = buildHunkDecisionKey(
getReviewKeyForFilePath(get().activeChangeSet?.files, 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) => {
const reviewKey = getReviewKeyForFilePath(get().activeChangeSet?.files, filePath);
set((state) => ({
fileDecisions: { ...state.fileDecisions, [reviewKey]: 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 = findReviewFileByPath(state.activeChangeSet?.files, filePath);
if (!file) return;
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
const newHunkDecisions = { ...state.hunkDecisions };
const reviewKey = getFileReviewKey(file);
for (let i = 0; i < count; i++) {
newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted';
}
set({
hunkDecisions: newHunkDecisions,
fileDecisions: { ...state.fileDecisions, [reviewKey]: 'accepted' },
});
},
rejectAllFile: (filePath: string) => {
const state = get();
const file = findReviewFileByPath(state.activeChangeSet?.files, filePath);
if (!file) return;
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
const newHunkDecisions = { ...state.hunkDecisions };
const reviewKey = getFileReviewKey(file);
for (let i = 0; i < count; i++) {
newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'rejected';
}
set({
hunkDecisions: newHunkDecisions,
fileDecisions: { ...state.fileDecisions, [reviewKey]: 'rejected' },
});
},
acceptAll: () => {
const state = get();
if (!state.activeChangeSet) return;
const newHunkDecisions: Record<string, HunkDecision> = {};
const newFileDecisions: Record<string, HunkDecision> = {};
for (const file of state.activeChangeSet.files) {
const reviewKey = getFileReviewKey(file);
newFileDecisions[reviewKey] = 'accepted';
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
for (let i = 0; i < count; i++) {
newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted';
}
}
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
},
rejectAll: () => {
const state = get();
if (!state.activeChangeSet) return;
const newHunkDecisions: Record<string, HunkDecision> = {};
const newFileDecisions: Record<string, HunkDecision> = {};
for (const file of state.activeChangeSet.files) {
const reviewKey = getFileReviewKey(file);
newFileDecisions[reviewKey] = 'rejected';
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
for (let i = 0; i < count; i++) {
newHunkDecisions[buildHunkDecisionKey(reviewKey, 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();
const fileEntry = findReviewFileByPath(state.activeChangeSet?.files, filePath);
const canonicalFilePath = fileEntry?.filePath ?? filePath;
// Skip if already loaded or loading
if (
state.fileContents[filePath] ||
state.fileContents[canonicalFilePath] ||
state.fileContentsLoading[filePath] ||
state.fileContentsLoading[canonicalFilePath]
)
return;
const changeSetEpoch = state.changeSetEpoch;
const fileVersion = state.fileContentVersionByPath[filePath] ?? 0;
const canonicalFileVersion = state.fileContentVersionByPath[canonicalFilePath] ?? 0;
set((s) => ({
fileContentsLoading: {
...s.fileContentsLoading,
[filePath]: true,
[canonicalFilePath]: true,
},
}));
try {
const snippets = fileEntry?.snippets ?? [];
const content = await api.review.getFileContent(
teamName,
memberName,
canonicalFilePath,
snippets
);
const latest = get();
if (changeSetEpoch !== latest.changeSetEpoch) return;
if ((latest.fileContentVersionByPath[filePath] ?? 0) !== fileVersion) return;
if ((latest.fileContentVersionByPath[canonicalFilePath] ?? 0) !== canonicalFileVersion)
return;
set((s) => {
const nextFileContents = { ...s.fileContents, [canonicalFilePath]: content };
if (canonicalFilePath !== filePath) {
delete nextFileContents[filePath];
}
const result: Partial<ChangeReviewSlice> = {
fileContents: nextFileContents,
fileContentsLoading: {
...s.fileContentsLoading,
[filePath]: false,
[canonicalFilePath]: 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) =>
reviewPathsEqual(f.filePath, canonicalFilePath)
? { ...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) {
const latest = get();
if (changeSetEpoch !== latest.changeSetEpoch) return;
if ((latest.fileContentVersionByPath[filePath] ?? 0) !== fileVersion) return;
if ((latest.fileContentVersionByPath[canonicalFilePath] ?? 0) !== canonicalFileVersion)
return;
logger.error('fetchFileContent error:', error);
set((s) => ({
fileContentsLoading: {
...s.fileContentsLoading,
[filePath]: false,
[canonicalFilePath]: 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;
const currentFingerprint = getReviewChangeSetIdentityToken(current);
const staleMessage =
'Changes have been updated since you started reviewing. Please re-review.';
if (memberName && current) {
const fresh = await api.review.getAgentChanges(teamName, memberName);
if (currentFingerprint !== getReviewChangeSetIdentityToken(fresh)) {
replaceActiveChangeSetAfterStaleRefresh(fresh, staleMessage);
return;
}
} else if (taskId && current) {
const fresh = await api.review.getTaskChanges(teamName, taskId, {
...(state.activeTaskChangeRequestOptions ?? {}),
forceFresh: true,
});
if (currentFingerprint !== getReviewChangeSetIdentityToken(fresh)) {
replaceActiveChangeSetAfterStaleRefresh(fresh, staleMessage);
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 reviewKey = getFileReviewKey(file);
const fileDecision = fileDecisions[reviewKey] ?? 'pending';
const hunkDecs: Record<number, HunkDecision> = {};
const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions);
const count = Math.max(baseCount, maxIdx + 1);
for (let i = 0; i < count; i++) {
const key = buildHunkDecisionKey(reviewKey, 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 = findReviewFileByPath(activeChangeSet.files, filePath);
if (!file) return null;
const reviewKey = getFileReviewKey(file);
const fileDecision = fileDecisions[reviewKey] ?? 'pending';
const hunkDecs: Record<number, HunkDecision> = {};
const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions);
const count = Math.max(baseCount, maxIdx + 1);
for (let i = 0; i < count; i++) {
hunkDecs[i] = hunkDecisions[buildHunkDecisionKey(reviewKey, i)] ?? 'pending';
}
const hasRejected =
fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected');
if (!hasRejected) return null;
try {
const content = fileContents[file.filePath] ?? fileContents[filePath];
const innerBaseCount = getFileHunkCount(
file.filePath,
file.snippets.length,
fileChunkCounts
);
const innerMaxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions);
const hunkContextHashes =
innerMaxIdx < innerBaseCount
? buildHunkContextHashesForFile(
content?.originalFullContent,
content?.modifiedFullContent,
innerBaseCount
)
: undefined;
const result = await api.review.applyDecisions({
teamName,
taskId,
memberName,
decisions: [
{
filePath: file.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 = findReviewFileByPath(s.activeChangeSet.files, filePath);
if (!existing) return s;
const nextFiles = s.activeChangeSet.files.filter(
(f) => !reviewPathsEqual(f.filePath, existing.filePath)
);
const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0);
const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0);
const aliases = new Set([filePath, existing.filePath]);
const addMatchingAliases = (record: Record<string, unknown>): void => {
for (const key of Object.keys(record)) {
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, existing.filePath)) {
aliases.add(key);
}
}
};
addMatchingAliases(s.fileChunkCounts);
addMatchingAliases(s.fileContents);
addMatchingAliases(s.fileContentsLoading);
addMatchingAliases(s.editedContents);
addMatchingAliases(s.reviewExternalChangesByFile);
addMatchingAliases(s.fileContentVersionByPath);
const nextHunkDecisions = { ...s.hunkDecisions };
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet.files, filePath);
const prefix = `${reviewKey}:`;
for (const key of Object.keys(nextHunkDecisions)) {
if (key.startsWith(prefix)) delete nextHunkDecisions[key];
}
const nextFileDecisions = { ...s.fileDecisions };
delete nextFileDecisions[reviewKey];
const nextFileChunkCounts = { ...s.fileChunkCounts };
for (const alias of aliases) delete nextFileChunkCounts[alias];
const nextFileContents = { ...s.fileContents };
for (const alias of aliases) delete nextFileContents[alias];
const nextFileContentsLoading = { ...s.fileContentsLoading };
for (const alias of aliases) delete nextFileContentsLoading[alias];
const nextEditedContents = { ...s.editedContents };
for (const alias of aliases) delete nextEditedContents[alias];
const nextHashes = { ...s.hunkContextHashesByFile };
delete nextHashes[reviewKey];
for (const alias of aliases) delete nextHashes[alias];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
for (const alias of aliases) delete nextReviewExternalChangesByFile[alias];
const nextFileContentVersionByPath = { ...s.fileContentVersionByPath };
for (const alias of aliases) {
nextFileContentVersionByPath[alias] = (s.fileContentVersionByPath[alias] ?? 0) + 1;
}
const nextSelected =
s.selectedReviewFilePath && reviewPathsEqual(s.selectedReviewFilePath, existing.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,
fileContentVersionByPath: nextFileContentVersionByPath,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
};
});
},
addReviewFile: (
file: FileChangeSummary,
options?: { index?: number; content?: FileChangeWithContent }
) => {
set((s) => {
if (!s.activeChangeSet) return s;
if (findReviewFileByPath(s.activeChangeSet.files, 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;
const nextFileContentVersionByPath = {
...s.fileContentVersionByPath,
[file.filePath]: s.fileContentVersionByPath[file.filePath] ?? 0,
};
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[file.filePath];
return {
activeChangeSet: {
...s.activeChangeSet,
files: nextFiles,
totalFiles: nextFiles.length,
totalLinesAdded,
totalLinesRemoved,
},
selectedReviewFilePath: s.selectedReviewFilePath ?? file.filePath,
fileContents: nextFileContents,
fileContentsLoading: nextFileContentsLoading,
fileContentVersionByPath: nextFileContentVersionByPath,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
};
});
},
clearReviewStateForFile: (filePath: string) => {
set((s) => {
const nextHunkDecisions = { ...s.hunkDecisions };
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, filePath);
const prefix = `${reviewKey}:`;
for (const key of Object.keys(nextHunkDecisions)) {
if (key.startsWith(prefix) && nextHunkDecisions[key] === 'rejected') {
delete nextHunkDecisions[key];
}
}
const nextFileDecisions = { ...s.fileDecisions };
if (nextFileDecisions[reviewKey] === 'rejected') {
delete nextFileDecisions[reviewKey];
}
const nextEditedContents = { ...s.editedContents };
delete nextEditedContents[filePath];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[filePath];
return {
hunkDecisions: nextHunkDecisions,
fileDecisions: nextFileDecisions,
editedContents: nextEditedContents,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
...buildResolvedFileInvalidation(s, filePath),
};
});
},
invalidateResolvedFileContent: (filePath: string) => {
set((s) => buildResolvedFileInvalidation(s, filePath));
},
markReviewFileExternallyChanged: (filePath: string, type: ReviewExternalChange['type']) => {
set((s) => ({
reviewExternalChangesByFile: {
...s.reviewExternalChangesByFile,
[filePath]: { type },
},
}));
},
clearReviewFileExternalChange: (filePath: string) => {
set((s) => {
if (!(filePath in s.reviewExternalChangesByFile)) return s;
const next = { ...s.reviewExternalChangesByFile };
delete next[filePath];
return { reviewExternalChangesByFile: next };
});
},
reloadReviewFileFromDisk: (filePath: string) => {
set((s) => {
const nextEditedContents = { ...s.editedContents };
delete nextEditedContents[filePath];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[filePath];
return {
editedContents: nextEditedContents,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
...buildResolvedFileInvalidation(s, filePath),
};
});
},
// ── 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 state = get();
const fileEntry = findReviewFileByPath(state.activeChangeSet?.files, filePath);
const canonicalFilePath = fileEntry?.filePath ?? filePath;
const hasRequestedDraft = filePath in state.editedContents;
const hasCanonicalDraft = canonicalFilePath in state.editedContents;
const content = hasRequestedDraft
? state.editedContents[filePath]
: state.editedContents[canonicalFilePath];
if (!hasRequestedDraft && !hasCanonicalDraft) return;
if (content === undefined) return;
set((s) => ({
fileContentsLoading: {
...s.fileContentsLoading,
[filePath]: false,
[canonicalFilePath]: false,
},
applying: true,
applyError: null,
fileContentVersionByPath: {
...s.fileContentVersionByPath,
[filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1,
[canonicalFilePath]: (s.fileContentVersionByPath[canonicalFilePath] ?? 0) + 1,
},
}));
try {
await api.review.saveEditedFile(canonicalFilePath, content, projectPath);
set((s) => {
const aliases = new Set([filePath, canonicalFilePath]);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.editedContents);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
addMatchingReviewPathAliases(
aliases,
filePath,
canonicalFilePath,
s.hunkContextHashesByFile
);
addMatchingReviewPathAliases(
aliases,
filePath,
canonicalFilePath,
s.reviewExternalChangesByFile
);
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
const nextEdited = { ...s.editedContents };
for (const alias of aliases) delete nextEdited[alias];
const nextFileChunkCounts = { ...s.fileChunkCounts };
for (const alias of aliases) delete nextFileChunkCounts[alias];
const nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile };
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, canonicalFilePath);
delete nextHunkContextHashesByFile[reviewKey];
for (const alias of aliases) delete nextHunkContextHashesByFile[alias];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
for (const alias of aliases) delete nextReviewExternalChangesByFile[alias];
// 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[canonicalFilePath] ?? nextContents[filePath];
for (const alias of aliases) {
if (alias !== canonicalFilePath) delete nextContents[alias];
}
if (existing) {
nextContents[canonicalFilePath] = {
...existing,
filePath: canonicalFilePath,
modifiedFullContent: content,
contentSource: 'disk-current',
};
}
return {
editedContents: nextEdited,
fileChunkCounts: nextFileChunkCounts,
hunkContextHashesByFile: nextHunkContextHashesByFile,
fileContents: nextContents,
reviewExternalChangesByFile: nextReviewExternalChangesByFile,
applying: false,
};
});
} catch (error) {
set({ applying: false, applyError: mapReviewError(error) });
}
},
checkTaskHasChanges: async (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions
) => {
const selectedTask =
get().selectedTeamName === teamName
? get().selectedTeamData?.tasks.find((task) => task.id === taskId)
: undefined;
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
const summaryCacheable = isTaskSummaryCacheableForOptions(options);
const cachedPresence = get().taskChangePresenceByKey[cacheKey];
if (
summaryCacheable &&
(cachedPresence === 'has_changes' || cachedPresence === 'needs_attention')
) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
return;
}
if (taskChangesCheckInFlight.has(cacheKey)) return;
const negativeTs = taskChangesNegativeCache.get(cacheKey);
const hasUnknownPresence = selectedTask?.changePresence === 'unknown';
if (negativeTs && Date.now() - negativeTs < NEGATIVE_CACHE_TTL && !hasUnknownPresence) return;
taskChangesCheckInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(teamName, taskId, {
...options,
summaryOnly: true,
});
const nextPresence = resolveTaskChangePresenceFromResult(data);
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
set((s) => ({
taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: nextPresence },
}));
taskChangesNegativeCache.delete(cacheKey);
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence);
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
void revalidateTaskChangePresence(teamName, taskId, options);
}
} else if (nextPresence === 'no_changes') {
set((s) => ({
taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: 'no_changes' },
}));
taskChangesNegativeCache.set(cacheKey, Date.now());
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes');
} else {
set((s) => {
const nextTaskChangePresenceByKey = { ...s.taskChangePresenceByKey };
delete nextTaskChangePresenceByKey[cacheKey];
return { taskChangePresenceByKey: nextTaskChangePresenceByKey };
});
taskChangesNegativeCache.delete(cacheKey);
if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'unknown');
}
}
} 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);
}
const entries = [...uniqueRequests.entries()];
const runWarmRequest = async (
cacheKey: string,
request: { teamName: string; taskId: string; options: TaskChangeRequestOptions }
): Promise<void> => {
const cachedPresence = get().taskChangePresenceByKey[cacheKey];
if (
cachedPresence === 'has_changes' ||
cachedPresence === 'needs_attention' ||
taskChangesCheckInFlight.has(cacheKey)
) {
return;
}
taskChangesCheckInFlight.add(cacheKey);
try {
const data = await api.review.getTaskChanges(request.teamName, request.taskId, {
...request.options,
summaryOnly: true,
});
const nextPresence = resolveTaskChangePresenceFromResult(data);
if (nextPresence) {
set((s) => ({
taskChangePresenceByKey: {
...s.taskChangePresenceByKey,
[cacheKey]: nextPresence,
},
}));
}
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
taskChangesNegativeCache.delete(cacheKey);
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
void revalidateTaskChangePresence(request.teamName, request.taskId, request.options);
}
} else if (nextPresence === 'no_changes') {
taskChangesNegativeCache.set(cacheKey, Date.now());
} else {
taskChangesNegativeCache.delete(cacheKey);
}
} catch {
// Best-effort warm path.
} finally {
taskChangesCheckInFlight.delete(cacheKey);
}
};
for (let index = 0; index < entries.length; index += TASK_CHANGE_WARM_CONCURRENCY) {
await Promise.all(
entries
.slice(index, index + TASK_CHANGE_WARM_CONCURRENCY)
.map(([cacheKey, request]) => runWarmRequest(cacheKey, request))
);
}
},
invalidateTaskChangePresence: (cacheKeys) => {
if (cacheKeys.length === 0) return;
const keySet = new Set(cacheKeys);
set((state) => {
const nextTaskChangePresenceByKey = { ...state.taskChangePresenceByKey };
let changed = false;
for (const key of keySet) {
if (key in nextTaskChangePresenceByKey) {
delete nextTaskChangePresenceByKey[key];
changed = true;
}
taskChangesNegativeCache.delete(key);
}
return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {};
});
},
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 };
});
},
};
};