From 3ead3207e64c96ba2a172b415e72dff7c86d2e2b Mon Sep 17 00:00:00 2001 From: 777genius Date: Mon, 11 May 2026 10:31:57 +0300 Subject: [PATCH] feat: refine team changes and docs theme --- .../product-docs/.vitepress/theme/custom.css | 69 +++- .../components/team/TeamChangesSection.tsx | 94 ++++-- .../components/team/TeamDetailView.tsx | 2 + .../useTeamChangesSummaries.test.tsx | 295 +++++++++++++++++- .../team/useTeamChangesSummaries.ts | 201 ++++++++++-- 5 files changed, 590 insertions(+), 71 deletions(-) diff --git a/landing/product-docs/.vitepress/theme/custom.css b/landing/product-docs/.vitepress/theme/custom.css index 5abda64f..6ad21ed3 100644 --- a/landing/product-docs/.vitepress/theme/custom.css +++ b/landing/product-docs/.vitepress/theme/custom.css @@ -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; diff --git a/src/renderer/components/team/TeamChangesSection.tsx b/src/renderer/components/team/TeamChangesSection.tsx index 2389e4eb..bd34400c 100644 --- a/src/renderer/components/team/TeamChangesSection.tsx +++ b/src/renderer/components/team/TeamChangesSection.tsx @@ -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; + 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(); + 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)]" > - + + {task.subject} + + {contributors[0] ? ( + + + {extraContributors > 0 ? ( + + +{extraContributors} + + ) : null} + + ) : ( + + {contributorLabel} + + )} + {badgeText ? ( + + {badgeText} + + ) : null} + + + + + + Review diff + + {summary.error ? (
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 131e157e..80a120fe 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -2719,6 +2719,8 @@ export const TeamDetailView = memo(function TeamDetailView({ setSelectedTask(task)} onViewChanges={handleViewChangesForFile} /> diff --git a/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx b/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx index 282f4bcd..b6b1a752 100644 --- a/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx +++ b/src/renderer/components/team/__tests__/useTeamChangesSummaries.test.tsx @@ -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) => 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; } @@ -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(); + 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(); + const second = createDeferred(); + 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(); + const second = createDeferred(); + 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( + '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( + '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( + '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( + '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; + } + } + }); }); diff --git a/src/renderer/components/team/useTeamChangesSummaries.ts b/src/renderer/components/team/useTeamChangesSummaries.ts index 22e856e6..78c2a37f 100644 --- a/src/renderer/components/team/useTeamChangesSummaries.ts +++ b/src/renderer/components/team/useTeamChangesSummaries.ts @@ -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; + 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): 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 >({}); + const [changeCountByTaskId, setChangeCountByTaskId] = useState>({}); + const [counterLoaded, setCounterLoaded] = useState(false); const [stats, setStats] = useState({ eligibleCount: 0, requestedCount: 0, @@ -161,14 +207,10 @@ export function useTeamChangesSummaries({ const activeRequestSeqRef = useRef(null); const queuedRefreshOptionsRef = useRef(null); const autoRefreshBlockedUntilRef = useRef(0); - const sectionOpenRef = useRef(sectionOpen); const unknownScanCursorRef = useRef(0); const lastRequestedTasksFingerprintRef = useRef(null); - const tasksFingerprint = useMemo( - () => (sectionOpen ? buildTeamChangesTasksFingerprint(tasks) : ''), - [sectionOpen, tasks] - ); - sectionOpenRef.current = sectionOpen; + const lastCounterTasksFingerprintRef = useRef(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 => { 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 = {}; + 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 = {}; - 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 = {}; + 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,