From b1a00d67eddf456a0e362528c4dabbf68f448226 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 22:22:42 +0200 Subject: [PATCH] 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. --- src/main/ipc/review.ts | 1 + .../services/team/ChangeExtractorService.ts | 118 +++++++++++---- src/preload/index.ts | 1 + src/renderer/api/httpClient.ts | 12 +- .../components/dashboard/DashboardView.tsx | 119 +++++++++------ .../team/CollapsibleTeamSection.tsx | 6 + .../team/dialogs/TaskDetailDialog.tsx | 135 ++++++++++++++---- src/shared/types/api.ts | 2 + 8 files changed, 291 insertions(+), 103 deletions(-) diff --git a/src/main/ipc/review.ts b/src/main/ipc/review.ts index 6efb399c..eb33ca92 100644 --- a/src/main/ipc/review.ts +++ b/src/main/ipc/review.ts @@ -174,6 +174,7 @@ async function handleGetTaskChanges( typeof (i as Record).completedAt === 'string') ) as { startedAt: string; completedAt?: string }[]) : undefined, + summaryOnly: (options as Record).summaryOnly === true, } : undefined; diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index f93cbf8e..8bff0c22 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -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(); private taskChangeCache = new Map(); + 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 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 { + 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 { + 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[] = []; @@ -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(); 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, - projectPath?: string + projectPath?: string, + includeDetails = true ): Promise { 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 { 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 { 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); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 6acf7f22..aabd845e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1112,6 +1112,7 @@ const electronAPI: ElectronAPI = { status?: string; intervals?: { startedAt: string; completedAt?: string }[]; since?: string; + summaryOnly?: boolean; } ) => { return invokeIpcWithResult( diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 6f1b36de..b54cfac9 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -934,7 +934,17 @@ export class HttpAPIClient implements ElectronAPI { getAgentChanges: async (_teamName: string, _memberName: string): Promise => { throw new Error('Review is not available in browser mode'); }, - getTaskChanges: async (_teamName: string, _taskId: string): Promise => { + getTaskChanges: async ( + _teamName: string, + _taskId: string, + _options?: { + owner?: string; + status?: string; + intervals?: { startedAt: string; completedAt?: string }[]; + since?: string; + summaryOnly?: boolean; + } + ): Promise => { throw new Error('Review is not available in browser mode'); }, getChangeStats: async (_teamName: string, _memberName: string): Promise => { diff --git a/src/renderer/components/dashboard/DashboardView.tsx b/src/renderer/components/dashboard/DashboardView.tsx index 80618c5c..6afa98fc 100644 --- a/src/renderer/components/dashboard/DashboardView.tsx +++ b/src/renderer/components/dashboard/DashboardView.tsx @@ -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): 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 ( -
- {!searchQuery.trim() && } - {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 ( - { - selectRepository(repo.id); - openTeamsTab(); - }} - isHighlighted={!!searchQuery.trim()} - taskCounts={globalTasksLoading ? undefined : counts} - tasksLoading={globalTasksLoading} - /> - ); - })} +
+
+ {!searchQuery.trim() && } + {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 ( + { + selectRepository(repo.id); + openTeamsTab(); + }} + isHighlighted={!!searchQuery.trim()} + taskCounts={globalTasksLoading ? undefined : counts} + tasksLoading={globalTasksLoading} + /> + ); + })} +
+ + {canLoadMore && ( +
+ +
+ )}
); }; diff --git a/src/renderer/components/team/CollapsibleTeamSection.tsx b/src/renderer/components/team/CollapsibleTeamSection.tsx index 83f95343..3c93fe27 100644 --- a/src/renderer/components/team/CollapsibleTeamSection.tsx +++ b/src/renderer/components/team/CollapsibleTeamSection.tsx @@ -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 (
(null); + const [taskChangesLoading, setTaskChangesLoading] = useState(false); + const [taskChangesError, setTaskChangesError] = useState(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 => { + 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 ( !v && onClose()}> @@ -735,19 +789,47 @@ export const TaskDetailDialog = ({ {/* Changes */} {variant === 'team' && isTaskCompleted && onViewChanges ? ( } badge={taskChangesFiles ? taskChangesFiles.length : undefined} + headerExtra={ + changesSectionOpen ? ( + + + + + Refresh + + ) : 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 ? (
Loading changes...
+ ) : taskChangesError ? ( +

{taskChangesError}

) : taskChangesFiles && taskChangesFiles.length > 0 ? (
{taskChangesFiles.map((file) => ( @@ -811,15 +893,16 @@ export const TaskDetailDialog = ({
))}
- ) : ( + ) : changesSectionOpen ? (

No file changes detected

- )} + ) : null} ) : null} {/* Execution Logs — sessions that reference this task */} {variant === 'team' ? ( } 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} >
Promise; getChangeStats: (teamName: string, memberName: string) => Promise;