diff --git a/CHANGELOG.md b/CHANGELOG.md index eb224271..03084e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index dbac2f50..746c3c05 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -752,6 +752,7 @@ const TurnBlock = React.memo(({ activityOwnerMessageId, isFirstAssistantInTurn: isFirstAssistant, isLastAssistantInTurn: isLastAssistant, + isLatestTurn: isLastTurn, isWorking: isLastTurn && sessionIsWorking && ( chatRenderMode === 'sorted' ? hasAnchoredActivitySegment diff --git a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx index e1b4a72f..370f4f58 100644 --- a/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx +++ b/packages/ui/src/components/chat/TurnChangedFilesDropdown.tsx @@ -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 = const [portalContainer, setPortalContainer] = React.useState(null); const triggerButtonRef = React.useRef(null); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); - const runtime = React.useContext(RuntimeAPIContext); const isGitRepo = useIsGitRepo(currentDirectory); const changedFiles = React.useMemo(() => { @@ -56,24 +52,16 @@ export const TurnChangedFilesDropdown: React.FC = 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); }; diff --git a/packages/ui/src/components/chat/lib/turns/types.ts b/packages/ui/src/components/chat/lib/turns/types.ts index 12b3ff52..1084c30b 100644 --- a/packages/ui/src/components/chat/lib/turns/types.ts +++ b/packages/ui/src/components/chat/lib/turns/types.ts @@ -115,6 +115,7 @@ export interface TurnGroupingContext { activityOwnerMessageId?: string; isFirstAssistantInTurn: boolean; isLastAssistantInTurn: boolean; + isLatestTurn: boolean; summaryBody?: string; activityParts?: TurnActivityRecord[]; activityGroupSegments?: TurnActivityGroup[]; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index e2e36000..00d9969c 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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 }) => ( + + + {getDisplayFileName(file.file)} + + +{file.additions} + / + -{file.deletions} + + +)); + +const TurnChangedFilePillButton = React.memo(({ + file, + onOpen, +}: { + file: TurnChangedFile; + onOpen: (file: string) => void; +}) => { + const { t } = useI18n(); + return ( + + ); +}); + +const StaticTurnChangedFilePills = React.memo(({ files }: { files: TurnChangedFile[] }) => ( + <> + {files.map((file) => ( + + + + ))} + +)); + +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 ( - - - - - - {getDisplayFileName(file.file)} - - +{file.additions} - / - -{file.deletions} - - - - - {file.file} - - ); - })} + {files.map((file) => ( + + ))} ); }); +const TurnChangedFilePills = React.memo(({ files, isInteractive }: { files?: TurnChangedFile[]; isInteractive: boolean }) => { + if (!files || files.length === 0) return null; + + return isInteractive ? : ; +}); + type SubtaskPartLike = Part & { type: 'subtask'; description?: unknown; @@ -2079,7 +2130,10 @@ const AssistantMessageBody = React.memo(({ ) : null} {!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? ( - + ) : null} )} diff --git a/packages/ui/src/components/chat/message/renderCompare.ts b/packages/ui/src/components/chat/message/renderCompare.ts index c65708ee..ccd8768a 100644 --- a/packages/ui/src/components/chat/message/renderCompare.ts +++ b/packages/ui/src/components/chat/message/renderCompare.ts @@ -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; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 28644e82..1a395639 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -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 diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index c1447a67..473711d4 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -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; + scope: Extract; workingCount: number; stagedCount: number; - onScopeChange?: (scope: Extract) => void; + turnCount: number; + onScopeChange?: (scope: Extract) => void; } const ChangeScopeSelector = React.memo(({ 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 ( @@ -236,7 +267,7 @@ const ChangeScopeSelector = React.memo(({ { - if (value === 'working' || value === 'staged') { + if (value === 'working' || value === 'staged' || value === 'turn') { onScopeChange?.(value); setOpen(false); } @@ -254,6 +285,12 @@ const ChangeScopeSelector = React.memo(({ {stagedCount} + + + {t('diffView.scope.lastTurn')} + {turnCount} + + @@ -532,6 +569,7 @@ interface MultiFileDiffEntryProps { onOpenInEditor?: (filePath: string, diffData: DiffData | null) => void; staged?: boolean; loadFullFiles?: boolean; + initialDiffData?: DiffData | null; } const MultiFileDiffEntry = React.memo(({ @@ -550,6 +588,7 @@ const MultiFileDiffEntry = React.memo(({ onOpenInEditor, staged = false, loadFullFiles = false, + initialDiffData = null, }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); @@ -578,11 +617,12 @@ const MultiFileDiffEntry = React.memo(({ const fileStatusKey = `${file.index}:${file.working_dir}:${file.insertions}:${file.deletions}`; const diffData = React.useMemo(() => { + 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(({ 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(({ 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) => void; + onDiffScopeChange?: (scope: Extract) => 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 = ({ 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 = ({ 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(null); const fileSectionRefs = React.useRef(new Map()); @@ -984,12 +1031,52 @@ export const DiffView: React.FC = ({ }); }, []); + 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(); + 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 = ({ 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 = ({ 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 = ({ 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 = ({ } 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 = ({ // 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 = ({ } 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 = ({ 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} /> ))} @@ -1534,7 +1627,7 @@ export const DiffView: React.FC = ({ ); } - if (isLoadingStatus && !status) { + if (activeDiffScope !== 'turn' && isLoadingStatus && !status) { return (
@@ -1543,7 +1636,7 @@ export const DiffView: React.FC = ({ ); } - if (isGitRepo === false) { + if (activeDiffScope !== 'turn' && isGitRepo === false) { return (
{t('diffView.state.notGitRepository')} @@ -1554,7 +1647,7 @@ export const DiffView: React.FC = ({ if (changedFiles.length === 0) { return (
- {t('diffView.state.cleanWorkingTree')} + {activeDiffScope === 'turn' ? t('diffView.state.noLastTurnChanges') : t('diffView.state.cleanWorkingTree')}
); } @@ -1566,12 +1659,16 @@ export const DiffView: React.FC = ({
{!isMobile && ( - diffScope === 'working' || diffScope === 'staged' ? ( + activeDiffScope === 'working' || activeDiffScope === 'staged' || activeDiffScope === 'turn' ? ( { + setActiveDiffScope(scope); + onDiffScopeChange?.(scope); + }} /> ) : (
diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index b2c4d9a0..eff6d89e 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 4c1a15d5..3dfecad7 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1220,6 +1220,7 @@ export const dict: Record = { "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 = { "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", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 9e247d6d..2042f531 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 78c2f82f..616b16c3 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1250,6 +1250,7 @@ export const dict: Record = { '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 = { 'diffView.summary.changedFilesPlural': '{count}ファイルが変更されました', 'diffView.scope.changed': '変更済み', 'diffView.scope.staged': 'ステージ済み', + 'diffView.scope.lastTurn': '最後のターン', 'diffView.scope.selectorAria': '変更モードを選択', 'diffView.actions.retry': '再試行', 'diffView.actions.renderAnyway': 'とにかくレンダリング', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 982a49b6..41746993 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1257,6 +1257,7 @@ export const dict: Record = { '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 = { 'diffView.summary.changedFilesPlural': '파일 {count}개 변경됨', "diffView.scope.changed": "Changed", "diffView.scope.staged": "Staged", + "diffView.scope.lastTurn": "마지막 턴", "diffView.scope.selectorAria": "변경 모드 선택", 'diffView.actions.retry': '다시 시도', 'diffView.actions.renderAnyway': '그래도 렌더링', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 96e66b5f..80df1a15 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1537,6 +1537,7 @@ export const dict: Record = { '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 = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 34ed5122..c0d785b1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1220,6 +1220,7 @@ export const dict: Record = { "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 = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 3575f0ba..03faa282 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1220,6 +1220,7 @@ export const dict: Record = { "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 = { "diffView.summary.changedFilesPlural": "Змінено файлів: {count}", "diffView.scope.changed": "Змінені", "diffView.scope.staged": "Індексовані", + "diffView.scope.lastTurn": "Останній хід", "diffView.scope.selectorAria": "Вибрати режим змін", "diffView.actions.retry": "Повторити спробу", "diffView.actions.renderAnyway": "Все одно відрендерити", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 12908732..c9e93f93 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1220,6 +1220,7 @@ export const dict: Record = { '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 = { 'diffView.summary.changedFilesPlural': '{count} 个文件已变更', "diffView.scope.changed": "已更改", "diffView.scope.staged": "已暂存", + "diffView.scope.lastTurn": "上一轮", "diffView.scope.selectorAria": "选择更改模式", 'diffView.actions.retry': '重试', 'diffView.actions.renderAnyway': '仍然渲染', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index c241ff23..1d6eb173 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1230,6 +1230,7 @@ export const dict: Record = { '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 = { 'diffView.summary.changedFilesPlural': '{count} 個檔案已變更', "diffView.scope.changed": "已變更", "diffView.scope.staged": "已暫存", + "diffView.scope.lastTurn": "上一輪", "diffView.scope.selectorAria": "選擇變更模式", 'diffView.actions.retry': '重試', 'diffView.actions.renderAnyway': '仍然渲染', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 23f5870a..b8f00a43 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -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()( sidebarOpenBeforeFullscreenTab: null, pendingDiffFile: null, pendingDiffStaged: false, + pendingDiffScope: null, pendingDiagramFile: null, pendingFileNavigation: null, pendingFileFocusPath: null, @@ -1042,17 +1055,20 @@ export const useUIStore = create()( }); }, - 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()( 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()( 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; },