feat: add last-turn diff view
Adds a Last turn scope to DiffView that renders OpenCode snapshot diffs from the latest user message summary without re-fetching git contents. The view hides Review in that mode and carries the selected diff scope through main and context-panel navigation. Connects latest-turn changed-file chips in chat to the snapshot diff view on desktop and mobile, while keeping older turn chips static/read-only to avoid misleading affordances and extra subscriptions. Updates localized labels and empty states plus changelog. Validation: bun run type-check (packages/ui); bun run lint (packages/ui).
This commit is contained in:
@@ -752,6 +752,7 @@ const TurnBlock = React.memo(({
|
||||
activityOwnerMessageId,
|
||||
isFirstAssistantInTurn: isFirstAssistant,
|
||||
isLastAssistantInTurn: isLastAssistant,
|
||||
isLatestTurn: isLastTurn,
|
||||
isWorking: isLastTurn && sessionIsWorking && (
|
||||
chatRenderMode === 'sorted'
|
||||
? hasAnchoredActivitySegment
|
||||
|
||||
@@ -4,13 +4,11 @@ import { Popover } from '@base-ui/react/popover';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useIsGitRepo } from '@/stores/useGitStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
||||
import {
|
||||
type ChangedFile,
|
||||
type ChangedFileEntry,
|
||||
FILE_EDIT_TOOLS,
|
||||
extractChangedFiles,
|
||||
isGitFile,
|
||||
toRelativePath,
|
||||
} from './changedFiles';
|
||||
import { ChangedFilesList } from './ChangedFilesList';
|
||||
@@ -18,7 +16,6 @@ import { changedFilesPopoverClassName, changedFilesPopoverStyle } from './change
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { TurnActivityRecord } from './lib/turns/types';
|
||||
import { toAbsoluteFilePath } from '@/lib/path-utils';
|
||||
|
||||
interface TurnChangedFilesDropdownProps {
|
||||
activityParts: TurnActivityRecord[] | undefined;
|
||||
@@ -29,7 +26,6 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||
const triggerButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const currentDirectory = useDirectoryStore((s) => s.currentDirectory);
|
||||
const runtime = React.useContext(RuntimeAPIContext);
|
||||
const isGitRepo = useIsGitRepo(currentDirectory);
|
||||
|
||||
const changedFiles = React.useMemo<ChangedFile[]>(() => {
|
||||
@@ -56,24 +52,16 @@ export const TurnChangedFilesDropdown: React.FC<TurnChangedFilesDropdownProps> =
|
||||
|
||||
const handleOpenFile = (file: ChangedFileEntry) => {
|
||||
if (!currentDirectory) return;
|
||||
if (isGitFile(file)) return;
|
||||
|
||||
const absolutePath = toAbsoluteFilePath(currentDirectory, file.path);
|
||||
|
||||
const editor = runtime?.editor;
|
||||
if (editor) {
|
||||
void editor.openFile(absolutePath);
|
||||
setIsExpanded(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const store = useUIStore.getState();
|
||||
const relativePath = toRelativePath(file.path, currentDirectory);
|
||||
if (!store.isMobile) {
|
||||
store.openContextFile(currentDirectory, absolutePath);
|
||||
store.openContextDiff(currentDirectory, relativePath, false, 'turn');
|
||||
setIsExpanded(false);
|
||||
return;
|
||||
}
|
||||
store.navigateToDiff(toRelativePath(file.path, currentDirectory));
|
||||
|
||||
store.navigateToDiff(relativePath, false, 'turn');
|
||||
store.setRightSidebarOpen(false);
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
@@ -115,6 +115,7 @@ export interface TurnGroupingContext {
|
||||
activityOwnerMessageId?: string;
|
||||
isFirstAssistantInTurn: boolean;
|
||||
isLastAssistantInTurn: boolean;
|
||||
isLatestTurn: boolean;
|
||||
summaryBody?: string;
|
||||
activityParts?: TurnActivityRecord[];
|
||||
activityGroupSegments?: TurnActivityGroup[];
|
||||
|
||||
@@ -65,37 +65,88 @@ const getDisplayFileName = (file: string): string => {
|
||||
return segments.at(-1) ?? file;
|
||||
};
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files }: { files?: TurnChangedFile[] }) => {
|
||||
if (!files || files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const TurnChangedFileChipContent = React.memo(({ file, interactive = false }: { file: TurnChangedFile; interactive?: boolean }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground',
|
||||
interactive && 'transition-colors hover:border-border/60 hover:bg-interactive-hover'
|
||||
)}
|
||||
>
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
));
|
||||
|
||||
const TurnChangedFilePillButton = React.memo(({
|
||||
file,
|
||||
onOpen,
|
||||
}: {
|
||||
file: TurnChangedFile;
|
||||
onOpen: (file: string) => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 max-w-full cursor-pointer items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
aria-label={t('chat.changedFiles.actions.openFileTitle', { path: file.file })}
|
||||
title={file.file}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpen(file.file);
|
||||
}}
|
||||
>
|
||||
<TurnChangedFileChipContent file={file} interactive />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
const StaticTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => (
|
||||
<>
|
||||
{files.map((file) => (
|
||||
<span key={file.file} className="inline-flex h-8 max-w-full items-center" title={file.file}>
|
||||
<TurnChangedFileChipContent file={file} />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
));
|
||||
|
||||
const InteractiveTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => {
|
||||
const effectiveDirectory = useEffectiveDirectory();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
|
||||
const openContextDiff = useUIStore((state) => state.openContextDiff);
|
||||
|
||||
const openLastTurnDiff = React.useCallback((file: string) => {
|
||||
if (!isMobile && effectiveDirectory) {
|
||||
openContextDiff(effectiveDirectory, file, false, 'turn');
|
||||
return;
|
||||
}
|
||||
|
||||
navigateToDiff(file, false, 'turn');
|
||||
}, [effectiveDirectory, isMobile, navigateToDiff, openContextDiff]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<Tooltip key={file.file}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-8 max-w-full items-center">
|
||||
<span className="inline-flex max-w-full items-center gap-1.5 rounded-lg border border-border/30 bg-muted/30 px-2 py-1 text-xs leading-[1.35] text-muted-foreground">
|
||||
<FileTypeIcon filePath={file.file} className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span className="max-w-52 truncate text-foreground/80" title={file.file}>{getDisplayFileName(file.file)}</span>
|
||||
<span className="flex-shrink-0 inline-flex items-center gap-0 typography-meta" style={{ fontSize: '0.8rem', lineHeight: '1' }}>
|
||||
<span style={{ color: 'var(--status-success)' }}>+{file.additions}</span>
|
||||
<span className="text-muted-foreground/70">/</span>
|
||||
<span style={{ color: 'var(--status-error)' }}>-{file.deletions}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{file.file}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{files.map((file) => (
|
||||
<TurnChangedFilePillButton key={file.file} file={file} onOpen={openLastTurnDiff} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
const TurnChangedFilePills = React.memo(({ files, isInteractive }: { files?: TurnChangedFile[]; isInteractive: boolean }) => {
|
||||
if (!files || files.length === 0) return null;
|
||||
|
||||
return isInteractive ? <InteractiveTurnChangedFilePills files={files} /> : <StaticTurnChangedFilePills files={files} />;
|
||||
});
|
||||
|
||||
type SubtaskPartLike = Part & {
|
||||
type: 'subtask';
|
||||
description?: unknown;
|
||||
@@ -2079,7 +2130,10 @@ const AssistantMessageBody = React.memo(({
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilePills files={turnGroupingContext?.changedFiles} />
|
||||
<TurnChangedFilePills
|
||||
files={turnGroupingContext?.changedFiles}
|
||||
isInteractive={turnGroupingContext?.isLatestTurn === true}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -280,6 +280,7 @@ export const areRelevantTurnGroupingContextsEqual = (
|
||||
if (left.turnId !== right.turnId) return false;
|
||||
if (left.isFirstAssistantInTurn !== right.isFirstAssistantInTurn) return false;
|
||||
if (left.isLastAssistantInTurn !== right.isLastAssistantInTurn) return false;
|
||||
if (left.isLatestTurn !== right.isLatestTurn) return false;
|
||||
if (left.isWorking !== right.isWorking) return false;
|
||||
if (left.hasTools !== right.hasTools) return false;
|
||||
if (left.hasReasoning !== right.hasReasoning) return false;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
|
||||
import { useUIStore, type ContextPanelMode, type PendingDiffScope } from '@/stores/useUIStore';
|
||||
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useInputStore } from '@/sync/input-store';
|
||||
@@ -2298,7 +2298,7 @@ export const ContextPanel: React.FC = () => {
|
||||
}
|
||||
}, [tabs]);
|
||||
|
||||
const handleDiffScopeChange = React.useCallback((nextScope: 'working' | 'staged') => {
|
||||
const handleDiffScopeChange = React.useCallback((nextScope: PendingDiffScope) => {
|
||||
if (!directoryKey || activeTab?.mode !== 'diff') {
|
||||
return;
|
||||
}
|
||||
@@ -2307,6 +2307,7 @@ export const ContextPanel: React.FC = () => {
|
||||
mode: 'diff',
|
||||
targetPath: activeTab.targetPath,
|
||||
stagedDiff: nextScope === 'staged',
|
||||
diffScope: nextScope,
|
||||
});
|
||||
}, [activeTab, directoryKey, openContextPanelTab]);
|
||||
|
||||
@@ -2649,7 +2650,7 @@ export const ContextPanel: React.FC = () => {
|
||||
stackedDefaultCollapsedAll
|
||||
pinSelectedFileHeaderToTopOnNavigate
|
||||
showOpenInEditorAction
|
||||
diffScope={tab.stagedDiff ? 'staged' : 'working'}
|
||||
diffScope={tab.diffScope ?? (tab.stagedDiff ? 'staged' : 'working')}
|
||||
onDiffScopeChange={handleDiffScopeChange}
|
||||
targetFilePath={tab.targetPath}
|
||||
flushContent
|
||||
|
||||
@@ -39,6 +39,7 @@ import { fileDiffFromPatch } from '@/lib/diff/patchFileDiff';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { startReviewFlow } from '@/lib/reviewFlow';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
import { getFirstChangedModifiedLineFromPatch } from './diffPatchUtils';
|
||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||
|
||||
@@ -74,7 +75,17 @@ type DiffData = {
|
||||
fileDiff?: FileDiffMetadata;
|
||||
contextMode?: DiffContextMode;
|
||||
};
|
||||
type DiffScope = 'all' | 'staged' | 'working';
|
||||
type DiffScope = 'all' | 'staged' | 'working' | 'turn';
|
||||
|
||||
type TurnSnapshotDiff = {
|
||||
file?: string;
|
||||
status?: string;
|
||||
before?: string;
|
||||
after?: string;
|
||||
patch?: string;
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
};
|
||||
|
||||
const BinaryDiffPlaceholder = React.memo(() => {
|
||||
const { t } = useI18n();
|
||||
@@ -164,6 +175,20 @@ const getFirstChangedModifiedLine = (original: string, modified: string): number
|
||||
const isBinaryPatch = (patch: string): boolean =>
|
||||
/^Binary files .+ differ$/m.test(patch) || /^GIT binary patch$/m.test(patch);
|
||||
|
||||
const listTurnDiffs = (value: unknown): TurnSnapshotDiff[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter((diff): diff is TurnSnapshotDiff => {
|
||||
if (!diff || typeof diff !== 'object') return false;
|
||||
return typeof (diff as TurnSnapshotDiff).file === 'string';
|
||||
});
|
||||
};
|
||||
|
||||
const statusToGitCode = (status?: string): string => {
|
||||
if (status === 'added') return 'A';
|
||||
if (status === 'deleted') return 'D';
|
||||
return 'M';
|
||||
};
|
||||
|
||||
const createTextDiffDataFromPatch = (filePath: string, patch: string, contextMode: DiffContextMode): DiffData => {
|
||||
if (isBinaryPatch(patch)) {
|
||||
return { original: '', modified: '', isBinary: true, patch, contextMode };
|
||||
@@ -201,22 +226,28 @@ const formatDiffTotals = (
|
||||
};
|
||||
|
||||
interface ChangeScopeSelectorProps {
|
||||
scope: Extract<DiffScope, 'working' | 'staged'>;
|
||||
scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>;
|
||||
workingCount: number;
|
||||
stagedCount: number;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged'>) => void;
|
||||
turnCount: number;
|
||||
onScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
}
|
||||
|
||||
const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
scope,
|
||||
workingCount,
|
||||
stagedCount,
|
||||
turnCount,
|
||||
onScopeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const currentCount = scope === 'staged' ? stagedCount : workingCount;
|
||||
const currentLabel = scope === 'staged' ? t('diffView.scope.staged') : t('diffView.scope.changed');
|
||||
const currentCount = scope === 'staged' ? stagedCount : scope === 'turn' ? turnCount : workingCount;
|
||||
const currentLabel = scope === 'staged'
|
||||
? t('diffView.scope.staged')
|
||||
: scope === 'turn'
|
||||
? t('diffView.scope.lastTurn')
|
||||
: t('diffView.scope.changed');
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -236,7 +267,7 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<DropdownMenuRadioGroup
|
||||
value={scope}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'working' || value === 'staged') {
|
||||
if (value === 'working' || value === 'staged' || value === 'turn') {
|
||||
onScopeChange?.(value);
|
||||
setOpen(false);
|
||||
}
|
||||
@@ -254,6 +285,12 @@ const ChangeScopeSelector = React.memo<ChangeScopeSelectorProps>(({
|
||||
<span className="typography-meta text-muted-foreground">{stagedCount}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="turn">
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span>{t('diffView.scope.lastTurn')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{turnCount}</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -532,6 +569,7 @@ interface MultiFileDiffEntryProps {
|
||||
onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void;
|
||||
staged?: boolean;
|
||||
loadFullFiles?: boolean;
|
||||
initialDiffData?: DiffData | null;
|
||||
}
|
||||
|
||||
const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
@@ -550,6 +588,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
onOpenInEditor,
|
||||
staged = false,
|
||||
loadFullFiles = false,
|
||||
initialDiffData = null,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { git } = useRuntimeAPIs();
|
||||
@@ -578,11 +617,12 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`;
|
||||
|
||||
const diffData = React.useMemo<DiffData | null>(() => {
|
||||
if (initialDiffData) return initialDiffData;
|
||||
if (staged) return stagedDiffData;
|
||||
if (localDiffData) return localDiffData;
|
||||
if (!cachedDiff) return null;
|
||||
return { original: cachedDiff.original, modified: cachedDiff.modified, isBinary: cachedDiff.isBinary, contextMode: 'full' };
|
||||
}, [cachedDiff, localDiffData, staged, stagedDiffData]);
|
||||
}, [cachedDiff, initialDiffData, localDiffData, staged, stagedDiffData]);
|
||||
|
||||
const diffDataMatchesContextMode = diffData?.contextMode === desiredContextMode;
|
||||
|
||||
@@ -612,7 +652,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isExpanded || !isMounted) return;
|
||||
if (!directory || (diffData && diffDataMatchesContextMode)) {
|
||||
if (!directory || initialDiffData || (diffData && diffDataMatchesContextMode)) {
|
||||
lastDiffRequestRef.current = null;
|
||||
setIsLoading(false);
|
||||
return;
|
||||
@@ -675,7 +715,7 @@ const MultiFileDiffEntry = React.memo<MultiFileDiffEntryProps>(({
|
||||
lastDiffRequestRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
||||
}, [desiredContextMode, diffData, diffDataMatchesContextMode, diffRetryNonce, directory, file.path, fileStatusKey, git, initialDiffData, isExpanded, isMounted, loadFullFiles, setDiff, staged]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
handleOpenChange(!isExpanded);
|
||||
@@ -900,7 +940,7 @@ interface DiffViewProps {
|
||||
pinSelectedFileHeaderToTopOnNavigate?: boolean;
|
||||
showOpenInEditorAction?: boolean;
|
||||
diffScope?: DiffScope;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged'>) => void;
|
||||
onDiffScopeChange?: (scope: Extract<DiffScope, 'working' | 'staged' | 'turn'>) => void;
|
||||
targetFilePath?: string | null;
|
||||
/** Render diff content flush with the container edges (no outer padding). */
|
||||
flushContent?: boolean;
|
||||
@@ -937,9 +977,15 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const [scrollRequestNonce, setScrollRequestNonce] = React.useState(0);
|
||||
const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false);
|
||||
const [reviewFlowSubmitting, setReviewFlowSubmitting] = React.useState(false);
|
||||
const [activeDiffScope, setActiveDiffScope] = React.useState(diffScope);
|
||||
|
||||
React.useEffect(() => {
|
||||
setActiveDiffScope(diffScope);
|
||||
}, [diffScope]);
|
||||
|
||||
const pendingDiffFile = useUIStore((state) => state.pendingDiffFile);
|
||||
const pendingDiffStaged = useUIStore((state) => state.pendingDiffStaged);
|
||||
const pendingDiffScope = useUIStore((state) => state.pendingDiffScope);
|
||||
const setPendingDiffFile = useUIStore((state) => state.setPendingDiffFile);
|
||||
const diffLayoutPreference = useUIStore((state) => state.diffLayoutPreference);
|
||||
const diffFileLayout = useUIStore((state) => state.diffFileLayout);
|
||||
@@ -948,12 +994,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines);
|
||||
const openContextFileAtLine = useUIStore((state) => state.openContextFileAtLine);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const sessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory ?? undefined);
|
||||
const diffWrapLines = diffWrapLinesStore;
|
||||
const forcedStaged = diffScope === 'staged' ? true : diffScope === 'working' ? false : null;
|
||||
const forcedStaged = activeDiffScope === 'staged' ? true : activeDiffScope === 'working' ? false : null;
|
||||
const activeDiffStaged = forcedStaged ?? displayFileStaged;
|
||||
|
||||
const isMobileLayout = isMobile || screenWidth <= 768;
|
||||
const showReviewAction = Boolean(currentSessionId) && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showReviewAction = Boolean(currentSessionId) && activeDiffScope !== 'turn' && !isMobileLayout && !isVSCodeRuntime();
|
||||
const showFileSidebar = !hideStackedFileSidebar && !isMobileLayout && screenWidth >= 1024;
|
||||
const diffScrollRef = React.useRef<HTMLElement | null>(null);
|
||||
const fileSectionRefs = React.useRef(new Map<string, HTMLDivElement | null>());
|
||||
@@ -984,12 +1031,52 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const lastTurnDiffs = React.useMemo(() => {
|
||||
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
||||
const message = sessionMessages[index] as { role?: string; summary?: { diffs?: unknown } };
|
||||
if (message.role !== 'user') continue;
|
||||
return listTurnDiffs(message.summary?.diffs);
|
||||
}
|
||||
return [];
|
||||
}, [sessionMessages]);
|
||||
|
||||
const lastTurnDiffData = React.useMemo(() => {
|
||||
const map = new Map<string, DiffData>();
|
||||
for (const diff of lastTurnDiffs) {
|
||||
if (!diff.file) continue;
|
||||
if (typeof diff.patch === 'string') {
|
||||
map.set(diff.file, createTextDiffDataFromPatch(diff.file, diff.patch, 'patch'));
|
||||
continue;
|
||||
}
|
||||
map.set(diff.file, {
|
||||
original: diff.before ?? '',
|
||||
modified: diff.after ?? '',
|
||||
contextMode: 'full',
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [lastTurnDiffs]);
|
||||
|
||||
const changedFiles: FileEntry[] = React.useMemo(() => {
|
||||
if (activeDiffScope === 'turn') {
|
||||
return lastTurnDiffs
|
||||
.map((diff) => ({
|
||||
path: diff.file ?? '',
|
||||
index: '',
|
||||
working_dir: statusToGitCode(diff.status),
|
||||
insertions: diff.additions ?? 0,
|
||||
deletions: diff.deletions ?? 0,
|
||||
isNew: diff.status === 'added',
|
||||
}))
|
||||
.filter((file) => file.path)
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
if (!status?.files) return [];
|
||||
const diffStats = status.diffStats ?? {};
|
||||
const includeFile = diffScope === 'staged'
|
||||
const includeFile = activeDiffScope === 'staged'
|
||||
? isStagedStatusFile
|
||||
: diffScope === 'working'
|
||||
: activeDiffScope === 'working'
|
||||
? isWorkingStatusFile
|
||||
: () => true;
|
||||
|
||||
@@ -1002,7 +1089,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
isNew: isNewStatusFile(file),
|
||||
}))
|
||||
.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [diffScope, status]);
|
||||
}, [activeDiffScope, lastTurnDiffs, status]);
|
||||
|
||||
const workingFileCount = React.useMemo(() => {
|
||||
if (!status?.files) return 0;
|
||||
@@ -1014,6 +1101,8 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
return status.files.filter(isStagedStatusFile).length;
|
||||
}, [status]);
|
||||
|
||||
const turnFileCount = lastTurnDiffs.length;
|
||||
|
||||
const changedFilePathsKey = React.useMemo(
|
||||
() => changedFiles.map((file) => file.path).join('\0'),
|
||||
[changedFiles],
|
||||
@@ -1022,7 +1111,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
React.useEffect(() => {
|
||||
const paths = changedFilePathsKey ? changedFilePathsKey.split('\0') : [];
|
||||
const pathSet = new Set(paths);
|
||||
const scopeKey = `${effectiveDirectory ?? ''}:${diffScope}:${stackedDefaultCollapsedAll ? 'collapsed' : 'default'}`;
|
||||
const scopeKey = `${effectiveDirectory ?? ''}:${activeDiffScope}:${stackedDefaultCollapsedAll ? 'collapsed' : 'default'}`;
|
||||
const shouldInitialize = stackedStateScopeRef.current !== scopeKey;
|
||||
stackedStateScopeRef.current = scopeKey;
|
||||
|
||||
@@ -1062,7 +1151,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
return changed ? next : previous;
|
||||
});
|
||||
}, [changedFilePathsKey, diffScope, effectiveDirectory, stackedDefaultCollapsedAll]);
|
||||
}, [activeDiffScope, changedFilePathsKey, effectiveDirectory, stackedDefaultCollapsedAll]);
|
||||
|
||||
const syncVisibleStackedFiles = React.useCallback(() => {
|
||||
visibleSyncFrameRef.current = null;
|
||||
@@ -1167,23 +1256,26 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
|
||||
// Handle pending diff file from external navigation
|
||||
React.useEffect(() => {
|
||||
if (diffScope !== 'all') {
|
||||
if (activeDiffScope !== 'all' && !pendingDiffScope) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingDiffFile) {
|
||||
if (pendingDiffScope) {
|
||||
setActiveDiffScope(pendingDiffScope);
|
||||
}
|
||||
setDisplayFile(pendingDiffFile);
|
||||
setDisplayFileStaged(pendingDiffStaged);
|
||||
setDisplayFileStaged(pendingDiffScope === 'staged' || (!pendingDiffScope && pendingDiffStaged));
|
||||
setPendingDiffFile(null);
|
||||
shouldPinAfterAlignRef.current = true;
|
||||
pendingScrollTargetRef.current = pendingDiffFile;
|
||||
expandStackedFile(pendingDiffFile);
|
||||
setScrollRequestNonce((value) => value + 1);
|
||||
}
|
||||
}, [diffScope, expandStackedFile, pendingDiffFile, pendingDiffStaged, setPendingDiffFile]);
|
||||
}, [activeDiffScope, expandStackedFile, pendingDiffFile, pendingDiffScope, pendingDiffStaged, setPendingDiffFile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (diffScope === 'all') {
|
||||
if (activeDiffScope === 'all') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1193,13 +1285,13 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
}
|
||||
|
||||
setDisplayFile(normalizedTarget);
|
||||
setDisplayFileStaged(diffScope === 'staged');
|
||||
setDisplayFileStaged(activeDiffScope === 'staged');
|
||||
|
||||
shouldPinAfterAlignRef.current = true;
|
||||
pendingScrollTargetRef.current = normalizedTarget;
|
||||
expandStackedFile(normalizedTarget);
|
||||
setScrollRequestNonce((value) => value + 1);
|
||||
}, [diffScope, expandStackedFile, targetFilePath]);
|
||||
}, [activeDiffScope, expandStackedFile, targetFilePath]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!displayFile) {
|
||||
@@ -1508,13 +1600,14 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
onSelect={handleSelectFile}
|
||||
onExpandedChange={handleStackedEntryExpandedChange}
|
||||
registerSectionRef={registerSectionRef}
|
||||
showOpenInEditorAction={showOpenInEditorAction}
|
||||
showOpenInEditorAction={showOpenInEditorAction && activeDiffScope !== 'turn'}
|
||||
isOpeningInEditor={openingEditorFilePath === file.path}
|
||||
onOpenInEditor={(filePath, diffData) => {
|
||||
void openFileInEditorAtChange(filePath, diffData);
|
||||
}}
|
||||
staged={getFileStaged(file.path)}
|
||||
loadFullFiles={loadFullFiles}
|
||||
initialDiffData={activeDiffScope === 'turn' ? lastTurnDiffData.get(file.path) ?? null : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1534,7 +1627,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingStatus && !status) {
|
||||
if (activeDiffScope !== 'turn' && isLoadingStatus && !status) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" />
|
||||
@@ -1543,7 +1636,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (isGitRepo === false) {
|
||||
if (activeDiffScope !== 'turn' && isGitRepo === false) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('diffView.state.notGitRepository')}
|
||||
@@ -1554,7 +1647,7 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
if (changedFiles.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('diffView.state.cleanWorkingTree')}
|
||||
{activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1566,12 +1659,16 @@ export const DiffView: React.FC<DiffViewProps> = ({
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
<div className="@container/diff-toolbar flex min-w-0 items-center gap-2 px-3 py-2 bg-background">
|
||||
{!isMobile && (
|
||||
diffScope === 'working' || diffScope === 'staged' ? (
|
||||
activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? (
|
||||
<ChangeScopeSelector
|
||||
scope={diffScope}
|
||||
scope={activeDiffScope}
|
||||
workingCount={workingFileCount}
|
||||
stagedCount={stagedFileCount}
|
||||
onScopeChange={onDiffScopeChange}
|
||||
turnCount={turnFileCount}
|
||||
onScopeChange={(scope) => {
|
||||
setActiveDiffScope(scope);
|
||||
onDiffScopeChange?.(scope);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 rounded-md px-2 py-1 text-muted-foreground shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user