feat: refine team changes and docs theme
This commit is contained in:
parent
298e81af82
commit
3ead3207e6
5 changed files with 590 additions and 71 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue