import { useCallback, useMemo, useState } from 'react' import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions' import { useTranslation } from '../../i18n' import { WorkspaceDiffSurface } from '../workspace/WorkspaceCodeSurface' type DiffPreviewState = { loading: boolean diff?: string error?: string } type CurrentTurnChangeCardProps = { sessionId: string targetUserMessageId: string checkpoint: SessionTurnCheckpoint workDir: string | null error: string | null isUndoing: boolean isLatest: boolean onUndo: () => void } type ChangedFileEntry = { apiPath: string displayPath: string } export function CurrentTurnChangeCard({ sessionId, targetUserMessageId, checkpoint, workDir, error, isUndoing, isLatest, onUndo, }: CurrentTurnChangeCardProps) { const t = useTranslation() const [expandedPath, setExpandedPath] = useState(null) const [diffByPath, setDiffByPath] = useState>({}) const files = useMemo( () => checkpoint.code.filesChanged.map((filePath) => ({ apiPath: filePath, displayPath: relativizeWorkspacePath(filePath, workDir), })), [checkpoint.code.filesChanged, workDir], ) const toggleDiff = useCallback((fileEntry: ChangedFileEntry) => { const nextExpandedPath = expandedPath === fileEntry.apiPath ? null : fileEntry.apiPath setExpandedPath(nextExpandedPath) if (!nextExpandedPath || diffByPath[fileEntry.apiPath]?.diff || diffByPath[fileEntry.apiPath]?.loading) { return } setDiffByPath((current) => ({ ...current, [fileEntry.apiPath]: { loading: true }, })) void sessionsApi .getTurnCheckpointDiff( sessionId, targetUserMessageId, fileEntry.apiPath, checkpoint.target.userMessageIndex, ) .then((result) => { setDiffByPath((current) => ({ ...current, [fileEntry.apiPath]: { loading: false, diff: result.state === 'ok' ? result.diff || '' : undefined, error: result.state === 'ok' ? undefined : result.error || t('chat.turnChangesDiffUnavailable'), }, })) }) .catch((diffError) => { setDiffByPath((current) => ({ ...current, [fileEntry.apiPath]: { loading: false, error: diffError instanceof Error ? diffError.message : String(diffError), }, })) }) }, [diffByPath, expandedPath, sessionId, t, targetUserMessageId]) const cardLabel = isLatest ? t('chat.turnChangesLatestCardLabel') : t('chat.turnChangesHistoricalCardLabel') const subtitle = isLatest ? t('chat.turnChangesLatestSubtitle') : t('chat.turnChangesHistoricalSubtitle') const undoLabel = isLatest ? t('chat.turnChangesLatestUndo') : t('chat.turnChangesHistoricalUndo') const undoAria = isLatest ? t('chat.turnChangesLatestUndoAria') : t('chat.turnChangesHistoricalUndoAria') return (
{t('chat.turnChangesTitle', { count: files.length })} +{checkpoint.code.insertions} -{checkpoint.code.deletions}
{subtitle}
{files.map((fileEntry) => { const isExpanded = expandedPath === fileEntry.apiPath const diffState = diffByPath[fileEntry.apiPath] return (
{isExpanded && (
{diffState?.loading ? (
{t('chat.turnChangesDiffLoading')}
) : diffState?.error ? (
{diffState.error}
) : diffState?.diff ? ( ) : (
{t('chat.turnChangesDiffUnavailable')}
)}
)}
) })}
{error && (
{error}
)}
) } export function relativizeWorkspacePath(filePath: string, workDir: string | null): string { const normalizedPath = filePath.replace(/\\/g, '/') const isAbsolute = normalizedPath.startsWith('/') || /^[a-zA-Z]:\//.test(normalizedPath) if (!workDir || !isAbsolute) return normalizedPath const normalizedWorkDir = workDir.replace(/\\/g, '/').replace(/\/+$/, '') const comparablePath = normalizedPath.toLowerCase() const comparableWorkDir = normalizedWorkDir.toLowerCase() if (comparablePath === comparableWorkDir) return '' if (comparablePath.startsWith(`${comparableWorkDir}/`)) { return normalizedPath.slice(normalizedWorkDir.length + 1) } return normalizedPath }