feat(team): improve graph member log previews

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

View file

@ -14,6 +14,11 @@ const PREVIEW_CACHE_TTL_MS = 3_500;
const DEFAULT_MAX_ITEMS = 3;
const DEFAULT_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 };
}

View file

@ -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';
}

View file

@ -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>

View file

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

View file

@ -1926,9 +1926,10 @@ export const CreateTeamDialog = ({
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
<p className="text-[11px] leading-relaxed text-sky-300">
Only the team lead (main process) will be started &mdash; no teammates will
be spawned. Works like a regular Claude session but with access to the task
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}

View file

@ -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)' }}>

View file

@ -1,4 +1,5 @@
export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 45_000;
// Main-process team summary batches have a 30s deadline; keep the renderer guard above it.
export const TEAM_CHANGES_LOAD_TIMEOUT_MS = 35_000;
export function withTeamChangesLoadTimeout<T>(
promise: Promise<T>,

View file

@ -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({});

View file

@ -193,7 +193,7 @@ describe('stable slot layout', () => {
});
});
it('uses three grid columns for six owners in rows layout', () => {
it('uses two grid columns for six owners in rows layout', () => {
const { nodes, layout } = buildSixOwnerGraph();
const 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', () => {

View file

@ -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',

View file

@ -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();
});