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')
|
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||||
) as { startedAt: string; completedAt?: string }[])
|
) as { startedAt: string; completedAt?: string }[])
|
||||||
: undefined,
|
: undefined,
|
||||||
|
summaryOnly: (options as Record<string, unknown>).summaryOnly === true,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ interface TaskChangeCacheEntry {
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParsedSnippetsCacheEntry {
|
||||||
|
data: SnippetDiff[];
|
||||||
|
mtime: number;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Ссылка на JSONL файл с привязкой к memberName */
|
/** Ссылка на JSONL файл с привязкой к memberName */
|
||||||
interface LogFileRef {
|
interface LogFileRef {
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
@ -45,8 +51,10 @@ interface LogFileRef {
|
||||||
export class ChangeExtractorService {
|
export class ChangeExtractorService {
|
||||||
private cache = new Map<string, CacheEntry>();
|
private cache = new Map<string, CacheEntry>();
|
||||||
private taskChangeCache = new Map<string, TaskChangeCacheEntry>();
|
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 cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
|
||||||
private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes
|
private readonly taskChangeCacheTtl = 20 * 1000; // 20 сек для task changes
|
||||||
|
private readonly parsedSnippetsCacheTtl = 20 * 1000; // 20 сек для parsed JSONL snippets
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly logsFinder: TeamMemberLogsFinder,
|
private readonly logsFinder: TeamMemberLogsFinder,
|
||||||
|
|
@ -120,10 +128,12 @@ export class ChangeExtractorService {
|
||||||
status?: string;
|
status?: string;
|
||||||
intervals?: { startedAt: string; completedAt?: string }[];
|
intervals?: { startedAt: string; completedAt?: string }[];
|
||||||
since?: string;
|
since?: string;
|
||||||
|
summaryOnly?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<TaskChangeSetV2> {
|
): Promise<TaskChangeSetV2> {
|
||||||
|
const includeDetails = options?.summaryOnly !== true;
|
||||||
const cacheKey = `task:${teamName}:${taskId}`;
|
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()) {
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
@ -138,10 +148,12 @@ export class ChangeExtractorService {
|
||||||
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
const logRefs = await this.resolveLogFileRefs(teamName, logs);
|
||||||
if (logRefs.length === 0) {
|
if (logRefs.length === 0) {
|
||||||
const empty = this.emptyTaskChangeSet(teamName, taskId);
|
const empty = this.emptyTaskChangeSet(teamName, taskId);
|
||||||
this.taskChangeCache.set(cacheKey, {
|
if (includeDetails) {
|
||||||
data: empty,
|
this.taskChangeCache.set(cacheKey, {
|
||||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
data: empty,
|
||||||
});
|
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||||
|
});
|
||||||
|
}
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,7 +174,7 @@ export class ChangeExtractorService {
|
||||||
const intervals = options?.intervals ?? taskMeta?.intervals;
|
const intervals = options?.intervals ?? taskMeta?.intervals;
|
||||||
if (Array.isArray(intervals) && intervals.length > 0) {
|
if (Array.isArray(intervals) && intervals.length > 0) {
|
||||||
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
||||||
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath);
|
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath, includeDetails);
|
||||||
|
|
||||||
const intervalScope: TaskChangeScope = {
|
const intervalScope: TaskChangeScope = {
|
||||||
taskId,
|
taskId,
|
||||||
|
|
@ -195,10 +207,12 @@ export class ChangeExtractorService {
|
||||||
? ['No file edits found within persisted workIntervals.']
|
? ['No file edits found within persisted workIntervals.']
|
||||||
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
|
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
|
||||||
};
|
};
|
||||||
this.taskChangeCache.set(cacheKey, {
|
if (includeDetails) {
|
||||||
data: intervalResult,
|
this.taskChangeCache.set(cacheKey, {
|
||||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
data: intervalResult,
|
||||||
});
|
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||||
|
});
|
||||||
|
}
|
||||||
return intervalResult;
|
return intervalResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -206,18 +220,26 @@ export class ChangeExtractorService {
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
logRefs,
|
logRefs,
|
||||||
projectPath
|
projectPath,
|
||||||
|
includeDetails
|
||||||
);
|
);
|
||||||
this.taskChangeCache.set(cacheKey, {
|
if (includeDetails) {
|
||||||
data: fallbackResult,
|
this.taskChangeCache.set(cacheKey, {
|
||||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
data: fallbackResult,
|
||||||
});
|
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||||
|
});
|
||||||
|
}
|
||||||
return fallbackResult;
|
return fallbackResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Фильтруем snippets по tool_use IDs из scope
|
// Фильтруем snippets по tool_use IDs из scope
|
||||||
const allowedToolUseIds = new Set(allScopes.flatMap((s) => s.toolUseIds));
|
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 worstTier = Math.max(...allScopes.map((s) => s.confidence.tier));
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
@ -237,10 +259,12 @@ export class ChangeExtractorService {
|
||||||
scope: allScopes[0],
|
scope: allScopes[0],
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
this.taskChangeCache.set(cacheKey, {
|
if (includeDetails) {
|
||||||
data: result,
|
this.taskChangeCache.set(cacheKey, {
|
||||||
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
data: result,
|
||||||
});
|
expiresAt: Date.now() + this.taskChangeCacheTtl,
|
||||||
|
});
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -339,7 +363,8 @@ export class ChangeExtractorService {
|
||||||
private async extractIntervalScopedChanges(
|
private async extractIntervalScopedChanges(
|
||||||
logRefs: LogFileRef[],
|
logRefs: LogFileRef[],
|
||||||
intervals: { startedAt: string; completedAt?: string }[],
|
intervals: { startedAt: string; completedAt?: string }[],
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
includeDetails = true
|
||||||
): Promise<{
|
): Promise<{
|
||||||
files: FileChangeSummary[];
|
files: FileChangeSummary[];
|
||||||
toolUseIds: string[];
|
toolUseIds: string[];
|
||||||
|
|
@ -395,7 +420,7 @@ export class ChangeExtractorService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = this.aggregateByFile(allowedSnippets, projectPath);
|
const files = this.aggregateByFile(allowedSnippets, projectPath, includeDetails);
|
||||||
return {
|
return {
|
||||||
files,
|
files,
|
||||||
toolUseIds: [...toolUseIdsSet],
|
toolUseIds: [...toolUseIdsSet],
|
||||||
|
|
@ -426,6 +451,19 @@ export class ChangeExtractorService {
|
||||||
|
|
||||||
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
|
/** Парсить один JSONL файл и извлечь все snippets (двухпроходный подход) */
|
||||||
private async parseJSONLFile(filePath: string): Promise<SnippetDiff[]> {
|
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>[] = [];
|
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;
|
return snippets;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -619,7 +663,11 @@ export class ChangeExtractorService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Агрегировать snippets в FileChangeSummary[] */
|
/** Агрегировать 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 }>();
|
const fileMap = new Map<string, { snippets: SnippetDiff[]; isNewFile: boolean }>();
|
||||||
|
|
||||||
for (const snippet of snippets) {
|
for (const snippet of snippets) {
|
||||||
|
|
@ -659,11 +707,11 @@ export class ChangeExtractorService {
|
||||||
return {
|
return {
|
||||||
filePath: fp,
|
filePath: fp,
|
||||||
relativePath: relative,
|
relativePath: relative,
|
||||||
snippets: data.snippets,
|
snippets: includeDetails ? data.snippets : [],
|
||||||
linesAdded: totalAdded,
|
linesAdded: totalAdded,
|
||||||
linesRemoved: totalRemoved,
|
linesRemoved: totalRemoved,
|
||||||
isNewFile: data.isNewFile,
|
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(
|
private async extractFilteredChanges(
|
||||||
logRefs: LogFileRef[],
|
logRefs: LogFileRef[],
|
||||||
allowedToolUseIds: Set<string>,
|
allowedToolUseIds: Set<string>,
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
includeDetails = true
|
||||||
): Promise<FileChangeSummary[]> {
|
): Promise<FileChangeSummary[]> {
|
||||||
const allSnippets: SnippetDiff[] = [];
|
const allSnippets: SnippetDiff[] = [];
|
||||||
for (const ref of logRefs) {
|
for (const ref of logRefs) {
|
||||||
|
|
@ -783,17 +832,18 @@ export class ChangeExtractorService {
|
||||||
allSnippets.push(...snippets);
|
allSnippets.push(...snippets);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return this.aggregateByFile(allSnippets, projectPath);
|
return this.aggregateByFile(allSnippets, projectPath, includeDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Извлечь все изменения из одного файла */
|
/** Извлечь все изменения из одного файла */
|
||||||
private async extractAllChanges(
|
private async extractAllChanges(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
_memberName: string,
|
_memberName: string,
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
includeDetails = true
|
||||||
): Promise<FileChangeSummary[]> {
|
): Promise<FileChangeSummary[]> {
|
||||||
const snippets = await this.parseJSONLFile(filePath);
|
const snippets = await this.parseJSONLFile(filePath);
|
||||||
return this.aggregateByFile(snippets, projectPath);
|
return this.aggregateByFile(snippets, projectPath, includeDetails);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
|
/** Fallback: вернуть все изменения из лог-файлов как Tier 4 */
|
||||||
|
|
@ -801,11 +851,17 @@ export class ChangeExtractorService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
logRefs: LogFileRef[],
|
logRefs: LogFileRef[],
|
||||||
projectPath?: string
|
projectPath?: string,
|
||||||
|
includeDetails = true
|
||||||
): Promise<TaskChangeSetV2> {
|
): Promise<TaskChangeSetV2> {
|
||||||
const allFiles: FileChangeSummary[] = [];
|
const allFiles: FileChangeSummary[] = [];
|
||||||
for (const ref of logRefs) {
|
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);
|
allFiles.push(...files);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1112,6 +1112,7 @@ const electronAPI: ElectronAPI = {
|
||||||
status?: string;
|
status?: string;
|
||||||
intervals?: { startedAt: string; completedAt?: string }[];
|
intervals?: { startedAt: string; completedAt?: string }[];
|
||||||
since?: string;
|
since?: string;
|
||||||
|
summaryOnly?: boolean;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
return invokeIpcWithResult<TaskChangeSetV2>(
|
return invokeIpcWithResult<TaskChangeSetV2>(
|
||||||
|
|
|
||||||
|
|
@ -934,7 +934,17 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
|
getAgentChanges: async (_teamName: string, _memberName: string): Promise<never> => {
|
||||||
throw new Error('Review is not available in browser mode');
|
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');
|
throw new Error('Review is not available in browser mode');
|
||||||
},
|
},
|
||||||
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {
|
getChangeStats: async (_teamName: string, _memberName: string): Promise<never> => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
|
import { getWorktreeNavigationState } from '@renderer/store/utils/stateResetHelpers';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
|
|
@ -482,9 +483,12 @@ interface ProjectsGridProps {
|
||||||
maxProjects?: number;
|
maxProjects?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INITIAL_RECENT_PROJECTS = 11;
|
||||||
|
const LOAD_MORE_STEP = 8;
|
||||||
|
|
||||||
const ProjectsGrid = ({
|
const ProjectsGrid = ({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
maxProjects = 12,
|
maxProjects = INITIAL_RECENT_PROJECTS,
|
||||||
}: Readonly<ProjectsGridProps>): React.JSX.Element => {
|
}: Readonly<ProjectsGridProps>): React.JSX.Element => {
|
||||||
const {
|
const {
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
|
|
@ -511,6 +515,7 @@ const ProjectsGrid = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasFetchedTasksRef = React.useRef(false);
|
const hasFetchedTasksRef = React.useRef(false);
|
||||||
|
const [visibleProjects, setVisibleProjects] = useState(maxProjects);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (repositoryGroups.length === 0 && !repositoryGroupsLoading) {
|
if (repositoryGroups.length === 0 && !repositoryGroupsLoading) {
|
||||||
|
|
@ -525,26 +530,36 @@ const ProjectsGrid = ({
|
||||||
}
|
}
|
||||||
}, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]);
|
}, [repositoryGroups.length, repositoryGroupsLoading, fetchAllTasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
setVisibleProjects(maxProjects);
|
||||||
|
}
|
||||||
|
}, [searchQuery, maxProjects]);
|
||||||
|
|
||||||
const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
const taskCountsMap = useMemo(() => buildTaskCountsByProject(globalTasks), [globalTasks]);
|
||||||
|
|
||||||
// Filter projects based on search query
|
// Filter projects based on search query
|
||||||
const filteredRepos = useMemo(() => {
|
const filteredRepos = useMemo(() => {
|
||||||
if (!searchQuery.trim()) {
|
|
||||||
return repositoryGroups.slice(0, maxProjects);
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = searchQuery.toLowerCase().trim();
|
const query = searchQuery.toLowerCase().trim();
|
||||||
return repositoryGroups
|
return repositoryGroups.filter((repo) => {
|
||||||
.filter((repo) => {
|
if (!query) return true;
|
||||||
// Match by name
|
// Match by name
|
||||||
if (repo.name.toLowerCase().includes(query)) return true;
|
if (repo.name.toLowerCase().includes(query)) return true;
|
||||||
// Match by path
|
// Match by path
|
||||||
const path = repo.worktrees[0]?.path || '';
|
const path = repo.worktrees[0]?.path || '';
|
||||||
if (path.toLowerCase().includes(query)) return true;
|
if (path.toLowerCase().includes(query)) return true;
|
||||||
return false;
|
return false;
|
||||||
})
|
});
|
||||||
.slice(0, maxProjects);
|
}, [repositoryGroups, searchQuery]);
|
||||||
}, [repositoryGroups, searchQuery, maxProjects]);
|
|
||||||
|
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) {
|
if (repositoryGroupsLoading) {
|
||||||
// Organic widths per card — no repeating stamp
|
// Organic widths per card — no repeating stamp
|
||||||
|
|
@ -645,35 +660,49 @@ const ProjectsGrid = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="space-y-4">
|
||||||
{!searchQuery.trim() && <NewProjectCard />}
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{filteredRepos.map((repo) => {
|
{!searchQuery.trim() && <NewProjectCard />}
|
||||||
const counts = repo.worktrees.reduce(
|
{displayedRepos.map((repo) => {
|
||||||
(acc, wt) => {
|
const counts = repo.worktrees.reduce(
|
||||||
const c = taskCountsMap.get(normalizePath(wt.path));
|
(acc, wt) => {
|
||||||
if (c) {
|
const c = taskCountsMap.get(normalizePath(wt.path));
|
||||||
acc.pending += c.pending;
|
if (c) {
|
||||||
acc.inProgress += c.inProgress;
|
acc.pending += c.pending;
|
||||||
acc.completed += c.completed;
|
acc.inProgress += c.inProgress;
|
||||||
}
|
acc.completed += c.completed;
|
||||||
return acc;
|
}
|
||||||
},
|
return acc;
|
||||||
{ pending: 0, inProgress: 0, completed: 0 }
|
},
|
||||||
);
|
{ pending: 0, inProgress: 0, completed: 0 }
|
||||||
return (
|
);
|
||||||
<RepositoryCard
|
return (
|
||||||
key={repo.id}
|
<RepositoryCard
|
||||||
repo={repo}
|
key={repo.id}
|
||||||
onClick={() => {
|
repo={repo}
|
||||||
selectRepository(repo.id);
|
onClick={() => {
|
||||||
openTeamsTab();
|
selectRepository(repo.id);
|
||||||
}}
|
openTeamsTab();
|
||||||
isHighlighted={!!searchQuery.trim()}
|
}}
|
||||||
taskCounts={globalTasksLoading ? undefined : counts}
|
isHighlighted={!!searchQuery.trim()}
|
||||||
tasksLoading={globalTasksLoading}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ interface CollapsibleTeamSectionProps {
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
|
onOpenChange?: (isOpen: boolean) => void;
|
||||||
action?: React.ReactNode;
|
action?: React.ReactNode;
|
||||||
/** Stable identifier used for programmatic section navigation. */
|
/** Stable identifier used for programmatic section navigation. */
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
|
|
@ -46,6 +47,7 @@ export const CollapsibleTeamSection = ({
|
||||||
headerExtra,
|
headerExtra,
|
||||||
defaultOpen = true,
|
defaultOpen = true,
|
||||||
forceOpen,
|
forceOpen,
|
||||||
|
onOpenChange,
|
||||||
action,
|
action,
|
||||||
sectionId,
|
sectionId,
|
||||||
contentClassName,
|
contentClassName,
|
||||||
|
|
@ -69,6 +71,10 @@ export const CollapsibleTeamSection = ({
|
||||||
return () => el.removeEventListener('team-section-navigate', handleNavigate);
|
return () => el.removeEventListener('team-section-navigate', handleNavigate);
|
||||||
}, [handleNavigate]);
|
}, [handleNavigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onOpenChange?.(isOpen);
|
||||||
|
}, [isOpen, onOpenChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '@renderer/api';
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
import {
|
import {
|
||||||
ImageLightbox,
|
ImageLightbox,
|
||||||
|
|
@ -53,6 +54,7 @@ import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Pencil,
|
Pencil,
|
||||||
PenLine,
|
PenLine,
|
||||||
|
RefreshCw,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
|
@ -65,6 +67,7 @@ import { TaskCommentInput } from './TaskCommentInput';
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
FileChangeSummary,
|
||||||
KanbanTaskState,
|
KanbanTaskState,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
|
|
@ -136,6 +139,10 @@ export const TaskDetailDialog = ({
|
||||||
|
|
||||||
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
const [logsRefreshing, setLogsRefreshing] = useState(false);
|
||||||
const [executionPreviewOnline, setExecutionPreviewOnline] = 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
|
// Inline editing: subject
|
||||||
const [editingSubject, setEditingSubject] = useState(false);
|
const [editingSubject, setEditingSubject] = useState(false);
|
||||||
|
|
@ -199,6 +206,15 @@ export const TaskDetailDialog = ({
|
||||||
setEditingDescription(false);
|
setEditingDescription(false);
|
||||||
}, [open, currentTask?.id]);
|
}, [open, currentTask?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setChangesSectionOpen(false);
|
||||||
|
setTaskChangesFiles(null);
|
||||||
|
setTaskChangesLoading(false);
|
||||||
|
setTaskChangesError(null);
|
||||||
|
setLogsRefreshing(false);
|
||||||
|
setExecutionPreviewOnline(false);
|
||||||
|
}, [open, currentTask?.id]);
|
||||||
|
|
||||||
const [replyTo, setReplyTo] = useState<{
|
const [replyTo, setReplyTo] = useState<{
|
||||||
taskId: string;
|
taskId: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|
@ -272,45 +288,83 @@ export const TaskDetailDialog = ({
|
||||||
|
|
||||||
// Lazy-load task changes when dialog is open and task is completed
|
// Lazy-load task changes when dialog is open and task is completed
|
||||||
const isTaskCompleted = currentTask?.status === 'completed';
|
const isTaskCompleted = currentTask?.status === 'completed';
|
||||||
|
const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]);
|
||||||
const setTaskNeedsClarification = useStore((s) => s.setTaskNeedsClarification);
|
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 loadTaskChangeSummary = useCallback(async (): Promise<FileChangeSummary[] | null> => {
|
||||||
const changesCacheKey = currentTask ? `${teamName}:${currentTask.id}` : '';
|
if (!currentTask || variant !== 'team' || !isTaskCompleted || !onViewChanges) return null;
|
||||||
const taskKnownHasChanges = useStore((s) => s.taskHasChanges[changesCacheKey]) === true;
|
const data = await api.review.getTaskChanges(teamName, currentTask.id, {
|
||||||
|
owner: currentTask.owner,
|
||||||
const taskChangesFiles = useMemo(() => {
|
status: currentTask.status,
|
||||||
if (!activeChangeSet || !currentTask) return null;
|
intervals: currentTask.workIntervals,
|
||||||
if ('taskId' in activeChangeSet && activeChangeSet.taskId === currentTask.id) {
|
since: taskSince,
|
||||||
return activeChangeSet.files;
|
summaryOnly: true,
|
||||||
}
|
});
|
||||||
return null;
|
return data.files;
|
||||||
}, [activeChangeSet, currentTask]);
|
}, [currentTask, isTaskCompleted, onViewChanges, teamName, taskSince, variant]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (variant !== 'team') return;
|
if (variant !== 'team') return;
|
||||||
if (!open || !currentTask || !isTaskCompleted || !onViewChanges) return;
|
if (!open || !currentTask || !isTaskCompleted || !onViewChanges || !changesSectionOpen) return;
|
||||||
// Only fetch if we don't already have data for this task
|
|
||||||
if (taskChangesFiles !== null) return;
|
let cancelled = false;
|
||||||
void fetchTaskChanges(teamName, currentTask.id);
|
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,
|
open,
|
||||||
currentTask,
|
currentTask,
|
||||||
isTaskCompleted,
|
isTaskCompleted,
|
||||||
teamName,
|
teamName,
|
||||||
fetchTaskChanges,
|
|
||||||
taskChangesFiles,
|
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
|
taskSince,
|
||||||
variant,
|
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 => {
|
const handleDependencyClick = (taskId: string): void => {
|
||||||
handleClose();
|
handleClose();
|
||||||
onScrollToTask?.(taskId);
|
onScrollToTask?.(taskId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangesSectionOpenChange = useCallback((isOpen: boolean): void => {
|
||||||
|
setChangesSectionOpen(isOpen);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
|
@ -735,19 +789,47 @@ export const TaskDetailDialog = ({
|
||||||
{/* Changes */}
|
{/* Changes */}
|
||||||
{variant === 'team' && isTaskCompleted && onViewChanges ? (
|
{variant === 'team' && isTaskCompleted && onViewChanges ? (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
key={`task-changes:${currentTask.id}`}
|
||||||
title="Changes"
|
title="Changes"
|
||||||
icon={<FileDiff size={14} />}
|
icon={<FileDiff size={14} />}
|
||||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
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"
|
contentClassName="pl-2.5"
|
||||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
headerContentClassName="pl-6"
|
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)]">
|
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
<Loader2 size={14} className="animate-spin" />
|
<Loader2 size={14} className="animate-spin" />
|
||||||
Loading changes...
|
Loading changes...
|
||||||
</div>
|
</div>
|
||||||
|
) : taskChangesError ? (
|
||||||
|
<p className="text-xs text-red-400">{taskChangesError}</p>
|
||||||
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
|
) : taskChangesFiles && taskChangesFiles.length > 0 ? (
|
||||||
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
|
||||||
{taskChangesFiles.map((file) => (
|
{taskChangesFiles.map((file) => (
|
||||||
|
|
@ -811,15 +893,16 @@ export const TaskDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : changesSectionOpen ? (
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
|
<p className="text-xs text-[var(--color-text-muted)]">No file changes detected</p>
|
||||||
)}
|
) : null}
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Execution Logs — sessions that reference this task */}
|
{/* Execution Logs — sessions that reference this task */}
|
||||||
{variant === 'team' ? (
|
{variant === 'team' ? (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
key={`task-logs:${currentTask.id}`}
|
||||||
title="Execution Logs"
|
title="Execution Logs"
|
||||||
icon={<ScrollText size={14} />}
|
icon={<ScrollText size={14} />}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
|
|
@ -846,7 +929,7 @@ export const TaskDetailDialog = ({
|
||||||
contentClassName="pl-2.5"
|
contentClassName="pl-2.5"
|
||||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
headerContentClassName="pl-6"
|
headerContentClassName="pl-6"
|
||||||
defaultOpen
|
defaultOpen={false}
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<MemberLogsTab
|
<MemberLogsTab
|
||||||
|
|
@ -855,7 +938,7 @@ export const TaskDetailDialog = ({
|
||||||
taskOwner={currentTask.owner}
|
taskOwner={currentTask.owner}
|
||||||
taskStatus={currentTask.status}
|
taskStatus={currentTask.status}
|
||||||
taskWorkIntervals={currentTask.workIntervals}
|
taskWorkIntervals={currentTask.workIntervals}
|
||||||
taskSince={deriveTaskSince(currentTask)}
|
taskSince={taskSince}
|
||||||
onRefreshingChange={setLogsRefreshing}
|
onRefreshingChange={setLogsRefreshing}
|
||||||
// Only show a "latest messages" preview when this task is owned by a subagent.
|
// 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),
|
// 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 }[];
|
intervals?: { startedAt: string; completedAt?: string }[];
|
||||||
/** Back-compat: single since timestamp (deprecated). */
|
/** Back-compat: single since timestamp (deprecated). */
|
||||||
since?: string;
|
since?: string;
|
||||||
|
/** Lightweight response for summary UIs; skips snippets/timeline details. */
|
||||||
|
summaryOnly?: boolean;
|
||||||
}
|
}
|
||||||
) => Promise<TaskChangeSetV2>;
|
) => Promise<TaskChangeSetV2>;
|
||||||
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
|
getChangeStats: (teamName: string, memberName: string) => Promise<ChangeStats>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue