diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 998ee010..28bc7200 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -85,7 +85,10 @@ export class TeamMemberLogsFinder { private readonly fileMentionsCache = new Map(); private readonly discoveryCache = new Map< string, - { result: NonNullable>>; expiresAt: number } + { + result: NonNullable>>; + expiresAt: number; + } >(); constructor( @@ -345,6 +348,29 @@ export class TeamMemberLogsFinder { } const tOwner = performance.now(); + // Dedup cumulative subagent snapshots: keep 1 file per sessionId+memberName (largest). + // In-process teammates produce cumulative JSONL files where each successive file + // contains ALL lines from the previous + a new delta. The largest file is a superset. + const preDedupCount = results.length; + { + const subagentsByKey = new Map(); + const nonSubagent: MemberLogSummary[] = []; + for (const r of results) { + if (r.kind !== 'subagent') { + nonSubagent.push(r); + continue; + } + const memberKey = r.memberName ? r.memberName.toLowerCase() : `_${r.subagentId}`; + const key = `${r.sessionId}:${memberKey}`; + const existing = subagentsByKey.get(key); + if (!existing || r.messageCount > existing.messageCount) { + subagentsByKey.set(key, r); + } + } + results.length = 0; + results.push(...nonSubagent, ...subagentsByKey.values()); + } + const sorted = results.sort( (a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime() ); @@ -353,7 +379,8 @@ export class TeamMemberLogsFinder { console.log( `[findLogsForTask] task=${taskId}@${teamName} | ` + `step2=${step2Count} (scan ${mentionHits}/${totalFiles} files) | ` + - `step3=${sorted.length - step2Count} (owner=${normalizedOwner ?? 'none'}, includeOwner=${includeOwnerSessions}) | ` + + `step3=${preDedupCount - step2Count} (owner=${normalizedOwner ?? 'none'}, includeOwner=${includeOwnerSessions}) | ` + + `dedup=${preDedupCount}→${sorted.length} | ` + `total=${sorted.length} | ` + `${(tTotal - t0).toFixed(0)}ms` ); @@ -525,6 +552,28 @@ export class TeamMemberLogsFinder { } const tOwner = performance.now(); + // Dedup cumulative subagent snapshots (same logic as findLogsForTask). + { + const refsByKey = new Map(); + const leadRefs: (typeof refs)[0][] = []; + for (const ref of refs) { + if (ref.memberName.toLowerCase() === leadMemberName.toLowerCase()) { + leadRefs.push(ref); + continue; + } + const parts = ref.filePath.split(path.sep); + const subagentsIdx = parts.lastIndexOf('subagents'); + const sessionId = subagentsIdx > 0 ? parts[subagentsIdx - 1] : ''; + const key = `${sessionId}:${ref.memberName.toLowerCase()}`; + const existing = refsByKey.get(key); + if (!existing || ref.sortTime > existing.sortTime) { + refsByKey.set(key, ref); + } + } + refs.length = 0; + refs.push(...leadRefs, ...refsByKey.values()); + } + const sortedRefs = [...refs].sort((a, b) => b.sortTime - a.sortTime); const tTotal = performance.now(); @@ -978,7 +1027,12 @@ export class TeamMemberLogsFinder { // that this session actually WORKED on the task. Agents commonly call // task_get to check dependencies from other tasks, producing false matches. const toolName = typeof b.name === 'string' ? b.name : ''; - if (toolName === 'task_get' || toolName === 'mcp__agent-teams__task_get') continue; + if ( + toolName === 'task_get' || + toolName === 'mcp__agent-teams__task_get' || + toolName === 'TaskGet' + ) + continue; const input = b.input as Record | undefined; if (!input) continue; diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index 4a668c84..4ecb97b0 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -27,6 +27,46 @@ import { import type { EnhancedChunk } from '@renderer/types/data'; import type { MemberLogSummary } from '@shared/types'; +// --------------------------------------------------------------------------- +// Chunk filtering by task work intervals +// --------------------------------------------------------------------------- + +const CHUNK_GRACE_BEFORE_MS = 30_000; // 30s before startedAt +const CHUNK_GRACE_AFTER_MS = 10_000; // 10s after completedAt + +function filterChunksByWorkIntervals( + chunks: EnhancedChunk[] | null, + intervals: { startedAt: string; completedAt?: string }[] | undefined +): EnhancedChunk[] | null { + if (!chunks) return null; + if (!intervals || intervals.length === 0) return chunks; + + const now = Date.now(); + const parsed = intervals + .map((i) => { + const s = Date.parse(i.startedAt); + if (!Number.isFinite(s)) return null; + const e = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : null; + return { + startMs: s - CHUNK_GRACE_BEFORE_MS, + endMs: e != null && Number.isFinite(e) ? e + CHUNK_GRACE_AFTER_MS : null, + }; + }) + .filter((v): v is { startMs: number; endMs: number | null } => v !== null); + + if (parsed.length === 0) return chunks; + + return chunks.filter((chunk) => { + const cs = chunk.startTime.getTime(); + const ce = chunk.endTime.getTime(); + if (!Number.isFinite(cs) || !Number.isFinite(ce)) return true; + return parsed.some((i) => { + const end = i.endMs ?? now; + return cs <= end && ce >= i.startMs; + }); + }); +} + interface MemberLogsTabProps { teamName: string; memberName?: string; @@ -342,7 +382,8 @@ export const MemberLogsTab = ({ try { const next = await fetchDetailForLog(previewLog); if (cancelled) return; - setPreviewChunks(next ? [...next] : null); + const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next; + setPreviewChunks(filtered ? [...filtered] : null); } catch { if (cancelled) return; setPreviewChunks(null); @@ -352,7 +393,7 @@ export const MemberLogsTab = ({ return () => { cancelled = true; }; - }, [fetchDetailForLog, previewLog, shouldShowPreview]); + }, [fetchDetailForLog, previewLog, shouldShowPreview, intervalsKey]); useEffect(() => { if (!shouldShowPreview) return; @@ -367,7 +408,8 @@ export const MemberLogsTab = ({ try { const next = await fetchDetailForLog(previewLog, { bypassCache: true }); if (cancelled) return; - setPreviewChunks(next ? [...next] : null); + const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next; + setPreviewChunks(filtered ? [...filtered] : null); } catch { // keep last successful preview } finally { @@ -386,6 +428,7 @@ export const MemberLogsTab = ({ previewLog, shouldShowPreview, taskStatus, + intervalsKey, ]); useEffect(() => { @@ -400,8 +443,8 @@ export const MemberLogsTab = ({ try { const next = await fetchDetailForLog(expandedLogSummary, { bypassCache: true }); if (cancelled) return; - // Ensure new reference so memoized transforms update. - setDetailChunks(next ? [...next] : null); + const filtered = taskId ? filterChunksByWorkIntervals(next, taskWorkIntervals) : next; + setDetailChunks(filtered ? [...filtered] : null); } catch { // Keep last successful data; avoid flicker during transient errors. } finally { @@ -416,7 +459,15 @@ export const MemberLogsTab = ({ cancelled = true; clearInterval(interval); }; - }, [beginRefreshing, endRefreshing, expandedLogSummary, fetchDetailForLog, taskId, taskStatus]); + }, [ + beginRefreshing, + endRefreshing, + expandedLogSummary, + fetchDetailForLog, + taskId, + taskStatus, + intervalsKey, + ]); const handleExpand = useCallback( async (log: MemberLogSummary) => { @@ -436,14 +487,15 @@ export const MemberLogsTab = ({ log, shouldBypassCache ? { bypassCache: true } : undefined ); - setDetailChunks(chunks ? [...chunks] : null); + const filtered = taskId ? filterChunksByWorkIntervals(chunks, taskWorkIntervals) : chunks; + setDetailChunks(filtered ? [...filtered] : null); } catch { setDetailChunks(null); } finally { setDetailLoading(false); } }, - [expandedId, fetchDetailForLog, getRowId, taskStatus] + [expandedId, fetchDetailForLog, getRowId, taskStatus, intervalsKey] ); if (loading && logs.length === 0) { @@ -581,9 +633,7 @@ const LogCard = ({ {updatedAgo} - - started {createdAgo} - + started {createdAgo} ) : ( @@ -601,7 +651,10 @@ const LogCard = ({ )} {log.lastOutputPreview && !expanded && ( -
+
{log.lastOutputPreview}
)} diff --git a/test/renderer/store/changeReviewSlice.test.ts b/test/renderer/store/changeReviewSlice.test.ts index 3e55e14b..771a1cc1 100644 --- a/test/renderer/store/changeReviewSlice.test.ts +++ b/test/renderer/store/changeReviewSlice.test.ts @@ -805,4 +805,110 @@ describe('changeReviewSlice task changes', () => { expect(store.getState().changeSetEpoch).toBe(3); expect(store.getState().fileContentVersionByPath).toEqual({}); }); + + it('forces re-review when snippet order changes even if file paths stay the same', async () => { + const store = createSliceStore(); + const first = makeSnippet({ + toolUseId: 'tool-1', + filePath: '/repo/file.ts', + oldString: 'a', + newString: 'b', + timestamp: '2026-03-01T10:00:00.000Z', + }); + const second = makeSnippet({ + toolUseId: 'tool-2', + filePath: '/repo/file.ts', + oldString: 'c', + newString: 'd', + timestamp: '2026-03-01T10:01:00.000Z', + }); + const current = { + memberName: 'alice', + teamName: 'team-a', + files: [ + { + ...makeFile('/repo/file.ts'), + snippets: [first, second], + }, + ], + totalFiles: 1, + totalLinesAdded: 1, + totalLinesRemoved: 1, + }; + const fresh = { + ...current, + files: [ + { + ...current.files[0], + snippets: [second, first], + }, + ], + }; + hoisted.getAgentChanges.mockResolvedValueOnce(fresh); + + store.setState({ + activeChangeSet: current, + hunkDecisions: { '/repo/file.ts:0': 'rejected' }, + fileDecisions: { '/repo/file.ts': 'rejected' }, + changeSetEpoch: 0, + fileContentVersionByPath: {}, + }); + + await store.getState().applyReview('team-a', undefined, 'alice'); + + expect(hoisted.applyDecisions).not.toHaveBeenCalled(); + expect(store.getState().activeChangeSet).toEqual(fresh); + expect(store.getState().applyError).toBe( + 'Changes have been updated since you started reviewing. Please re-review.' + ); + }); + + it('does not force re-review when only top-level file order changes', async () => { + const store = createSliceStore(); + const firstFile = makeFile('/repo/a.ts', { newString: 'after-a' }); + const secondFile = makeFile('/repo/b.ts', { newString: 'after-b' }); + const current = { + memberName: 'alice', + teamName: 'team-a', + files: [firstFile, secondFile], + totalFiles: 2, + totalLinesAdded: firstFile.linesAdded + secondFile.linesAdded, + totalLinesRemoved: firstFile.linesRemoved + secondFile.linesRemoved, + }; + const fresh = { + ...current, + files: [secondFile, firstFile], + }; + hoisted.getAgentChanges.mockResolvedValueOnce(fresh); + hoisted.applyDecisions.mockResolvedValueOnce({ + applied: 0, + skipped: 0, + conflicts: 0, + errors: [], + }); + + store.setState({ + activeChangeSet: current, + hunkDecisions: { '/repo/a.ts:0': 'rejected' }, + fileDecisions: { '/repo/a.ts': 'rejected' }, + changeSetEpoch: 0, + fileContentVersionByPath: {}, + }); + + await store.getState().applyReview('team-a', undefined, 'alice'); + + expect(store.getState().applyError).toBeNull(); + expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1); + expect(hoisted.applyDecisions).toHaveBeenCalledWith({ + teamName: 'team-a', + taskId: undefined, + memberName: 'alice', + decisions: [ + expect.objectContaining({ + filePath: '/repo/a.ts', + }), + ], + }); + expect(store.getState().activeChangeSet).toEqual(current); + }); });