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:
parent
d6a0f4c3a1
commit
b1a00d67ed
8 changed files with 291 additions and 103 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1112,6 +1112,7 @@ const electronAPI: ElectronAPI = {
|
|||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
summaryOnly?: boolean;
|
||||
}
|
||||
) => {
|
||||
return invokeIpcWithResult<TaskChangeSetV2>(
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue