feat: add summaryOnly option for task changes retrieval and enhance caching mechanisms

- Introduced a summaryOnly option in the API for fetching task changes, allowing for lightweight responses that skip detailed snippets and timelines.
- Enhanced ChangeExtractorService to utilize the summaryOnly option, improving performance by conditionally caching task change data.
- Updated related components and services to support the new summaryOnly feature, ensuring consistent behavior across the application.
- Improved state management in TaskDetailDialog for task changes, including loading and error handling enhancements.
This commit is contained in:
iliya 2026-03-10 22:22:42 +02:00
parent d6a0f4c3a1
commit b1a00d67ed
8 changed files with 291 additions and 103 deletions

View file

@ -174,6 +174,7 @@ async function handleGetTaskChanges(
typeof (i as Record<string, unknown>).completedAt === 'string')
) as { startedAt: string; completedAt?: string }[])
: undefined,
summaryOnly: (options as Record<string, unknown>).summaryOnly === true,
}
: undefined;

View file

@ -36,6 +36,12 @@ interface TaskChangeCacheEntry {
expiresAt: number;
}
interface ParsedSnippetsCacheEntry {
data: SnippetDiff[];
mtime: number;
expiresAt: number;
}
/** Ссылка на JSONL файл с привязкой к memberName */
interface LogFileRef {
filePath: string;
@ -45,8 +51,10 @@ interface LogFileRef {
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private taskChangeCache = new Map<string, TaskChangeCacheEntry>();
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes
private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
@ -120,10 +128,12 @@ export class ChangeExtractorService {
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
summaryOnly?: boolean;
}
): Promise<TaskChangeSetV2> {
const includeDetails = options?.summaryOnly !== true;
const cacheKey = `task:${teamName}:${taskId}`;
const cached = this.taskChangeCache.get(cacheKey);
const cached = includeDetails ? this.taskChangeCache.get(cacheKey) : undefined;
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
@ -138,10 +148,12 @@ export class ChangeExtractorService {
const logRefs = await this.resolveLogFileRefs(teamName, logs);
if (logRefs.length === 0) {
const empty = this.emptyTaskChangeSet(teamName, taskId);
this.taskChangeCache.set(cacheKey, {
data: empty,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: empty,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return empty;
}
@ -162,7 +174,7 @@ export class ChangeExtractorService {
const intervals = options?.intervals ?? taskMeta?.intervals;
if (Array.isArray(intervals) && intervals.length > 0) {
const { files, toolUseIds, startTimestamp, endTimestamp } =
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath);
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
const intervalScope: TaskChangeScope = {
taskId,
@ -195,10 +207,12 @@ export class ChangeExtractorService {
? ['No file edits found within persisted workIntervals.']
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
};
this.taskChangeCache.set(cacheKey, {
data: intervalResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: intervalResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return intervalResult;
}
@ -206,18 +220,26 @@ export class ChangeExtractorService {
teamName,
taskId,
logRefs,
projectPath
projectPath,
includeDetails
);
this.taskChangeCache.set(cacheKey, {
data: fallbackResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: fallbackResult,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return fallbackResult;
}
// Фильтруем snippets по tool_use IDs из scope
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
const files = await this.extractFilteredChanges(logRefs, allowedToolUseIds, projectPath);
const files = await this.extractFilteredChanges(
logRefs,
allowedToolUseIds,
projectPath,
includeDetails
);
const worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
const warnings: string[] = [];
@ -237,10 +259,12 @@ export class ChangeExtractorService {
scope: allScopes[0],
warnings,
};
this.taskChangeCache.set(cacheKey, {
data: result,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
if (includeDetails) {
this.taskChangeCache.set(cacheKey, {
data: result,
expiresAt: Date.now() + this.taskChangeCacheTtl,
});
}
return result;
}
@ -339,7 +363,8 @@ export class ChangeExtractorService {
private async extractIntervalScopedChanges(
logRefs: LogFileRef[],
intervals: { startedAt: string; completedAt?: string }[],
projectPath?: string
projectPath?: string,
includeDetails = true
): Promise<{
files: FileChangeSummary[];
toolUseIds: string[];
@ -395,7 +420,7 @@ export class ChangeExtractorService {
}
}
const files = this.aggregateByFile(allowedSnippets, projectPath);
const files = this.aggregateByFile(allowedSnippets, projectPath, includeDetails);
return {
files,
toolUseIds: [...toolUseIdsSet],
@ -426,6 +451,19 @@ export class ChangeExtractorService {
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
let fileMtime = 0;
try {
const fileStat = await stat(filePath);
fileMtime = fileStat.mtimeMs;
const cached = this.parsedSnippetsCache.get(filePath);
if (cached?.mtime === fileMtime && cached.expiresAt > Date.now()) {
return cached.data;
}
} catch (err) {
logger.debug(`Не удалось stat файла ${filePath}: ${String(err)}`);
return [];
}
// Сначала считываем все записи в память для двух проходов
const entries: Record<string, unknown>[] = [];
@ -558,6 +596,12 @@ export class ChangeExtractorService {
}
}
this.parsedSnippetsCache.set(filePath, {
data: snippets,
mtime: fileMtime,
expiresAt: Date.now() + this.parsedSnippetsCacheTtl,
});
return snippets;
}
@ -619,7 +663,11 @@ export class ChangeExtractorService {
}
/** Агрегировать snippets в FileChangeSummary[] */
private aggregateByFile(snippets: SnippetDiff[], projectPath?: string): FileChangeSummary[] {
private aggregateByFile(
snippets: SnippetDiff[],
projectPath?: string,
includeDetails = true
): FileChangeSummary[] {
const fileMap = new Map<string, { snippets: SnippetDiff[]; isNewFile: boolean }>();
for (const snippet of snippets) {
@ -659,11 +707,11 @@ export class ChangeExtractorService {
return {
filePath: fp,
relativePath: relative,
snippets: data.snippets,
snippets: includeDetails ? data.snippets : [],
linesAdded: totalAdded,
linesRemoved: totalRemoved,
isNewFile: data.isNewFile,
timeline: this.buildTimeline(fp, data.snippets),
timeline: includeDetails ? this.buildTimeline(fp, data.snippets) : undefined,
};
});
}
@ -767,7 +815,8 @@ export class ChangeExtractorService {
private async extractFilteredChanges(
logRefs: LogFileRef[],
allowedToolUseIds: Set<string>,
projectPath?: string
projectPath?: string,
includeDetails = true
): Promise<FileChangeSummary[]> {
const allSnippets: SnippetDiff[] = [];
for (const ref of logRefs) {
@ -783,17 +832,18 @@ export class ChangeExtractorService {
allSnippets.push(...snippets);
}
}
return this.aggregateByFile(allSnippets, projectPath);
return this.aggregateByFile(allSnippets, projectPath, includeDetails);
}
/** Извлечь все изменения из одного файла */
private async extractAllChanges(
filePath: string,
_memberName: string,
projectPath?: string
projectPath?: string,
includeDetails = true
): Promise<FileChangeSummary[]> {
const snippets = await this.parseJSONLFile(filePath);
return this.aggregateByFile(snippets, projectPath);
return this.aggregateByFile(snippets, projectPath, includeDetails);
}
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
@ -801,11 +851,17 @@ export class ChangeExtractorService {
teamName: string,
taskId: string,
logRefs: LogFileRef[],
projectPath?: string
projectPath?: string,
includeDetails = true
): Promise<TaskChangeSetV2> {
const allFiles: FileChangeSummary[] = [];
for (const ref of logRefs) {
const files = await this.extractAllChanges(ref.filePath, ref.memberName, projectPath);
const files = await this.extractAllChanges(
ref.filePath,
ref.memberName,
projectPath,
includeDetails
);
allFiles.push(...files);
}

View file

@ -1112,6 +1112,7 @@ const electronAPI: ElectronAPI = {
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
summaryOnly?: boolean;
}
) => {
return invokeIpcWithResult<TaskChangeSetV2>(

View file

@ -934,7 +934,17 @@ export class HttpAPIClient implements ElectronAPI {
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getTaskChanges: async (_teamName: string, _taskId: string): Promise<never> => {
getTaskChanges: async (
_teamName: string,
_taskId: string,
_options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
summaryOnly?: boolean;
}
): Promise<never> => {
throw new Error('Review is not available in browser mode');
},
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {

View file

@ -10,6 +10,7 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
@ -482,9 +483,12 @@ interface ProjectsGridProps {
maxProjects?: number;
}
const INITIAL_RECENT_PROJECTS = 11;
const LOAD_MORE_STEP = 8;
const ProjectsGrid = ({
searchQuery,
maxProjects = 12,
maxProjects = INITIAL_RECENT_PROJECTS,
}: Readonly<ProjectsGridProps>): React.JSX.Element => {
const {
repositoryGroups,
@ -511,6 +515,7 @@ const ProjectsGrid = ({
);
const hasFetchedTasksRef = React.useRef(false);
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
useEffect(() => {
if (repositoryGroups.length === 0 && !repositoryGroupsLoading) {
@ -525,26 +530,36 @@ const ProjectsGrid = ({
}
}, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]);
useEffect(() => {
if (!searchQuery.trim()) {
setVisibleProjects(maxProjects);
}
}, [searchQuery, maxProjects]);
const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
// Filter projects based on search query
const filteredRepos = useMemo(() => {
if (!searchQuery.trim()) {
return repositoryGroups.slice(0, maxProjects);
}
const query = searchQuery.toLowerCase().trim();
return repositoryGroups
.filter((repo) => {
// Match by name
if (repo.name.toLowerCase().includes(query)) return true;
// Match by path
const path = repo.worktrees[0]?.path || '';
if (path.toLowerCase().includes(query)) return true;
return false;
})
.slice(0, maxProjects);
}, [repositoryGroups, searchQuery, maxProjects]);
return repositoryGroups.filter((repo) => {
if (!query) return true;
// Match by name
if (repo.name.toLowerCase().includes(query)) return true;
// Match by path
const path = repo.worktrees[0]?.path || '';
if (path.toLowerCase().includes(query)) return true;
return false;
});
}, [repositoryGroups, searchQuery]);
const displayedRepos = useMemo(() => {
if (searchQuery.trim()) {
return filteredRepos;
}
return filteredRepos.slice(0, visibleProjects);
}, [filteredRepos, searchQuery, visibleProjects]);
const canLoadMore = !searchQuery.trim() && filteredRepos.length > visibleProjects;
if (repositoryGroupsLoading) {
// Organic widths per card — no repeating stamp
@ -645,35 +660,49 @@ const ProjectsGrid = ({
}
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{!searchQuery.trim() && <NewProjectCard />}
{filteredRepos.map((repo) => {
const counts = repo.worktrees.reduce(
(acc, wt) => {
const c = taskCountsMap.get(normalizePath(wt.path));
if (c) {
acc.pending += c.pending;
acc.inProgress += c.inProgress;
acc.completed += c.completed;
}
return acc;
},
{ pending: 0, inProgress: 0, completed: 0 }
);
return (
<RepositoryCard
key={repo.id}
repo={repo}
onClick={() => {
selectRepository(repo.id);
openTeamsTab();
}}
isHighlighted={!!searchQuery.trim()}
taskCounts={globalTasksLoading ? undefined : counts}
tasksLoading={globalTasksLoading}
/>
);
})}
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
{!searchQuery.trim() && <NewProjectCard />}
{displayedRepos.map((repo) => {
const counts = repo.worktrees.reduce(
(acc, wt) => {
const c = taskCountsMap.get(normalizePath(wt.path));
if (c) {
acc.pending += c.pending;
acc.inProgress += c.inProgress;
acc.completed += c.completed;
}
return acc;
},
{ pending: 0, inProgress: 0, completed: 0 }
);
return (
<RepositoryCard
key={repo.id}
repo={repo}
onClick={() => {
selectRepository(repo.id);
openTeamsTab();
}}
isHighlighted={!!searchQuery.trim()}
taskCounts={globalTasksLoading ? undefined : counts}
tasksLoading={globalTasksLoading}
/>
);
})}
</div>
{canLoadMore && (
<div className="flex justify-center">
<Button
variant="outline"
size="sm"
onClick={() => setVisibleProjects((prev) => prev + LOAD_MORE_STEP)}
>
Load more
</Button>
</div>
)}
</div>
);
};

View file

@ -25,6 +25,7 @@ interface CollapsibleTeamSectionProps {
headerExtra?: React.ReactNode;
defaultOpen?: boolean;
forceOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
action?: React.ReactNode;
/** Stable identifier used for programmatic section navigation. */
sectionId?: string;
@ -46,6 +47,7 @@ export const CollapsibleTeamSection = ({
headerExtra,
defaultOpen = true,
forceOpen,
onOpenChange,
action,
sectionId,
contentClassName,
@ -69,6 +71,10 @@ export const CollapsibleTeamSection = ({
return () => el.removeEventListener('team-section-navigate', handleNavigate);
}, [handleNavigate]);
useEffect(() => {
onOpenChange?.(isOpen);
}, [isOpen, onOpenChange]);
return (
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
<div

View file

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import {
ImageLightbox,
@ -53,6 +54,7 @@ import {
MessageSquare,
Pencil,
PenLine,
RefreshCw,
ScrollText,
SquarePen,
Trash2,
@ -65,6 +67,7 @@ import { TaskCommentInput } from './TaskCommentInput';
import { TaskCommentsSection } from './TaskCommentsSection';
import type {
FileChangeSummary,
KanbanTaskState,
ResolvedTeamMember,
TaskAttachmentMeta,
@ -136,6 +139,10 @@ export const TaskDetailDialog = ({
const [logsRefreshing, setLogsRefreshing] = useState(false);
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
const [taskChangesError, setTaskChangesError] = useState<string | null>(null);
// Inline editing: subject
const [editingSubject, setEditingSubject] = useState(false);
@ -199,6 +206,15 @@ export const TaskDetailDialog = ({
setEditingDescription(false);
}, [open, currentTask?.id]);
useEffect(() => {
setChangesSectionOpen(false);
setTaskChangesFiles(null);
setTaskChangesLoading(false);
setTaskChangesError(null);
setLogsRefreshing(false);
setExecutionPreviewOnline(false);
}, [open, currentTask?.id]);
const [replyTo, setReplyTo] = useState<{
taskId: string;
author: string;
@ -272,45 +288,83 @@ export const TaskDetailDialog = ({
// Lazy-load task changes when dialog is open and task is completed
const isTaskCompleted = currentTask?.status === 'completed';
const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
const activeChangeSet = useStore((s) => s.activeChangeSet);
const changeSetLoading = useStore((s) => s.changeSetLoading);
const fetchTaskChanges = useStore((s) => s.fetchTaskChanges);
// Use the lightweight cache to know if changes exist before full data loads
const changesCacheKey = currentTask ? `${teamName}:${currentTask.id}` : '';
const taskKnownHasChanges = useStore((s) => s.taskHasChanges[changesCacheKey]) === true;
const taskChangesFiles = useMemo(() => {
if (!activeChangeSet || !currentTask) return null;
if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) {
return activeChangeSet.files;
}
return null;
}, [activeChangeSet, currentTask]);
const loadTaskChangeSummary = useCallback(async (): Promise<FileChangeSummary[] | null> => {
if (!currentTask || variant !== 'team' || !isTaskCompleted || !onViewChanges) return null;
const data = await api.review.getTaskChanges(teamName, currentTask.id, {
owner: currentTask.owner,
status: currentTask.status,
intervals: currentTask.workIntervals,
since: taskSince,
summaryOnly: true,
});
return data.files;
}, [currentTask, isTaskCompleted, onViewChanges, teamName, taskSince, variant]);
useEffect(() => {
if (variant !== 'team') return;
if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return;
// Only fetch if we don't already have data for this task
if (taskChangesFiles !== null) return;
void fetchTaskChanges(teamName, currentTask.id);
if (!open || !currentTask || !isTaskCompleted || !onViewChanges || !changesSectionOpen) return;
let cancelled = false;
setTaskChangesLoading(true);
setTaskChangesError(null);
void loadTaskChangeSummary()
.then((files) => {
if (!cancelled) setTaskChangesFiles(files ?? null);
})
.catch((error) => {
if (!cancelled) {
setTaskChangesFiles(null);
setTaskChangesError(
error instanceof Error ? error.message : 'Failed to load task changes summary'
);
}
})
.finally(() => {
if (!cancelled) setTaskChangesLoading(false);
});
return () => {
cancelled = true;
};
}, [
changesSectionOpen,
open,
currentTask,
isTaskCompleted,
teamName,
fetchTaskChanges,
taskChangesFiles,
onViewChanges,
taskSince,
variant,
loadTaskChangeSummary,
]);
const handleRefreshChanges = useCallback(() => {
if (!currentTask || variant !== 'team' || !isTaskCompleted || !onViewChanges) return;
setTaskChangesLoading(true);
setTaskChangesError(null);
void loadTaskChangeSummary()
.then((files) => setTaskChangesFiles(files ?? null))
.catch((error) => {
setTaskChangesFiles(null);
setTaskChangesError(
error instanceof Error ? error.message : 'Failed to load task changes summary'
);
})
.finally(() => setTaskChangesLoading(false));
}, [currentTask, isTaskCompleted, onViewChanges, loadTaskChangeSummary, variant]);
const handleDependencyClick = (taskId: string): void => {
handleClose();
onScrollToTask?.(taskId);
};
const handleChangesSectionOpenChange = useCallback((isOpen: boolean): void => {
setChangesSectionOpen(isOpen);
}, []);
if (loading) {
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
@ -735,19 +789,47 @@ export const TaskDetailDialog = ({
{/* Changes */}
{variant === 'team' && isTaskCompleted && onViewChanges ? (
<CollapsibleTeamSection
key={`task-changes:${currentTask.id}`}
title="Changes"
icon={<FileDiff size={14} />}
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
headerExtra={
changesSectionOpen ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="pointer-events-auto rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-section-hover)] hover:text-[var(--color-text)] disabled:opacity-50"
onClick={(e) => {
e.stopPropagation();
handleRefreshChanges();
}}
disabled={taskChangesLoading}
aria-label="Refresh changes"
>
<RefreshCw
size={12}
className={taskChangesLoading ? 'animate-spin' : undefined}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">Refresh</TooltipContent>
</Tooltip>
) : null
}
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen={taskKnownHasChanges}
defaultOpen={false}
onOpenChange={handleChangesSectionOpenChange}
>
{changeSetLoading || (!taskChangesFiles && taskKnownHasChanges) ? (
{taskChangesLoading ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Loading changes...
</div>
) : taskChangesError ? (
<p className="text-xs text-red-400">{taskChangesError}</p>
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
{taskChangesFiles.map((file) => (
@ -811,15 +893,16 @@ export const TaskDetailDialog = ({
</div>
))}
</div>
) : (
) : changesSectionOpen ? (
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
)}
) : null}
</CollapsibleTeamSection>
) : null}
{/* Execution Logs — sessions that reference this task */}
{variant === 'team' ? (
<CollapsibleTeamSection
key={`task-logs:${currentTask.id}`}
title="Execution Logs"
icon={<ScrollText size={14} />}
headerExtra={
@ -846,7 +929,7 @@ export const TaskDetailDialog = ({
contentClassName="pl-2.5"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
defaultOpen
defaultOpen={false}
>
<div className="min-w-0">
<MemberLogsTab
@ -855,7 +938,7 @@ export const TaskDetailDialog = ({
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals}
taskSince={deriveTaskSince(currentTask)}
taskSince={taskSince}
onRefreshingChange={setLogsRefreshing}
// Only show a "latest messages" preview when this task is owned by a subagent.
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),

View file

@ -595,6 +595,8 @@ export interface ReviewAPI {
intervals?: { startedAt: string; completedAt?: string }[];
/** Back-compat: single since timestamp (deprecated). */
since?: string;
/** Lightweight response for summary UIs; skips snippets/timeline details. */
summaryOnly?: boolean;
}
) => Promise<TaskChangeSetV2>;
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;