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:
Bohdan Triapitsyn
2026-07-07 14:44:20 +03:00
parent 1c44146a4f
commit 292e78f067
19 changed files with 269 additions and 84 deletions
+2
View File
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- Diff/Chat: added a Last turn mode to the Diff view, and latest-turn changed-file chips in chat now open that snapshot while older turn chips stay read-only.
## [1.14.1] - 2026-07-07
- Chat: finished agent replies can now show a short recap and a suggested next message, with separate settings for each and a Small Model setting for choosing the utility model used for those helpers.
@@ -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
+127 -30
View File
@@ -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">
+2
View File
@@ -1254,6 +1254,7 @@ export const dict = {
'diffView.state.loadingRepositoryStatus': 'Loading repository status...',
'diffView.state.notGitRepository': 'Not a git repository. Use the Git tab to initialize or change directories.',
'diffView.state.cleanWorkingTree': 'Working tree clean, no changes to display',
'diffView.state.noLastTurnChanges': 'No last turn changes to display',
'diffView.state.failedToLoadDiff': 'Failed to load diff',
'diffView.state.loadingDiff': 'Loading diff...',
'diffView.state.loadingChanges': 'Loading changes...',
@@ -1263,6 +1264,7 @@ export const dict = {
'diffView.summary.changedFilesPlural': '{count} files changed',
'diffView.scope.changed': 'Changed',
'diffView.scope.staged': 'Staged',
'diffView.scope.lastTurn': 'Last turn',
'diffView.scope.selectorAria': 'Select change mode',
'diffView.actions.retry': 'Retry',
'diffView.actions.renderAnyway': 'Render anyway',
+2
View File
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
"diffView.state.loadingRepositoryStatus": "Cargando estado del repositorio...",
"diffView.state.notGitRepository": "No es un repositorio Git. Usa la pestaña Git para inicializar o cambiar de directorio.",
"diffView.state.cleanWorkingTree": "Worktree limpio, no hay cambios para mostrar",
"diffView.state.noLastTurnChanges": "No hay cambios del último turno para mostrar",
"diffView.state.failedToLoadDiff": "No se pudo cargar la diferencia",
"diffView.state.loadingDiff": "Cargando diff...",
"diffView.state.loadingChanges": "Cargando cambios...",
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
"diffView.summary.changedFilesPlural": "{count} archivos modificados",
"diffView.scope.changed": "Cambiados",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Último turno",
"diffView.scope.selectorAria": "Seleccionar modo de cambios",
"diffView.actions.retry": "Volver a intentar",
"diffView.actions.renderAnyway": "Renderizar de todos modos",
+2
View File
@@ -1088,6 +1088,7 @@ export const dict = {
'diffView.state.loadingRepositoryStatus': 'Chargement de l\'état du dépôt...',
'diffView.state.notGitRepository': 'Pas un dépôt git. Utilisez l\'onglet Git pour initialiser ou modifier des répertoires.',
'diffView.state.cleanWorkingTree': 'Worktree sans modifications, rien à afficher',
'diffView.state.noLastTurnChanges': 'Aucun changement du dernier tour à afficher',
'diffView.state.failedToLoadDiff': 'Échec du chargement du différentiel',
'diffView.state.loadingDiff': 'Chargement du différentiel...',
'diffView.state.loadingChanges': 'Chargement des modifications...',
@@ -1097,6 +1098,7 @@ export const dict = {
'diffView.summary.changedFilesPlural': 'Fichiers {count} modifiés',
"diffView.scope.changed": "Modifiés",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Dernier tour",
"diffView.scope.selectorAria": "Sélectionner le mode de changements",
'diffView.actions.retry': 'Réessayer',
'diffView.actions.renderAnyway': 'Afficher quand même',
+2
View File
@@ -1250,6 +1250,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.state.loadingRepositoryStatus': 'リポジトリステータスを読み込み中...',
'diffView.state.notGitRepository': 'Gitリポジトリではありません。Gitタブを使用して初期化するかディレクトリを変更してください。',
'diffView.state.cleanWorkingTree': 'ワーキングツリーはクリーンで、表示する変更はありません',
'diffView.state.noLastTurnChanges': '表示する最後のターンの変更はありません',
'diffView.state.failedToLoadDiff': '差分の読み込みに失敗しました',
'diffView.state.loadingDiff': '差分を読み込み中...',
'diffView.state.loadingChanges': '変更を読み込み中...',
@@ -1259,6 +1260,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.summary.changedFilesPlural': '{count}ファイルが変更されました',
'diffView.scope.changed': '変更済み',
'diffView.scope.staged': 'ステージ済み',
'diffView.scope.lastTurn': '最後のターン',
'diffView.scope.selectorAria': '変更モードを選択',
'diffView.actions.retry': '再試行',
'diffView.actions.renderAnyway': 'とにかくレンダリング',
+2
View File
@@ -1257,6 +1257,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.state.loadingRepositoryStatus': '레포지토리 상태 로드 중…',
'diffView.state.notGitRepository': 'Git 레포지토리가 아닙니다. Git 탭에서 초기화하거나 디렉터리를 변경하세요.',
'diffView.state.cleanWorkingTree': '워킹 트리가 깨끗합니다. 표시할 변경 사항이 없습니다.',
'diffView.state.noLastTurnChanges': '표시할 마지막 턴 변경 사항이 없습니다.',
'diffView.state.failedToLoadDiff': '변경사항을 불러오지 못했습니다',
'diffView.state.loadingDiff': '변경사항 불러오는 중…',
'diffView.state.loadingChanges': '변경 사항 로드 중…',
@@ -1266,6 +1267,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.summary.changedFilesPlural': '파일 {count}개 변경됨',
"diffView.scope.changed": "Changed",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "마지막 턴",
"diffView.scope.selectorAria": "변경 모드 선택",
'diffView.actions.retry': '다시 시도',
'diffView.actions.renderAnyway': '그래도 렌더링',
+2
View File
@@ -1537,6 +1537,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.section.files': 'Pliki',
'diffView.selector.selectFile': 'Wybierz plik',
'diffView.state.cleanWorkingTree': 'Drzewo robocze jest czyste, brak zmian do wyświetlenia',
'diffView.state.noLastTurnChanges': 'Brak zmian z ostatniej tury do wyświetlenia',
'diffView.state.failedToLoadDiff': 'Nie udało się wczytać diffu',
'diffView.state.largeDiff': 'Duży diff ({count} zmienionych linii)',
'diffView.state.largeDiffDescription': 'Renderowanie może być wolne. Nadal możesz wyświetlić diff przyciskiem poniżej.',
@@ -1548,6 +1549,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.summary.changedFilesPlural': 'Zmieniono {count} plików',
"diffView.scope.changed": "Zmienione",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Ostatnia tura",
"diffView.scope.selectorAria": "Wybierz tryb zmian",
'diffView.summary.changedFilesSingle': 'Zmieniono {count} plik',
'directoryExplorerDialog.actions.addProject': 'Dodaj projekt',
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
"diffView.state.loadingRepositoryStatus": "Carregando status do repositório...",
"diffView.state.notGitRepository": "Não é um repositório Git. Use a aba Git para inicializar ou mudar de diretório.",
"diffView.state.cleanWorkingTree": "Worktree limpo, não há alterações para mostrar",
"diffView.state.noLastTurnChanges": "Não há alterações do último turno para exibir",
"diffView.state.failedToLoadDiff": "Não foi possível carregar a diferencia",
"diffView.state.loadingDiff": "Carregando diff...",
"diffView.state.loadingChanges": "Carregando alterações...",
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
"diffView.summary.changedFilesPlural": "{count} arquivos modificados",
"diffView.scope.changed": "Alteradas",
"diffView.scope.staged": "Staged",
"diffView.scope.lastTurn": "Último turno",
"diffView.scope.selectorAria": "Selecionar modo de alterações",
"diffView.actions.retry": "Tentar novamente",
"diffView.actions.renderAnyway": "Renderizar mesmo assim",
+2
View File
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
"diffView.state.loadingRepositoryStatus": "Завантаження статусу сховища...",
"diffView.state.notGitRepository": "Це не Git-репозиторій. Скористайтеся вкладкою Git, щоб ініціалізувати або змінити каталог.",
"diffView.state.cleanWorkingTree": "Worktree чистий, без змін для відображення",
"diffView.state.noLastTurnChanges": "Немає змін останнього ходу для відображення",
"diffView.state.failedToLoadDiff": "Не вдалося завантажити diff",
"diffView.state.loadingDiff": "Завантаження diff...",
"diffView.state.loadingChanges": "Завантаження змін...",
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
"diffView.summary.changedFilesPlural": "Змінено файлів: {count}",
"diffView.scope.changed": "Змінені",
"diffView.scope.staged": "Індексовані",
"diffView.scope.lastTurn": "Останній хід",
"diffView.scope.selectorAria": "Вибрати режим змін",
"diffView.actions.retry": "Повторити спробу",
"diffView.actions.renderAnyway": "Все одно відрендерити",
@@ -1220,6 +1220,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.state.loadingRepositoryStatus': '正在加载仓库状态...',
'diffView.state.notGitRepository': '当前不是 Git 仓库。请在 Git 标签中初始化或切换目录。',
'diffView.state.cleanWorkingTree': '工作区干净,没有可显示的改动',
'diffView.state.noLastTurnChanges': '没有可显示的上一轮更改',
'diffView.state.failedToLoadDiff': '加载差异失败',
'diffView.state.loadingDiff': '正在加载差异...',
'diffView.state.loadingChanges': '正在加载变更...',
@@ -1229,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.summary.changedFilesPlural': '{count} 个文件已变更',
"diffView.scope.changed": "已更改",
"diffView.scope.staged": "已暂存",
"diffView.scope.lastTurn": "上一轮",
"diffView.scope.selectorAria": "选择更改模式",
'diffView.actions.retry': '重试',
'diffView.actions.renderAnyway': '仍然渲染',
@@ -1230,6 +1230,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.state.loadingRepositoryStatus': '正在載入儲存庫狀態...',
'diffView.state.notGitRepository': '目前不是 Git 儲存庫。請在 Git 標籤中初始化或切換目錄。',
'diffView.state.cleanWorkingTree': '工作區乾淨,沒有可顯示的改動',
'diffView.state.noLastTurnChanges': '沒有可顯示的上一輪變更',
'diffView.state.failedToLoadDiff': '載入差異失敗',
'diffView.state.loadingDiff': '正在載入差異...',
'diffView.state.loadingChanges': '正在載入變更...',
@@ -1239,6 +1240,7 @@ export const dict: Record<I18nKey, string> = {
'diffView.summary.changedFilesPlural': '{count} 個檔案已變更',
"diffView.scope.changed": "已變更",
"diffView.scope.staged": "已暫存",
"diffView.scope.lastTurn": "上一輪",
"diffView.scope.selectorAria": "選擇變更模式",
'diffView.actions.retry': '重試',
'diffView.actions.renderAnyway': '仍然渲染',
+30 -10
View File
@@ -10,6 +10,7 @@ import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobi
import { getRuntimeKey } from '@/lib/runtime-switch';
export type MainTab = 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files' | 'context' | 'diagram';
export type PendingDiffScope = 'working' | 'staged' | 'turn';
export type RightSidebarTab = 'git' | 'files' | 'context';
export type ContextPanelMode = 'diff' | 'file' | 'context' | 'plan' | 'chat' | 'preview' | 'browser';
export type MermaidRenderingMode = 'svg' | 'ascii';
@@ -34,6 +35,7 @@ type ContextPanelTab = {
sessionTitleFallback: string | null;
readOnly: boolean;
stagedDiff: boolean;
diffScope: PendingDiffScope | null;
touchedAt: number;
};
@@ -45,6 +47,7 @@ type ContextPanelTabDescriptor = {
sessionTitleFallback?: string | null;
readOnly?: boolean;
stagedDiff?: boolean;
diffScope?: PendingDiffScope | null;
};
type ContextPanelDirectoryState = {
@@ -177,6 +180,10 @@ const normalizeContextTabLabel = (value: string | null | undefined): string | nu
: trimmed;
};
const normalizePendingDiffScope = (value: unknown): PendingDiffScope | null => {
return value === 'working' || value === 'staged' || value === 'turn' ? value : null;
};
const buildDefaultContextPanelTabDedupeKey = (mode: ContextPanelMode, targetPath: string | null): string => {
if (mode === 'file') {
return targetPath || mode;
@@ -228,6 +235,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
sessionTitleFallback: normalizeContextTabLabel(descriptor.sessionTitleFallback),
readOnly: descriptor.readOnly === true,
stagedDiff: descriptor.stagedDiff === true,
diffScope: normalizePendingDiffScope(descriptor.diffScope) ?? (descriptor.stagedDiff === true ? 'staged' : 'working'),
touchedAt: Date.now(),
};
};
@@ -269,6 +277,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
sessionTitleFallback?: unknown;
readOnly?: unknown;
stagedDiff?: unknown;
diffScope?: unknown;
touchedAt?: unknown;
};
@@ -297,6 +306,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
sessionTitleFallback: normalizeContextTabLabel(typeof candidate.sessionTitleFallback === 'string' ? candidate.sessionTitleFallback : null),
readOnly: candidate.readOnly === true,
stagedDiff: candidate.stagedDiff === true,
diffScope: normalizePendingDiffScope(candidate.diffScope) ?? (candidate.stagedDiff === true ? 'staged' : 'working'),
touchedAt: typeof candidate.touchedAt === 'number' && Number.isFinite(candidate.touchedAt)
? candidate.touchedAt
: Date.now(),
@@ -357,6 +367,7 @@ const upsertContextPanelTab = (
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
@@ -536,6 +547,7 @@ interface UIStore {
sidebarOpenBeforeFullscreenTab: boolean | null;
pendingDiffFile: string | null;
pendingDiffStaged: boolean;
pendingDiffScope: PendingDiffScope | null;
pendingDiagramFile: string | null;
pendingFileNavigation: PendingFileNavigation | null;
pendingFileFocusPath: string | null;
@@ -656,7 +668,7 @@ interface UIStore {
setRightSidebarWidth: (width: number) => void;
setRightSidebarTab: (tab: RightSidebarTab) => void;
openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean) => void;
openContextDiff: (directory: string, filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
openContextFile: (directory: string, filePath: string) => void;
openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void;
openContextOverview: (directory: string) => void;
@@ -682,11 +694,11 @@ interface UIStore {
prepareForRuntimeSwitch: (runtimeKey?: string | null) => void;
restoreForRuntimeSwitch: (runtimeKey?: string | null) => void;
setMainTabGuard: (guard: MainTabGuard | null) => void;
setPendingDiffFile: (filePath: string | null, staged?: boolean) => void;
setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void;
setPendingDiagramFile: (filePath: string | null) => void;
setPendingFileNavigation: (navigation: PendingFileNavigation | null) => void;
setPendingFileFocusPath: (path: string | null) => void;
navigateToDiff: (filePath: string, staged?: boolean) => void;
navigateToDiff: (filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void;
consumePendingDiffFile: () => string | null;
navigateToDiagram: (filePath: string) => void;
consumePendingDiagramFile: () => string | null;
@@ -833,6 +845,7 @@ export const useUIStore = create<UIStore>()(
sidebarOpenBeforeFullscreenTab: null,
pendingDiffFile: null,
pendingDiffStaged: false,
pendingDiffScope: null,
pendingDiagramFile: null,
pendingFileNavigation: null,
pendingFileFocusPath: null,
@@ -1042,17 +1055,20 @@ export const useUIStore = create<UIStore>()(
});
},
openContextDiff: (directory, filePath, staged = false) => {
openContextDiff: (directory, filePath, staged = false, scope = null) => {
const normalizedDirectory = normalizeDirectoryPath((directory || '').trim());
const normalizedFilePath = (filePath || '').trim();
if (!normalizedDirectory || !normalizedFilePath) {
return;
}
const diffScope = normalizePendingDiffScope(scope) ?? (staged ? 'staged' : 'working');
get().openContextPanelTab(normalizedDirectory, {
mode: 'diff',
targetPath: normalizedFilePath,
stagedDiff: staged,
stagedDiff: diffScope === 'staged',
diffScope,
});
},
@@ -1414,8 +1430,12 @@ export const useUIStore = create<UIStore>()(
set({ activeMainTab: restored });
},
setPendingDiffFile: (filePath, staged = false) => {
set({ pendingDiffFile: filePath, pendingDiffStaged: filePath ? staged : false });
setPendingDiffFile: (filePath, staged = false, scope = null) => {
set({
pendingDiffFile: filePath,
pendingDiffStaged: filePath ? staged : false,
pendingDiffScope: filePath ? scope : null,
});
},
setPendingDiagramFile: (filePath) => {
@@ -1430,18 +1450,18 @@ export const useUIStore = create<UIStore>()(
set({ pendingFileFocusPath: path });
},
navigateToDiff: (filePath, staged = false) => {
navigateToDiff: (filePath, staged = false, scope = null) => {
const guard = get().mainTabGuard;
if (guard && !guard('diff')) {
return;
}
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, activeMainTab: 'diff' });
set({ pendingDiffFile: filePath, pendingDiffStaged: staged, pendingDiffScope: scope, activeMainTab: 'diff' });
},
consumePendingDiffFile: () => {
const { pendingDiffFile } = get();
if (pendingDiffFile) {
set({ pendingDiffFile: null, pendingDiffStaged: false });
set({ pendingDiffFile: null, pendingDiffStaged: false, pendingDiffScope: null });
}
return pendingDiffFile;
},