487 lines
20 KiB
TypeScript
487 lines
20 KiB
TypeScript
import { memo, useMemo, useState } from 'react';
|
|
|
|
import { useAppTranslation } from '@features/localization/renderer';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
|
import { AlertTriangle, FileDiff, GitCompareArrows, Info, Loader2, RefreshCw } from 'lucide-react';
|
|
|
|
import { FileIcon } from './editor/FileIcon';
|
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
|
import { MemberBadge } from './MemberBadge';
|
|
import {
|
|
getTeamChangeTaskTimeMs,
|
|
TEAM_CHANGES_MAX_RENDERED_FILE_ROWS,
|
|
} from './teamChangesRequestPlan';
|
|
import { type TeamChangeSummaryState, useTeamChangesSummaries } from './useTeamChangesSummaries';
|
|
|
|
import type { FileChangeSummary, TaskChangeSetV2, TeamTaskWithKanban } from '@shared/types';
|
|
|
|
interface TeamChangesSectionProps {
|
|
teamName: string;
|
|
tasks: TeamTaskWithKanban[];
|
|
memberColorMap?: ReadonlyMap<string, string>;
|
|
onOpenTask: (task: TeamTaskWithKanban) => void;
|
|
onViewChanges: (taskId: string, filePath?: string) => void;
|
|
}
|
|
|
|
interface RenderedTeamChangeSummary {
|
|
summary: TeamChangeSummaryState;
|
|
task: TeamTaskWithKanban;
|
|
visibleFiles: FileChangeSummary[];
|
|
fileBudget: number;
|
|
}
|
|
|
|
const EMPTY_MEMBER_COLOR_MAP = new Map<string, string>();
|
|
const COMPACT_HIDDEN_INTERVAL_SCOPE_WARNINGS = new Set([
|
|
'Task boundaries missing - scoped by workIntervals timestamps.',
|
|
'Task start boundary missing - scoped by persisted workIntervals timestamps.',
|
|
]);
|
|
|
|
function getChangeSetFiles(changeSet: TaskChangeSetV2 | null): FileChangeSummary[] {
|
|
if (!Array.isArray(changeSet?.files)) {
|
|
return [];
|
|
}
|
|
return changeSet.files.filter((file): file is FileChangeSummary =>
|
|
Boolean(
|
|
file &&
|
|
typeof file === 'object' &&
|
|
typeof (file as Partial<FileChangeSummary>).filePath === 'string'
|
|
)
|
|
);
|
|
}
|
|
|
|
function getChangeSetWarnings(changeSet: TaskChangeSetV2): string[] {
|
|
return Array.isArray(changeSet.warnings)
|
|
? changeSet.warnings.filter((warning): warning is string => typeof warning === 'string')
|
|
: [];
|
|
}
|
|
|
|
function getTaskChangeContributors(
|
|
task: TeamTaskWithKanban,
|
|
changeSet: TaskChangeSetV2 | null
|
|
): string[] {
|
|
const names = new Set<string>();
|
|
const contributors = Array.isArray(changeSet?.scope?.contributors)
|
|
? changeSet.scope.contributors
|
|
: [];
|
|
for (const contributor of contributors) {
|
|
const memberName =
|
|
contributor && typeof contributor.memberName === 'string' ? contributor.memberName : '';
|
|
if (memberName) names.add(memberName);
|
|
}
|
|
const memberNames = Array.isArray(changeSet?.scope?.memberNames)
|
|
? changeSet.scope.memberNames
|
|
: [];
|
|
for (const name of memberNames) {
|
|
if (typeof name === 'string' && name) names.add(name);
|
|
}
|
|
if (
|
|
typeof changeSet?.scope?.primaryMemberName === 'string' &&
|
|
changeSet.scope.primaryMemberName
|
|
) {
|
|
names.add(changeSet.scope.primaryMemberName);
|
|
}
|
|
for (const file of getChangeSetFiles(changeSet)) {
|
|
const fileMemberNames = Array.isArray(file.ledgerSummary?.memberNames)
|
|
? file.ledgerSummary.memberNames
|
|
: [];
|
|
for (const name of fileMemberNames) {
|
|
if (typeof name === 'string' && name) names.add(name);
|
|
}
|
|
}
|
|
if (names.size === 0 && task.owner) {
|
|
names.add(task.owner);
|
|
}
|
|
return [...names];
|
|
}
|
|
|
|
function getVisibleFileName(file: FileChangeSummary): string {
|
|
const value = getVisibleFilePath(file);
|
|
return value.split(/[\\/]/).pop() ?? value;
|
|
}
|
|
|
|
function getVisibleFilePath(file: FileChangeSummary): string {
|
|
return typeof file.relativePath === 'string' && file.relativePath.trim() !== ''
|
|
? file.relativePath
|
|
: file.filePath;
|
|
}
|
|
|
|
function getTaskSummaryBadge(
|
|
changeSet: TaskChangeSetV2 | null,
|
|
labels: {
|
|
files: (count: number) => string;
|
|
attention: string;
|
|
noSafeDiff: string;
|
|
}
|
|
): string | undefined {
|
|
if (!changeSet) return undefined;
|
|
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
|
if (changeSet.totalFiles > 0) return labels.files(changeSet.totalFiles);
|
|
if (reviewability === 'attention_required') return labels.attention;
|
|
if (reviewability === 'diagnostic_only') return labels.noSafeDiff;
|
|
return undefined;
|
|
}
|
|
|
|
function isWorkIntervalScopedFileChange(changeSet: TaskChangeSetV2): boolean {
|
|
const reason = changeSet.scope?.confidence?.reason;
|
|
return (
|
|
getChangeSetFiles(changeSet).length > 0 &&
|
|
changeSet.confidence === 'medium' &&
|
|
typeof reason === 'string' &&
|
|
reason.toLowerCase().includes('workinterval')
|
|
);
|
|
}
|
|
|
|
function shouldHideCompactDiagnostic(changeSet: TaskChangeSetV2, message: string): boolean {
|
|
return (
|
|
isWorkIntervalScopedFileChange(changeSet) &&
|
|
COMPACT_HIDDEN_INTERVAL_SCOPE_WARNINGS.has(message.trim())
|
|
);
|
|
}
|
|
|
|
function getTaskChangeDiagnosticMessages(changeSet: TaskChangeSetV2): string[] {
|
|
const status = classifyTaskChangeReviewability(changeSet);
|
|
if (status.reviewability === 'unknown' || status.reviewability === 'none') {
|
|
return [];
|
|
}
|
|
const messages =
|
|
status.diagnostics.length > 0
|
|
? status.diagnostics.map((diagnostic) => diagnostic.message)
|
|
: getChangeSetWarnings(changeSet);
|
|
return [
|
|
...new Set(
|
|
messages.filter(
|
|
(message) => message.trim().length > 0 && !shouldHideCompactDiagnostic(changeSet, message)
|
|
)
|
|
),
|
|
];
|
|
}
|
|
|
|
export const TeamChangesSection = memo(function TeamChangesSection({
|
|
teamName,
|
|
tasks,
|
|
memberColorMap = EMPTY_MEMBER_COLOR_MAP,
|
|
onOpenTask,
|
|
onViewChanges,
|
|
}: TeamChangesSectionProps): React.JSX.Element {
|
|
const { t } = useAppTranslation('team');
|
|
const [sectionOpen, setSectionOpen] = useState(false);
|
|
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(() => {
|
|
return Object.values(summariesByTaskId)
|
|
.map((summary) => ({ summary, task: taskMap.get(summary.taskId) }))
|
|
.filter((entry): entry is { summary: TeamChangeSummaryState; task: TeamTaskWithKanban } => {
|
|
const changeSet = entry.summary.changeSet;
|
|
return (
|
|
Boolean(entry.task) &&
|
|
(Boolean(entry.summary.error) ||
|
|
getChangeSetFiles(changeSet).length > 0 ||
|
|
(changeSet ? getTaskChangeDiagnosticMessages(changeSet).length > 0 : false))
|
|
);
|
|
})
|
|
.sort((a, b) => getTeamChangeTaskTimeMs(b.task) - getTeamChangeTaskTimeMs(a.task));
|
|
}, [summariesByTaskId, taskMap]);
|
|
|
|
const totalFiles = visibleSummaries.reduce(
|
|
(sum, entry) => sum + getChangeSetFiles(entry.summary.changeSet).length,
|
|
0
|
|
);
|
|
const hiddenFileRows = Math.max(0, totalFiles - TEAM_CHANGES_MAX_RENDERED_FILE_ROWS);
|
|
const badge = badgeCount ?? undefined;
|
|
const renderedSummaries = useMemo(() => {
|
|
const entries: RenderedTeamChangeSummary[] = [];
|
|
let remainingFileRows = TEAM_CHANGES_MAX_RENDERED_FILE_ROWS;
|
|
for (const entry of visibleSummaries) {
|
|
const files = getChangeSetFiles(entry.summary.changeSet);
|
|
const fileBudget = Math.max(0, remainingFileRows);
|
|
const visibleFiles = files.slice(0, fileBudget);
|
|
entries.push({ ...entry, visibleFiles, fileBudget });
|
|
remainingFileRows -= visibleFiles.length;
|
|
}
|
|
return entries;
|
|
}, [visibleSummaries]);
|
|
|
|
return (
|
|
<CollapsibleTeamSection
|
|
sectionId="changes"
|
|
title={t('taskDetail.changes.title')}
|
|
icon={<FileDiff size={14} />}
|
|
badge={badge}
|
|
defaultOpen={false}
|
|
onOpenChange={setSectionOpen}
|
|
headerExtra={
|
|
loading && !sectionOpen ? (
|
|
<Loader2
|
|
size={12}
|
|
className="pointer-events-none animate-spin text-[var(--color-text-muted)]"
|
|
/>
|
|
) : sectionOpen ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="pointer-events-auto rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-section-hover)] hover:text-[var(--color-text)] disabled:opacity-50"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
refresh();
|
|
}}
|
|
disabled={loading || refreshing}
|
|
aria-label={t('taskDetail.changes.refreshTeamChanges')}
|
|
>
|
|
<RefreshCw
|
|
size={12}
|
|
className={loading || refreshing ? 'animate-spin' : undefined}
|
|
/>
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">{t('taskDetail.changes.refreshShort')}</TooltipContent>
|
|
</Tooltip>
|
|
) : null
|
|
}
|
|
contentClassName="pl-2.5"
|
|
>
|
|
{visibleSummaries.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<div className="max-h-[360px] space-y-2 overflow-y-auto pr-1">
|
|
{renderedSummaries.map(({ summary, task, visibleFiles, fileBudget }) => {
|
|
const changeSet = summary.changeSet;
|
|
const files = getChangeSetFiles(changeSet);
|
|
const reviewability = changeSet
|
|
? classifyTaskChangeReviewability(changeSet).reviewability
|
|
: 'unknown';
|
|
const contributors = getTaskChangeContributors(task, changeSet);
|
|
const contributorLabel =
|
|
contributors.length > 0
|
|
? contributors.slice(0, 3).join(', ')
|
|
: t('taskDetail.unassigned');
|
|
const extraContributors = Math.max(0, contributors.length - 3);
|
|
const badgeText = getTaskSummaryBadge(changeSet, {
|
|
files: (count) => t('taskDetail.changes.fileCount', { count }),
|
|
attention: t('taskDetail.changes.badges.attention'),
|
|
noSafeDiff: t('taskDetail.changes.badges.noSafeDiff'),
|
|
});
|
|
const diagnosticMessages = changeSet
|
|
? getTaskChangeDiagnosticMessages(changeSet)
|
|
: [];
|
|
|
|
if (visibleFiles.length === 0 && !summary.error && diagnosticMessages.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={summary.taskId}
|
|
className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)]"
|
|
>
|
|
<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={t('taskDetail.changes.openTask', { subject: task.subject })}
|
|
>
|
|
<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>
|
|
{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={t('taskDetail.changes.reviewTaskDiff')}
|
|
>
|
|
<GitCompareArrows size={13} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{t('taskDetail.changes.reviewDiff')}
|
|
</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">
|
|
<AlertTriangle size={13} className="shrink-0" />
|
|
<span className="min-w-0 truncate">{summary.error}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
{diagnosticMessages.length ? (
|
|
<div className="space-y-1 border-t border-[var(--color-border)] px-2 py-1.5">
|
|
{diagnosticMessages.slice(0, 2).map((message) => (
|
|
<div
|
|
key={message}
|
|
className={`flex items-center gap-2 text-xs ${
|
|
reviewability === 'attention_required'
|
|
? 'text-[var(--step-warning-text)]'
|
|
: 'text-[var(--color-text-muted)]'
|
|
}`}
|
|
>
|
|
{reviewability === 'attention_required' ? (
|
|
<AlertTriangle size={13} className="shrink-0" />
|
|
) : (
|
|
<Info size={13} className="shrink-0" />
|
|
)}
|
|
<span className="min-w-0 truncate">{message}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{visibleFiles.length > 0 ? (
|
|
<div className="border-t border-[var(--color-border)] py-0.5">
|
|
{visibleFiles.map((file) => (
|
|
<div
|
|
key={`${summary.taskId}:${file.filePath}`}
|
|
role="button"
|
|
tabIndex={0}
|
|
title={getVisibleFilePath(file)}
|
|
className="group flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)] focus:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)]"
|
|
onClick={() => onViewChanges(task.id, file.filePath)}
|
|
onKeyDown={(event) => {
|
|
if (event.target !== event.currentTarget) return;
|
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
event.preventDefault();
|
|
onViewChanges(task.id, file.filePath);
|
|
}
|
|
}}
|
|
>
|
|
<FileIcon fileName={getVisibleFileName(file)} className="size-3.5" />
|
|
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors group-hover:text-[var(--color-text)]">
|
|
{getVisibleFilePath(file)}
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-1.5">
|
|
{file.linesAdded > 0 ? (
|
|
<span className="text-emerald-400">+{file.linesAdded}</span>
|
|
) : null}
|
|
{file.linesRemoved > 0 ? (
|
|
<span className="text-red-400">-{file.linesRemoved}</span>
|
|
) : null}
|
|
</span>
|
|
<span className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
onViewChanges(task.id, file.filePath);
|
|
}}
|
|
aria-label={t('taskDetail.changes.reviewDiff')}
|
|
>
|
|
<GitCompareArrows size={13} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{t('taskDetail.changes.reviewDiff')}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
{files.length > visibleFiles.length && fileBudget > 0 ? (
|
|
<div className="border-t border-[var(--color-border)] px-2 py-1.5 text-xs text-[var(--color-text-muted)]">
|
|
{t('taskDetail.changes.moreFiles', {
|
|
count: files.length - visibleFiles.length,
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
|
{loading || refreshing ? (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Loader2 size={11} className="animate-spin" />
|
|
{t('taskDetail.changes.refreshing')}
|
|
</span>
|
|
) : null}
|
|
{error ? (
|
|
<span className="text-red-400">
|
|
{t('taskDetail.changes.refreshFailed', { error })}
|
|
</span>
|
|
) : null}
|
|
{hiddenFileRows > 0 ? (
|
|
<span>{t('taskDetail.changes.fileRowsHidden', { count: hiddenFileRows })}</span>
|
|
) : null}
|
|
{stats.deferredCount > 0 ? (
|
|
<span>{t('taskDetail.changes.tasksDeferred', { count: stats.deferredCount })}</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : loading || refreshing ? (
|
|
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
{loading ? t('taskDetail.changes.loading') : t('taskDetail.changes.refreshingChanges')}
|
|
</div>
|
|
) : error ? (
|
|
<p className="text-xs text-red-400">{error}</p>
|
|
) : (
|
|
<div className="space-y-1 py-1">
|
|
<p className="text-xs text-[var(--color-text-muted)]">
|
|
{t('taskDetail.changes.empty.noFileChangesRecorded')}
|
|
</p>
|
|
{stats.eligibleCount > 0 ? (
|
|
<p className="text-[10px] text-[var(--color-text-muted)]">
|
|
{t('taskDetail.changes.scannedCandidateTasks', {
|
|
requested: stats.requestedCount,
|
|
eligible: stats.eligibleCount,
|
|
})}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</CollapsibleTeamSection>
|
|
);
|
|
});
|
|
|
|
TeamChangesSection.displayName = 'TeamChangesSection';
|