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_TEXT_LIMIT = 200;
|
||||
|
||||
interface PendingReloadOptions {
|
||||
forceRefresh: boolean;
|
||||
background: boolean;
|
||||
}
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
|
@ -65,15 +70,79 @@ function mergeMemberPreviews(
|
|||
return next;
|
||||
}
|
||||
|
||||
function hasUnloadedMemberPreview(
|
||||
memberNames: readonly string[],
|
||||
previewsByMember: ReadonlyMap<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(
|
||||
memberName: string,
|
||||
laneIdsByMember: Readonly<Record<string, string>>
|
||||
): string {
|
||||
return (
|
||||
laneIdsByMember[memberName]?.trim() ??
|
||||
laneIdsByMember[normalizeMemberName(memberName)]?.trim() ??
|
||||
''
|
||||
);
|
||||
const directLaneId = laneIdsByMember[memberName]?.trim();
|
||||
if (directLaneId) return directLaneId;
|
||||
const normalizedLaneId = laneIdsByMember[normalizeMemberName(memberName)]?.trim();
|
||||
return normalizedLaneId || '';
|
||||
}
|
||||
|
||||
function buildMemberCacheKey(input: {
|
||||
|
|
@ -92,6 +161,24 @@ function buildMemberCacheKey(input: {
|
|||
]);
|
||||
}
|
||||
|
||||
function buildLaneIdsKey(laneIdsByMember: Readonly<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(
|
||||
memberNames: readonly string[],
|
||||
laneIdsByMember: Readonly<Record<string, string>>
|
||||
|
|
@ -168,6 +255,10 @@ export function useGraphMemberLogPreviews(input: {
|
|||
}
|
||||
return result;
|
||||
}, [input.memberNames]);
|
||||
const laneKey = useMemo(
|
||||
() => buildLaneIdsKey(buildLaneIdsForMembers(memberNames, laneIdsByMember)),
|
||||
[laneIdsByMember, memberNames]
|
||||
);
|
||||
const memberKey = useMemo(
|
||||
() =>
|
||||
memberNames
|
||||
|
|
@ -186,25 +277,42 @@ export function useGraphMemberLogPreviews(input: {
|
|||
const inFlightRef = useRef(new Map<string, Promise<Map<string, MemberLogPreviewMember>>>());
|
||||
const activeRequestKeyByMemberRef = useRef(new Map<string, string>());
|
||||
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingReloadRef = useRef<PendingReloadOptions | null>(null);
|
||||
const requestGenerationRef = useRef(0);
|
||||
const teamNameRef = useRef(input.teamName);
|
||||
const laneKeyRef = useRef(laneKey);
|
||||
const memberNamesRef = useRef(memberNames);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
previewsByMemberRef.current = previewsByMember;
|
||||
}, [previewsByMember]);
|
||||
previewsByMemberRef.current = previewsByMember;
|
||||
memberNamesRef.current = memberNames;
|
||||
|
||||
const clearScheduledReload = useCallback((): void => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
pendingReloadRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (teamNameRef.current !== input.teamName) {
|
||||
teamNameRef.current = input.teamName;
|
||||
laneKeyRef.current = laneKey;
|
||||
requestGenerationRef.current += 1;
|
||||
clearScheduledReload();
|
||||
cacheRef.current.clear();
|
||||
inFlightRef.current.clear();
|
||||
activeRequestKeyByMemberRef.current.clear();
|
||||
setPreviewsByMember(new Map());
|
||||
const emptyPreviews = new Map<string, MemberLogPreviewMember>();
|
||||
previewsByMemberRef.current = emptyPreviews;
|
||||
setPreviewsByMember(emptyPreviews);
|
||||
}
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
setError(null);
|
||||
}, [enabled, input.teamName, memberKey, memberNames.length]);
|
||||
}, [clearScheduledReload, enabled, input.teamName, laneKey, memberKey, memberNames.length]);
|
||||
|
||||
const loadPreviews = useCallback(
|
||||
async (options?: { forceRefresh?: boolean; background?: boolean }): Promise<void> => {
|
||||
|
|
@ -221,6 +329,7 @@ export function useGraphMemberLogPreviews(input: {
|
|||
const membersToRequest: string[] = [];
|
||||
const cachedMembers: MemberLogPreviewMember[] = [];
|
||||
let hasMissingPreview = false;
|
||||
let hasEmptyOrMissingPreviewForForceRefresh = false;
|
||||
|
||||
for (const memberName of memberNames) {
|
||||
const cacheKey = buildMemberCacheKey({
|
||||
|
|
@ -238,9 +347,13 @@ export function useGraphMemberLogPreviews(input: {
|
|||
membersToRequest.push(memberName);
|
||||
}
|
||||
const normalizedMemberName = normalizeMemberName(memberName);
|
||||
if (!cached && !previewsByMemberRef.current.has(normalizedMemberName)) {
|
||||
const existingPreview = previewsByMemberRef.current.get(normalizedMemberName);
|
||||
if (!cached && !existingPreview) {
|
||||
hasMissingPreview = true;
|
||||
}
|
||||
if (options?.forceRefresh && (!existingPreview || existingPreview.items.length === 0)) {
|
||||
hasEmptyOrMissingPreviewForForceRefresh = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedMembers.length > 0) {
|
||||
|
|
@ -263,11 +376,31 @@ export function useGraphMemberLogPreviews(input: {
|
|||
forceRefresh: options?.forceRefresh,
|
||||
});
|
||||
const requestTeamName = input.teamName;
|
||||
const requestGeneration = requestGenerationRef.current;
|
||||
for (const memberName of membersToRequest) {
|
||||
activeRequestKeyByMemberRef.current.set(normalizeMemberName(memberName), requestKey);
|
||||
}
|
||||
const requestStillActive = (): boolean =>
|
||||
mountedRef.current &&
|
||||
teamNameRef.current === requestTeamName &&
|
||||
requestGenerationRef.current === requestGeneration &&
|
||||
hasActiveMemberPreviewRequest(
|
||||
membersToRequest,
|
||||
requestKey,
|
||||
activeRequestKeyByMemberRef.current
|
||||
);
|
||||
const requestStillVisible = (): boolean =>
|
||||
mountedRef.current &&
|
||||
teamNameRef.current === requestTeamName &&
|
||||
requestGenerationRef.current === requestGeneration &&
|
||||
hasVisibleActiveMemberPreviewRequest(
|
||||
membersToRequest,
|
||||
memberNamesRef.current,
|
||||
requestKey,
|
||||
activeRequestKeyByMemberRef.current
|
||||
);
|
||||
|
||||
if (!options?.background && hasMissingPreview) {
|
||||
if ((!options?.background && hasMissingPreview) || hasEmptyOrMissingPreviewForForceRefresh) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
|
@ -288,31 +421,39 @@ export function useGraphMemberLogPreviews(input: {
|
|||
.then((response) => {
|
||||
const normalized = normalizeMemberLogPreviewResponse(response);
|
||||
const members = memberMapFromResponse(normalized.members);
|
||||
for (const member of members.values()) {
|
||||
cacheRef.current.set(
|
||||
buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
}),
|
||||
{
|
||||
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
|
||||
member,
|
||||
}
|
||||
);
|
||||
if (
|
||||
mountedRef.current &&
|
||||
teamNameRef.current === requestTeamName &&
|
||||
requestGenerationRef.current === requestGeneration
|
||||
) {
|
||||
for (const member of members.values()) {
|
||||
cacheRef.current.set(
|
||||
buildMemberCacheKey({
|
||||
teamName: input.teamName,
|
||||
memberName: member.memberName,
|
||||
laneIdsByMember,
|
||||
maxItemsPerMember,
|
||||
textLimit,
|
||||
}),
|
||||
{
|
||||
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
|
||||
member,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return members;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current.delete(requestKey);
|
||||
if (inFlightRef.current.get(requestKey) === request) {
|
||||
inFlightRef.current.delete(requestKey);
|
||||
}
|
||||
});
|
||||
inFlightRef.current.set(requestKey, request);
|
||||
}
|
||||
|
||||
const members = await request;
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
if (!requestStillActive()) {
|
||||
return;
|
||||
}
|
||||
const currentMembers = Array.from(members.values()).filter((member) => {
|
||||
|
|
@ -324,70 +465,129 @@ export function useGraphMemberLogPreviews(input: {
|
|||
if (currentMembers.length > 0) {
|
||||
setPreviewsByMember((current) => mergeMemberPreviews(current, currentMembers));
|
||||
}
|
||||
setError(null);
|
||||
if (requestStillVisible()) {
|
||||
setError(null);
|
||||
}
|
||||
} catch (loadError) {
|
||||
if (teamNameRef.current !== requestTeamName) {
|
||||
if (!requestStillVisible()) {
|
||||
return;
|
||||
}
|
||||
setError(
|
||||
loadError instanceof Error ? loadError.message : 'Failed to load graph log previews'
|
||||
);
|
||||
} finally {
|
||||
if (teamNameRef.current === requestTeamName) {
|
||||
if (
|
||||
requestStillVisible() &&
|
||||
!hasInFlightMemberPreviewRequest(
|
||||
memberNamesRef.current,
|
||||
activeRequestKeyByMemberRef.current,
|
||||
inFlightRef.current
|
||||
) &&
|
||||
!hasPendingLoadingReload(
|
||||
pendingReloadRef.current,
|
||||
memberNamesRef.current,
|
||||
previewsByMemberRef.current
|
||||
)
|
||||
) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, input.teamName, laneIdsByMember, maxItemsPerMember, memberNames, textLimit]
|
||||
);
|
||||
const loadPreviewsRef = useRef(loadPreviews);
|
||||
loadPreviewsRef.current = loadPreviews;
|
||||
|
||||
const scheduleReload = useCallback(
|
||||
(options?: { forceRefresh?: boolean; background?: boolean }) => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
if (memberNamesRef.current.length === 0) return;
|
||||
|
||||
if (
|
||||
options?.forceRefresh === true &&
|
||||
hasEmptyOrUnloadedMemberPreview(memberNamesRef.current, previewsByMemberRef.current)
|
||||
) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const current = pendingReloadRef.current;
|
||||
pendingReloadRef.current = {
|
||||
forceRefresh: (current?.forceRefresh ?? false) || options?.forceRefresh === true,
|
||||
background: (current?.background ?? true) && options?.background === true,
|
||||
};
|
||||
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
}
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
const pending = pendingReloadRef.current;
|
||||
pendingReloadRef.current = null;
|
||||
void loadPreviewsRef.current({
|
||||
background: pending?.background,
|
||||
forceRefresh: pending?.forceRefresh,
|
||||
});
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearScheduledReload();
|
||||
};
|
||||
}, [clearScheduledReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || memberNames.length === 0) {
|
||||
clearScheduledReload();
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
const hasUnloadedPreview = hasUnloadedMemberPreview(memberNames, previewsByMemberRef.current);
|
||||
const laneKeyChanged = laneKeyRef.current !== laneKey;
|
||||
laneKeyRef.current = laneKey;
|
||||
if (hasUnloadedPreview) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
}
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews();
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, loadPreviews, memberKey, memberNames.length]);
|
||||
scheduleReload({ forceRefresh: hasUnloadedPreview || laneKeyChanged });
|
||||
}, [
|
||||
clearScheduledReload,
|
||||
enabled,
|
||||
input.teamName,
|
||||
laneKey,
|
||||
memberKey,
|
||||
memberNames.length,
|
||||
scheduleReload,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const scheduleReload = (forceRefresh: boolean): void => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
if (memberNames.length === 0) return;
|
||||
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = setTimeout(() => {
|
||||
reloadTimerRef.current = null;
|
||||
void loadPreviews({ background: true, forceRefresh });
|
||||
}, LIVE_RELOAD_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const unsubscribe = api.teams.onTeamChange?.((_event: unknown, event: TeamChangeEvent) => {
|
||||
if (event.teamName !== input.teamName) return;
|
||||
if (event.type === 'log-source-change') {
|
||||
scheduleReload(true);
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
return;
|
||||
}
|
||||
if (event.type === 'tool-activity') {
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
return;
|
||||
}
|
||||
if (event.type === 'task-log-change') {
|
||||
scheduleReload(true);
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
}
|
||||
});
|
||||
|
||||
const handleVisibilityChange = (): void => {
|
||||
if (document.visibilityState === 'visible') scheduleReload(false);
|
||||
if (document.visibilityState === 'visible') {
|
||||
scheduleReload({ background: true, forceRefresh: true });
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
|
|
@ -395,16 +595,12 @@ export function useGraphMemberLogPreviews(input: {
|
|||
}
|
||||
|
||||
return () => {
|
||||
if (reloadTimerRef.current) {
|
||||
clearTimeout(reloadTimerRef.current);
|
||||
reloadTimerRef.current = null;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}
|
||||
if (typeof unsubscribe === 'function') unsubscribe();
|
||||
};
|
||||
}, [enabled, input.teamName, loadPreviews, memberNames.length]);
|
||||
}, [enabled, input.teamName, scheduleReload]);
|
||||
|
||||
return { previewsByMember, loading, error, reload: loadPreviews };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,11 +113,11 @@ function resolveEmptyText(
|
|||
loading: boolean,
|
||||
error: string | null
|
||||
): string {
|
||||
if (loading && !preview) return 'Loading logs';
|
||||
if (error && !preview) return 'Logs unavailable';
|
||||
if (preview?.warnings.some((warning) => warning.code === 'codex_member_wide_not_supported')) {
|
||||
return 'Unsupported provider';
|
||||
}
|
||||
if (loading && (!preview || preview.items.length === 0)) return 'Loading logs';
|
||||
if (error && (!preview || preview.items.length === 0)) return 'Logs unavailable';
|
||||
return 'No recent logs';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,23 +28,56 @@ interface RenderedTeamChangeSummary {
|
|||
fileBudget: number;
|
||||
}
|
||||
|
||||
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
|
||||
if (!Array.isArray(changeSet?.files)) {
|
||||
return [];
|
||||
}
|
||||
return changeSet.files.filter((file): file is FileChangeSummary =>
|
||||
Boolean(
|
||||
file &&
|
||||
typeof file === 'object' &&
|
||||
typeof (file as Partial<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(
|
||||
task: TeamTaskWithKanban,
|
||||
changeSet: TaskChangeSetV2 | null
|
||||
): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const contributor of changeSet?.scope.contributors ?? []) {
|
||||
if (contributor.memberName) names.add(contributor.memberName);
|
||||
const contributors = Array.isArray(changeSet?.scope?.contributors)
|
||||
? changeSet.scope.contributors
|
||||
: [];
|
||||
for (const contributor of contributors) {
|
||||
const memberName =
|
||||
contributor && typeof contributor.memberName === 'string' ? contributor.memberName : '';
|
||||
if (memberName) names.add(memberName);
|
||||
}
|
||||
for (const name of changeSet?.scope.memberNames ?? []) {
|
||||
names.add(name);
|
||||
const memberNames = Array.isArray(changeSet?.scope?.memberNames)
|
||||
? changeSet.scope.memberNames
|
||||
: [];
|
||||
for (const name of memberNames) {
|
||||
if (typeof name === 'string' && name) names.add(name);
|
||||
}
|
||||
if (changeSet?.scope.primaryMemberName) {
|
||||
if (
|
||||
typeof changeSet?.scope?.primaryMemberName === 'string' &&
|
||||
changeSet.scope.primaryMemberName
|
||||
) {
|
||||
names.add(changeSet.scope.primaryMemberName);
|
||||
}
|
||||
for (const file of changeSet?.files ?? []) {
|
||||
for (const name of file.ledgerSummary?.memberNames ?? []) {
|
||||
names.add(name);
|
||||
for (const file of getChangeSetFiles(changeSet)) {
|
||||
const fileMemberNames = Array.isArray(file.ledgerSummary?.memberNames)
|
||||
? file.ledgerSummary.memberNames
|
||||
: [];
|
||||
for (const name of fileMemberNames) {
|
||||
if (typeof name === 'string' && name) names.add(name);
|
||||
}
|
||||
}
|
||||
if (names.size === 0 && task.owner) {
|
||||
|
|
@ -54,10 +87,16 @@ function getTaskChangeContributors(
|
|||
}
|
||||
|
||||
function getVisibleFileName(file: FileChangeSummary): string {
|
||||
const value = file.relativePath || file.filePath;
|
||||
const value = getVisibleFilePath(file);
|
||||
return value.split(/[\\/]/).pop() ?? value;
|
||||
}
|
||||
|
||||
function getVisibleFilePath(file: FileChangeSummary): string {
|
||||
return typeof file.relativePath === 'string' && file.relativePath.trim() !== ''
|
||||
? file.relativePath
|
||||
: file.filePath;
|
||||
}
|
||||
|
||||
function getTaskSummaryBadge(changeSet: TaskChangeSetV2 | null): string | undefined {
|
||||
if (!changeSet) return undefined;
|
||||
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
||||
|
|
@ -75,7 +114,7 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
|||
const messages =
|
||||
status.diagnostics.length > 0
|
||||
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
||||
: changeSet.warnings;
|
||||
: getChangeSetWarnings(changeSet);
|
||||
return [...new Set(messages.filter((message) => message.trim().length > 0))];
|
||||
}
|
||||
|
||||
|
|
@ -102,7 +141,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
return (
|
||||
Boolean(entry.task) &&
|
||||
(Boolean(entry.summary.error) ||
|
||||
(changeSet?.files.length ?? 0) > 0 ||
|
||||
getChangeSetFiles(changeSet).length > 0 ||
|
||||
(changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false))
|
||||
);
|
||||
})
|
||||
|
|
@ -110,7 +149,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
}, [summariesByTaskId, taskMap]);
|
||||
|
||||
const totalFiles = visibleSummaries.reduce(
|
||||
(sum, entry) => sum + (entry.summary.changeSet?.files.length ?? 0),
|
||||
(sum, entry) => sum + getChangeSetFiles(entry.summary.changeSet).length,
|
||||
0
|
||||
);
|
||||
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
|
||||
|
|
@ -119,7 +158,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
const entries: RenderedTeamChangeSummary[] = [];
|
||||
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
|
||||
for (const entry of visibleSummaries) {
|
||||
const files = entry.summary.changeSet?.files ?? [];
|
||||
const files = getChangeSetFiles(entry.summary.changeSet);
|
||||
const fileBudget = Math.max(0, remainingFileRows);
|
||||
const visibleFiles = files.slice(0, fileBudget);
|
||||
entries.push({ ...entry, visibleFiles, fileBudget });
|
||||
|
|
@ -167,19 +206,12 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
}
|
||||
contentClassName="pl-2.5"
|
||||
>
|
||||
{loading && visibleSummaries.length === 0 ? (
|
||||
<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 ? (
|
||||
{visibleSummaries.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
|
||||
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
|
||||
const changeSet = summary.changeSet;
|
||||
const files = changeSet?.files ?? [];
|
||||
const files = getChangeSetFiles(changeSet);
|
||||
const reviewability = changeSet
|
||||
? classifyTaskChangeReviewability(changeSet).reviewability
|
||||
: 'unknown';
|
||||
|
|
@ -267,9 +299,9 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
onClick={() => onViewChanges(task.id, file.filePath)}
|
||||
title={file.relativePath || file.filePath}
|
||||
title={getVisibleFilePath(file)}
|
||||
>
|
||||
{file.relativePath || file.filePath}
|
||||
{getVisibleFilePath(file)}
|
||||
</button>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
|
|
@ -310,18 +342,26 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<Loader2 size={11} className="animate-spin" />
|
||||
Refreshing
|
||||
</span>
|
||||
) : null}
|
||||
{error ? <span className="text-red-400">Refresh failed: {error}</span> : null}
|
||||
{hiddenFileRows > 0 ? <span>{hiddenFileRows} file rows hidden</span> : null}
|
||||
{stats.deferredCount > 0 ? (
|
||||
<span>{stats.deferredCount} tasks deferred this pass</span>
|
||||
) : null}
|
||||
</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">
|
||||
<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" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
Only the team lead (main process) will be started — no teammates will
|
||||
be spawned. Works like a regular Claude session but with access to the task
|
||||
board for planning. Saves tokens by avoiding teammate coordination overhead.
|
||||
You can add members later from the team settings.
|
||||
be spawned. Works like a regular agent session in your chosen runtime
|
||||
(Claude Code, Codex, OpenCode, Gemini) but with access to the task board for
|
||||
planning. Saves tokens by avoiding teammate coordination overhead. You can
|
||||
add members later from the team settings.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Button } from '@renderer/components/ui/button';
|
|||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import { Label } from '@renderer/components/ui/label';
|
||||
import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRoles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
|
@ -365,26 +366,6 @@ export const MembersEditorSection = ({
|
|||
{headerExtra}
|
||||
{!hideContent && (
|
||||
<>
|
||||
{showWorktreeIsolationControls ? (
|
||||
<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 ? (
|
||||
<p className="text-[11px] text-[var(--color-text-muted)]">{addMemberLockReason}</p>
|
||||
) : null}
|
||||
|
|
@ -396,98 +377,126 @@ export const MembersEditorSection = ({
|
|||
onClose={toggleJsonEditor}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-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
|
||||
className={
|
||||
showWorktreeIsolationControls
|
||||
? 'overflow-hidden rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{showWorktreeIsolationControls ? (
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-[var(--color-border)] 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}
|
||||
<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>
|
||||
{hasDuplicates ? (
|
||||
<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>(
|
||||
promise: Promise<T>,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,15 @@ import {
|
|||
buildTeamChangesTasksFingerprint,
|
||||
} from './teamChangesRequestPlan';
|
||||
|
||||
import type { TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
|
||||
import type {
|
||||
TaskChangePresenceState,
|
||||
TaskChangeSetV2,
|
||||
TeamTaskChangeSummaryItem,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
|
||||
const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
|
||||
|
||||
export interface TeamChangeSummaryState {
|
||||
taskId: string;
|
||||
|
|
@ -47,6 +53,89 @@ interface UseTeamChangesSummariesResult {
|
|||
refresh: () => void;
|
||||
}
|
||||
|
||||
function normalizeTeamChangeSummaryItem(item: unknown): TeamTaskChangeSummaryItem | null {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = item as Partial<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({
|
||||
teamName,
|
||||
tasks,
|
||||
|
|
@ -71,6 +160,7 @@ export function useTeamChangesSummaries({
|
|||
const requestSeqRef = useRef(0);
|
||||
const activeRequestSeqRef = useRef<number | null>(null);
|
||||
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
|
||||
const autoRefreshBlockedUntilRef = useRef(0);
|
||||
const sectionOpenRef = useRef(sectionOpen);
|
||||
const unknownScanCursorRef = useRef(0);
|
||||
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
|
||||
|
|
@ -81,11 +171,16 @@ export function useTeamChangesSummaries({
|
|||
sectionOpenRef.current = sectionOpen;
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
hasLoadedRef.current = false;
|
||||
unknownScanCursorRef.current = 0;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -95,6 +190,12 @@ export function useTeamChangesSummaries({
|
|||
showSpinner = false,
|
||||
preserveOnError = true,
|
||||
}: TeamChangesLoadOptions = {}): Promise<void> => {
|
||||
if (forceFresh) {
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
} else if (autoRefreshBlockedUntilRef.current > Date.now()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
|
||||
const previous = queuedRefreshOptionsRef.current;
|
||||
queuedRefreshOptionsRef.current = {
|
||||
|
|
@ -104,7 +205,6 @@ export function useTeamChangesSummaries({
|
|||
? Boolean(previous.preserveOnError && preserveOnError)
|
||||
: preserveOnError,
|
||||
};
|
||||
requestSeqRef.current += 1;
|
||||
if (activeRequestSeqRef.current === null && sectionOpenRef.current) {
|
||||
setQueuedRefreshTick((value) => value + 1);
|
||||
}
|
||||
|
|
@ -124,6 +224,7 @@ export function useTeamChangesSummaries({
|
|||
|
||||
if (plan.requests.length === 0) {
|
||||
setSummariesByTaskId({});
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
|
|
@ -143,16 +244,22 @@ export function useTeamChangesSummaries({
|
|||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
if (queuedRefreshOptionsRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
const responseItems = getSafeResponseItems(response);
|
||||
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
for (const item of response.items) {
|
||||
for (const item of responseItems) {
|
||||
const changeSet = item.changeSet;
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!changeSet || !options) continue;
|
||||
|
||||
const nextPresence = resolveTaskChangePresenceFromResult(changeSet);
|
||||
const nextPresence = resolveCacheablePresenceFromChangeSet(changeSet);
|
||||
if (!nextPresence) continue;
|
||||
recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
|
||||
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence ?? 'unknown');
|
||||
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
|
||||
}
|
||||
|
||||
setSummariesByTaskId((previous) => {
|
||||
|
|
@ -162,7 +269,7 @@ export function useTeamChangesSummaries({
|
|||
next[taskId] = summary;
|
||||
}
|
||||
}
|
||||
for (const item of response.items) {
|
||||
for (const item of responseItems) {
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!options) continue;
|
||||
next[item.taskId] = {
|
||||
|
|
@ -177,6 +284,8 @@ export function useTeamChangesSummaries({
|
|||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
|
||||
if (!preserveOnError) {
|
||||
setSummariesByTaskId({});
|
||||
}
|
||||
|
|
@ -208,6 +317,7 @@ export function useTeamChangesSummaries({
|
|||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
unknownScanCursorRef.current = 0;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
setSummariesByTaskId({});
|
||||
|
|
@ -220,6 +330,7 @@ export function useTeamChangesSummaries({
|
|||
requestSeqRef.current += 1;
|
||||
activeRequestSeqRef.current = null;
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
hasLoadedRef.current = false;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
setSummariesByTaskId({});
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ describe('stable slot layout', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('uses three grid columns for six owners in rows layout', () => {
|
||||
it('uses two grid columns for six owners in rows layout', () => {
|
||||
const { nodes, layout } = buildSixOwnerGraph();
|
||||
const snapshot = getSnapshot(nodes, {
|
||||
...layout,
|
||||
|
|
@ -202,8 +202,8 @@ describe('stable slot layout', () => {
|
|||
});
|
||||
|
||||
expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead');
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 1, 1, 2, 2]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 0, 1, 0, 1]);
|
||||
});
|
||||
|
||||
it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => {
|
||||
|
|
|
|||
|
|
@ -72,13 +72,15 @@ const basePreviewsByMember = new Map<string, MemberLogPreviewMember>([
|
|||
],
|
||||
]);
|
||||
let mockedPreviewsByMember = basePreviewsByMember;
|
||||
let mockedLoading = false;
|
||||
let mockedError: string | null = null;
|
||||
|
||||
vi.mock('@features/agent-graph/renderer/hooks/useGraphMemberLogPreviews', () => ({
|
||||
buildGraphLogPreviewLaneIdsByMember: () => ({ alice: 'secondary:opencode:alice' }),
|
||||
useGraphMemberLogPreviews: () => ({
|
||||
previewsByMember: mockedPreviewsByMember,
|
||||
loading: false,
|
||||
error: null,
|
||||
loading: mockedLoading,
|
||||
error: mockedError,
|
||||
reload: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
|
@ -114,6 +116,8 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-03T00:01:00.000Z'));
|
||||
mockedPreviewsByMember = basePreviewsByMember;
|
||||
mockedLoading = false;
|
||||
mockedError = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -645,6 +649,89 @@ describe('GraphMemberLogPreviewHud', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows loading for empty previews while preserving unsupported provider text', async () => {
|
||||
const codexNode: GraphNode = {
|
||||
id: 'member:alpha-team:codex-dev',
|
||||
kind: 'member',
|
||||
label: 'codex-dev',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'codex-dev' },
|
||||
};
|
||||
const quietNode: GraphNode = {
|
||||
id: 'member:alpha-team:quiet-dev',
|
||||
kind: 'member',
|
||||
label: 'quiet-dev',
|
||||
state: 'idle',
|
||||
domainRef: { kind: 'member', teamName: 'alpha-team', memberName: 'quiet-dev' },
|
||||
};
|
||||
mockedLoading = true;
|
||||
mockedPreviewsByMember = new Map<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 () => {
|
||||
const leadNode: GraphNode = {
|
||||
id: 'lead:alpha-team',
|
||||
|
|
|
|||
|
|
@ -22,12 +22,15 @@ vi.mock('@renderer/api', () => ({
|
|||
function createDeferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => 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;
|
||||
reject = innerReject;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function response(memberName: string, generatedAt: string): MemberLogPreviewResponse {
|
||||
|
|
@ -57,6 +60,23 @@ function response(memberName: string, generatedAt: string): MemberLogPreviewResp
|
|||
};
|
||||
}
|
||||
|
||||
function emptyResponse(memberName: string, generatedAt: string): MemberLogPreviewResponse {
|
||||
return {
|
||||
generatedAt,
|
||||
members: [
|
||||
{
|
||||
memberName,
|
||||
items: [],
|
||||
coverage: [{ provider: 'claude_transcript', status: 'skipped' }],
|
||||
warnings: [],
|
||||
truncated: false,
|
||||
overflowCount: 0,
|
||||
generatedAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function batchResponse(memberNames: string[], generatedAt: string): MemberLogPreviewResponse {
|
||||
return {
|
||||
generatedAt,
|
||||
|
|
@ -107,6 +127,28 @@ const HookProbe = ({
|
|||
return null;
|
||||
};
|
||||
|
||||
const ReloadProbe = ({
|
||||
teamName,
|
||||
memberNames,
|
||||
onState,
|
||||
onReload,
|
||||
}: {
|
||||
teamName: string;
|
||||
memberNames: string[];
|
||||
onState: (state: ReturnType<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', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
|
@ -162,6 +204,7 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
expect.objectContaining({
|
||||
maxItemsPerMember: 3,
|
||||
textLimit: 200,
|
||||
forceRefresh: true,
|
||||
laneIdsByMember: { alice: 'secondary:opencode:alice' },
|
||||
})
|
||||
);
|
||||
|
|
@ -171,6 +214,104 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows loading while the first visible preview request is still debounced', async () => {
|
||||
const firstLoad = createDeferred<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 () => {
|
||||
const aliceLoad = 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 () => {
|
||||
const firstLoad = createDeferred<MemberLogPreviewResponse>();
|
||||
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 () => {
|
||||
let teamChangeListener:
|
||||
| ((event: unknown, data: { teamName: string; type: string }) => void)
|
||||
|
|
@ -450,7 +1284,7 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' });
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'tool-activity' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
|
@ -462,6 +1296,19 @@ describe('useGraphMemberLogPreviews', () => {
|
|||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
teamChangeListener?.(null, { teamName: 'alpha-team', type: 'task-log-change' });
|
||||
vi.advanceTimersByTime(700);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenCalledTimes(4);
|
||||
expect(apiMock.memberLogStream.getMemberLogPreviews).toHaveBeenLastCalledWith(
|
||||
'alpha-team',
|
||||
['alice'],
|
||||
expect.objectContaining({ forceRefresh: true })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue