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-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;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { AlertTriangle, FileDiff, GitCompareArrows, Info, Loader2, RefreshCw } f
|
|||
|
||||
import { FileIcon } from './editor/FileIcon';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import { MemberBadge } from './MemberBadge';
|
||||
import {
|
||||
getTeamChangeTaskTimeMs,
|
||||
TEAM_CHANGES_MAX_RENDERED_FILE_ROWS,
|
||||
|
|
@ -18,6 +19,8 @@ import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@sh
|
|||
interface TeamChangesSectionProps {
|
||||
teamName: string;
|
||||
tasks: TeamTaskWithKanban[];
|
||||
memberColorMap?: ReadonlyMap<string, string>;
|
||||
onOpenTask: (task: TeamTaskWithKanban) => void;
|
||||
onViewChanges: (taskId: string, filePath?: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +31,8 @@ interface RenderedTeamChangeSummary {
|
|||
fileBudget: number;
|
||||
}
|
||||
|
||||
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
|
||||
|
||||
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
|
||||
if (!Array.isArray(changeSet?.files)) {
|
||||
return [];
|
||||
|
|
@ -121,16 +126,17 @@ function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
|||
export const TeamChangesSection = memo(function TeamChangesSection({
|
||||
teamName,
|
||||
tasks,
|
||||
memberColorMap = EMPTY_MEMBER_COLOR_MAP,
|
||||
onOpenTask,
|
||||
onViewChanges,
|
||||
}: TeamChangesSectionProps): React.JSX.Element {
|
||||
const [sectionOpen, setSectionOpen] = useState(false);
|
||||
const { summariesByTaskId, stats, loading, refreshing, error, refresh } = useTeamChangesSummaries(
|
||||
{
|
||||
const { summariesByTaskId, badgeCount, stats, loading, refreshing, error, refresh } =
|
||||
useTeamChangesSummaries({
|
||||
teamName,
|
||||
tasks,
|
||||
sectionOpen,
|
||||
}
|
||||
);
|
||||
});
|
||||
const taskMap = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
|
||||
|
||||
const visibleSummaries = useMemo(() => {
|
||||
|
|
@ -153,7 +159,7 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
0
|
||||
);
|
||||
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
|
||||
const badge = totalFiles > 0 ? totalFiles : visibleSummaries.length || undefined;
|
||||
const badge = badgeCount ?? undefined;
|
||||
const renderedSummaries = useMemo(() => {
|
||||
const entries: RenderedTeamChangeSummary[] = [];
|
||||
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
|
||||
|
|
@ -233,30 +239,64 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
key={summary.taskId}
|
||||
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-t-md px-2 py-1.5 text-left transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
onClick={() => onViewChanges(task.id)}
|
||||
>
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
#{deriveTaskDisplayId(task.id)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</span>
|
||||
<span
|
||||
className="hidden max-w-[180px] shrink-0 truncate text-[10px] text-[var(--color-text-muted)] sm:inline"
|
||||
title={contributors.join(', ')}
|
||||
<div className="flex min-w-0 items-center gap-1 rounded-t-md px-2 py-1.5 transition-colors hover:bg-[var(--color-surface-raised)]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 text-left"
|
||||
onClick={() => onOpenTask(task)}
|
||||
aria-label={`Open task ${task.subject}`}
|
||||
>
|
||||
{contributorLabel}
|
||||
{extraContributors > 0 ? ` +${extraContributors}` : ''}
|
||||
</span>
|
||||
{badgeText ? (
|
||||
<span className="shrink-0 rounded bg-[var(--color-bg-tertiary)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{badgeText}
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
|
||||
#{deriveTaskDisplayId(task.id)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[var(--color-text)]">
|
||||
{task.subject}
|
||||
</span>
|
||||
{contributors[0] ? (
|
||||
<span className="hidden min-w-0 max-w-[220px] shrink-0 items-center gap-1 sm:inline-flex">
|
||||
<MemberBadge
|
||||
name={contributors[0]}
|
||||
color={memberColorMap.get(contributors[0])}
|
||||
size="xs"
|
||||
disableHoverCard
|
||||
/>
|
||||
{extraContributors > 0 ? (
|
||||
<span
|
||||
className="shrink-0 text-[10px] text-[var(--color-text-muted)]"
|
||||
title={contributors.join(', ')}
|
||||
>
|
||||
+{extraContributors}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="hidden max-w-[180px] shrink-0 truncate text-[10px] text-[var(--color-text-muted)] sm:inline"
|
||||
title={contributors.join(', ')}
|
||||
>
|
||||
{contributorLabel}
|
||||
</span>
|
||||
)}
|
||||
{badgeText ? (
|
||||
<span className="shrink-0 rounded bg-[var(--color-bg-tertiary)] px-1.5 py-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{badgeText}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
onClick={() => onViewChanges(task.id)}
|
||||
aria-label="Review task diff"
|
||||
>
|
||||
<GitCompareArrows size={13} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Review diff</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{summary.error ? (
|
||||
<div className="flex items-center gap-2 border-t border-[var(--color-border)] px-2 py-1.5 text-xs text-red-400">
|
||||
|
|
|
|||
|
|
@ -2719,6 +2719,8 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
<TeamChangesSection
|
||||
teamName={teamName}
|
||||
tasks={data.tasks}
|
||||
memberColorMap={resolvedMemberColorMap}
|
||||
onOpenTask={(task) => setSelectedTask(task)}
|
||||
onViewChanges={handleViewChangesForFile}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
fetchConfig: vi.fn(),
|
||||
getTeamTaskChangeSummaries: vi.fn(),
|
||||
recordTaskChangePresence: vi.fn(),
|
||||
setSelectedTeamTaskChangePresence: vi.fn(),
|
||||
|
|
@ -31,8 +32,15 @@ vi.mock('@renderer/api', () => ({
|
|||
vi.mock('@renderer/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||
selector({
|
||||
appConfig: { general: { theme: 'dark' } },
|
||||
configLoading: false,
|
||||
fetchConfig: hoisted.fetchConfig,
|
||||
memberActivityMetaByTeam: {},
|
||||
recordTaskChangePresence: hoisted.recordTaskChangePresence,
|
||||
setSelectedTeamTaskChangePresence: hoisted.setSelectedTeamTaskChangePresence,
|
||||
selectedTeamData: null,
|
||||
selectedTeamName: undefined,
|
||||
teamDataCacheByName: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -187,6 +195,7 @@ interface HookSnapshot {
|
|||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
error: string | null;
|
||||
badgeCount: number | null;
|
||||
summariesByTaskId: Record<string, TeamChangeSummaryState>;
|
||||
}
|
||||
|
||||
|
|
@ -207,9 +216,17 @@ const HookHarness = ({
|
|||
loading: state.loading,
|
||||
refreshing: state.refreshing,
|
||||
error: state.error,
|
||||
badgeCount: state.badgeCount,
|
||||
summariesByTaskId: state.summariesByTaskId,
|
||||
});
|
||||
}, [onSnapshot, state.error, state.loading, state.refreshing, state.summariesByTaskId]);
|
||||
}, [
|
||||
onSnapshot,
|
||||
state.badgeCount,
|
||||
state.error,
|
||||
state.loading,
|
||||
state.refreshing,
|
||||
state.summariesByTaskId,
|
||||
]);
|
||||
return null;
|
||||
};
|
||||
|
||||
|
|
@ -585,6 +602,7 @@ describe('useTeamChangesSummaries', () => {
|
|||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task()],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
|
|
@ -615,4 +633,279 @@ describe('useTeamChangesSummaries', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the closed-section counter only after the background count load resolves', async () => {
|
||||
const deferred = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries.mockReturnValue(deferred.promise);
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task()],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Changes');
|
||||
expect(container.textContent).not.toContain('0');
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve(response());
|
||||
await deferred.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('loads the closed-section counter without rendering full change rows', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task()],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
||||
expect(container.textContent).toContain('1');
|
||||
expect(container.textContent).not.toContain('src/app.ts');
|
||||
});
|
||||
|
||||
it('runs a queued closed counter refresh when tasks change during an active count load', async () => {
|
||||
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task()],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task({ updatedAt: '2026-05-10T10:00:02.000Z' })],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(lowConfidenceFileResponse());
|
||||
await first.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(response());
|
||||
await second.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('0');
|
||||
});
|
||||
|
||||
it('does not lose the full load queued by opening the section during a failed count load', async () => {
|
||||
const first = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
const second = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
|
||||
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollIntoView'
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
try {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task()],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand section"]'
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
expandButton?.click();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('Loading changes...');
|
||||
|
||||
await act(async () => {
|
||||
first.reject(new Error('silent count failed'));
|
||||
await first.promise.catch(() => undefined);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.getTeamTaskChangeSummaries).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
second.resolve(lowConfidenceFileResponse());
|
||||
await second.promise;
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('src/app.ts');
|
||||
expect(container.textContent).not.toContain('silent count failed');
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
} else {
|
||||
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('opens the task popup from summary header and keeps diff on the review action', async () => {
|
||||
const taskItem = task({ changePresence: 'has_changes' });
|
||||
const onOpenTask = vi.fn();
|
||||
const onViewChanges = vi.fn();
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(lowConfidenceFileResponse());
|
||||
|
||||
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollIntoView'
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
try {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [taskItem],
|
||||
memberColorMap: new Map([['alice', 'blue']]),
|
||||
onOpenTask,
|
||||
onViewChanges,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand section"]'
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
expandButton?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.querySelector('img')).not.toBeNull();
|
||||
|
||||
const openTaskButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Open task Task 1"]'
|
||||
);
|
||||
expect(openTaskButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
openTaskButton?.click();
|
||||
});
|
||||
|
||||
expect(onOpenTask).toHaveBeenCalledWith(taskItem);
|
||||
expect(onViewChanges).not.toHaveBeenCalled();
|
||||
|
||||
const reviewTaskDiffButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Review task diff"]'
|
||||
);
|
||||
expect(reviewTaskDiffButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
reviewTaskDiffButton?.click();
|
||||
});
|
||||
|
||||
expect(onViewChanges).toHaveBeenCalledWith('task-1');
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
} else {
|
||||
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
||||
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||
|
||||
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
|
||||
import {
|
||||
|
|
@ -18,6 +19,7 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
const TEAM_CHANGES_AUTO_REFRESH_MS = 30_000;
|
||||
const TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS = 60_000;
|
||||
const TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS = 120_000;
|
||||
|
||||
export interface TeamChangeSummaryState {
|
||||
|
|
@ -36,6 +38,9 @@ interface TeamChangesLoadOptions {
|
|||
forceFresh?: boolean;
|
||||
showSpinner?: boolean;
|
||||
preserveOnError?: boolean;
|
||||
storeSummaries?: boolean;
|
||||
reportError?: boolean;
|
||||
blockAutoRetryOnError?: boolean;
|
||||
}
|
||||
|
||||
interface UseTeamChangesSummariesInput {
|
||||
|
|
@ -46,6 +51,7 @@ interface UseTeamChangesSummariesInput {
|
|||
|
||||
interface UseTeamChangesSummariesResult {
|
||||
summariesByTaskId: Record<string, TeamChangeSummaryState>;
|
||||
badgeCount: number | null;
|
||||
stats: TeamChangeStats;
|
||||
loading: boolean;
|
||||
refreshing: boolean;
|
||||
|
|
@ -101,6 +107,19 @@ function hasSafeFileSummaries(changeSet: TaskChangeSetV2): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function hasDisplayableFileSummaries(changeSet: TaskChangeSetV2): boolean {
|
||||
return (
|
||||
Array.isArray(changeSet.files) &&
|
||||
changeSet.files.some(
|
||||
(file) =>
|
||||
file &&
|
||||
typeof file === 'object' &&
|
||||
typeof file.filePath === 'string' &&
|
||||
file.filePath.trim().length > 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isMinimalPresenceChangeSet(changeSet: TaskChangeSetV2): boolean {
|
||||
return Boolean(
|
||||
Array.isArray(changeSet.files) &&
|
||||
|
|
@ -136,6 +155,31 @@ function resolveCacheablePresenceFromChangeSet(
|
|||
return null;
|
||||
}
|
||||
|
||||
function isCountableTeamChangeSummary(item: TeamTaskChangeSummaryItem): boolean {
|
||||
if (item.error) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const changeSet = item.changeSet;
|
||||
if (!changeSet) {
|
||||
return false;
|
||||
}
|
||||
if (hasDisplayableFileSummaries(changeSet)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
||||
return reviewability === 'attention_required' || reviewability === 'diagnostic_only';
|
||||
}
|
||||
|
||||
function countChangedTasks(changeCountByTaskId: Record<string, boolean>): number {
|
||||
return Object.values(changeCountByTaskId).filter(Boolean).length;
|
||||
}
|
||||
|
||||
function isDocumentHidden(): boolean {
|
||||
return typeof document !== 'undefined' && document.visibilityState === 'hidden';
|
||||
}
|
||||
|
||||
export function useTeamChangesSummaries({
|
||||
teamName,
|
||||
tasks,
|
||||
|
|
@ -146,6 +190,8 @@ export function useTeamChangesSummaries({
|
|||
const [summariesByTaskId, setSummariesByTaskId] = useState<
|
||||
Record<string, TeamChangeSummaryState>
|
||||
>({});
|
||||
const [changeCountByTaskId, setChangeCountByTaskId] = useState<Record<string, boolean>>({});
|
||||
const [counterLoaded, setCounterLoaded] = useState(false);
|
||||
const [stats, setStats] = useState<TeamChangeStats>({
|
||||
eligibleCount: 0,
|
||||
requestedCount: 0,
|
||||
|
|
@ -161,14 +207,10 @@ export function useTeamChangesSummaries({
|
|||
const activeRequestSeqRef = useRef<number | null>(null);
|
||||
const queuedRefreshOptionsRef = useRef<TeamChangesLoadOptions | null>(null);
|
||||
const autoRefreshBlockedUntilRef = useRef(0);
|
||||
const sectionOpenRef = useRef(sectionOpen);
|
||||
const unknownScanCursorRef = useRef(0);
|
||||
const lastRequestedTasksFingerprintRef = useRef<string | null>(null);
|
||||
const tasksFingerprint = useMemo(
|
||||
() => (sectionOpen ? buildTeamChangesTasksFingerprint(tasks) : ''),
|
||||
[sectionOpen, tasks]
|
||||
);
|
||||
sectionOpenRef.current = sectionOpen;
|
||||
const lastCounterTasksFingerprintRef = useRef<string | null>(null);
|
||||
const tasksFingerprint = useMemo(() => buildTeamChangesTasksFingerprint(tasks), [tasks]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
|
@ -181,6 +223,7 @@ export function useTeamChangesSummaries({
|
|||
hasLoadedRef.current = false;
|
||||
unknownScanCursorRef.current = 0;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
lastCounterTasksFingerprintRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -189,6 +232,9 @@ export function useTeamChangesSummaries({
|
|||
forceFresh = false,
|
||||
showSpinner = false,
|
||||
preserveOnError = true,
|
||||
storeSummaries = true,
|
||||
reportError = true,
|
||||
blockAutoRetryOnError = true,
|
||||
}: TeamChangesLoadOptions = {}): Promise<void> => {
|
||||
if (forceFresh) {
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
|
|
@ -204,8 +250,18 @@ export function useTeamChangesSummaries({
|
|||
preserveOnError: previous
|
||||
? Boolean(previous.preserveOnError && preserveOnError)
|
||||
: preserveOnError,
|
||||
storeSummaries: Boolean(previous?.storeSummaries || storeSummaries),
|
||||
reportError: previous ? Boolean(previous.reportError || reportError) : reportError,
|
||||
blockAutoRetryOnError: previous
|
||||
? Boolean(previous.blockAutoRetryOnError || blockAutoRetryOnError)
|
||||
: blockAutoRetryOnError,
|
||||
};
|
||||
if (activeRequestSeqRef.current === null && sectionOpenRef.current) {
|
||||
if (showSpinner) {
|
||||
setLoading(true);
|
||||
} else if (storeSummaries) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
if (activeRequestSeqRef.current === null) {
|
||||
setQueuedRefreshTick((value) => value + 1);
|
||||
}
|
||||
return;
|
||||
|
|
@ -223,7 +279,11 @@ export function useTeamChangesSummaries({
|
|||
setError(null);
|
||||
|
||||
if (plan.requests.length === 0) {
|
||||
setSummariesByTaskId({});
|
||||
if (storeSummaries) {
|
||||
setSummariesByTaskId({});
|
||||
}
|
||||
setChangeCountByTaskId({});
|
||||
setCounterLoaded(true);
|
||||
autoRefreshBlockedUntilRef.current = 0;
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
|
|
@ -232,7 +292,7 @@ export function useTeamChangesSummaries({
|
|||
|
||||
if (showSpinner) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
} else if (storeSummaries) {
|
||||
setRefreshing(true);
|
||||
}
|
||||
activeRequestSeqRef.current = requestSeq;
|
||||
|
|
@ -250,6 +310,22 @@ export function useTeamChangesSummaries({
|
|||
autoRefreshBlockedUntilRef.current = 0;
|
||||
const responseItems = getSafeResponseItems(response);
|
||||
|
||||
setChangeCountByTaskId((previous) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
for (const [taskId, countable] of Object.entries(previous)) {
|
||||
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
|
||||
next[taskId] = countable;
|
||||
}
|
||||
}
|
||||
for (const item of responseItems) {
|
||||
if (!plan.requestOptionsByTaskId.has(item.taskId)) continue;
|
||||
next[item.taskId] = isCountableTeamChangeSummary(item);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setCounterLoaded(true);
|
||||
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
for (const item of responseItems) {
|
||||
const changeSet = item.changeSet;
|
||||
|
|
@ -262,41 +338,55 @@ export function useTeamChangesSummaries({
|
|||
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
|
||||
}
|
||||
|
||||
setSummariesByTaskId((previous) => {
|
||||
const next: Record<string, TeamChangeSummaryState> = {};
|
||||
for (const [taskId, summary] of Object.entries(previous)) {
|
||||
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
|
||||
next[taskId] = summary;
|
||||
if (storeSummaries) {
|
||||
setSummariesByTaskId((previous) => {
|
||||
const next: Record<string, TeamChangeSummaryState> = {};
|
||||
for (const [taskId, summary] of Object.entries(previous)) {
|
||||
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
|
||||
next[taskId] = summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of responseItems) {
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!options) continue;
|
||||
next[item.taskId] = {
|
||||
taskId: item.taskId,
|
||||
changeSet: item.changeSet,
|
||||
error: item.error,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
for (const item of responseItems) {
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!options) continue;
|
||||
next[item.taskId] = {
|
||||
taskId: item.taskId,
|
||||
changeSet: item.changeSet,
|
||||
error: item.error,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mountedRef.current || requestSeqRef.current !== requestSeq) {
|
||||
return;
|
||||
}
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
autoRefreshBlockedUntilRef.current = Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
|
||||
const queuedOptions = queuedRefreshOptionsRef.current as TeamChangesLoadOptions | null;
|
||||
const shouldRunVisibleQueuedRefreshAfterSilentFailure =
|
||||
!storeSummaries &&
|
||||
!reportError &&
|
||||
Boolean(queuedOptions?.showSpinner || queuedOptions?.storeSummaries);
|
||||
if (!shouldRunVisibleQueuedRefreshAfterSilentFailure) {
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
}
|
||||
if (blockAutoRetryOnError) {
|
||||
autoRefreshBlockedUntilRef.current =
|
||||
Date.now() + TEAM_CHANGES_ERROR_AUTO_RETRY_COOLDOWN_MS;
|
||||
}
|
||||
if (!preserveOnError) {
|
||||
setSummariesByTaskId({});
|
||||
}
|
||||
setError(err instanceof Error ? err.message : 'Failed to load team changes');
|
||||
if (reportError) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load team changes');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
const hasQueuedRefresh = queuedRefreshOptionsRef.current !== null;
|
||||
if (activeRequestSeqRef.current === requestSeq) {
|
||||
activeRequestSeqRef.current = null;
|
||||
}
|
||||
if (hasQueuedRefresh && activeRequestSeqRef.current === null && sectionOpenRef.current) {
|
||||
if (hasQueuedRefresh && activeRequestSeqRef.current === null) {
|
||||
setQueuedRefreshTick((value) => value + 1);
|
||||
}
|
||||
const shouldStopIndicators =
|
||||
|
|
@ -320,7 +410,10 @@ export function useTeamChangesSummaries({
|
|||
autoRefreshBlockedUntilRef.current = 0;
|
||||
unknownScanCursorRef.current = 0;
|
||||
lastRequestedTasksFingerprintRef.current = null;
|
||||
lastCounterTasksFingerprintRef.current = null;
|
||||
setSummariesByTaskId({});
|
||||
setChangeCountByTaskId({});
|
||||
setCounterLoaded(false);
|
||||
setError(null);
|
||||
setStats({ eligibleCount: 0, requestedCount: 0, deferredCount: 0 });
|
||||
}, [teamName]);
|
||||
|
|
@ -341,6 +434,23 @@ export function useTeamChangesSummaries({
|
|||
}
|
||||
}, [sectionOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionOpen) {
|
||||
return;
|
||||
}
|
||||
if (lastCounterTasksFingerprintRef.current === tasksFingerprint && counterLoaded) {
|
||||
return;
|
||||
}
|
||||
lastCounterTasksFingerprintRef.current = tasksFingerprint;
|
||||
void loadSummaries({
|
||||
showSpinner: false,
|
||||
preserveOnError: true,
|
||||
storeSummaries: false,
|
||||
reportError: false,
|
||||
blockAutoRetryOnError: false,
|
||||
});
|
||||
}, [counterLoaded, loadSummaries, sectionOpen, tasksFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen || hasLoadedRef.current) {
|
||||
return;
|
||||
|
|
@ -362,7 +472,7 @@ export function useTeamChangesSummaries({
|
|||
}, [loadSummaries, sectionOpen, tasksFingerprint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen || activeRequestSeqRef.current !== null) {
|
||||
if (activeRequestSeqRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
const options = queuedRefreshOptionsRef.current;
|
||||
|
|
@ -371,7 +481,7 @@ export function useTeamChangesSummaries({
|
|||
}
|
||||
queuedRefreshOptionsRef.current = null;
|
||||
void loadSummaries(options);
|
||||
}, [loadSummaries, queuedRefreshTick, sectionOpen]);
|
||||
}, [loadSummaries, queuedRefreshTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sectionOpen) {
|
||||
|
|
@ -390,12 +500,39 @@ export function useTeamChangesSummaries({
|
|||
};
|
||||
}, [loadSummaries, sectionOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sectionOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setInterval(() => {
|
||||
if (isDocumentHidden()) {
|
||||
return;
|
||||
}
|
||||
if (activeRequestSeqRef.current !== null || queuedRefreshOptionsRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
void loadSummaries({
|
||||
showSpinner: false,
|
||||
preserveOnError: true,
|
||||
storeSummaries: false,
|
||||
reportError: false,
|
||||
blockAutoRetryOnError: false,
|
||||
});
|
||||
}, TEAM_CHANGES_COUNTER_AUTO_REFRESH_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [loadSummaries, sectionOpen]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void loadSummaries({ forceFresh: true, showSpinner: true, preserveOnError: false });
|
||||
}, [loadSummaries]);
|
||||
|
||||
return {
|
||||
summariesByTaskId,
|
||||
badgeCount: counterLoaded ? countChangedTasks(changeCountByTaskId) : null,
|
||||
stats,
|
||||
loading,
|
||||
refreshing,
|
||||
|
|
|
|||
Loading…
Reference in a new issue