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
+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;
},