From 26c394674b4208af4af2d6f949cd3cd4f58dc2f7 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 10 May 2026 23:49:38 +0300 Subject: [PATCH] feat(team): improve graph member log previews --- .../hooks/useGraphMemberLogPreviews.ts | 320 +++++-- .../renderer/ui/GraphMemberLogPreviewHud.tsx | 4 +- .../components/team/TeamChangesSection.tsx | 90 +- .../useTeamChangesSummaries.test.tsx | 618 +++++++++++++ .../team/dialogs/CreateTeamDialog.tsx | 7 +- .../team/members/MembersEditorSection.tsx | 229 ++--- .../components/team/teamChangesLoadTimeout.ts | 3 +- .../team/useTeamChangesSummaries.ts | 123 ++- test/agent-graph/stableSlots.test.ts | 6 +- .../GraphMemberLogPreviewHud.test.tsx | 91 +- .../useGraphMemberLogPreviews.test.tsx | 853 +++++++++++++++++- 11 files changed, 2127 insertions(+), 217 deletions(-) create mode 100644 src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx diff --git a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts index 65be098e..b30cd1a3 100644 --- a/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts +++ b/src/features/agent-graph/renderer/hooks/useGraphMemberLogPreviews.ts @@ -14,6 +14,11 @@ const PREVIEW_CACHE_TTL_MS = 3_500; const DEFAULT_MAX_ITEMS = 3; const DEFAULT_TEXT_LIMIT = 200; +interface PendingReloadOptions { + forceRefresh: boolean; + background: boolean; +} + function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } @@ -65,15 +70,79 @@ function mergeMemberPreviews( return next; } +function hasUnloadedMemberPreview( + memberNames: readonly string[], + previewsByMember: ReadonlyMap +): boolean { + return memberNames.some((memberName) => !previewsByMember.has(normalizeMemberName(memberName))); +} + +function hasEmptyOrUnloadedMemberPreview( + memberNames: readonly string[], + previewsByMember: ReadonlyMap +): boolean { + return memberNames.some((memberName) => { + const preview = previewsByMember.get(normalizeMemberName(memberName)); + return !preview || preview.items.length === 0; + }); +} + +function hasInFlightMemberPreviewRequest( + memberNames: readonly string[], + activeRequestKeyByMember: ReadonlyMap, + inFlightRequests: ReadonlyMap +): boolean { + return memberNames.some((memberName) => { + const activeRequestKey = activeRequestKeyByMember.get(normalizeMemberName(memberName)); + return activeRequestKey ? inFlightRequests.has(activeRequestKey) : false; + }); +} + +function hasPendingLoadingReload( + pendingReload: PendingReloadOptions | null, + memberNames: readonly string[], + previewsByMember: ReadonlyMap +): boolean { + return ( + pendingReload?.forceRefresh === true && + hasEmptyOrUnloadedMemberPreview(memberNames, previewsByMember) + ); +} + +function hasActiveMemberPreviewRequest( + memberNames: readonly string[], + requestKey: string, + activeRequestKeyByMember: ReadonlyMap +): boolean { + return memberNames.some( + (memberName) => activeRequestKeyByMember.get(normalizeMemberName(memberName)) === requestKey + ); +} + +function hasVisibleActiveMemberPreviewRequest( + requestedMemberNames: readonly string[], + visibleMemberNames: readonly string[], + requestKey: string, + activeRequestKeyByMember: ReadonlyMap +): boolean { + const visibleMemberNameSet = new Set(visibleMemberNames.map(normalizeMemberName)); + return requestedMemberNames.some((memberName) => { + const normalizedMemberName = normalizeMemberName(memberName); + return ( + visibleMemberNameSet.has(normalizedMemberName) && + activeRequestKeyByMember.get(normalizedMemberName) === requestKey + ); + }); +} + function laneIdForMember( memberName: string, laneIdsByMember: Readonly> ): string { - return ( - laneIdsByMember[memberName]?.trim() ?? - laneIdsByMember[normalizeMemberName(memberName)]?.trim() ?? - '' - ); + const directLaneId = laneIdsByMember[memberName]?.trim(); + if (directLaneId) return directLaneId; + const normalizedLaneId = laneIdsByMember[normalizeMemberName(memberName)]?.trim(); + return normalizedLaneId || ''; } function buildMemberCacheKey(input: { @@ -92,6 +161,24 @@ function buildMemberCacheKey(input: { ]); } +function buildLaneIdsKey(laneIdsByMember: Readonly>): string { + const laneEntriesByMember = new Map(); + for (const [memberName, laneId] of Object.entries(laneIdsByMember)) { + const normalizedMemberName = normalizeMemberName(memberName); + const trimmedLaneId = laneId.trim(); + if (!normalizedMemberName || !trimmedLaneId || laneEntriesByMember.has(normalizedMemberName)) { + continue; + } + laneEntriesByMember.set(normalizedMemberName, trimmedLaneId); + } + return JSON.stringify( + Array.from(laneEntriesByMember.entries()).sort((left, right) => { + const byMember = left[0].localeCompare(right[0]); + return byMember !== 0 ? byMember : left[1].localeCompare(right[1]); + }) + ); +} + function buildLaneIdsForMembers( memberNames: readonly string[], laneIdsByMember: Readonly> @@ -168,6 +255,10 @@ export function useGraphMemberLogPreviews(input: { } return result; }, [input.memberNames]); + const laneKey = useMemo( + () => buildLaneIdsKey(buildLaneIdsForMembers(memberNames, laneIdsByMember)), + [laneIdsByMember, memberNames] + ); const memberKey = useMemo( () => memberNames @@ -186,25 +277,42 @@ export function useGraphMemberLogPreviews(input: { const inFlightRef = useRef(new Map>>()); const activeRequestKeyByMemberRef = useRef(new Map()); const reloadTimerRef = useRef | null>(null); + const pendingReloadRef = useRef(null); + const requestGenerationRef = useRef(0); const teamNameRef = useRef(input.teamName); + const laneKeyRef = useRef(laneKey); + const memberNamesRef = useRef(memberNames); + const mountedRef = useRef(true); - useEffect(() => { - previewsByMemberRef.current = previewsByMember; - }, [previewsByMember]); + previewsByMemberRef.current = previewsByMember; + memberNamesRef.current = memberNames; + + const clearScheduledReload = useCallback((): void => { + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + reloadTimerRef.current = null; + } + pendingReloadRef.current = null; + }, []); useEffect(() => { if (teamNameRef.current !== input.teamName) { teamNameRef.current = input.teamName; + laneKeyRef.current = laneKey; + requestGenerationRef.current += 1; + clearScheduledReload(); cacheRef.current.clear(); inFlightRef.current.clear(); activeRequestKeyByMemberRef.current.clear(); - setPreviewsByMember(new Map()); + const emptyPreviews = new Map(); + previewsByMemberRef.current = emptyPreviews; + setPreviewsByMember(emptyPreviews); } if (!enabled || memberNames.length === 0) { setLoading(false); } setError(null); - }, [enabled, input.teamName, memberKey, memberNames.length]); + }, [clearScheduledReload, enabled, input.teamName, laneKey, memberKey, memberNames.length]); const loadPreviews = useCallback( async (options?: { forceRefresh?: boolean; background?: boolean }): Promise => { @@ -221,6 +329,7 @@ export function useGraphMemberLogPreviews(input: { const membersToRequest: string[] = []; const cachedMembers: MemberLogPreviewMember[] = []; let hasMissingPreview = false; + let hasEmptyOrMissingPreviewForForceRefresh = false; for (const memberName of memberNames) { const cacheKey = buildMemberCacheKey({ @@ -238,9 +347,13 @@ export function useGraphMemberLogPreviews(input: { membersToRequest.push(memberName); } const normalizedMemberName = normalizeMemberName(memberName); - if (!cached && !previewsByMemberRef.current.has(normalizedMemberName)) { + const existingPreview = previewsByMemberRef.current.get(normalizedMemberName); + if (!cached && !existingPreview) { hasMissingPreview = true; } + if (options?.forceRefresh && (!existingPreview || existingPreview.items.length === 0)) { + hasEmptyOrMissingPreviewForForceRefresh = true; + } } if (cachedMembers.length > 0) { @@ -263,11 +376,31 @@ export function useGraphMemberLogPreviews(input: { forceRefresh: options?.forceRefresh, }); const requestTeamName = input.teamName; + const requestGeneration = requestGenerationRef.current; for (const memberName of membersToRequest) { activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey); } + const requestStillActive = (): boolean => + mountedRef.current && + teamNameRef.current === requestTeamName && + requestGenerationRef.current === requestGeneration && + hasActiveMemberPreviewRequest( + membersToRequest, + requestKey, + activeRequestKeyByMemberRef.current + ); + const requestStillVisible = (): boolean => + mountedRef.current && + teamNameRef.current === requestTeamName && + requestGenerationRef.current === requestGeneration && + hasVisibleActiveMemberPreviewRequest( + membersToRequest, + memberNamesRef.current, + requestKey, + activeRequestKeyByMemberRef.current + ); - if (!options?.background && hasMissingPreview) { + if ((!options?.background && hasMissingPreview) || hasEmptyOrMissingPreviewForForceRefresh) { setLoading(true); setError(null); } @@ -288,31 +421,39 @@ export function useGraphMemberLogPreviews(input: { .then((response) => { const normalized = normalizeMemberLogPreviewResponse(response); const members = memberMapFromResponse(normalized.members); - for (const member of members.values()) { - cacheRef.current.set( - buildMemberCacheKey({ - teamName: input.teamName, - memberName: member.memberName, - laneIdsByMember, - maxItemsPerMember, - textLimit, - }), - { - expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS, - member, - } - ); + if ( + mountedRef.current && + teamNameRef.current === requestTeamName && + requestGenerationRef.current === requestGeneration + ) { + for (const member of members.values()) { + cacheRef.current.set( + buildMemberCacheKey({ + teamName: input.teamName, + memberName: member.memberName, + laneIdsByMember, + maxItemsPerMember, + textLimit, + }), + { + expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS, + member, + } + ); + } } return members; }) .finally(() => { - inFlightRef.current.delete(requestKey); + if (inFlightRef.current.get(requestKey) === request) { + inFlightRef.current.delete(requestKey); + } }); inFlightRef.current.set(requestKey, request); } const members = await request; - if (teamNameRef.current !== requestTeamName) { + if (!requestStillActive()) { return; } const currentMembers = Array.from(members.values()).filter((member) => { @@ -324,70 +465,129 @@ export function useGraphMemberLogPreviews(input: { if (currentMembers.length > 0) { setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers)); } - setError(null); + if (requestStillVisible()) { + setError(null); + } } catch (loadError) { - if (teamNameRef.current !== requestTeamName) { + if (!requestStillVisible()) { return; } setError( loadError instanceof Error ? loadError.message : 'Failed to load graph log previews' ); } finally { - if (teamNameRef.current === requestTeamName) { + if ( + requestStillVisible() && + !hasInFlightMemberPreviewRequest( + memberNamesRef.current, + activeRequestKeyByMemberRef.current, + inFlightRef.current + ) && + !hasPendingLoadingReload( + pendingReloadRef.current, + memberNamesRef.current, + previewsByMemberRef.current + ) + ) { setLoading(false); } } }, [enabled, input.teamName, laneIdsByMember, maxItemsPerMember, memberNames, textLimit] ); + const loadPreviewsRef = useRef(loadPreviews); + loadPreviewsRef.current = loadPreviews; + + const scheduleReload = useCallback( + (options?: { forceRefresh?: boolean; background?: boolean }) => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + if (memberNamesRef.current.length === 0) return; + + if ( + options?.forceRefresh === true && + hasEmptyOrUnloadedMemberPreview(memberNamesRef.current, previewsByMemberRef.current) + ) { + setLoading(true); + setError(null); + } + + const current = pendingReloadRef.current; + pendingReloadRef.current = { + forceRefresh: (current?.forceRefresh ?? false) || options?.forceRefresh === true, + background: (current?.background ?? true) && options?.background === true, + }; + + if (reloadTimerRef.current) { + clearTimeout(reloadTimerRef.current); + } + reloadTimerRef.current = setTimeout(() => { + reloadTimerRef.current = null; + const pending = pendingReloadRef.current; + pendingReloadRef.current = null; + void loadPreviewsRef.current({ + background: pending?.background, + forceRefresh: pending?.forceRefresh, + }); + }, LIVE_RELOAD_DEBOUNCE_MS); + }, + [] + ); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + clearScheduledReload(); + }; + }, [clearScheduledReload]); useEffect(() => { if (!enabled || memberNames.length === 0) { + clearScheduledReload(); setLoading(false); setError(null); return; } - if (reloadTimerRef.current) { - clearTimeout(reloadTimerRef.current); + const hasUnloadedPreview = hasUnloadedMemberPreview(memberNames, previewsByMemberRef.current); + const laneKeyChanged = laneKeyRef.current !== laneKey; + laneKeyRef.current = laneKey; + if (hasUnloadedPreview) { + setLoading(true); + setError(null); } - reloadTimerRef.current = setTimeout(() => { - reloadTimerRef.current = null; - void loadPreviews(); - }, LIVE_RELOAD_DEBOUNCE_MS); - return () => { - if (reloadTimerRef.current) { - clearTimeout(reloadTimerRef.current); - reloadTimerRef.current = null; - } - }; - }, [enabled, loadPreviews, memberKey, memberNames.length]); + scheduleReload({ forceRefresh: hasUnloadedPreview || laneKeyChanged }); + }, [ + clearScheduledReload, + enabled, + input.teamName, + laneKey, + memberKey, + memberNames.length, + scheduleReload, + ]); useEffect(() => { if (!enabled) return; - const scheduleReload = (forceRefresh: boolean): void => { - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; - if (memberNames.length === 0) return; - if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current); - reloadTimerRef.current = setTimeout(() => { - reloadTimerRef.current = null; - void loadPreviews({ background: true, forceRefresh }); - }, LIVE_RELOAD_DEBOUNCE_MS); - }; - const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => { if (event.teamName !== input.teamName) return; if (event.type === 'log-source-change') { - scheduleReload(true); + scheduleReload({ background: true, forceRefresh: true }); + return; + } + if (event.type === 'tool-activity') { + scheduleReload({ background: true, forceRefresh: true }); return; } if (event.type === 'task-log-change') { - scheduleReload(true); + scheduleReload({ background: true, forceRefresh: true }); } }); const handleVisibilityChange = (): void => { - if (document.visibilityState === 'visible') scheduleReload(false); + if (document.visibilityState === 'visible') { + scheduleReload({ background: true, forceRefresh: true }); + } }; if (typeof document !== 'undefined') { @@ -395,16 +595,12 @@ export function useGraphMemberLogPreviews(input: { } return () => { - if (reloadTimerRef.current) { - clearTimeout(reloadTimerRef.current); - reloadTimerRef.current = null; - } if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', handleVisibilityChange); } if (typeof unsubscribe === 'function') unsubscribe(); }; - }, [enabled, input.teamName, loadPreviews, memberNames.length]); + }, [enabled, input.teamName, scheduleReload]); return { previewsByMember, loading, error, reload: loadPreviews }; } diff --git a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx index f686f804..5af8b1b8 100644 --- a/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphMemberLogPreviewHud.tsx @@ -113,11 +113,11 @@ function resolveEmptyText( loading: boolean, error: string | null ): string { - if (loading && !preview) return 'Loading logs'; - if (error && !preview) return 'Logs unavailable'; if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) { return 'Unsupported provider'; } + if (loading && (!preview || preview.items.length === 0)) return 'Loading logs'; + if (error && (!preview || preview.items.length === 0)) return 'Logs unavailable'; return 'No recent logs'; } diff --git a/src/renderer/components/team/TeamChangesSection.tsx b/src/renderer/components/team/TeamChangesSection.tsx index b7c92e75..2389e4eb 100644 --- a/src/renderer/components/team/TeamChangesSection.tsx +++ b/src/renderer/components/team/TeamChangesSection.tsx @@ -28,23 +28,56 @@ interface RenderedTeamChangeSummary { fileBudget: number; } +function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] { + if (!Array.isArray(changeSet?.files)) { + return []; + } + return changeSet.files.filter((file): file is FileChangeSummary => + Boolean( + file && + typeof file === 'object' && + typeof (file as Partial).filePath === 'string' + ) + ); +} + +function getChangeSetWarnings(changeSet: TaskChangeSetV2): string[] { + return Array.isArray(changeSet.warnings) + ? changeSet.warnings.filter((warning): warning is string => typeof warning === 'string') + : []; +} + function getTaskChangeContributors( task: TeamTaskWithKanban, changeSet: TaskChangeSetV2 | null ): string[] { const names = new Set(); - for (const contributor of changeSet?.scope.contributors ?? []) { - if (contributor.memberName) names.add(contributor.memberName); + const contributors = Array.isArray(changeSet?.scope?.contributors) + ? changeSet.scope.contributors + : []; + for (const contributor of contributors) { + const memberName = + contributor && typeof contributor.memberName === 'string' ? contributor.memberName : ''; + if (memberName) names.add(memberName); } - for (const name of changeSet?.scope.memberNames ?? []) { - names.add(name); + const memberNames = Array.isArray(changeSet?.scope?.memberNames) + ? changeSet.scope.memberNames + : []; + for (const name of memberNames) { + if (typeof name === 'string' && name) names.add(name); } - if (changeSet?.scope.primaryMemberName) { + if ( + typeof changeSet?.scope?.primaryMemberName === 'string' && + changeSet.scope.primaryMemberName + ) { names.add(changeSet.scope.primaryMemberName); } - for (const file of changeSet?.files ?? []) { - for (const name of file.ledgerSummary?.memberNames ?? []) { - names.add(name); + for (const file of getChangeSetFiles(changeSet)) { + const fileMemberNames = Array.isArray(file.ledgerSummary?.memberNames) + ? file.ledgerSummary.memberNames + : []; + for (const name of fileMemberNames) { + if (typeof name === 'string' && name) names.add(name); } } if (names.size === 0 && task.owner) { @@ -54,10 +87,16 @@ function getTaskChangeContributors( } function getVisibleFileName(file: FileChangeSummary): string { - const value = file.relativePath || file.filePath; + const value = getVisibleFilePath(file); return value.split(/[\\/]/).pop() ?? value; } +function getVisibleFilePath(file: FileChangeSummary): string { + return typeof file.relativePath === 'string' && file.relativePath.trim() !== '' + ? file.relativePath + : file.filePath; +} + function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined { if (!changeSet) return undefined; const reviewability = classifyTaskChangeReviewability(changeSet).reviewability; @@ -75,7 +114,7 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] { const messages = status.diagnostics.length > 0 ? status.diagnostics.map((diagnostic) => diagnostic.message) - : changeSet.warnings; + : getChangeSetWarnings(changeSet); return [...new Set(messages.filter((message) => message.trim().length > 0))]; } @@ -102,7 +141,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({ return ( Boolean(entry.task) && (Boolean(entry.summary.error) || - (changeSet?.files.length ?? 0) > 0 || + getChangeSetFiles(changeSet).length > 0 || (changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false)) ); }) @@ -110,7 +149,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({ }, [summariesByTaskId, taskMap]); const totalFiles = visibleSummaries.reduce( - (sum, entry) => sum + (entry.summary.changeSet?.files.length ?? 0), + (sum, entry) => sum + getChangeSetFiles(entry.summary.changeSet).length, 0 ); const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS); @@ -119,7 +158,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({ const entries: RenderedTeamChangeSummary[] = []; let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS; for (const entry of visibleSummaries) { - const files = entry.summary.changeSet?.files ?? []; + const files = getChangeSetFiles(entry.summary.changeSet); const fileBudget = Math.max(0, remainingFileRows); const visibleFiles = files.slice(0, fileBudget); entries.push({ ...entry, visibleFiles, fileBudget }); @@ -167,19 +206,12 @@ export const TeamChangesSection = memo(function TeamChangesSection({ } contentClassName="pl-2.5" > - {loading && visibleSummaries.length === 0 ? ( -
- - Loading changes... -
- ) : error ? ( -

{error}

- ) : visibleSummaries.length > 0 ? ( + {visibleSummaries.length > 0 ? (
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => { const changeSet = summary.changeSet; - const files = changeSet?.files ?? []; + const files = getChangeSetFiles(changeSet); const reviewability = changeSet ? classifyTaskChangeReviewability(changeSet).reviewability : 'unknown'; @@ -267,9 +299,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({ type="button" className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]" onClick={() => onViewChanges(task.id, file.filePath)} - title={file.relativePath || file.filePath} + title={getVisibleFilePath(file)} > - {file.relativePath || file.filePath} + {getVisibleFilePath(file)} {file.linesAdded > 0 ? ( @@ -310,18 +342,26 @@ export const TeamChangesSection = memo(function TeamChangesSection({
- {refreshing ? ( + {loading || refreshing ? ( Refreshing ) : null} + {error ? Refresh failed: {error} : null} {hiddenFileRows > 0 ? {hiddenFileRows} file rows hidden : null} {stats.deferredCount > 0 ? ( {stats.deferredCount} tasks deferred this pass ) : null}
+ ) : loading || refreshing ? ( +
+ + {loading ? 'Loading changes...' : 'Refreshing changes...'} +
+ ) : error ? ( +

{error}

) : (

No file changes recorded

diff --git a/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx b/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx new file mode 100644 index 00000000..282f4bcd --- /dev/null +++ b/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx @@ -0,0 +1,618 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { TooltipProvider } from '@renderer/components/ui/tooltip'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { TEAM_CHANGES_LOAD_TIMEOUT_MS } from '../teamChangesLoadTimeout'; +import { TeamChangesSection } from '../TeamChangesSection'; +import { type TeamChangeSummaryState, useTeamChangesSummaries } from '../useTeamChangesSummaries'; + +import type { + TaskChangeSetV2, + TeamTaskChangeSummariesResponse, + TeamTaskWithKanban, +} from '@shared/types'; + +const hoisted = vi.hoisted(() => ({ + getTeamTaskChangeSummaries: vi.fn(), + recordTaskChangePresence: vi.fn(), + setSelectedTeamTaskChangePresence: vi.fn(), +})); + +vi.mock('@renderer/api', () => ({ + api: { + review: { + getTeamTaskChangeSummaries: hoisted.getTeamTaskChangeSummaries, + }, + }, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: Record) => unknown) => + selector({ + recordTaskChangePresence: hoisted.recordTaskChangePresence, + setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence, + }), +})); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function task(overrides: Partial = {}): TeamTaskWithKanban { + return { + id: 'task-1', + subject: 'Task 1', + status: 'completed', + owner: 'alice', + createdAt: '2026-05-10T10:00:00.000Z', + updatedAt: '2026-05-10T10:00:00.000Z', + changePresence: 'unknown', + ...overrides, + }; +} + +function changeSet(taskId = 'task-1'): TaskChangeSetV2 { + return { + teamName: 'team-a', + taskId, + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + confidence: 'high', + computedAt: '2026-05-10T10:00:00.000Z', + scope: { + taskId, + memberName: 'alice', + startLine: 0, + endLine: 0, + startTimestamp: '2026-05-10T10:00:00.000Z', + endTimestamp: '2026-05-10T10:01:00.000Z', + toolUseIds: [], + filePaths: [], + confidence: { tier: 1, label: 'high', reason: 'test' }, + }, + warnings: [], + }; +} + +function fileChange( + overrides: Partial = {} +): TaskChangeSetV2['files'][number] { + return { + filePath: '/repo/src/app.ts', + relativePath: 'src/app.ts', + snippets: [], + linesAdded: 1, + linesRemoved: 0, + isNewFile: false, + ...overrides, + }; +} + +function response(summary: TaskChangeSetV2 = changeSet()): TeamTaskChangeSummariesResponse { + return { + teamName: 'team-a', + computedAt: '2026-05-10T10:00:01.000Z', + items: [{ taskId: 'task-1', changeSet: summary }], + }; +} + +function malformedLegacyChangeSet(): TaskChangeSetV2 { + return { + ...changeSet(), + files: undefined, + scope: undefined, + totalFiles: 1, + warnings: ['legacy warning'], + } as unknown as TaskChangeSetV2; +} + +function malformedResponse(): TeamTaskChangeSummariesResponse { + return { + teamName: 'team-a', + computedAt: '2026-05-10T10:00:01.000Z', + items: undefined, + } as unknown as TeamTaskChangeSummariesResponse; +} + +function malformedItemResponse(): TeamTaskChangeSummariesResponse { + return { + teamName: 'team-a', + computedAt: '2026-05-10T10:00:01.000Z', + items: [ + { + taskId: ' task-1 ', + changeSet: 'not-a-change-set', + error: { message: 'not a string' }, + }, + ], + } as unknown as TeamTaskChangeSummariesResponse; +} + +function incompleteChangeSetResponse(): TeamTaskChangeSummariesResponse { + return { + teamName: 'team-a', + computedAt: '2026-05-10T10:00:01.000Z', + items: [ + { + taskId: 'task-1', + changeSet: { + teamName: 'team-a', + taskId: 'task-1', + files: [], + warnings: [], + confidence: 'high', + }, + }, + ], + } as unknown as TeamTaskChangeSummariesResponse; +} + +function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse { + return response({ + ...changeSet(), + confidence: 'low', + files: [fileChange()], + totalFiles: 1, + totalLinesAdded: 1, + }); +} + +function invalidFileSummaryResponse(): TeamTaskChangeSummariesResponse { + return response({ + ...changeSet(), + confidence: 'low', + files: [{} as TaskChangeSetV2['files'][number]], + totalFiles: 1, + totalLinesAdded: 1, + }); +} + +interface HookSnapshot { + loading: boolean; + refreshing: boolean; + error: string | null; + summariesByTaskId: Record; +} + +const HookHarness = ({ + tasks, + onSnapshot, +}: { + tasks: TeamTaskWithKanban[]; + onSnapshot: (snapshot: HookSnapshot) => void; +}): null => { + const state = useTeamChangesSummaries({ + teamName: 'team-a', + tasks, + sectionOpen: true, + }); + React.useEffect(() => { + onSnapshot({ + loading: state.loading, + refreshing: state.refreshing, + error: state.error, + summariesByTaskId: state.summariesByTaskId, + }); + }, [onSnapshot, state.error, state.loading, state.refreshing, state.summariesByTaskId]); + return null; +}; + +describe('useTeamChangesSummaries', () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + container?.remove(); + container = null; + root = null; + vi.clearAllMocks(); + }); + + it('does not keep initial loading stuck when tasks change during an active request', async () => { + const first = createDeferred(); + const second = createDeferred(); + hoisted.getTeamTaskChangeSummaries + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + }); + + expect(snapshots.at(-1)?.loading).toBe(true); + + await act(async () => { + root?.render( + React.createElement(HookHarness, { + tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })], + onSnapshot, + }) + ); + }); + + await act(async () => { + first.resolve(response()); + await first.promise; + }); + + expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2); + expect(snapshots.at(-1)?.loading).toBe(false); + expect(snapshots.at(-1)?.refreshing).toBe(true); + }); + + it('does not cache a stale active response when a newer task snapshot is queued', async () => { + const first = createDeferred(); + const second = createDeferred(); + hoisted.getTeamTaskChangeSummaries + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + }); + + await act(async () => { + root?.render( + React.createElement(HookHarness, { + tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })], + onSnapshot, + }) + ); + }); + + await act(async () => { + first.resolve( + response({ + ...changeSet(), + files: [fileChange({ filePath: '/repo/src/stale.ts', relativePath: 'src/stale.ts' })], + totalFiles: 1, + totalLinesAdded: 1, + }) + ); + await first.promise; + }); + + expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2); + expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled(); + expect(snapshots.at(-1)?.summariesByTaskId).toEqual({}); + + await act(async () => { + second.resolve(response()); + await second.promise; + }); + + expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'task-1', + expect.any(Object), + 'no_changes' + ); + expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'task-1', + 'no_changes' + ); + }); + + it('retries the initial load after React StrictMode effect remount replay', async () => { + const first = createDeferred(); + const second = createDeferred(); + hoisted.getTeamTaskChangeSummaries + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement( + React.StrictMode, + null, + React.createElement(HookHarness, { tasks: [task()], onSnapshot }) + ) + ); + }); + + expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2); + + await act(async () => { + first.resolve(response()); + await first.promise; + }); + + expect(snapshots.at(-1)?.loading).toBe(true); + + await act(async () => { + second.resolve(response()); + await second.promise; + }); + + expect(snapshots.at(-1)?.loading).toBe(false); + expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.taskId).toBe('task-1'); + }); + + it('clears initial loading and reports an error when the batch request times out', async () => { + vi.useFakeTimers(); + try { + hoisted.getTeamTaskChangeSummaries.mockReturnValue(new Promise(() => undefined)); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + }); + + expect(snapshots.at(-1)?.loading).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(TEAM_CHANGES_LOAD_TIMEOUT_MS); + }); + + expect(snapshots.at(-1)?.loading).toBe(false); + expect(snapshots.at(-1)?.refreshing).toBe(false); + expect(snapshots.at(-1)?.error).toBe('Team changes request timed out. Refresh to try again.'); + } finally { + vi.useRealTimers(); + } + }); + + it('does not immediately run a queued refresh after a request failure', async () => { + const first = createDeferred(); + hoisted.getTeamTaskChangeSummaries.mockReturnValue(first.promise); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + }); + + await act(async () => { + root?.render( + React.createElement(HookHarness, { + tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })], + onSnapshot, + }) + ); + }); + + await act(async () => { + first.reject(new Error('boom')); + await first.promise.catch(() => undefined); + await Promise.resolve(); + }); + + expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1); + expect(snapshots.at(-1)?.loading).toBe(false); + expect(snapshots.at(-1)?.refreshing).toBe(false); + expect(snapshots.at(-1)?.error).toBe('boom'); + }); + + it('clears loading and reports an error for a malformed batch response', async () => { + hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedResponse()); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + await Promise.resolve(); + }); + + expect(snapshots.at(-1)?.loading).toBe(false); + expect(snapshots.at(-1)?.refreshing).toBe(false); + expect(snapshots.at(-1)?.error).toBe('Team changes response was malformed.'); + expect(snapshots.at(-1)?.summariesByTaskId).toEqual({}); + }); + + it('normalizes malformed batch response items before storing summaries', async () => { + hoisted.getTeamTaskChangeSummaries.mockResolvedValue(malformedItemResponse()); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + await Promise.resolve(); + }); + + expect(snapshots.at(-1)?.error).toBeNull(); + expect(snapshots.at(-1)?.summariesByTaskId['task-1']).toEqual({ + taskId: 'task-1', + changeSet: null, + }); + expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled(); + }); + + it('does not cache presence for incomplete change summaries', async () => { + hoisted.getTeamTaskChangeSummaries.mockResolvedValue(incompleteChangeSetResponse()); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + await Promise.resolve(); + }); + + expect(snapshots.at(-1)?.loading).toBe(false); + expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull(); + expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled(); + expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled(); + }); + + it('caches has_changes for low-confidence summaries with safe file details', async () => { + hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse()); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + await Promise.resolve(); + }); + + expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet?.confidence).toBe('low'); + expect(hoisted.recordTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'task-1', + expect.any(Object), + 'has_changes' + ); + expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith( + 'team-a', + 'task-1', + 'has_changes' + ); + }); + + it('does not cache presence for summaries with unsafe file details', async () => { + hoisted.getTeamTaskChangeSummaries.mockResolvedValue(invalidFileSummaryResponse()); + + const snapshots: HookSnapshot[] = []; + const onSnapshot = (snapshot: HookSnapshot): void => { + snapshots.push(snapshot); + }; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render(React.createElement(HookHarness, { tasks: [task()], onSnapshot })); + await Promise.resolve(); + }); + + expect(snapshots.at(-1)?.summariesByTaskId['task-1']?.changeSet).not.toBeNull(); + expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled(); + expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled(); + }); + + it('renders legacy malformed summaries without crashing the section', async () => { + hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(malformedLegacyChangeSet())); + + const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor( + Element.prototype, + 'scrollIntoView' + ); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + try { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + await act(async () => { + root?.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TeamChangesSection, { + teamName: 'team-a', + tasks: [task()], + onViewChanges: vi.fn(), + }) + ) + ); + }); + + const expandButton = container.querySelector( + 'button[aria-label="Expand section"]' + ); + expect(expandButton).not.toBeNull(); + + await act(async () => { + expandButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('legacy warning'); + expect(container.textContent).toContain( + 'The change summary reported one file without safe review details.' + ); + expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled(); + } finally { + if (scrollIntoViewDescriptor) { + Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor); + } else { + delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView; + } + } + }); +}); diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index feee2c0c..391e8498 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -1926,9 +1926,10 @@ export const CreateTeamDialog = ({

Only the team lead (main process) will be started — no teammates will - be spawned. Works like a regular Claude session but with access to the task - board for planning. Saves tokens by avoiding teammate coordination overhead. - You can add members later from the team settings. + be spawned. Works like a regular agent session in your chosen runtime + (Claude Code, Codex, OpenCode, Gemini) but with access to the task board for + planning. Saves tokens by avoiding teammate coordination overhead. You can + add members later from the team settings.

) : null} diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 1a8f2988..cfd8250e 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Label } from '@renderer/components/ui/label'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; +import { cn } from '@renderer/lib/utils'; import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; @@ -365,26 +366,6 @@ export const MembersEditorSection = ({ {headerExtra} {!hideContent && ( <> - {showWorktreeIsolationControls ? ( -
- updateTeammateWorktreeDefault(checked === true)} - /> - -
- ) : null} {disableAddMember && addMemberLockReason ? (

{addMemberLockReason}

) : null} @@ -396,98 +377,126 @@ export const MembersEditorSection = ({ onClose={toggleJsonEditor} /> ) : null} -
- {activeMembers.map((member, index) => ( - - ))} - {softDeleteMembers && removedMembers.length > 0 ? ( -
-
- Removed ({removedMembers.length}) -
-
- {removedMembers.map((member, index) => ( - - ))} -
+
+ {showWorktreeIsolationControls ? ( +
+ updateTeammateWorktreeDefault(checked === true)} + /> +
) : null} +
+ {activeMembers.map((member, index) => ( + + ))} + {softDeleteMembers && removedMembers.length > 0 ? ( +
+
+ Removed ({removedMembers.length}) +
+
+ {removedMembers.map((member, index) => ( + + ))} +
+
+ ) : null} +
{hasDuplicates ? (

diff --git a/src/renderer/components/team/teamChangesLoadTimeout.ts b/src/renderer/components/team/teamChangesLoadTimeout.ts index abafe862..a4470ea4 100644 --- a/src/renderer/components/team/teamChangesLoadTimeout.ts +++ b/src/renderer/components/team/teamChangesLoadTimeout.ts @@ -1,4 +1,5 @@ -export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 45_000; +// Main-process team summary batches have a 30s deadline; keep the renderer guard above it. +export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 35_000; export function withTeamChangesLoadTimeout( promise: Promise, diff --git a/src/renderer/components/team/useTeamChangesSummaries.ts b/src/renderer/components/team/useTeamChangesSummaries.ts index 80f3cd91..22e856e6 100644 --- a/src/renderer/components/team/useTeamChangesSummaries.ts +++ b/src/renderer/components/team/useTeamChangesSummaries.ts @@ -10,9 +10,15 @@ import { buildTeamChangesTasksFingerprint, } from './teamChangesRequestPlan'; -import type { TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types'; +import type { + TaskChangePresenceState, + TaskChangeSetV2, + TeamTaskChangeSummaryItem, + TeamTaskWithKanban, +} from '@shared/types'; const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000; +const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000; export interface TeamChangeSummaryState { taskId: string; @@ -47,6 +53,89 @@ interface UseTeamChangesSummariesResult { refresh: () => void; } +function normalizeTeamChangeSummaryItem(item: unknown): TeamTaskChangeSummaryItem | null { + if (!item || typeof item !== 'object') { + return null; + } + + const candidate = item as Partial; + const taskId = typeof candidate.taskId === 'string' ? candidate.taskId.trim() : ''; + if (!taskId) { + return null; + } + + const changeSet = + candidate.changeSet && + typeof candidate.changeSet === 'object' && + !Array.isArray(candidate.changeSet) + ? candidate.changeSet + : null; + const error = typeof candidate.error === 'string' ? candidate.error : undefined; + return { + taskId, + changeSet, + ...(error ? { error } : {}), + }; +} + +function getSafeResponseItems(response: unknown): TeamTaskChangeSummaryItem[] { + if ( + !response || + typeof response !== 'object' || + !Array.isArray((response as { items?: unknown }).items) + ) { + throw new Error('Team changes response was malformed.'); + } + return (response as { items: unknown[] }).items + .map(normalizeTeamChangeSummaryItem) + .filter((item): item is TeamTaskChangeSummaryItem => item !== null); +} + +function hasSafeFileSummaries(changeSet: TaskChangeSetV2): boolean { + return changeSet.files.every( + (file) => + file && + typeof file === 'object' && + typeof file.filePath === 'string' && + file.filePath.trim().length > 0 + ); +} + +function isMinimalPresenceChangeSet(changeSet: TaskChangeSetV2): boolean { + return Boolean( + Array.isArray(changeSet.files) && + hasSafeFileSummaries(changeSet) && + Array.isArray(changeSet.warnings) && + Number.isFinite(changeSet.totalFiles) && + Number(changeSet.totalFiles) >= 0 && + typeof changeSet.computedAt === 'string' && + changeSet.computedAt.trim().length > 0 && + changeSet.scope && + typeof changeSet.scope === 'object' && + !Array.isArray(changeSet.scope) + ); +} + +function resolveCacheablePresenceFromChangeSet( + changeSet: TaskChangeSetV2 +): Exclude | null { + if (!isMinimalPresenceChangeSet(changeSet)) { + return null; + } + + const nextPresence = resolveTaskChangePresenceFromResult(changeSet); + if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') { + return nextPresence; + } + if ( + nextPresence === 'no_changes' && + (changeSet.confidence === 'high' || changeSet.confidence === 'medium') + ) { + return nextPresence; + } + return null; +} + export function useTeamChangesSummaries({ teamName, tasks, @@ -71,6 +160,7 @@ export function useTeamChangesSummaries({ const requestSeqRef = useRef(0); const activeRequestSeqRef = useRef(null); const queuedRefreshOptionsRef = useRef(null); + const autoRefreshBlockedUntilRef = useRef(0); const sectionOpenRef = useRef(sectionOpen); const unknownScanCursorRef = useRef(0); const lastRequestedTasksFingerprintRef = useRef(null); @@ -81,11 +171,16 @@ export function useTeamChangesSummaries({ sectionOpenRef.current = sectionOpen; useEffect(() => { + mountedRef.current = true; return () => { mountedRef.current = false; requestSeqRef.current += 1; activeRequestSeqRef.current = null; queuedRefreshOptionsRef.current = null; + autoRefreshBlockedUntilRef.current = 0; + hasLoadedRef.current = false; + unknownScanCursorRef.current = 0; + lastRequestedTasksFingerprintRef.current = null; }; }, []); @@ -95,6 +190,12 @@ export function useTeamChangesSummaries({ showSpinner = false, preserveOnError = true, }: TeamChangesLoadOptions = {}): Promise => { + if (forceFresh) { + autoRefreshBlockedUntilRef.current = 0; + } else if (autoRefreshBlockedUntilRef.current > Date.now()) { + return; + } + if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) { const previous = queuedRefreshOptionsRef.current; queuedRefreshOptionsRef.current = { @@ -104,7 +205,6 @@ export function useTeamChangesSummaries({ ? Boolean(previous.preserveOnError && preserveOnError) : preserveOnError, }; - requestSeqRef.current += 1; if (activeRequestSeqRef.current === null && sectionOpenRef.current) { setQueuedRefreshTick((value) => value + 1); } @@ -124,6 +224,7 @@ export function useTeamChangesSummaries({ if (plan.requests.length === 0) { setSummariesByTaskId({}); + autoRefreshBlockedUntilRef.current = 0; setLoading(false); setRefreshing(false); return; @@ -143,16 +244,22 @@ export function useTeamChangesSummaries({ if (!mountedRef.current || requestSeqRef.current !== requestSeq) { return; } + if (queuedRefreshOptionsRef.current !== null) { + return; + } + autoRefreshBlockedUntilRef.current = 0; + const responseItems = getSafeResponseItems(response); const currentTaskIds = new Set(tasks.map((task) => task.id)); - for (const item of response.items) { + for (const item of responseItems) { const changeSet = item.changeSet; const options = plan.requestOptionsByTaskId.get(item.taskId); if (!changeSet || !options) continue; - const nextPresence = resolveTaskChangePresenceFromResult(changeSet); + const nextPresence = resolveCacheablePresenceFromChangeSet(changeSet); + if (!nextPresence) continue; recordTaskChangePresence(teamName, item.taskId, options, nextPresence); - setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown'); + setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence); } setSummariesByTaskId((previous) => { @@ -162,7 +269,7 @@ export function useTeamChangesSummaries({ next[taskId] = summary; } } - for (const item of response.items) { + for (const item of responseItems) { const options = plan.requestOptionsByTaskId.get(item.taskId); if (!options) continue; next[item.taskId] = { @@ -177,6 +284,8 @@ export function useTeamChangesSummaries({ if (!mountedRef.current || requestSeqRef.current !== requestSeq) { return; } + queuedRefreshOptionsRef.current = null; + autoRefreshBlockedUntilRef.current = Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS; if (!preserveOnError) { setSummariesByTaskId({}); } @@ -208,6 +317,7 @@ export function useTeamChangesSummaries({ requestSeqRef.current += 1; activeRequestSeqRef.current = null; queuedRefreshOptionsRef.current = null; + autoRefreshBlockedUntilRef.current = 0; unknownScanCursorRef.current = 0; lastRequestedTasksFingerprintRef.current = null; setSummariesByTaskId({}); @@ -220,6 +330,7 @@ export function useTeamChangesSummaries({ requestSeqRef.current += 1; activeRequestSeqRef.current = null; queuedRefreshOptionsRef.current = null; + autoRefreshBlockedUntilRef.current = 0; hasLoadedRef.current = false; lastRequestedTasksFingerprintRef.current = null; setSummariesByTaskId({}); diff --git a/test/agent-graph/stableSlots.test.ts b/test/agent-graph/stableSlots.test.ts index 263e4397..dcedf80a 100644 --- a/test/agent-graph/stableSlots.test.ts +++ b/test/agent-graph/stableSlots.test.ts @@ -193,7 +193,7 @@ describe('stable slot layout', () => { }); }); - it('uses three grid columns for six owners in rows layout', () => { + it('uses two grid columns for six owners in rows layout', () => { const { nodes, layout } = buildSixOwnerGraph(); const snapshot = getSnapshot(nodes, { ...layout, @@ -202,8 +202,8 @@ describe('stable slot layout', () => { }); expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead'); - expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]); - expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]); + expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 1, 1, 2, 2]); + expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 0, 1, 0, 1]); }); it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => { diff --git a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx index c2fd1e85..302891cf 100644 --- a/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx +++ b/test/renderer/features/agent-graph/GraphMemberLogPreviewHud.test.tsx @@ -72,13 +72,15 @@ const basePreviewsByMember = new Map([ ], ]); let mockedPreviewsByMember = basePreviewsByMember; +let mockedLoading = false; +let mockedError: string | null = null; vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({ buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }), useGraphMemberLogPreviews: () => ({ previewsByMember: mockedPreviewsByMember, - loading: false, - error: null, + loading: mockedLoading, + error: mockedError, reload: vi.fn(), }), })); @@ -114,6 +116,8 @@ describe('GraphMemberLogPreviewHud', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z')); mockedPreviewsByMember = basePreviewsByMember; + mockedLoading = false; + mockedError = null; }); afterEach(() => { @@ -645,6 +649,89 @@ describe('GraphMemberLogPreviewHud', () => { }); }); + it('shows loading for empty previews while preserving unsupported provider text', async () => { + const codexNode: GraphNode = { + id: 'member:alpha-team:codex-dev', + kind: 'member', + label: 'codex-dev', + state: 'idle', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'codex-dev' }, + }; + const quietNode: GraphNode = { + id: 'member:alpha-team:quiet-dev', + kind: 'member', + label: 'quiet-dev', + state: 'idle', + domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' }, + }; + mockedLoading = true; + mockedPreviewsByMember = new Map([ + [ + 'codex-dev', + { + memberName: 'codex-dev', + items: [], + coverage: [{ provider: 'codex_native_trace', status: 'skipped' }], + warnings: [ + { + code: 'codex_member_wide_not_supported', + message: 'Codex member-wide native trace is not available in this variant yet.', + }, + ], + truncated: false, + overflowCount: 0, + generatedAt: '2026-04-03T00:00:00.000Z', + }, + ], + [ + 'quiet-dev', + { + memberName: 'quiet-dev', + items: [], + coverage: [{ provider: 'claude_transcript', status: 'skipped' }], + warnings: [], + truncated: false, + overflowCount: 0, + generatedAt: '2026-04-03T00:00:00.000Z', + }, + ], + ]); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + ({ + left: ownerNodeId.includes('quiet') ? 360 : 40, + top: 80, + right: ownerNodeId.includes('quiet') ? 620 : 300, + bottom: 372, + width: 260, + height: 292, + })} + getCameraZoom={() => 1} + worldToScreen={(x, y) => ({ x, y })} + getViewportSize={() => ({ width: 1200, height: 800 })} + focusNodeIds={null} + /> + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('Unsupported provider'); + expect(host.textContent).toContain('Loading logs'); + expect(host.textContent).not.toContain('No recent logs'); + + act(() => { + root.unmount(); + }); + }); + it('renders lead log previews and opens the lead profile logs tab', async () => { const leadNode: GraphNode = { id: 'lead:alpha-team', diff --git a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx index ec7c7d6c..11cdd9b2 100644 --- a/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx +++ b/test/renderer/features/agent-graph/useGraphMemberLogPreviews.test.tsx @@ -22,12 +22,15 @@ vi.mock('@renderer/api', () => ({ function createDeferred(): { promise: Promise; resolve: (value: T) => void; + reject: (reason?: unknown) => void; } { let resolve!: (value: T) => void; - const promise = new Promise((innerResolve) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; + reject = innerReject; }); - return { promise, resolve }; + return { promise, resolve, reject }; } function response(memberName: string, generatedAt: string): MemberLogPreviewResponse { @@ -57,6 +60,23 @@ function response(memberName: string, generatedAt: string): MemberLogPreviewResp }; } +function emptyResponse(memberName: string, generatedAt: string): MemberLogPreviewResponse { + return { + generatedAt, + members: [ + { + memberName, + items: [], + coverage: [{ provider: 'claude_transcript', status: 'skipped' }], + warnings: [], + truncated: false, + overflowCount: 0, + generatedAt, + }, + ], + }; +} + function batchResponse(memberNames: string[], generatedAt: string): MemberLogPreviewResponse { return { generatedAt, @@ -107,6 +127,28 @@ const HookProbe = ({ return null; }; +const ReloadProbe = ({ + teamName, + memberNames, + onState, + onReload, +}: { + teamName: string; + memberNames: string[]; + onState: (state: ReturnType) => void; + onReload: (reload: ReturnType['reload']) => void; +}): React.JSX.Element | null => { + const state = useGraphMemberLogPreviews({ + teamName, + memberNames, + }); + useEffect(() => { + onState(state); + onReload(state.reload); + }, [onReload, onState, state]); + return null; +}; + describe('useGraphMemberLogPreviews', () => { beforeEach(() => { vi.useFakeTimers(); @@ -162,6 +204,7 @@ describe('useGraphMemberLogPreviews', () => { expect.objectContaining({ maxItemsPerMember: 3, textLimit: 200, + forceRefresh: true, laneIdsByMember: { alice: 'secondary:opencode:alice' }, }) ); @@ -171,6 +214,104 @@ describe('useGraphMemberLogPreviews', () => { }); }); + it('shows loading while the first visible preview request is still debounced', async () => { + const firstLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(latestState()?.loading).toBe(true); + expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ forceRefresh: true }) + ); + + await act(async () => { + firstLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + act(() => { + root.unmount(); + }); + }); + + it('does not duplicate the initial debounced request in React StrictMode', async () => { + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('alice', '2026-04-03T00:00:00.000Z') + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + + undefined} /> + + ); + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it('clears a scheduled preview request when unmounted before the debounce fires', async () => { + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('alice', '2026-04-03T00:00:00.000Z') + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( undefined} />); + await Promise.resolve(); + }); + act(() => { + root.unmount(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).not.toHaveBeenCalled(); + }); + it('keeps completed previews cached after the visible member set changes', async () => { const aliceLoad = createDeferred(); const bobLoad = createDeferred(); @@ -278,6 +419,68 @@ describe('useGraphMemberLogPreviews', () => { }); }); + it('does not show stale previews as loaded after switching teams with the same visible member', async () => { + const betaLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews + .mockResolvedValueOnce(response('alice', '2026-04-03T00:00:00.000Z')) + .mockReturnValueOnce(betaLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( + 'alice:2026-04-03T00:00:00.000Z' + ); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(true); + expect(latestState()?.previewsByMember.get('alice')).toBeUndefined(); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'beta-team', + ['alice'], + expect.objectContaining({ forceRefresh: true }) + ); + + await act(async () => { + betaLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( + 'alice:2026-04-03T00:01:00.000Z' + ); + + act(() => { + root.unmount(); + }); + }); + it('does not duplicate preview requests when the same visible members are reordered', async () => { const firstLoad = createDeferred(); apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise); @@ -409,6 +612,637 @@ describe('useGraphMemberLogPreviews', () => { }); }); + it('does not reload when only a non-visible member lane changes', async () => { + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('alice', '2026-04-03T00:00:00.000Z') + ); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + act(() => { + root.unmount(); + }); + }); + + it('falls back to normalized lane ids when an exact member key is blank', async () => { + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('Alice', '2026-04-03T00:00:00.000Z') + ); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['Alice'], + expect.objectContaining({ + laneIdsByMember: { + Alice: 'secondary:opencode:alice', + alice: 'secondary:opencode:alice', + }, + }) + ); + + act(() => { + root.unmount(); + }); + }); + + it('preserves a pending forced reload when lane metadata rerenders before debounce fires', async () => { + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('alice', '2026-04-03T00:00:00.000Z') + ); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + root.render( + undefined} + /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ + forceRefresh: true, + laneIdsByMember: { alice: 'secondary:opencode:alice:new' }, + }) + ); + + act(() => { + root.unmount(); + }); + }); + + it('force refreshes visible previews after returning from a hidden document', async () => { + apiMock.memberLogStream.getMemberLogPreviews.mockResolvedValue( + response('alice', '2026-04-03T00:00:00.000Z') + ); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( undefined} />); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + await act(async () => { + document.dispatchEvent(new Event('visibilitychange')); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ forceRefresh: true }) + ); + + act(() => { + root.unmount(); + }); + }); + + it('marks empty cached previews as loading while a forced event refresh is pending', async () => { + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + const refreshLoad = createDeferred(); + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogPreviews + .mockResolvedValueOnce(emptyResponse('alice', '2026-04-03T00:00:00.000Z')) + .mockReturnValueOnce(refreshLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + await Promise.resolve(); + }); + + expect(latestState()?.loading).toBe(true); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ forceRefresh: true }) + ); + + await act(async () => { + refreshLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + act(() => { + root.unmount(); + }); + }); + + it('keeps loading when an empty visible response arrives before a pending forced refresh starts', async () => { + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + const initialLoad = createDeferred(); + const refreshLoad = createDeferred(); + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogPreviews + .mockReturnValueOnce(initialLoad.promise) + .mockReturnValueOnce(refreshLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + expect(latestState()?.loading).toBe(true); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(true); + + await act(async () => { + initialLoad.resolve(emptyResponse('alice', '2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(true); + expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0); + + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + + await act(async () => { + refreshLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + act(() => { + root.unmount(); + }); + }); + + it('marks empty cached previews as loading during a direct forced reload', async () => { + const refreshLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews + .mockResolvedValueOnce(emptyResponse('alice', '2026-04-03T00:00:00.000Z')) + .mockReturnValueOnce(refreshLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + let reload: ReturnType['reload'] | null = null; + + await act(async () => { + root.render( + { + reload = nextReload; + }} + /> + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0); + + await act(async () => { + void reload?.({ background: true, forceRefresh: true }); + await Promise.resolve(); + }); + + expect(latestState()?.loading).toBe(true); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ forceRefresh: true }) + ); + + await act(async () => { + refreshLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + + act(() => { + root.unmount(); + }); + }); + + it('keeps loading and ignores stale errors while a newer empty-preview refresh is in flight', async () => { + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + const staleRefresh = createDeferred(); + const latestRefresh = createDeferred(); + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogPreviews + .mockResolvedValueOnce(emptyResponse('alice', '2026-04-03T00:00:00.000Z')) + .mockReturnValueOnce(staleRefresh.promise) + .mockReturnValueOnce(latestRefresh.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + expect(latestState()?.loading).toBe(true); + + await act(async () => { + root.render( + + ); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(3); + + await act(async () => { + staleRefresh.reject(new Error('stale lane failed')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(true); + expect(latestState()?.error).toBeNull(); + expect(latestState()?.previewsByMember.get('alice')?.items).toHaveLength(0); + + await act(async () => { + latestRefresh.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.error).toBeNull(); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.preview).toBe('alice'); + + act(() => { + root.unmount(); + }); + }); + + it('ignores hidden member request loading and errors after the visible member changes', async () => { + const hiddenAliceLoad = createDeferred(); + const visibleBobLoad = createDeferred(); + apiMock.memberLogStream.getMemberLogPreviews + .mockReturnValueOnce(hiddenAliceLoad.promise) + .mockReturnValueOnce(visibleBobLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + expect(latestState()?.loading).toBe(true); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + hiddenAliceLoad.reject(new Error('hidden alice failed before bob starts')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(true); + expect(latestState()?.error).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + + await act(async () => { + visibleBobLoad.resolve(emptyResponse('bob', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.error).toBeNull(); + expect(latestState()?.previewsByMember.get('bob')?.items).toHaveLength(0); + + act(() => { + root.unmount(); + }); + }); + + it('ignores old same-key responses after switching away from and back to a team', async () => { + let teamChangeListener: + | ((event: unknown, data: { teamName: string; type: string }) => void) + | null = null; + const oldAlphaLoad = createDeferred(); + const currentAlphaLoad = createDeferred(); + apiMock.teams.onTeamChange.mockImplementation((callback) => { + teamChangeListener = callback as typeof teamChangeListener; + return () => undefined; + }); + apiMock.memberLogStream.getMemberLogPreviews + .mockReturnValueOnce(oldAlphaLoad.promise) + .mockReturnValueOnce(currentAlphaLoad.promise); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const states: ReturnType[] = []; + const onState = vi.fn((state: ReturnType) => { + states.push(state); + }); + const latestState = (): ReturnType | undefined => + states.at(-1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + expect(latestState()?.previewsByMember.size).toBe(0); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + + await act(async () => { + oldAlphaLoad.resolve(response('alice', '2026-04-03T00:00:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(true); + expect(latestState()?.previewsByMember.get('alice')).toBeUndefined(); + + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(2); + + await act(async () => { + currentAlphaLoad.resolve(response('alice', '2026-04-03T00:01:00.000Z')); + await Promise.resolve(); + }); + expect(latestState()?.loading).toBe(false); + expect(latestState()?.previewsByMember.get('alice')?.items[0]?.id).toBe( + 'alice:2026-04-03T00:01:00.000Z' + ); + + act(() => { + root.unmount(); + }); + }); + it('reloads visible members on log change events with force refresh', async () => { let teamChangeListener: | ((event: unknown, data: { teamName: string; type: string }) => void) @@ -450,7 +1284,7 @@ describe('useGraphMemberLogPreviews', () => { ); await act(async () => { - teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' }); + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' }); vi.advanceTimersByTime(700); await Promise.resolve(); }); @@ -462,6 +1296,19 @@ describe('useGraphMemberLogPreviews', () => { expect.objectContaining({ forceRefresh: true }) ); + await act(async () => { + teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' }); + vi.advanceTimersByTime(700); + await Promise.resolve(); + }); + + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(4); + expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith( + 'alpha-team', + ['alice'], + expect.objectContaining({ forceRefresh: true }) + ); + act(() => { root.unmount(); });