feat(team): improve graph member log previews

This commit is contained in:
777genius 2026-05-10 23:49:38 +03:00
parent 8943194a84
commit 26c394674b
11 changed files with 2127 additions and 217 deletions

View file

@ -14,6 +14,11 @@ const PREVIEW_CACHE_TTL_MS = 3_500;
const DEFAULT_MAX_ITEMS = 3; const DEFAULT_MAX_ITEMS = 3;
const DEFAULT_TEXT_LIMIT = 200; const DEFAULT_TEXT_LIMIT = 200;
interface PendingReloadOptions {
forceRefresh: boolean;
background: boolean;
}
function normalizeMemberName(value: string): string { function normalizeMemberName(value: string): string {
return value.trim().toLowerCase(); return value.trim().toLowerCase();
} }
@ -65,15 +70,79 @@ function mergeMemberPreviews(
return next; return next;
} }
function hasUnloadedMemberPreview(
memberNames: readonly string[],
previewsByMember: ReadonlyMap<string, MemberLogPreviewMember>
): boolean {
return memberNames.some((memberName) => !previewsByMember.has(normalizeMemberName(memberName)));
}
function hasEmptyOrUnloadedMemberPreview(
memberNames: readonly string[],
previewsByMember: ReadonlyMap<string, MemberLogPreviewMember>
): boolean {
return memberNames.some((memberName) => {
const preview = previewsByMember.get(normalizeMemberName(memberName));
return !preview || preview.items.length === 0;
});
}
function hasInFlightMemberPreviewRequest(
memberNames: readonly string[],
activeRequestKeyByMember: ReadonlyMap<string, string>,
inFlightRequests: ReadonlyMap<string, unknown>
): 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<string, MemberLogPreviewMember>
): boolean {
return (
pendingReload?.forceRefresh === true &&
hasEmptyOrUnloadedMemberPreview(memberNames, previewsByMember)
);
}
function hasActiveMemberPreviewRequest(
memberNames: readonly string[],
requestKey: string,
activeRequestKeyByMember: ReadonlyMap<string, string>
): boolean {
return memberNames.some(
(memberName) => activeRequestKeyByMember.get(normalizeMemberName(memberName)) === requestKey
);
}
function hasVisibleActiveMemberPreviewRequest(
requestedMemberNames: readonly string[],
visibleMemberNames: readonly string[],
requestKey: string,
activeRequestKeyByMember: ReadonlyMap<string, string>
): 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( function laneIdForMember(
memberName: string, memberName: string,
laneIdsByMember: Readonly<Record<string, string>> laneIdsByMember: Readonly<Record<string, string>>
): string { ): string {
return ( const directLaneId = laneIdsByMember[memberName]?.trim();
laneIdsByMember[memberName]?.trim() ?? if (directLaneId) return directLaneId;
laneIdsByMember[normalizeMemberName(memberName)]?.trim() ?? const normalizedLaneId = laneIdsByMember[normalizeMemberName(memberName)]?.trim();
'' return normalizedLaneId || '';
);
} }
function buildMemberCacheKey(input: { function buildMemberCacheKey(input: {
@ -92,6 +161,24 @@ function buildMemberCacheKey(input: {
]); ]);
} }
function buildLaneIdsKey(laneIdsByMember: Readonly<Record<string, string>>): string {
const laneEntriesByMember = new Map<string, string>();
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( function buildLaneIdsForMembers(
memberNames: readonly string[], memberNames: readonly string[],
laneIdsByMember: Readonly<Record<string, string>> laneIdsByMember: Readonly<Record<string, string>>
@ -168,6 +255,10 @@ export function useGraphMemberLogPreviews(input: {
} }
return result; return result;
}, [input.memberNames]); }, [input.memberNames]);
const laneKey = useMemo(
() => buildLaneIdsKey(buildLaneIdsForMembers(memberNames, laneIdsByMember)),
[laneIdsByMember, memberNames]
);
const memberKey = useMemo( const memberKey = useMemo(
() => () =>
memberNames memberNames
@ -186,25 +277,42 @@ export function useGraphMemberLogPreviews(input: {
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>()); const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
const activeRequestKeyByMemberRef = useRef(new Map<string, string>()); const activeRequestKeyByMemberRef = useRef(new Map<string, string>());
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingReloadRef = useRef<PendingReloadOptions | null>(null);
const requestGenerationRef = useRef(0);
const teamNameRef = useRef(input.teamName); const teamNameRef = useRef(input.teamName);
const laneKeyRef = useRef(laneKey);
const memberNamesRef = useRef(memberNames);
const mountedRef = useRef(true);
useEffect(() => { previewsByMemberRef.current = previewsByMember;
previewsByMemberRef.current = previewsByMember; memberNamesRef.current = memberNames;
}, [previewsByMember]);
const clearScheduledReload = useCallback((): void => {
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
pendingReloadRef.current = null;
}, []);
useEffect(() => { useEffect(() => {
if (teamNameRef.current !== input.teamName) { if (teamNameRef.current !== input.teamName) {
teamNameRef.current = input.teamName; teamNameRef.current = input.teamName;
laneKeyRef.current = laneKey;
requestGenerationRef.current += 1;
clearScheduledReload();
cacheRef.current.clear(); cacheRef.current.clear();
inFlightRef.current.clear(); inFlightRef.current.clear();
activeRequestKeyByMemberRef.current.clear(); activeRequestKeyByMemberRef.current.clear();
setPreviewsByMember(new Map()); const emptyPreviews = new Map<string, MemberLogPreviewMember>();
previewsByMemberRef.current = emptyPreviews;
setPreviewsByMember(emptyPreviews);
} }
if (!enabled || memberNames.length === 0) { if (!enabled || memberNames.length === 0) {
setLoading(false); setLoading(false);
} }
setError(null); setError(null);
}, [enabled, input.teamName, memberKey, memberNames.length]); }, [clearScheduledReload, enabled, input.teamName, laneKey, memberKey, memberNames.length]);
const loadPreviews = useCallback( const loadPreviews = useCallback(
async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => { async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => {
@ -221,6 +329,7 @@ export function useGraphMemberLogPreviews(input: {
const membersToRequest: string[] = []; const membersToRequest: string[] = [];
const cachedMembers: MemberLogPreviewMember[] = []; const cachedMembers: MemberLogPreviewMember[] = [];
let hasMissingPreview = false; let hasMissingPreview = false;
let hasEmptyOrMissingPreviewForForceRefresh = false;
for (const memberName of memberNames) { for (const memberName of memberNames) {
const cacheKey = buildMemberCacheKey({ const cacheKey = buildMemberCacheKey({
@ -238,9 +347,13 @@ export function useGraphMemberLogPreviews(input: {
membersToRequest.push(memberName); membersToRequest.push(memberName);
} }
const normalizedMemberName = normalizeMemberName(memberName); const normalizedMemberName = normalizeMemberName(memberName);
if (!cached && !previewsByMemberRef.current.has(normalizedMemberName)) { const existingPreview = previewsByMemberRef.current.get(normalizedMemberName);
if (!cached && !existingPreview) {
hasMissingPreview = true; hasMissingPreview = true;
} }
if (options?.forceRefresh && (!existingPreview || existingPreview.items.length === 0)) {
hasEmptyOrMissingPreviewForForceRefresh = true;
}
} }
if (cachedMembers.length > 0) { if (cachedMembers.length > 0) {
@ -263,11 +376,31 @@ export function useGraphMemberLogPreviews(input: {
forceRefresh: options?.forceRefresh, forceRefresh: options?.forceRefresh,
}); });
const requestTeamName = input.teamName; const requestTeamName = input.teamName;
const requestGeneration = requestGenerationRef.current;
for (const memberName of membersToRequest) { for (const memberName of membersToRequest) {
activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey); 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); setLoading(true);
setError(null); setError(null);
} }
@ -288,31 +421,39 @@ export function useGraphMemberLogPreviews(input: {
.then((response) => { .then((response) => {
const normalized = normalizeMemberLogPreviewResponse(response); const normalized = normalizeMemberLogPreviewResponse(response);
const members = memberMapFromResponse(normalized.members); const members = memberMapFromResponse(normalized.members);
for (const member of members.values()) { if (
cacheRef.current.set( mountedRef.current &&
buildMemberCacheKey({ teamNameRef.current === requestTeamName &&
teamName: input.teamName, requestGenerationRef.current === requestGeneration
memberName: member.memberName, ) {
laneIdsByMember, for (const member of members.values()) {
maxItemsPerMember, cacheRef.current.set(
textLimit, buildMemberCacheKey({
}), teamName: input.teamName,
{ memberName: member.memberName,
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS, laneIdsByMember,
member, maxItemsPerMember,
} textLimit,
); }),
{
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
member,
}
);
}
} }
return members; return members;
}) })
.finally(() => { .finally(() => {
inFlightRef.current.delete(requestKey); if (inFlightRef.current.get(requestKey) === request) {
inFlightRef.current.delete(requestKey);
}
}); });
inFlightRef.current.set(requestKey, request); inFlightRef.current.set(requestKey, request);
} }
const members = await request; const members = await request;
if (teamNameRef.current !== requestTeamName) { if (!requestStillActive()) {
return; return;
} }
const currentMembers = Array.from(members.values()).filter((member) => { const currentMembers = Array.from(members.values()).filter((member) => {
@ -324,70 +465,129 @@ export function useGraphMemberLogPreviews(input: {
if (currentMembers.length > 0) { if (currentMembers.length > 0) {
setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers)); setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers));
} }
setError(null); if (requestStillVisible()) {
setError(null);
}
} catch (loadError) { } catch (loadError) {
if (teamNameRef.current !== requestTeamName) { if (!requestStillVisible()) {
return; return;
} }
setError( setError(
loadError instanceof Error ? loadError.message : 'Failed to load graph log previews' loadError instanceof Error ? loadError.message : 'Failed to load graph log previews'
); );
} finally { } finally {
if (teamNameRef.current === requestTeamName) { if (
requestStillVisible() &&
!hasInFlightMemberPreviewRequest(
memberNamesRef.current,
activeRequestKeyByMemberRef.current,
inFlightRef.current
) &&
!hasPendingLoadingReload(
pendingReloadRef.current,
memberNamesRef.current,
previewsByMemberRef.current
)
) {
setLoading(false); setLoading(false);
} }
} }
}, },
[enabled, input.teamName, laneIdsByMember, maxItemsPerMember, memberNames, textLimit] [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(() => { useEffect(() => {
if (!enabled || memberNames.length === 0) { if (!enabled || memberNames.length === 0) {
clearScheduledReload();
setLoading(false); setLoading(false);
setError(null); setError(null);
return; return;
} }
if (reloadTimerRef.current) { const hasUnloadedPreview = hasUnloadedMemberPreview(memberNames, previewsByMemberRef.current);
clearTimeout(reloadTimerRef.current); const laneKeyChanged = laneKeyRef.current !== laneKey;
laneKeyRef.current = laneKey;
if (hasUnloadedPreview) {
setLoading(true);
setError(null);
} }
reloadTimerRef.current = setTimeout(() => { scheduleReload({ forceRefresh: hasUnloadedPreview || laneKeyChanged });
reloadTimerRef.current = null; }, [
void loadPreviews(); clearScheduledReload,
}, LIVE_RELOAD_DEBOUNCE_MS); enabled,
return () => { input.teamName,
if (reloadTimerRef.current) { laneKey,
clearTimeout(reloadTimerRef.current); memberKey,
reloadTimerRef.current = null; memberNames.length,
} scheduleReload,
}; ]);
}, [enabled, loadPreviews, memberKey, memberNames.length]);
useEffect(() => { useEffect(() => {
if (!enabled) return; 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) => { const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => {
if (event.teamName !== input.teamName) return; if (event.teamName !== input.teamName) return;
if (event.type === 'log-source-change') { 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; return;
} }
if (event.type === 'task-log-change') { if (event.type === 'task-log-change') {
scheduleReload(true); scheduleReload({ background: true, forceRefresh: true });
} }
}); });
const handleVisibilityChange = (): void => { const handleVisibilityChange = (): void => {
if (document.visibilityState === 'visible') scheduleReload(false); if (document.visibilityState === 'visible') {
scheduleReload({ background: true, forceRefresh: true });
}
}; };
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
@ -395,16 +595,12 @@ export function useGraphMemberLogPreviews(input: {
} }
return () => { return () => {
if (reloadTimerRef.current) {
clearTimeout(reloadTimerRef.current);
reloadTimerRef.current = null;
}
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
} }
if (typeof unsubscribe === 'function') unsubscribe(); if (typeof unsubscribe === 'function') unsubscribe();
}; };
}, [enabled, input.teamName, loadPreviews, memberNames.length]); }, [enabled, input.teamName, scheduleReload]);
return { previewsByMember, loading, error, reload: loadPreviews }; return { previewsByMember, loading, error, reload: loadPreviews };
} }

View file

@ -113,11 +113,11 @@ function resolveEmptyText(
loading: boolean, loading: boolean,
error: string | null error: string | null
): string { ): 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')) { if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
return 'Unsupported provider'; 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'; return 'No recent logs';
} }

View file

@ -28,23 +28,56 @@ interface RenderedTeamChangeSummary {
fileBudget: number; 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<FileChangeSummary>).filePath === 'string'
)
);
}
function getChangeSetWarnings(changeSet: TaskChangeSetV2): string[] {
return Array.isArray(changeSet.warnings)
? changeSet.warnings.filter((warning): warning is string => typeof warning === 'string')
: [];
}
function getTaskChangeContributors( function getTaskChangeContributors(
task: TeamTaskWithKanban, task: TeamTaskWithKanban,
changeSet: TaskChangeSetV2 | null changeSet: TaskChangeSetV2 | null
): string[] { ): string[] {
const names = new Set<string>(); const names = new Set<string>();
for (const contributor of changeSet?.scope.contributors ?? []) { const contributors = Array.isArray(changeSet?.scope?.contributors)
if (contributor.memberName) names.add(contributor.memberName); ? 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 ?? []) { const memberNames = Array.isArray(changeSet?.scope?.memberNames)
names.add(name); ? 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); names.add(changeSet.scope.primaryMemberName);
} }
for (const file of changeSet?.files ?? []) { for (const file of getChangeSetFiles(changeSet)) {
for (const name of file.ledgerSummary?.memberNames ?? []) { const fileMemberNames = Array.isArray(file.ledgerSummary?.memberNames)
names.add(name); ? file.ledgerSummary.memberNames
: [];
for (const name of fileMemberNames) {
if (typeof name === 'string' && name) names.add(name);
} }
} }
if (names.size === 0 && task.owner) { if (names.size === 0 && task.owner) {
@ -54,10 +87,16 @@ function getTaskChangeContributors(
} }
function getVisibleFileName(file: FileChangeSummary): string { function getVisibleFileName(file: FileChangeSummary): string {
const value = file.relativePath || file.filePath; const value = getVisibleFilePath(file);
return value.split(/[\\/]/).pop() ?? value; 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 { function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
if (!changeSet) return undefined; if (!changeSet) return undefined;
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability; const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
@ -75,7 +114,7 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
const messages = const messages =
status.diagnostics.length > 0 status.diagnostics.length > 0
? status.diagnostics.map((diagnostic) => diagnostic.message) ? status.diagnostics.map((diagnostic) => diagnostic.message)
: changeSet.warnings; : getChangeSetWarnings(changeSet);
return [...new Set(messages.filter((message) => message.trim().length > 0))]; return [...new Set(messages.filter((message) => message.trim().length > 0))];
} }
@ -102,7 +141,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
return ( return (
Boolean(entry.task) && Boolean(entry.task) &&
(Boolean(entry.summary.error) || (Boolean(entry.summary.error) ||
(changeSet?.files.length ?? 0) > 0 || getChangeSetFiles(changeSet).length > 0 ||
(changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false)) (changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false))
); );
}) })
@ -110,7 +149,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
}, [summariesByTaskId, taskMap]); }, [summariesByTaskId, taskMap]);
const totalFiles = visibleSummaries.reduce( const totalFiles = visibleSummaries.reduce(
(sum, entry) => sum + (entry.summary.changeSet?.files.length ?? 0), (sum, entry) => sum + getChangeSetFiles(entry.summary.changeSet).length,
0 0
); );
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS); 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[] = []; const entries: RenderedTeamChangeSummary[] = [];
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS; let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
for (const entry of visibleSummaries) { for (const entry of visibleSummaries) {
const files = entry.summary.changeSet?.files ?? []; const files = getChangeSetFiles(entry.summary.changeSet);
const fileBudget = Math.max(0, remainingFileRows); const fileBudget = Math.max(0, remainingFileRows);
const visibleFiles = files.slice(0, fileBudget); const visibleFiles = files.slice(0, fileBudget);
entries.push({ ...entry, visibleFiles, fileBudget }); entries.push({ ...entry, visibleFiles, fileBudget });
@ -167,19 +206,12 @@ export const TeamChangesSection = memo(function TeamChangesSection({
} }
contentClassName="pl-2.5" contentClassName="pl-2.5"
> >
{loading && visibleSummaries.length === 0 ? ( {visibleSummaries.length > 0 ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
Loading changes...
</div>
) : error ? (
<p className="text-xs text-red-400">{error}</p>
) : visibleSummaries.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1"> <div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => { {renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
const changeSet = summary.changeSet; const changeSet = summary.changeSet;
const files = changeSet?.files ?? []; const files = getChangeSetFiles(changeSet);
const reviewability = changeSet const reviewability = changeSet
? classifyTaskChangeReviewability(changeSet).reviewability ? classifyTaskChangeReviewability(changeSet).reviewability
: 'unknown'; : 'unknown';
@ -267,9 +299,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({
type="button" 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)]" 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)} onClick={() => onViewChanges(task.id, file.filePath)}
title={file.relativePath || file.filePath} title={getVisibleFilePath(file)}
> >
{file.relativePath || file.filePath} {getVisibleFilePath(file)}
</button> </button>
<span className="flex shrink-0 items-center gap-1.5"> <span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? ( {file.linesAdded > 0 ? (
@ -310,18 +342,26 @@ export const TeamChangesSection = memo(function TeamChangesSection({
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]"> <div className="flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{refreshing ? ( {loading || refreshing ? (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<Loader2 size={11} className="animate-spin" /> <Loader2 size={11} className="animate-spin" />
Refreshing Refreshing
</span> </span>
) : null} ) : null}
{error ? <span className="text-red-400">Refresh failed: {error}</span> : null}
{hiddenFileRows > 0 ? <span>{hiddenFileRows} file rows hidden</span> : null} {hiddenFileRows > 0 ? <span>{hiddenFileRows} file rows hidden</span> : null}
{stats.deferredCount > 0 ? ( {stats.deferredCount > 0 ? (
<span>{stats.deferredCount} tasks deferred this pass</span> <span>{stats.deferredCount} tasks deferred this pass</span>
) : null} ) : null}
</div> </div>
</div> </div>
) : loading || refreshing ? (
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={14} className="animate-spin" />
{loading ? 'Loading changes...' : 'Refreshing changes...'}
</div>
) : error ? (
<p className="text-xs text-red-400">{error}</p>
) : ( ) : (
<div className="space-y-1 py-1"> <div className="space-y-1 py-1">
<p className="text-xs text-[var(--color-text-muted)]">No file changes recorded</p> <p className="text-xs text-[var(--color-text-muted)]">No file changes recorded</p>

View file

@ -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<string, unknown>) => unknown) =>
selector({
recordTaskChangePresence: hoisted.recordTaskChangePresence,
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
}),
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function task(overrides: Partial<TeamTaskWithKanban> = {}): 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]> = {}
): 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<string, TeamChangeSummaryState>;
}
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<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
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<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
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<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
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<TeamTaskChangeSummariesResponse>();
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<HTMLButtonElement>(
'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;
}
}
});
});

View file

@ -1926,9 +1926,10 @@ export const CreateTeamDialog = ({
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" /> <Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300"> <p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; no teammates will Only the team lead (main process) will be started &mdash; no teammates will
be spawned. Works like a regular Claude session but with access to the task be spawned. Works like a regular agent session in your chosen runtime
board for planning. Saves tokens by avoiding teammate coordination overhead. (Claude Code, Codex, OpenCode, Gemini) but with access to the task board for
You can add members later from the team settings. planning. Saves tokens by avoiding teammate coordination overhead. You can
add members later from the team settings.
</p> </p>
</div> </div>
) : null} ) : null}

View file

@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label'; import { Label } from '@renderer/components/ui/label';
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles'; import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
import { cn } from '@renderer/lib/utils';
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog'; import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
@ -365,26 +366,6 @@ export const MembersEditorSection = ({
{headerExtra} {headerExtra}
{!hideContent && ( {!hideContent && (
<> <>
{showWorktreeIsolationControls ? (
<div
className="flex items-center gap-2 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2.5 py-2"
title={worktreeIsolationDisabledReason ?? undefined}
>
<Checkbox
id={worktreeDefaultControlId}
checked={teammateWorktreeDefault}
disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
/>
<Label
htmlFor={worktreeDefaultControlId}
className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
>
<GitBranch className="size-3.5 shrink-0" />
<span className="truncate">Run teammates in separate worktrees</span>
</Label>
</div>
) : null}
{disableAddMember && addMemberLockReason ? ( {disableAddMember && addMemberLockReason ? (
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p> <p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
) : null} ) : null}
@ -396,98 +377,126 @@ export const MembersEditorSection = ({
onClose={toggleJsonEditor} onClose={toggleJsonEditor}
/> />
) : null} ) : null}
<div className="space-y-2"> <div
{activeMembers.map((member, index) => ( className={
<MemberDraftRow showWorktreeIsolationControls
key={member.id} ? 'overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]'
member={member} : undefined
index={index} }
avatarSrc={getParticipantAvatarUrlByIndex(index + 1)} >
resolvedColor={memberColorMap.get(member.id)} {showWorktreeIsolationControls ? (
nameError={validateMemberName?.(member.name) ?? null} <div
onNameChange={updateMemberName} className="flex items-center gap-2 border-b border-[var(--color-border)] px-2.5 py-2"
onRoleChange={updateMemberRole} title={worktreeIsolationDisabledReason ?? undefined}
onCustomRoleChange={updateMemberCustomRole} >
onRemove={removeMember} <Checkbox
showWorkflow={showWorkflow} id={worktreeDefaultControlId}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined} checked={teammateWorktreeDefault}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined} disabled={Boolean(worktreeIsolationDisabledReason && !teammateWorktreeDefault)}
onProviderChange={updateMemberProvider} onCheckedChange={(checked) => updateTeammateWorktreeDefault(checked === true)}
onModelChange={updateMemberModel} />
onEffortChange={updateMemberEffort} <Label
showWorktreeIsolationControls={showWorktreeIsolationControls} htmlFor={worktreeDefaultControlId}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason} className="flex min-w-0 cursor-pointer items-center gap-1.5 text-xs font-normal text-[var(--color-text-secondary)]"
onWorktreeIsolationChange={updateMemberIsolation} >
inheritedProviderId={inheritedProviderId} <GitBranch className="size-3.5 shrink-0" />
inheritedModel={inheritedModel} <span className="truncate">Run teammates in separate worktrees</span>
inheritedEffort={inheritedEffort} </Label>
limitContext={limitContext}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel={lockProviderModel}
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
identityLockReason={identityLockReason}
modelLockReason={modelLockReason}
warningText={memberWarningById?.[member.id] ?? null}
infoText={memberInfoById?.[member.id] ?? null}
disableGeminiOption={disableGeminiOption}
modelIssueText={memberModelIssueById?.[member.id] ?? null}
modelIssueReasonByProvider={modelIssueReasonByProvider}
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
/>
))}
{softDeleteMembers && removedMembers.length > 0 ? (
<div className="pt-2">
<div className="mb-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length})
</div>
<div className="space-y-2">
{removedMembers.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={activeMembers.length + index}
avatarSrc={getParticipantAvatarUrlByIndex(activeMembers.length + index + 1)}
resolvedColor={memberColorMap.get(member.id)}
nameError={null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
onRestore={restoreMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls}
onWorktreeIsolationChange={updateMemberIsolation}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
limitContext={limitContext}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved
warningText={null}
disableGeminiOption={disableGeminiOption}
modelIssueText={null}
/>
))}
</div>
</div> </div>
) : null} ) : null}
<div className={cn('space-y-2', showWorktreeIsolationControls && 'p-2')}>
{activeMembers.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={index}
avatarSrc={getParticipantAvatarUrlByIndex(index + 1)}
resolvedColor={memberColorMap.get(member.id)}
nameError={validateMemberName?.(member.name) ?? null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls}
worktreeIsolationDisabledReason={worktreeIsolationDisabledReason}
onWorktreeIsolationChange={updateMemberIsolation}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
limitContext={limitContext}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel={lockProviderModel}
lockIdentity={lockExistingMemberIdentity && Boolean(member.originalName?.trim())}
identityLockReason={identityLockReason}
modelLockReason={modelLockReason}
warningText={memberWarningById?.[member.id] ?? null}
infoText={memberInfoById?.[member.id] ?? null}
disableGeminiOption={disableGeminiOption}
modelIssueText={memberModelIssueById?.[member.id] ?? null}
modelIssueReasonByProvider={modelIssueReasonByProvider}
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
/>
))}
{softDeleteMembers && removedMembers.length > 0 ? (
<div className="pt-2">
<div className="mb-2 text-[10px] text-[var(--color-text-muted)]">
Removed ({removedMembers.length})
</div>
<div className="space-y-2">
{removedMembers.map((member, index) => (
<MemberDraftRow
key={member.id}
member={member}
index={activeMembers.length + index}
avatarSrc={getParticipantAvatarUrlByIndex(activeMembers.length + index + 1)}
resolvedColor={memberColorMap.get(member.id)}
nameError={null}
onNameChange={updateMemberName}
onRoleChange={updateMemberRole}
onCustomRoleChange={updateMemberCustomRole}
onRemove={removeMember}
onRestore={restoreMember}
showWorkflow={showWorkflow}
onWorkflowChange={showWorkflow ? updateMemberWorkflow : undefined}
onWorkflowChipsChange={showWorkflow ? updateMemberWorkflowChips : undefined}
onProviderChange={updateMemberProvider}
onModelChange={updateMemberModel}
onEffortChange={updateMemberEffort}
showWorktreeIsolationControls={showWorktreeIsolationControls}
onWorktreeIsolationChange={updateMemberIsolation}
inheritedProviderId={inheritedProviderId}
inheritedModel={inheritedModel}
inheritedEffort={inheritedEffort}
limitContext={limitContext}
forceInheritedModelSettings={forceInheritedModelSettings}
draftKeyPrefix={draftKeyPrefix}
projectPath={projectPath}
mentionSuggestions={mentionSuggestions}
taskSuggestions={taskSuggestions}
teamSuggestions={teamSuggestions}
lockProviderModel
modelLockReason="Removed members are kept for soft delete history. Restore them to edit settings."
isRemoved
warningText={null}
disableGeminiOption={disableGeminiOption}
modelIssueText={null}
/>
))}
</div>
</div>
) : null}
</div>
</div> </div>
{hasDuplicates ? ( {hasDuplicates ? (
<p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}> <p className="text-[11px]" style={{ color: 'var(--field-error-text)' }}>

View file

@ -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<T>( export function withTeamChangesLoadTimeout<T>(
promise: Promise<T>, promise: Promise<T>,

View file

@ -10,9 +10,15 @@ import {
buildTeamChangesTasksFingerprint, buildTeamChangesTasksFingerprint,
} from './teamChangesRequestPlan'; } 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_AUTO_REFRESH_MS = 30_000;
const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
export interface TeamChangeSummaryState { export interface TeamChangeSummaryState {
taskId: string; taskId: string;
@ -47,6 +53,89 @@ interface UseTeamChangesSummariesResult {
refresh: () => void; refresh: () => void;
} }
function normalizeTeamChangeSummaryItem(item: unknown): TeamTaskChangeSummaryItem | null {
if (!item || typeof item !== 'object') {
return null;
}
const candidate = item as Partial<TeamTaskChangeSummaryItem>;
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<TaskChangePresenceState, 'unknown'> | 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({ export function useTeamChangesSummaries({
teamName, teamName,
tasks, tasks,
@ -71,6 +160,7 @@ export function useTeamChangesSummaries({
const requestSeqRef = useRef(0); const requestSeqRef = useRef(0);
const activeRequestSeqRef = useRef<number | null>(null); const activeRequestSeqRef = useRef<number | null>(null);
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null); const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const autoRefreshBlockedUntilRef = useRef(0);
const sectionOpenRef = useRef(sectionOpen); const sectionOpenRef = useRef(sectionOpen);
const unknownScanCursorRef = useRef(0); const unknownScanCursorRef = useRef(0);
const lastRequestedTasksFingerprintRef = useRef<string | null>(null); const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
@ -81,11 +171,16 @@ export function useTeamChangesSummaries({
sectionOpenRef.current = sectionOpen; sectionOpenRef.current = sectionOpen;
useEffect(() => { useEffect(() => {
mountedRef.current = true;
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
requestSeqRef.current += 1; requestSeqRef.current += 1;
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.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, showSpinner = false,
preserveOnError = true, preserveOnError = true,
}: TeamChangesLoadOptions = {}): Promise<void> => { }: TeamChangesLoadOptions = {}): Promise<void> => {
if (forceFresh) {
autoRefreshBlockedUntilRef.current = 0;
} else if (autoRefreshBlockedUntilRef.current > Date.now()) {
return;
}
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) { if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
const previous = queuedRefreshOptionsRef.current; const previous = queuedRefreshOptionsRef.current;
queuedRefreshOptionsRef.current = { queuedRefreshOptionsRef.current = {
@ -104,7 +205,6 @@ export function useTeamChangesSummaries({
? Boolean(previous.preserveOnError && preserveOnError) ? Boolean(previous.preserveOnError && preserveOnError)
: preserveOnError, : preserveOnError,
}; };
requestSeqRef.current += 1;
if (activeRequestSeqRef.current === null && sectionOpenRef.current) { if (activeRequestSeqRef.current === null && sectionOpenRef.current) {
setQueuedRefreshTick((value) => value + 1); setQueuedRefreshTick((value) => value + 1);
} }
@ -124,6 +224,7 @@ export function useTeamChangesSummaries({
if (plan.requests.length === 0) { if (plan.requests.length === 0) {
setSummariesByTaskId({}); setSummariesByTaskId({});
autoRefreshBlockedUntilRef.current = 0;
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
return; return;
@ -143,16 +244,22 @@ export function useTeamChangesSummaries({
if (!mountedRef.current || requestSeqRef.current !== requestSeq) { if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return; return;
} }
if (queuedRefreshOptionsRef.current !== null) {
return;
}
autoRefreshBlockedUntilRef.current = 0;
const responseItems = getSafeResponseItems(response);
const currentTaskIds = new Set(tasks.map((task) => task.id)); 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 changeSet = item.changeSet;
const options = plan.requestOptionsByTaskId.get(item.taskId); const options = plan.requestOptionsByTaskId.get(item.taskId);
if (!changeSet || !options) continue; if (!changeSet || !options) continue;
const nextPresence = resolveTaskChangePresenceFromResult(changeSet); const nextPresence = resolveCacheablePresenceFromChangeSet(changeSet);
if (!nextPresence) continue;
recordTaskChangePresence(teamName, item.taskId, options, nextPresence); recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown'); setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
} }
setSummariesByTaskId((previous) => { setSummariesByTaskId((previous) => {
@ -162,7 +269,7 @@ export function useTeamChangesSummaries({
next[taskId] = summary; next[taskId] = summary;
} }
} }
for (const item of response.items) { for (const item of responseItems) {
const options = plan.requestOptionsByTaskId.get(item.taskId); const options = plan.requestOptionsByTaskId.get(item.taskId);
if (!options) continue; if (!options) continue;
next[item.taskId] = { next[item.taskId] = {
@ -177,6 +284,8 @@ export function useTeamChangesSummaries({
if (!mountedRef.current || requestSeqRef.current !== requestSeq) { if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return; return;
} }
queuedRefreshOptionsRef.current = null;
autoRefreshBlockedUntilRef.current = Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
if (!preserveOnError) { if (!preserveOnError) {
setSummariesByTaskId({}); setSummariesByTaskId({});
} }
@ -208,6 +317,7 @@ export function useTeamChangesSummaries({
requestSeqRef.current += 1; requestSeqRef.current += 1;
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.current = null; queuedRefreshOptionsRef.current = null;
autoRefreshBlockedUntilRef.current = 0;
unknownScanCursorRef.current = 0; unknownScanCursorRef.current = 0;
lastRequestedTasksFingerprintRef.current = null; lastRequestedTasksFingerprintRef.current = null;
setSummariesByTaskId({}); setSummariesByTaskId({});
@ -220,6 +330,7 @@ export function useTeamChangesSummaries({
requestSeqRef.current += 1; requestSeqRef.current += 1;
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
queuedRefreshOptionsRef.current = null; queuedRefreshOptionsRef.current = null;
autoRefreshBlockedUntilRef.current = 0;
hasLoadedRef.current = false; hasLoadedRef.current = false;
lastRequestedTasksFingerprintRef.current = null; lastRequestedTasksFingerprintRef.current = null;
setSummariesByTaskId({}); setSummariesByTaskId({});

View file

@ -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 { nodes, layout } = buildSixOwnerGraph();
const snapshot = getSnapshot(nodes, { const snapshot = getSnapshot(nodes, {
...layout, ...layout,
@ -202,8 +202,8 @@ describe('stable slot layout', () => {
}); });
expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead'); 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.ringIndex)).toEqual([0, 0, 1, 1, 2, 2]);
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 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', () => { it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => {

View file

@ -72,13 +72,15 @@ const basePreviewsByMember = new Map<string, MemberLogPreviewMember>([
], ],
]); ]);
let mockedPreviewsByMember = basePreviewsByMember; let mockedPreviewsByMember = basePreviewsByMember;
let mockedLoading = false;
let mockedError: string | null = null;
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({ vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }), buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
useGraphMemberLogPreviews: () => ({ useGraphMemberLogPreviews: () => ({
previewsByMember: mockedPreviewsByMember, previewsByMember: mockedPreviewsByMember,
loading: false, loading: mockedLoading,
error: null, error: mockedError,
reload: vi.fn(), reload: vi.fn(),
}), }),
})); }));
@ -114,6 +116,8 @@ describe('GraphMemberLogPreviewHud', () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z')); vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
mockedPreviewsByMember = basePreviewsByMember; mockedPreviewsByMember = basePreviewsByMember;
mockedLoading = false;
mockedError = null;
}); });
afterEach(() => { 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<string, MemberLogPreviewMember>([
[
'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(
<GraphMemberLogPreviewHud
teamName="alpha-team"
nodes={[codexNode, quietNode]}
getLogWorldRect={(ownerNodeId) => ({
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 () => { it('renders lead log previews and opens the lead profile logs tab', async () => {
const leadNode: GraphNode = { const leadNode: GraphNode = {
id: 'lead:alpha-team', id: 'lead:alpha-team',

View file

@ -22,12 +22,15 @@ vi.mock('@renderer/api', () => ({
function createDeferred<T>(): { function createDeferred<T>(): {
promise: Promise<T>; promise: Promise<T>;
resolve: (value: T) => void; resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} { } {
let resolve!: (value: T) => void; let resolve!: (value: T) => void;
const promise = new Promise<T>((innerResolve) => { let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve; resolve = innerResolve;
reject = innerReject;
}); });
return { promise, resolve }; return { promise, resolve, reject };
} }
function response(memberName: string, generatedAt: string): MemberLogPreviewResponse { 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 { function batchResponse(memberNames: string[], generatedAt: string): MemberLogPreviewResponse {
return { return {
generatedAt, generatedAt,
@ -107,6 +127,28 @@ const HookProbe = ({
return null; return null;
}; };
const ReloadProbe = ({
teamName,
memberNames,
onState,
onReload,
}: {
teamName: string;
memberNames: string[];
onState: (state: ReturnType<typeof useGraphMemberLogPreviews>) => void;
onReload: (reload: ReturnType<typeof useGraphMemberLogPreviews>['reload']) => void;
}): React.JSX.Element | null => {
const state = useGraphMemberLogPreviews({
teamName,
memberNames,
});
useEffect(() => {
onState(state);
onReload(state.reload);
}, [onReload, onState, state]);
return null;
};
describe('useGraphMemberLogPreviews', () => { describe('useGraphMemberLogPreviews', () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@ -162,6 +204,7 @@ describe('useGraphMemberLogPreviews', () => {
expect.objectContaining({ expect.objectContaining({
maxItemsPerMember: 3, maxItemsPerMember: 3,
textLimit: 200, textLimit: 200,
forceRefresh: true,
laneIdsByMember: { alice: 'secondary:opencode:alice' }, 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<MemberLogPreviewResponse>();
apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const states: ReturnType<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
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(
<React.StrictMode>
<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => undefined} />
</React.StrictMode>
);
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(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => 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 () => { it('keeps completed previews cached after the visible member set changes', async () => {
const aliceLoad = createDeferred<MemberLogPreviewResponse>(); const aliceLoad = createDeferred<MemberLogPreviewResponse>();
const bobLoad = createDeferred<MemberLogPreviewResponse>(); const bobLoad = createDeferred<MemberLogPreviewResponse>();
@ -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<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
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(<HookProbe teamName="beta-team" memberNames={['alice']} onState={onState} />);
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 () => { it('does not duplicate preview requests when the same visible members are reordered', async () => {
const firstLoad = createDeferred<MemberLogPreviewResponse>(); const firstLoad = createDeferred<MemberLogPreviewResponse>();
apiMock.memberLogStream.getMemberLogPreviews.mockReturnValueOnce(firstLoad.promise); 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(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{
alice: 'secondary:opencode:alice',
bob: 'secondary:opencode:bob:old',
}}
onState={() => undefined}
/>
);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{
alice: 'secondary:opencode:alice',
bob: 'secondary:opencode:bob:new',
}}
onState={() => 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(
<HookProbe
teamName="alpha-team"
memberNames={['Alice']}
laneIdsByMember={{
Alice: ' ',
alice: 'secondary:opencode:alice',
}}
onState={() => 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(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{ alice: 'secondary:opencode:alice:old' }}
onState={() => 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(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{ alice: 'secondary:opencode:alice:new' }}
onState={() => 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(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={() => 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<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
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<MemberLogPreviewResponse>();
const refreshLoad = createDeferred<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
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<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
let reload: ReturnType<typeof useGraphMemberLogPreviews>['reload'] | null = null;
await act(async () => {
root.render(
<ReloadProbe
teamName="alpha-team"
memberNames={['alice']}
onState={onState}
onReload={(nextReload) => {
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<MemberLogPreviewResponse>();
const latestRefresh = createDeferred<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{ alice: 'secondary:opencode:alice:old' }}
onState={onState}
/>
);
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(
<HookProbe
teamName="alpha-team"
memberNames={['alice']}
laneIdsByMember={{ alice: 'secondary:opencode:alice:new' }}
onState={onState}
/>
);
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<MemberLogPreviewResponse>();
const visibleBobLoad = createDeferred<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
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(<HookProbe teamName="alpha-team" memberNames={['bob']} onState={onState} />);
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<MemberLogPreviewResponse>();
const currentAlphaLoad = createDeferred<MemberLogPreviewResponse>();
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<typeof useGraphMemberLogPreviews>[] = [];
const onState = vi.fn((state: ReturnType<typeof useGraphMemberLogPreviews>) => {
states.push(state);
});
const latestState = (): ReturnType<typeof useGraphMemberLogPreviews> | undefined =>
states.at(-1);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
await Promise.resolve();
});
await act(async () => {
vi.advanceTimersByTime(700);
await Promise.resolve();
});
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(1);
await act(async () => {
root.render(<HookProbe teamName="beta-team" memberNames={[]} onState={onState} />);
await Promise.resolve();
});
expect(latestState()?.previewsByMember.size).toBe(0);
await act(async () => {
root.render(<HookProbe teamName="alpha-team" memberNames={['alice']} onState={onState} />);
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 () => { it('reloads visible members on log change events with force refresh', async () => {
let teamChangeListener: let teamChangeListener:
| ((event: unknown, data: { teamName: string; type: string }) => void) | ((event: unknown, data: { teamName: string; type: string }) => void)
@ -450,7 +1284,7 @@ describe('useGraphMemberLogPreviews', () => {
); );
await act(async () => { await act(async () => {
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' }); teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
vi.advanceTimersByTime(700); vi.advanceTimersByTime(700);
await Promise.resolve(); await Promise.resolve();
}); });
@ -462,6 +1296,19 @@ describe('useGraphMemberLogPreviews', () => {
expect.objectContaining({ forceRefresh: true }) 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(() => { act(() => {
root.unmount(); root.unmount();
}); });