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-2: #009fb0;
--vp-c-brand-3: #005c66; --vp-c-brand-3: #005c66;
--vp-c-brand-soft: rgba(0, 128, 144, 0.12); --vp-c-brand-soft: rgba(0, 128, 144, 0.12);
--vp-c-bg: var(--at-c-light-2); --vp-c-bg: #f7f9fc;
--vp-c-bg-alt: var(--at-c-light-1); --vp-c-bg-alt: #fbfcfe;
--vp-c-bg-elv: var(--at-c-light-0); --vp-c-bg-elv: var(--at-c-light-0);
--vp-c-bg-soft: rgba(255, 255, 255, 0.74); --vp-c-bg-soft: rgba(255, 255, 255, 0.74);
--vp-c-text-1: var(--at-c-text-light-1); --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-button-alt-hover-bg: rgba(255, 255, 255, 0.92);
--vp-code-bg: rgba(8, 145, 178, 0.08); --vp-code-bg: rgba(8, 145, 178, 0.08);
--vp-code-color: var(--at-c-cyan-deep); --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 { .dark {
@ -48,6 +55,14 @@
--vp-button-brand-bg: var(--at-c-cyan); --vp-button-brand-bg: var(--at-c-cyan);
--vp-code-bg: rgba(0, 240, 255, 0.1); --vp-code-bg: rgba(0, 240, 255, 0.1);
--vp-code-color: var(--at-c-cyan); --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 { html {
@ -69,8 +84,9 @@ body {
.Layout::before { .Layout::before {
content: ""; content: "";
position: fixed; position: absolute;
inset: 0; inset: 0 0 auto;
height: 560px;
z-index: -2; z-index: -2;
pointer-events: none; pointer-events: none;
background: background:
@ -83,14 +99,12 @@ body {
.Layout::after { .Layout::after {
content: ""; content: "";
position: fixed; position: absolute;
inset: 0; inset: 0 0 auto;
height: 560px;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background: background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--vp-c-bg) 72%, transparent) 58%, var(--vp-c-bg) 100%);
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;
opacity: 0.6; opacity: 0.6;
} }
@ -262,6 +276,39 @@ body {
border-color: var(--at-c-border-strong) !important; 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 { .VPFeature {
position: relative; position: relative;
overflow: hidden; overflow: hidden;

View file

@ -7,6 +7,7 @@ import { AlertTriangle, FileDiff, GitCompareArrows, Info, Loader2, RefreshCw } f
import { FileIcon } from './editor/FileIcon'; import { FileIcon } from './editor/FileIcon';
import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection';
import { MemberBadge } from './MemberBadge';
import { import {
getTeamChangeTaskTimeMs, getTeamChangeTaskTimeMs,
TEAM_CHANGES_MAX_RENDERED_FILE_ROWS, TEAM_CHANGES_MAX_RENDERED_FILE_ROWS,
@ -18,6 +19,8 @@ import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@sh
interface TeamChangesSectionProps { interface TeamChangesSectionProps {
teamName: string; teamName: string;
tasks: TeamTaskWithKanban[]; tasks: TeamTaskWithKanban[];
memberColorMap?: ReadonlyMap<string, string>;
onOpenTask: (task: TeamTaskWithKanban) => void;
onViewChanges: (taskId: string, filePath?: string) => void; onViewChanges: (taskId: string, filePath?: string) => void;
} }
@ -28,6 +31,8 @@ interface RenderedTeamChangeSummary {
fileBudget: number; fileBudget: number;
} }
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] { function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
if (!Array.isArray(changeSet?.files)) { if (!Array.isArray(changeSet?.files)) {
return []; return [];
@ -121,16 +126,17 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
export const TeamChangesSection = memo(function TeamChangesSection({ export const TeamChangesSection = memo(function TeamChangesSection({
teamName, teamName,
tasks, tasks,
memberColorMap = EMPTY_MEMBER_COLOR_MAP,
onOpenTask,
onViewChanges, onViewChanges,
}: TeamChangesSectionProps): React.JSX.Element { }: TeamChangesSectionProps): React.JSX.Element {
const [sectionOpen, setSectionOpen] = useState(false); const [sectionOpen, setSectionOpen] = useState(false);
const { summariesByTaskId, stats, loading, refreshing, error, refresh } = useTeamChangesSummaries( const { summariesByTaskId, badgeCount, stats, loading, refreshing, error, refresh } =
{ useTeamChangesSummaries({
teamName, teamName,
tasks, tasks,
sectionOpen, sectionOpen,
} });
);
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]); const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const visibleSummaries = useMemo(() => { const visibleSummaries = useMemo(() => {
@ -153,7 +159,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
0 0
); );
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS); const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined; const badge = badgeCount ?? undefined;
const renderedSummaries = useMemo(() => { const renderedSummaries = useMemo(() => {
const entries: RenderedTeamChangeSummary[] = []; const entries: RenderedTeamChangeSummary[] = [];
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS; let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
@ -233,30 +239,64 @@ export const TeamChangesSection = memo(function TeamChangesSection({
key={summary.taskId} key={summary.taskId}
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)]" className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)]"
> >
<button <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)]">
type="button" <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)]" type="button"
onClick={() => onViewChanges(task.id)} className="flex min-w-0 flex-1 items-center gap-2 text-left"
> onClick={() => onOpenTask(task)}
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]"> aria-label={`Open task ${task.subject}`}
#{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(', ')}
> >
{contributorLabel} <span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
{extraContributors > 0 ? ` +${extraContributors}` : ''} #{deriveTaskDisplayId(task.id)}
</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> </span>
) : null} <span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--color-text)]">
</button> {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 ? ( {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"> <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 <TeamChangesSection
teamName={teamName} teamName={teamName}
tasks={data.tasks} tasks={data.tasks}
memberColorMap={resolvedMemberColorMap}
onOpenTask={(task) => setSelectedTask(task)}
onViewChanges={handleViewChangesForFile} onViewChanges={handleViewChangesForFile}
/> />

View file

@ -15,6 +15,7 @@ import type {
} from '@shared/types'; } from '@shared/types';
const hoisted = vi.hoisted(() => ({ const hoisted = vi.hoisted(() => ({
fetchConfig: vi.fn(),
getTeamTaskChangeSummaries: vi.fn(), getTeamTaskChangeSummaries: vi.fn(),
recordTaskChangePresence: vi.fn(), recordTaskChangePresence: vi.fn(),
setSelectedTeamTaskChangePresence: vi.fn(), setSelectedTeamTaskChangePresence: vi.fn(),
@ -31,8 +32,15 @@ vi.mock('@renderer/api', () => ({
vi.mock('@renderer/store', () => ({ vi.mock('@renderer/store', () => ({
useStore: (selector: (state: Record<string, unknown>) => unknown) => useStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ selector({
appConfig: { general: { theme: 'dark' } },
configLoading: false,
fetchConfig: hoisted.fetchConfig,
memberActivityMetaByTeam: {},
recordTaskChangePresence: hoisted.recordTaskChangePresence, recordTaskChangePresence: hoisted.recordTaskChangePresence,
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence, setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
selectedTeamData: null,
selectedTeamName: undefined,
teamDataCacheByName: {},
}), }),
})); }));
@ -187,6 +195,7 @@ interface HookSnapshot {
loading: boolean; loading: boolean;
refreshing: boolean; refreshing: boolean;
error: string | null; error: string | null;
badgeCount: number | null;
summariesByTaskId: Record<string, TeamChangeSummaryState>; summariesByTaskId: Record<string, TeamChangeSummaryState>;
} }
@ -207,9 +216,17 @@ const HookHarness = ({
loading: state.loading, loading: state.loading,
refreshing: state.refreshing, refreshing: state.refreshing,
error: state.error, error: state.error,
badgeCount: state.badgeCount,
summariesByTaskId: state.summariesByTaskId, 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; return null;
}; };
@ -585,6 +602,7 @@ describe('useTeamChangesSummaries', () => {
React.createElement(TeamChangesSection, { React.createElement(TeamChangesSection, {
teamName: 'team-a', teamName: 'team-a',
tasks: [task()], tasks: [task()],
onOpenTask: vi.fn(),
onViewChanges: 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 { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout'; import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
import { import {
@ -18,6 +19,7 @@ import type {
} from '@shared/types'; } from '@shared/types';
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000; 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; const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
export interface TeamChangeSummaryState { export interface TeamChangeSummaryState {
@ -36,6 +38,9 @@ interface TeamChangesLoadOptions {
forceFresh?: boolean; forceFresh?: boolean;
showSpinner?: boolean; showSpinner?: boolean;
preserveOnError?: boolean; preserveOnError?: boolean;
storeSummaries?: boolean;
reportError?: boolean;
blockAutoRetryOnError?: boolean;
} }
interface UseTeamChangesSummariesInput { interface UseTeamChangesSummariesInput {
@ -46,6 +51,7 @@ interface UseTeamChangesSummariesInput {
interface UseTeamChangesSummariesResult { interface UseTeamChangesSummariesResult {
summariesByTaskId: Record<string, TeamChangeSummaryState>; summariesByTaskId: Record<string, TeamChangeSummaryState>;
badgeCount: number | null;
stats: TeamChangeStats; stats: TeamChangeStats;
loading: boolean; loading: boolean;
refreshing: 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 { function isMinimalPresenceChangeSet(changeSet: TaskChangeSetV2): boolean {
return Boolean( return Boolean(
Array.isArray(changeSet.files) && Array.isArray(changeSet.files) &&
@ -136,6 +155,31 @@ function resolveCacheablePresenceFromChangeSet(
return null; 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({ export function useTeamChangesSummaries({
teamName, teamName,
tasks, tasks,
@ -146,6 +190,8 @@ export function useTeamChangesSummaries({
const [summariesByTaskId, setSummariesByTaskId] = useState< const [summariesByTaskId, setSummariesByTaskId] = useState<
Record<string, TeamChangeSummaryState> Record<string, TeamChangeSummaryState>
>({}); >({});
const [changeCountByTaskId, setChangeCountByTaskId] = useState<Record<string, boolean>>({});
const [counterLoaded, setCounterLoaded] = useState(false);
const [stats, setStats] = useState<TeamChangeStats>({ const [stats, setStats] = useState<TeamChangeStats>({
eligibleCount: 0, eligibleCount: 0,
requestedCount: 0, requestedCount: 0,
@ -161,14 +207,10 @@ export function useTeamChangesSummaries({
const activeRequestSeqRef = useRef<number | null>(null); const activeRequestSeqRef = useRef<number | null>(null);
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null); const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
const autoRefreshBlockedUntilRef = useRef(0); const autoRefreshBlockedUntilRef = useRef(0);
const sectionOpenRef = useRef(sectionOpen);
const unknownScanCursorRef = useRef(0); const unknownScanCursorRef = useRef(0);
const lastRequestedTasksFingerprintRef = useRef<string | null>(null); const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
const tasksFingerprint = useMemo( const lastCounterTasksFingerprintRef = useRef<string | null>(null);
() => (sectionOpen ? buildTeamChangesTasksFingerprint(tasks) : ''), const tasksFingerprint = useMemo(() => buildTeamChangesTasksFingerprint(tasks), [tasks]);
[sectionOpen, tasks]
);
sectionOpenRef.current = sectionOpen;
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
@ -181,6 +223,7 @@ export function useTeamChangesSummaries({
hasLoadedRef.current = false; hasLoadedRef.current = false;
unknownScanCursorRef.current = 0; unknownScanCursorRef.current = 0;
lastRequestedTasksFingerprintRef.current = null; lastRequestedTasksFingerprintRef.current = null;
lastCounterTasksFingerprintRef.current = null;
}; };
}, []); }, []);
@ -189,6 +232,9 @@ export function useTeamChangesSummaries({
forceFresh = false, forceFresh = false,
showSpinner = false, showSpinner = false,
preserveOnError = true, preserveOnError = true,
storeSummaries = true,
reportError = true,
blockAutoRetryOnError = true,
}: TeamChangesLoadOptions = {}): Promise<void> => { }: TeamChangesLoadOptions = {}): Promise<void> => {
if (forceFresh) { if (forceFresh) {
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
@ -204,8 +250,18 @@ export function useTeamChangesSummaries({
preserveOnError: previous preserveOnError: previous
? Boolean(previous.preserveOnError && preserveOnError) ? Boolean(previous.preserveOnError && 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); setQueuedRefreshTick((value) => value + 1);
} }
return; return;
@ -223,7 +279,11 @@ export function useTeamChangesSummaries({
setError(null); setError(null);
if (plan.requests.length === 0) { if (plan.requests.length === 0) {
setSummariesByTaskId({}); if (storeSummaries) {
setSummariesByTaskId({});
}
setChangeCountByTaskId({});
setCounterLoaded(true);
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
@ -232,7 +292,7 @@ export function useTeamChangesSummaries({
if (showSpinner) { if (showSpinner) {
setLoading(true); setLoading(true);
} else { } else if (storeSummaries) {
setRefreshing(true); setRefreshing(true);
} }
activeRequestSeqRef.current = requestSeq; activeRequestSeqRef.current = requestSeq;
@ -250,6 +310,22 @@ export function useTeamChangesSummaries({
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
const responseItems = getSafeResponseItems(response); 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)); const currentTaskIds = new Set(tasks.map((task) => task.id));
for (const item of responseItems) { for (const item of responseItems) {
const changeSet = item.changeSet; const changeSet = item.changeSet;
@ -262,41 +338,55 @@ export function useTeamChangesSummaries({
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence); setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
} }
setSummariesByTaskId((previous) => { if (storeSummaries) {
const next: Record<string, TeamChangeSummaryState> = {}; setSummariesByTaskId((previous) => {
for (const [taskId, summary] of Object.entries(previous)) { const next: Record<string, TeamChangeSummaryState> = {};
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) { for (const [taskId, summary] of Object.entries(previous)) {
next[taskId] = summary; if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
next[taskId] = summary;
}
} }
} for (const item of responseItems) {
for (const item of responseItems) { const options = plan.requestOptionsByTaskId.get(item.taskId);
const options = plan.requestOptionsByTaskId.get(item.taskId); if (!options) continue;
if (!options) continue; next[item.taskId] = {
next[item.taskId] = { taskId: item.taskId,
taskId: item.taskId, changeSet: item.changeSet,
changeSet: item.changeSet, error: item.error,
error: item.error, };
}; }
} return next;
return next; });
}); }
} catch (err) { } catch (err) {
if (!mountedRef.current || requestSeqRef.current !== requestSeq) { if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
return; return;
} }
queuedRefreshOptionsRef.current = null; const queuedOptions = queuedRefreshOptionsRef.current as TeamChangesLoadOptions | null;
autoRefreshBlockedUntilRef.current = Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS; 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) { if (!preserveOnError) {
setSummariesByTaskId({}); 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 { } finally {
if (mountedRef.current) { if (mountedRef.current) {
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null; const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
if (activeRequestSeqRef.current === requestSeq) { if (activeRequestSeqRef.current === requestSeq) {
activeRequestSeqRef.current = null; activeRequestSeqRef.current = null;
} }
if (hasQueuedRefresh && activeRequestSeqRef.current === null && sectionOpenRef.current) { if (hasQueuedRefresh && activeRequestSeqRef.current === null) {
setQueuedRefreshTick((value) => value + 1); setQueuedRefreshTick((value) => value + 1);
} }
const shouldStopIndicators = const shouldStopIndicators =
@ -320,7 +410,10 @@ export function useTeamChangesSummaries({
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
unknownScanCursorRef.current = 0; unknownScanCursorRef.current = 0;
lastRequestedTasksFingerprintRef.current = null; lastRequestedTasksFingerprintRef.current = null;
lastCounterTasksFingerprintRef.current = null;
setSummariesByTaskId({}); setSummariesByTaskId({});
setChangeCountByTaskId({});
setCounterLoaded(false);
setError(null); setError(null);
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 }); setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
}, [teamName]); }, [teamName]);
@ -341,6 +434,23 @@ export function useTeamChangesSummaries({
} }
}, [sectionOpen]); }, [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(() => { useEffect(() => {
if (!sectionOpen || hasLoadedRef.current) { if (!sectionOpen || hasLoadedRef.current) {
return; return;
@ -362,7 +472,7 @@ export function useTeamChangesSummaries({
}, [loadSummaries, sectionOpen, tasksFingerprint]); }, [loadSummaries, sectionOpen, tasksFingerprint]);
useEffect(() => { useEffect(() => {
if (!sectionOpen || activeRequestSeqRef.current !== null) { if (activeRequestSeqRef.current !== null) {
return; return;
} }
const options = queuedRefreshOptionsRef.current; const options = queuedRefreshOptionsRef.current;
@ -371,7 +481,7 @@ export function useTeamChangesSummaries({
} }
queuedRefreshOptionsRef.current = null; queuedRefreshOptionsRef.current = null;
void loadSummaries(options); void loadSummaries(options);
}, [loadSummaries, queuedRefreshTick, sectionOpen]); }, [loadSummaries, queuedRefreshTick]);
useEffect(() => { useEffect(() => {
if (!sectionOpen) { if (!sectionOpen) {
@ -390,12 +500,39 @@ export function useTeamChangesSummaries({
}; };
}, [loadSummaries, sectionOpen]); }, [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(() => { const refresh = useCallback(() => {
void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false }); void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false });
}, [loadSummaries]); }, [loadSummaries]);
return { return {
summariesByTaskId, summariesByTaskId,
badgeCount: counterLoaded ? countChangedTasks(changeCountByTaskId) : null,
stats, stats,
loading, loading,
refreshing, refreshing,