feat(team): improve graph member log previews
This commit is contained in:
parent
8943194a84
commit
26c394674b
11 changed files with 2127 additions and 217 deletions
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 — no teammates will
|
Only the team lead (main process) will be started — no teammates will
|
||||||
be spawned. Works like a regular Claude session but with access to the task
|
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}
|
||||||
|
|
|
||||||
|
|
@ -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)' }}>
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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({});
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue