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; 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(); 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).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(); 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 ( } badge={badge} defaultOpen={false} onOpenChange={setSectionOpen} headerExtra={ loading && !sectionOpen ? ( ) : sectionOpen ? ( {t('taskDetail.changes.refreshShort')} ) : null } contentClassName="pl-2.5" > {visibleSummaries.length > 0 ? (
{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 (
{t('taskDetail.changes.reviewDiff')}
{summary.error ? (
{summary.error}
) : null} {diagnosticMessages.length ? (
{diagnosticMessages.slice(0, 2).map((message) => (
{reviewability === 'attention_required' ? ( ) : ( )} {message}
))}
) : null} {visibleFiles.length > 0 ? (
{visibleFiles.map((file) => (
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); } }} > {getVisibleFilePath(file)} {file.linesAdded > 0 ? ( +{file.linesAdded} ) : null} {file.linesRemoved > 0 ? ( -{file.linesRemoved} ) : null} {t('taskDetail.changes.reviewDiff')}
))}
) : null} {files.length > visibleFiles.length && fileBudget > 0 ? (
{t('taskDetail.changes.moreFiles', { count: files.length - visibleFiles.length, })}
) : null}
); })}
{loading || refreshing ? ( {t('taskDetail.changes.refreshing')} ) : null} {error ? ( {t('taskDetail.changes.refreshFailed', { error })} ) : null} {hiddenFileRows > 0 ? ( {t('taskDetail.changes.fileRowsHidden', { count: hiddenFileRows })} ) : null} {stats.deferredCount > 0 ? ( {t('taskDetail.changes.tasksDeferred', { count: stats.deferredCount })} ) : null}
) : loading || refreshing ? (
{loading ? t('taskDetail.changes.loading') : t('taskDetail.changes.refreshingChanges')}
) : error ? (

{error}

) : (

{t('taskDetail.changes.empty.noFileChangesRecorded')}

{stats.eligibleCount > 0 ? (

{t('taskDetail.changes.scannedCandidateTasks', { requested: stats.requestedCount, eligible: stats.eligibleCount, })}

) : null}
)}
); }); TeamChangesSection.displayName = 'TeamChangesSection';