feat: refine team changes and docs theme

This commit is contained in:
777genius 2026-05-11 10:31:57 +03:00
parent 298e81af82
commit 3ead3207e6
5 changed files with 590 additions and 71 deletions

View file

@ -5,8 +5,8 @@
--vp-c-brand-2: #009fb0;
--vp-c-brand-3: #005c66;
--vp-c-brand-soft: rgba(0, 128, 144, 0.12);
--vp-c-bg: var(--at-c-light-2);
--vp-c-bg-alt: var(--at-c-light-1);
--vp-c-bg: #f7f9fc;
--vp-c-bg-alt: #fbfcfe;
--vp-c-bg-elv: var(--at-c-light-0);
--vp-c-bg-soft: rgba(255, 255, 255, 0.74);
--vp-c-text-1: var(--at-c-text-light-1);
@ -24,7 +24,14 @@
--vp-button-alt-hover-bg: rgba(255, 255, 255, 0.92);
--vp-code-bg: rgba(8, 145, 178, 0.08);
--vp-code-color: var(--at-c-cyan-deep);
--vp-code-block-bg: #0a0a0f;
--vp-code-block-bg: #ffffff;
--vp-code-block-color: var(--at-c-text-light-1);
--vp-code-block-divider-color: rgba(8, 145, 178, 0.12);
--vp-code-lang-color: var(--at-c-text-light-3);
--vp-code-line-highlight-color: rgba(8, 145, 178, 0.08);
--vp-code-copy-code-border-color: rgba(8, 145, 178, 0.18);
--vp-code-copy-code-bg: rgba(255, 255, 255, 0.78);
--vp-code-copy-code-hover-bg: #ffffff;
}
.dark {
@ -48,6 +55,14 @@
--vp-button-brand-bg: var(--at-c-cyan);
--vp-code-bg: rgba(0, 240, 255, 0.1);
--vp-code-color: var(--at-c-cyan);
--vp-code-block-bg: #0a0a0f;
--vp-code-block-color: var(--at-c-text-dark-2);
--vp-code-block-divider-color: rgba(0, 240, 255, 0.1);
--vp-code-lang-color: var(--at-c-text-dark-muted);
--vp-code-line-highlight-color: rgba(0, 240, 255, 0.08);
--vp-code-copy-code-border-color: rgba(0, 240, 255, 0.14);
--vp-code-copy-code-bg: rgba(10, 10, 15, 0.72);
--vp-code-copy-code-hover-bg: rgba(18, 18, 26, 0.96);
}
html {
@ -69,8 +84,9 @@ body {
.Layout::before {
content: "";
position: fixed;
inset: 0;
position: absolute;
inset: 0 0 auto;
height: 560px;
z-index: -2;
pointer-events: none;
background:
@ -83,14 +99,12 @@ body {
.Layout::after {
content: "";
position: fixed;
inset: 0;
position: absolute;
inset: 0 0 auto;
height: 560px;
z-index: -1;
pointer-events: none;
background:
linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%),
linear-gradient(90deg, transparent 0, transparent calc(100% - 1px), var(--vp-c-divider) calc(100% - 1px), var(--vp-c-divider) 100%);
background-size: auto, 72px 72px;
background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%);
opacity: 0.6;
}
@ -262,6 +276,39 @@ body {
border-color: var(--at-c-border-strong) !important;
}
.markdown-copy-buttons-inner {
gap: 6px;
margin: 10px 0 12px;
}
.markdown-copy-buttons .dropdown-trigger {
border-radius: var(--at-radius-sm);
font-size: 13px;
}
.markdown-copy-buttons .copy-page {
gap: 6px;
padding: 6px 12px;
}
.markdown-copy-buttons .chevron-wrapper {
padding: 0 9px;
}
.markdown-copy-buttons .download-btn {
padding: 6px 9px;
border-radius: var(--at-radius-sm);
}
.markdown-copy-buttons .divider {
height: 20px;
}
.markdown-copy-buttons .icon {
width: 16px;
height: 16px;
}
.VPFeature {
position: relative;
overflow: hidden;

View file

@ -7,6 +7,7 @@ import { AlertTriangle, FileDiff, GitCompareArrows, Info, Loader2, RefreshCw } f
import { FileIcon } from './editor/FileIcon';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { MemberBadge } from './MemberBadge';
import {
getTeamChangeTaskTimeMs,
TEAM_CHANGES_MAX_RENDERED_FILE_ROWS,
@ -18,6 +19,8 @@ import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@sh
interface TeamChangesSectionProps {
teamName: string;
tasks: TeamTaskWithKanban[];
memberColorMap?: ReadonlyMap<string, string>;
onOpenTask: (task: TeamTaskWithKanban) => void;
onViewChanges: (taskId: string, filePath?: string) => void;
}
@ -28,6 +31,8 @@ interface RenderedTeamChangeSummary {
fileBudget: number;
}
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
if (!Array.isArray(changeSet?.files)) {
return [];
@ -121,16 +126,17 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
export const TeamChangesSection = memo(function TeamChangesSection({
teamName,
tasks,
memberColorMap = EMPTY_MEMBER_COLOR_MAP,
onOpenTask,
onViewChanges,
}: TeamChangesSectionProps): React.JSX.Element {
const [sectionOpen, setSectionOpen] = useState(false);
const { summariesByTaskId, stats, loading, refreshing, error, refresh } = useTeamChangesSummaries(
{
const { summariesByTaskId, badgeCount, stats, loading, refreshing, error, refresh } =
useTeamChangesSummaries({
teamName,
tasks,
sectionOpen,
}
);
});
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const visibleSummaries = useMemo(() => {
@ -153,7 +159,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
0
);
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined;
const badge = badgeCount ?? undefined;
const renderedSummaries = useMemo(() => {
const entries: RenderedTeamChangeSummary[] = [];
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
@ -233,30 +239,64 @@ export const TeamChangesSection = memo(function TeamChangesSection({
key={summary.taskId}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)]"
>
<button
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-t-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
onClick={() => onViewChanges(task.id)}
>
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
#{deriveTaskDisplayId(task.id)}
</span>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--color-text)]">
{task.subject}
</span>
<span
className="hidden max-w-[180px] shrink-0 truncate text-[10px] text-[var(--color-text-muted)] sm:inline"
title={contributors.join(', ')}
<div className="flex min-w-0 items-center gap-1 rounded-t-md px-2 py-1.5 transition-colors hover:bg-[var(--color-surface-raised)]">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => onOpenTask(task)}
aria-label={`Open task ${task.subject}`}
>
{contributorLabel}
{extraContributors > 0 ? ` +${extraContributors}` : ''}
</span>
{badgeText ? (
<span className="shrink-0 rounded bg-[var(--color-bg-tertiary)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
{badgeText}
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
#{deriveTaskDisplayId(task.id)}
</span>
) : null}
</button>
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--color-text)]">
{task.subject}
</span>
{contributors[0] ? (
<span className="hidden min-w-0 max-w-[220px] shrink-0 items-center gap-1 sm:inline-flex">
<MemberBadge
name={contributors[0]}
color={memberColorMap.get(contributors[0])}
size="xs"
disableHoverCard
/>
{extraContributors > 0 ? (
<span
className="shrink-0 text-[10px] text-[var(--color-text-muted)]"
title={contributors.join(', ')}
>
+{extraContributors}
</span>
) : null}
</span>
) : (
<span
className="hidden max-w-[180px] shrink-0 truncate text-[10px] text-[var(--color-text-muted)] sm:inline"
title={contributors.join(', ')}
>
{contributorLabel}
</span>
)}
{badgeText ? (
<span className="shrink-0 rounded bg-[var(--color-bg-tertiary)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
{badgeText}
</span>
) : null}
</button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => onViewChanges(task.id)}
aria-label="Review task diff"
>
<GitCompareArrows size={13} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Review diff</TooltipContent>
</Tooltip>
</div>
{summary.error ? (
<div className="flex items-center gap-2 border-t border-[var(--color-border)] px-2 py-1.5 text-xs text-red-400">

View file

@ -2719,6 +2719,8 @@ export const TeamDetailView = memo(function TeamDetailView({
<TeamChangesSection
teamName={teamName}
tasks={data.tasks}
memberColorMap={resolvedMemberColorMap}
onOpenTask={(task) => setSelectedTask(task)}
onViewChanges={handleViewChangesForFile}
/>

View file

@ -15,6 +15,7 @@ import type {
} from '@shared/types';
const hoisted = vi.hoisted(() => ({
fetchConfig: vi.fn(),
getTeamTaskChangeSummaries: vi.fn(),
recordTaskChangePresence: vi.fn(),
setSelectedTeamTaskChangePresence: vi.fn(),
@ -31,8 +32,15 @@ vi.mock('@renderer/api', () => ({
vi.mock('@renderer/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
appConfig: { general: { theme: 'dark' } },
configLoading: false,
fetchConfig: hoisted.fetchConfig,
memberActivityMetaByTeam: {},
recordTaskChangePresence: hoisted.recordTaskChangePresence,
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
selectedTeamData: null,
selectedTeamName: undefined,
teamDataCacheByName: {},
}),
}));
@ -187,6 +195,7 @@ interface HookSnapshot {
loading: boolean;
refreshing: boolean;
error: string | null;
badgeCount: number | null;
summariesByTaskId: Record<string, TeamChangeSummaryState>;
}
@ -207,9 +216,17 @@ const HookHarness = ({
loading: state.loading,
refreshing: state.refreshing,
error: state.error,
badgeCount: state.badgeCount,
summariesByTaskId: state.summariesByTaskId,
});
}, [onSnapshot, state.error, state.loading, state.refreshing, state.summariesByTaskId]);
}, [
onSnapshot,
state.badgeCount,
state.error,
state.loading,
state.refreshing,
state.summariesByTaskId,
]);
return null;
};
@ -585,6 +602,7 @@ describe('useTeamChangesSummaries', () => {
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
@ -615,4 +633,279 @@ describe('useTeamChangesSummaries', () => {
}
}
});
it('shows the closed-section counter only after the background count load resolves', async () => {
const deferred = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries.mockReturnValue(deferred.promise);
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()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
expect(container.textContent).toContain('Changes');
expect(container.textContent).not.toContain('0');
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
await act(async () => {
deferred.resolve(response());
await deferred.promise;
await Promise.resolve();
});
expect(container.textContent).toContain('0');
});
it('loads the closed-section counter without rendering full change rows', async () => {
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
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()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
await Promise.resolve();
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain('1');
expect(container.textContent).not.toContain('src/app.ts');
});
it('runs a queued closed counter refresh when tasks change during an active count load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
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()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
await act(async () => {
root?.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TeamChangesSection, {
teamName: 'team-a',
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
await act(async () => {
first.resolve(lowConfidenceFileResponse());
await first.promise;
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => {
second.resolve(response());
await second.promise;
await Promise.resolve();
});
expect(container.textContent).toContain('0');
});
it('does not lose the full load queued by opening the section during a failed count load', async () => {
const first = createDeferred<TeamTaskChangeSummariesResponse>();
const second = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
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()],
onOpenTask: vi.fn(),
onViewChanges: vi.fn(),
})
)
);
});
const expandButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Expand section"]'
);
expect(expandButton).not.toBeNull();
await act(async () => {
expandButton?.click();
});
expect(container.textContent).toContain('Loading changes...');
await act(async () => {
first.reject(new Error('silent count failed'));
await first.promise.catch(() => undefined);
await Promise.resolve();
});
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
await act(async () => {
second.resolve(lowConfidenceFileResponse());
await second.promise;
await Promise.resolve();
});
expect(container.textContent).toContain('src/app.ts');
expect(container.textContent).not.toContain('silent count failed');
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
it('opens the task popup from summary header and keeps diff on the review action', async () => {
const taskItem = task({ changePresence: 'has_changes' });
const onOpenTask = vi.fn();
const onViewChanges = vi.fn();
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
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: [taskItem],
memberColorMap: new Map([['alice', 'blue']]),
onOpenTask,
onViewChanges,
})
)
);
});
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.querySelector('img')).not.toBeNull();
const openTaskButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Open task Task 1"]'
);
expect(openTaskButton).not.toBeNull();
await act(async () => {
openTaskButton?.click();
});
expect(onOpenTask).toHaveBeenCalledWith(taskItem);
expect(onViewChanges).not.toHaveBeenCalled();
const reviewTaskDiffButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Review task diff"]'
);
expect(reviewTaskDiffButton).not.toBeNull();
await act(async () => {
reviewTaskDiffButton?.click();
});
expect(onViewChanges).toHaveBeenCalledWith('task-1');
} finally {
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
}
}
});
});

View file

@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { useStore } from '@renderer/store';
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
import {
@ -18,6 +19,7 @@ import type {
} from '@shared/types';
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
const TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS = 60_000;
const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
export interface TeamChangeSummaryState {
@ -36,6 +38,9 @@ interface TeamChangesLoadOptions {
forceFresh?: boolean;
showSpinner?: boolean;
preserveOnError?: boolean;
storeSummaries?: boolean;
reportError?: boolean;
blockAutoRetryOnError?: boolean;
}
interface UseTeamChangesSummariesInput {
@ -46,6 +51,7 @@ interface UseTeamChangesSummariesInput {
interface UseTeamChangesSummariesResult {
summariesByTaskId: Record<string, TeamChangeSummaryState>;
badgeCount: number | null;
stats: TeamChangeStats;
loading: boolean;
refreshing: boolean;
@ -101,6 +107,19 @@ function hasSafeFileSummaries(changeSet: TaskChangeSetV2): boolean {
);
}
function hasDisplayableFileSummaries(changeSet: TaskChangeSetV2): boolean {
return (
Array.isArray(changeSet.files) &&
changeSet.files.some(
(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) &&
@ -136,6 +155,31 @@ function resolveCacheablePresenceFromChangeSet(
return null;
}
function isCountableTeamChangeSummary(item: TeamTaskChangeSummaryItem): boolean {
if (item.error) {
return true;
}
const changeSet = item.changeSet;
if (!changeSet) {
return false;
}
if (hasDisplayableFileSummaries(changeSet)) {
return true;
}
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
return reviewability === 'attention_required' || reviewability === 'diagnostic_only';
}
function countChangedTasks(changeCountByTaskId: Record<string, boolean>): number {
return Object.values(changeCountByTaskId).filter(Boolean).length;
}
function isDocumentHidden(): boolean {
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
}
export function useTeamChangesSummaries({
teamName,
tasks,
@ -146,6 +190,8 @@ export function useTeamChangesSummaries({
const [summariesByTaskId, setSummariesByTaskId] = useState<
Record<string, TeamChangeSummaryState>
>({});
const [changeCountByTaskId, setChangeCountByTaskId] = useState<Record<string, boolean>>({});
const [counterLoaded, setCounterLoaded] = useState(false);
const [stats, setStats] = useState<TeamChangeStats>({
eligibleCount: 0,
requestedCount: 0,
@ -161,14 +207,10 @@ export function useTeamChangesSummaries({
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);
const tasksFingerprint = useMemo(
() => (sectionOpen ? buildTeamChangesTasksFingerprint(tasks) : ''),
[sectionOpen, tasks]
);
sectionOpenRef.current = sectionOpen;
const lastCounterTasksFingerprintRef = useRef<string | null>(null);
const tasksFingerprint = useMemo(() => buildTeamChangesTasksFingerprint(tasks), [tasks]);
useEffect(() => {
mountedRef.current = true;
@ -181,6 +223,7 @@ export function useTeamChangesSummaries({
hasLoadedRef.current = false;
unknownScanCursorRef.current = 0;
lastRequestedTasksFingerprintRef.current = null;
lastCounterTasksFingerprintRef.current = null;
};
}, []);
@ -189,6 +232,9 @@ export function useTeamChangesSummaries({
forceFresh = false,
showSpinner = false,
preserveOnError = true,
storeSummaries = true,
reportError = true,
blockAutoRetryOnError = true,
}: TeamChangesLoadOptions = {}): Promise<void> => {
if (forceFresh) {
autoRefreshBlockedUntilRef.current = 0;
@ -204,8 +250,18 @@ export function useTeamChangesSummaries({
preserveOnError: previous
? Boolean(previous.preserveOnError && preserveOnError)
: preserveOnError,
storeSummaries: Boolean(previous?.storeSummaries || storeSummaries),
reportError: previous ? Boolean(previous.reportError || reportError) : reportError,
blockAutoRetryOnError: previous
? Boolean(previous.blockAutoRetryOnError || blockAutoRetryOnError)
: blockAutoRetryOnError,
};
if (activeRequestSeqRef.current === null && sectionOpenRef.current) {
if (showSpinner) {
setLoading(true);
} else if (storeSummaries) {
setRefreshing(true);
}
if (activeRequestSeqRef.current === null) {
setQueuedRefreshTick((value) => value + 1);
}
return;
@ -223,7 +279,11 @@ export function useTeamChangesSummaries({
setError(null);
if (plan.requests.length === 0) {
setSummariesByTaskId({});
if (storeSummaries) {
setSummariesByTaskId({});
}
setChangeCountByTaskId({});
setCounterLoaded(true);
autoRefreshBlockedUntilRef.current = 0;
setLoading(false);
setRefreshing(false);
@ -232,7 +292,7 @@ export function useTeamChangesSummaries({
if (showSpinner) {
setLoading(true);
} else {
} else if (storeSummaries) {
setRefreshing(true);
}
activeRequestSeqRef.current = requestSeq;
@ -250,6 +310,22 @@ export function useTeamChangesSummaries({
autoRefreshBlockedUntilRef.current = 0;
const responseItems = getSafeResponseItems(response);
setChangeCountByTaskId((previous) => {
const next: Record<string, boolean> = {};
const currentTaskIds = new Set(tasks.map((task) => task.id));
for (const [taskId, countable] of Object.entries(previous)) {
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
next[taskId] = countable;
}
}
for (const item of responseItems) {
if (!plan.requestOptionsByTaskId.has(item.taskId)) continue;
next[item.taskId] = isCountableTeamChangeSummary(item);
}
return next;
});
setCounterLoaded(true);
const currentTaskIds = new Set(tasks.map((task) => task.id));
for (const item of responseItems) {
const changeSet = item.changeSet;
@ -262,41 +338,55 @@ export function useTeamChangesSummaries({
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
}
setSummariesByTaskId((previous) => {
const next: Record<string, TeamChangeSummaryState> = {};
for (const [taskId, summary] of Object.entries(previous)) {
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
next[taskId] = summary;
if (storeSummaries) {
setSummariesByTaskId((previous) => {
const next: Record<string, TeamChangeSummaryState> = {};
for (const [taskId, summary] of Object.entries(previous)) {
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
next[taskId] = summary;
}
}
}
for (const item of responseItems) {
const options = plan.requestOptionsByTaskId.get(item.taskId);
if (!options) continue;
next[item.taskId] = {
taskId: item.taskId,
changeSet: item.changeSet,
error: item.error,
};
}
return next;
});
for (const item of responseItems) {
const options = plan.requestOptionsByTaskId.get(item.taskId);
if (!options) continue;
next[item.taskId] = {
taskId: item.taskId,
changeSet: item.changeSet,
error: item.error,
};
}
return next;
});
}
} catch (err) {
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return;
}
queuedRefreshOptionsRef.current = null;
autoRefreshBlockedUntilRef.current = Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
const queuedOptions = queuedRefreshOptionsRef.current as TeamChangesLoadOptions | null;
const shouldRunVisibleQueuedRefreshAfterSilentFailure =
!storeSummaries &&
!reportError &&
Boolean(queuedOptions?.showSpinner || queuedOptions?.storeSummaries);
if (!shouldRunVisibleQueuedRefreshAfterSilentFailure) {
queuedRefreshOptionsRef.current = null;
}
if (blockAutoRetryOnError) {
autoRefreshBlockedUntilRef.current =
Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
}
if (!preserveOnError) {
setSummariesByTaskId({});
}
setError(err instanceof Error ? err.message : 'Failed to load team changes');
if (reportError) {
setError(err instanceof Error ? err.message : 'Failed to load team changes');
}
} finally {
if (mountedRef.current) {
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
if (activeRequestSeqRef.current === requestSeq) {
activeRequestSeqRef.current = null;
}
if (hasQueuedRefresh && activeRequestSeqRef.current === null && sectionOpenRef.current) {
if (hasQueuedRefresh && activeRequestSeqRef.current === null) {
setQueuedRefreshTick((value) => value + 1);
}
const shouldStopIndicators =
@ -320,7 +410,10 @@ export function useTeamChangesSummaries({
autoRefreshBlockedUntilRef.current = 0;
unknownScanCursorRef.current = 0;
lastRequestedTasksFingerprintRef.current = null;
lastCounterTasksFingerprintRef.current = null;
setSummariesByTaskId({});
setChangeCountByTaskId({});
setCounterLoaded(false);
setError(null);
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
}, [teamName]);
@ -341,6 +434,23 @@ export function useTeamChangesSummaries({
}
}, [sectionOpen]);
useEffect(() => {
if (sectionOpen) {
return;
}
if (lastCounterTasksFingerprintRef.current === tasksFingerprint && counterLoaded) {
return;
}
lastCounterTasksFingerprintRef.current = tasksFingerprint;
void loadSummaries({
showSpinner: false,
preserveOnError: true,
storeSummaries: false,
reportError: false,
blockAutoRetryOnError: false,
});
}, [counterLoaded, loadSummaries, sectionOpen, tasksFingerprint]);
useEffect(() => {
if (!sectionOpen || hasLoadedRef.current) {
return;
@ -362,7 +472,7 @@ export function useTeamChangesSummaries({
}, [loadSummaries, sectionOpen, tasksFingerprint]);
useEffect(() => {
if (!sectionOpen || activeRequestSeqRef.current !== null) {
if (activeRequestSeqRef.current !== null) {
return;
}
const options = queuedRefreshOptionsRef.current;
@ -371,7 +481,7 @@ export function useTeamChangesSummaries({
}
queuedRefreshOptionsRef.current = null;
void loadSummaries(options);
}, [loadSummaries, queuedRefreshTick, sectionOpen]);
}, [loadSummaries, queuedRefreshTick]);
useEffect(() => {
if (!sectionOpen) {
@ -390,12 +500,39 @@ export function useTeamChangesSummaries({
};
}, [loadSummaries, sectionOpen]);
useEffect(() => {
if (sectionOpen) {
return;
}
const timer = window.setInterval(() => {
if (isDocumentHidden()) {
return;
}
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
return;
}
void loadSummaries({
showSpinner: false,
preserveOnError: true,
storeSummaries: false,
reportError: false,
blockAutoRetryOnError: false,
});
}, TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS);
return () => {
window.clearInterval(timer);
};
}, [loadSummaries, sectionOpen]);
const refresh = useCallback(() => {
void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false });
}, [loadSummaries]);
return {
summariesByTaskId,
badgeCount: counterLoaded ? countChangedTasks(changeCountByTaskId) : null,
stats,
loading,
refreshing,