From e95bfdebaf1af4960e719cd0eb67b6301f1def57 Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Wed, 14 Jan 2026 10:15:16 -0300 Subject: [PATCH] feat: Add stacked diff mode controls and mobile dropdown mode selector (#142) * feat: add diffViewMode to UI store * feat(PierreDiffViewer): add layout prop for inline diff * feat: add diff view modes and change descriptors * feat(openchamber): add diff view mode toggle * feat: add diff view mode selector --- .../openchamber/OpenChamberVisualSettings.tsx | 44 +- packages/ui/src/components/views/DiffView.tsx | 694 +++++++++++++++++- .../src/components/views/PierreDiffViewer.tsx | 9 +- packages/ui/src/stores/useUIStore.ts | 8 + 4 files changed, 724 insertions(+), 31 deletions(-) diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index fba5ac05..6591c42a 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -56,6 +56,19 @@ const DIFF_LAYOUT_OPTIONS: Option<'dynamic' | 'inline' | 'side-by-side'>[] = [ }, ]; +const DIFF_VIEW_MODE_OPTIONS: Option<'single' | 'stacked'>[] = [ + { + id: 'single', + label: 'Single file', + description: 'Show one file at a time in the Diff tab.', + }, + { + id: 'stacked', + label: 'All files', + description: 'Stack all changed files together in the Diff tab.', + }, +]; + export type VisibleSetting = 'theme' | 'fontSize' | 'spacing' | 'inputBarOffset' | 'toolOutput' | 'diffLayout' | 'reasoning' | 'queueMode'; interface OpenChamberVisualSettingsProps { @@ -77,6 +90,8 @@ export const OpenChamberVisualSettings: React.FC const setInputBarOffset = useUIStore(state => state.setInputBarOffset); const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference); const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference); + const diffViewMode = useUIStore(state => state.diffViewMode); + const setDiffViewMode = useUIStore(state => state.setDiffViewMode); const queueModeEnabled = useMessageQueueStore(state => state.queueModeEnabled); const setQueueMode = useMessageQueueStore(state => state.setQueueMode); const { @@ -296,7 +311,7 @@ export const OpenChamberVisualSettings: React.FC )} {shouldShow('diffLayout') && !isMobile && ( -
+

Diff layout (Diff tab) @@ -323,6 +338,33 @@ export const OpenChamberVisualSettings: React.FC {DIFF_LAYOUT_OPTIONS.find((option) => option.id === diffLayoutPreference)?.description}

+ +
+

+ Diff view (Diff tab) +

+

+ Choose whether the Diff tab defaults to a single file or all files. +

+
+ +
+
+ {DIFF_VIEW_MODE_OPTIONS.map((option) => ( + setDiffViewMode(option.id)} + > + {option.label} + + ))} +
+

+ {DIFF_VIEW_MODE_OPTIONS.find((option) => option.id === diffViewMode)?.description} +

+
)} diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index b7752810..e9c5b31d 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react'; +import { RiArrowDownSLine, RiArrowRightSLine, RiGitCommitLine, RiLoader4Line, RiTextWrap } from '@remixicon/react'; import { useSessionStore } from '@/stores/useSessionStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -9,11 +9,14 @@ import type { GitStatus } from '@/lib/api/types'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { RiArrowDownSLine } from '@remixicon/react'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { DiffViewToggle } from '@/components/chat/message/DiffViewToggle'; @@ -23,6 +26,7 @@ import { useDeviceInfo } from '@/lib/device'; // Minimum width for side-by-side diff view (px) const SIDE_BY_SIDE_MIN_WIDTH = 1100; +const DIFF_REQUEST_TIMEOUT_MS = 15000; type FileEntry = GitStatus['files'][number] & { insertions: number; @@ -32,6 +36,57 @@ type FileEntry = GitStatus['files'][number] & { type DiffData = { original: string; modified: string }; +type DiffTabViewMode = 'single' | 'stacked'; + +type ChangeDescriptor = { + code: string; + color: string; + description: string; +}; + +const CHANGE_DESCRIPTORS: Record = { + '?': { code: '?', color: 'var(--status-info)', description: 'Untracked file' }, + A: { code: 'A', color: 'var(--status-success)', description: 'New file' }, + D: { code: 'D', color: 'var(--status-error)', description: 'Deleted file' }, + R: { code: 'R', color: 'var(--status-info)', description: 'Renamed file' }, + C: { code: 'C', color: 'var(--status-info)', description: 'Copied file' }, + M: { code: 'M', color: 'var(--status-warning)', description: 'Modified file' }, +}; + +const DEFAULT_CHANGE_DESCRIPTOR = CHANGE_DESCRIPTORS.M; + +const DIFF_VIEW_MODE_OPTIONS: Array<{ + value: DiffTabViewMode; + label: string; + description: string; +}> = [ + { + value: 'single', + label: 'Single file', + description: 'Show one file at a time', + }, + { + value: 'stacked', + label: 'All files', + description: 'Stack all modified files together', + }, +]; + +const getChangeSymbol = (file: GitStatus['files'][number]): string => { + const indexCode = file.index?.trim(); + const workingCode = file.working_dir?.trim(); + + if (indexCode && indexCode !== '?') return indexCode.charAt(0); + if (workingCode) return workingCode.charAt(0); + + return indexCode?.charAt(0) || workingCode?.charAt(0) || 'M'; +}; + +const describeChange = (file: GitStatus['files'][number]): ChangeDescriptor => { + const symbol = getChangeSymbol(file); + return CHANGE_DESCRIPTORS[symbol] ?? DEFAULT_CHANGE_DESCRIPTOR; +}; + const isNewStatusFile = (file: GitStatus['files'][number]): boolean => { const { index, working_dir: workingDir } = file; return index === 'A' || workingDir === 'A' || index === '?' || workingDir === '?'; @@ -55,6 +110,9 @@ interface FileSelectorProps { selectedFileEntry: FileEntry | null; onSelectFile: (path: string) => void; isMobile: boolean; + showModeSelector?: boolean; + mode?: DiffTabViewMode; + onModeChange?: (mode: DiffTabViewMode) => void; } const FileSelector = React.memo(({ @@ -63,6 +121,9 @@ const FileSelector = React.memo(({ selectedFileEntry, onSelectFile, isMobile, + showModeSelector = false, + mode, + onModeChange, }) => { const getLabel = React.useCallback((path: string) => { if (!isMobile) return path; @@ -90,6 +151,30 @@ const FileSelector = React.memo(({ + {showModeSelector && mode && onModeChange ? ( + <> + + View mode + + onModeChange(value as DiffTabViewMode)} + > + {DIFF_VIEW_MODE_OPTIONS.map((option) => ( + + + {option.label} + + + ))} + + + + ) : null} {changedFiles.map((file) => ( @@ -109,6 +194,111 @@ const FileSelector = React.memo(({ ); }); +interface DiffViewModeSelectorProps { + mode: DiffTabViewMode; + onModeChange: (mode: DiffTabViewMode) => void; +} + +const DiffViewModeSelector = React.memo(({ mode, onModeChange }) => { + const currentOption = + DIFF_VIEW_MODE_OPTIONS.find((option) => option.value === mode) ?? DIFF_VIEW_MODE_OPTIONS[0]; + + return ( + + + + + + onModeChange(value as DiffTabViewMode)} + > + {DIFF_VIEW_MODE_OPTIONS.map((option) => ( + +
+ + {option.label} + + + {option.description} + +
+
+ ))} +
+
+
+ ); +}); + +interface FileListProps { + changedFiles: FileEntry[]; + selectedFile: string | null; + onSelectFile: (path: string) => void; + isCompact: boolean; +} + +const FileList = React.memo(({ + changedFiles, + selectedFile, + onSelectFile, + isCompact, +}) => { + const getLabel = React.useCallback((path: string) => { + if (!isCompact) return path; + const lastSlash = path.lastIndexOf('/'); + return lastSlash >= 0 ? path.slice(lastSlash + 1) : path; + }, [isCompact]); + + if (changedFiles.length === 0) return null; + + return ( + +
    + {changedFiles.map((file) => { + const descriptor = describeChange(file); + const isActive = selectedFile === file.path; + + return ( +
  • + +
  • + ); + })} +
+
+ ); +}); + // Image diff viewer for binary image files interface ImageDiffViewerProps { filePath: string; @@ -171,6 +361,103 @@ const ImageDiffViewer = React.memo(({ ); }); +interface InlineImageDiffViewerProps { + filePath: string; + diff: DiffData; + renderSideBySide: boolean; +} + +const InlineImageDiffViewer = React.memo(({ + filePath, + diff, + renderSideBySide, +}) => { + const hasOriginal = diff.original.length > 0; + const hasModified = diff.modified.length > 0; + + const containerClass = renderSideBySide + ? 'flex flex-row gap-6 items-start justify-center' + : 'flex flex-col gap-4 items-center'; + + const imageContainerClass = renderSideBySide + ? 'flex flex-col items-center gap-2 flex-1 min-w-0' + : 'flex flex-col items-center gap-2'; + + return ( +
+
+ {hasOriginal && ( +
+ Original + {`Original: +
+ )} + {hasModified && ( +
+ + {hasOriginal ? 'Modified' : 'New'} + + {`Modified: +
+ )} +
+
+ ); +}); + +interface InlineDiffViewerProps { + filePath: string; + diff: DiffData; + renderSideBySide: boolean; + wrapLines: boolean; +} + +const InlineDiffViewer = React.memo(({ + filePath, + diff, + renderSideBySide, + wrapLines, +}) => { + const language = React.useMemo( + () => getLanguageFromExtension(filePath) || 'text', + [filePath] + ); + + if (isImageFile(filePath)) { + return ( + + ); + } + + return ( +
+ +
+ ); +}); + // Single diff viewer instance - stays mounted interface SingleDiffViewerProps { filePath: string; @@ -274,6 +561,228 @@ const DiffViewerEntry = React.memo(({ ); }); +interface MultiFileDiffEntryProps { + directory: string; + file: FileEntry; + layout: 'inline' | 'side-by-side'; + wrapLines: boolean; + scrollRootRef: React.RefObject; + isSelected: boolean; + onSelect: (path: string) => void; + registerSectionRef: (path: string, node: HTMLDivElement | null) => void; +} + +const MultiFileDiffEntry = React.memo(({ + directory, + file, + layout, + wrapLines, + scrollRootRef, + isSelected, + onSelect, + registerSectionRef, +}) => { + const { git } = useRuntimeAPIs(); + const cachedDiff = useGitStore( + React.useCallback((state) => { + return state.directories.get(directory)?.diffCache.get(file.path) ?? null; + }, [directory, file.path]) + ); + const setDiff = useGitStore((state) => state.setDiff); + const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout); + + const [isExpanded, setIsExpanded] = React.useState(true); + const [hasBeenVisible, setHasBeenVisible] = React.useState(false); + const [diffRetryNonce, setDiffRetryNonce] = React.useState(0); + const [diffLoadError, setDiffLoadError] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const lastDiffRequestRef = React.useRef(null); + const sectionRef = React.useRef(null); + + const descriptor = React.useMemo(() => describeChange(file), [file]); + const renderSideBySide = layout === 'side-by-side'; + + const diffData = React.useMemo(() => { + if (!cachedDiff) return null; + return { original: cachedDiff.original, modified: cachedDiff.modified }; + }, [cachedDiff]); + + const setSectionRef = React.useCallback((node: HTMLDivElement | null) => { + sectionRef.current = node; + registerSectionRef(file.path, node); + }, [file.path, registerSectionRef]); + + const handleOpenChange = React.useCallback((open: boolean) => { + setIsExpanded(open); + if (open) { + setHasBeenVisible(true); + } + }, []); + + const handleSelect = React.useCallback(() => { + onSelect(file.path); + }, [file.path, onSelect]); + + React.useEffect(() => { + if (!isExpanded || hasBeenVisible) return; + const target = sectionRef.current; + if (!target) return; + + if (!scrollRootRef.current || typeof IntersectionObserver === 'undefined') { + setHasBeenVisible(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setHasBeenVisible(true); + observer.disconnect(); + } + }, + { root: scrollRootRef.current, rootMargin: '200px 0px', threshold: 0.1 } + ); + + observer.observe(target); + return () => observer.disconnect(); + }, [hasBeenVisible, isExpanded, scrollRootRef]); + + React.useEffect(() => { + if (!isExpanded || !hasBeenVisible) return; + if (!directory || diffData) { + lastDiffRequestRef.current = null; + setIsLoading(false); + return; + } + + const requestKey = `${directory}::${file.path}::${diffRetryNonce}`; + if (lastDiffRequestRef.current === requestKey) { + return; + } + lastDiffRequestRef.current = requestKey; + setDiffLoadError(null); + setIsLoading(true); + + let cancelled = false; + void (async () => { + try { + const fetchPromise = git.getGitFileDiff(directory, { path: file.path }); + const timeoutMs = DIFF_REQUEST_TIMEOUT_MS; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + const response = await Promise.race([fetchPromise, timeoutPromise]); + if (cancelled) return; + + setDiff(directory, file.path, { + original: response.original ?? '', + modified: response.modified ?? '', + }); + setIsLoading(false); + } catch (error) { + if (cancelled) return; + const message = error instanceof Error ? error.message : String(error); + setDiffLoadError(message); + setIsLoading(false); + } + })(); + + return () => { + cancelled = true; + if (lastDiffRequestRef.current === requestKey) { + lastDiffRequestRef.current = null; + } + }; + }, [directory, diffData, diffRetryNonce, file.path, git, hasBeenVisible, isExpanded, setDiff]); + + return ( +
+ + +
+ + {isExpanded ? ( + + ) : ( + + )} + + + {descriptor.code} + + + {file.path} + +
+
+ {formatDiffTotals(file.insertions, file.deletions)} + { + const nextLayout: 'inline' | 'side-by-side' = + mode === 'side-by-side' ? 'side-by-side' : 'inline'; + setDiffFileLayout(file.path, nextLayout); + }} + className="opacity-70" + /> +
+
+ +
+ {diffLoadError ? ( +
+
+ Failed to load diff +
+
+ {diffLoadError} +
+ +
+ ) : null} + {isLoading && !diffData && !diffLoadError ? ( +
+ + Loading diff… +
+ ) : null} + {diffData ? ( + + ) : null} +
+
+
+
+ ); +}); + const useEffectiveDirectory = () => { const { currentSessionId, sessions, worktreeMetadata: worktreeMap } = useSessionStore(); const { currentDirectory: fallbackDirectory } = useDirectoryStore(); @@ -307,9 +816,18 @@ export const DiffView: React.FC = () => { const setDiffFileLayout = useUIStore((state) => state.setDiffFileLayout); const diffWrapLinesStore = useUIStore((state) => state.diffWrapLines); const setDiffWrapLines = useUIStore((state) => state.setDiffWrapLines); + const diffViewMode = useUIStore((state) => state.diffViewMode); + const setDiffViewMode = useUIStore((state) => state.setDiffViewMode); // Default to wrap on mobile const diffWrapLines = isMobile || diffWrapLinesStore; + const isStackedView = diffViewMode === 'stacked'; + const isMobileLayout = isMobile || screenWidth <= 768; + const showFileSidebar = !isMobileLayout && screenWidth >= 1024; + const diffScrollRef = React.useRef(null); + const fileSectionRefs = React.useRef(new Map()); + const pendingScrollTargetRef = React.useRef(null); + const changedFiles: FileEntry[] = React.useMemo(() => { if (!status?.files) return []; const diffStats = status.diffStats ?? {}; @@ -329,14 +847,10 @@ export const DiffView: React.FC = () => { return changedFiles.find((file) => file.path === selectedFile) ?? null; }, [changedFiles, selectedFile]); - const currentLayoutForSelectedFile = React.useMemo<'inline' | 'side-by-side' | null>(() => { - if (!selectedFileEntry) return null; - - // Per-file override takes priority - const override = diffFileLayout[selectedFileEntry.path]; + const getLayoutForFile = React.useCallback((file: FileEntry): 'inline' | 'side-by-side' => { + const override = diffFileLayout[file.path]; if (override) return override; - // Explicit user preference - respect it regardless of screen width if (diffLayoutPreference === 'inline') { return 'inline'; } @@ -345,14 +859,18 @@ export const DiffView: React.FC = () => { return 'side-by-side'; } - // Dynamic mode: auto-switch based on file type and screen width const isNarrow = screenWidth < SIDE_BY_SIDE_MIN_WIDTH; - if (selectedFileEntry.isNew || isNarrow) { + if (file.isNew || isNarrow) { return 'inline'; } return 'side-by-side'; - }, [selectedFileEntry, diffFileLayout, diffLayoutPreference, screenWidth]); + }, [diffFileLayout, diffLayoutPreference, screenWidth]); + + const currentLayoutForSelectedFile = React.useMemo<'inline' | 'side-by-side' | null>(() => { + if (!selectedFileEntry) return null; + return getLayoutForFile(selectedFileEntry); + }, [getLayoutForFile, selectedFileEntry]); // Fetch git status on mount React.useEffect(() => { @@ -370,8 +888,11 @@ export const DiffView: React.FC = () => { if (pendingDiffFile) { setSelectedFile(pendingDiffFile); setPendingDiffFile(null); + if (isStackedView) { + pendingScrollTargetRef.current = pendingDiffFile; + } } - }, [pendingDiffFile, setPendingDiffFile]); + }, [isStackedView, pendingDiffFile, setPendingDiffFile]); // Auto-select first file (skip if we have a pending file to consume) React.useEffect(() => { @@ -380,6 +901,22 @@ export const DiffView: React.FC = () => { } }, [changedFiles, selectedFile, pendingDiffFile]); + React.useEffect(() => { + if (!isStackedView) { + pendingScrollTargetRef.current = null; + return; + } + + const target = pendingScrollTargetRef.current; + if (!target) return; + + const node = fileSectionRefs.current.get(target); + if (!node) return; + + node.scrollIntoView({ behavior: 'smooth', block: 'start' }); + pendingScrollTargetRef.current = null; + }, [changedFiles, isStackedView]); + // Clear selection if file no longer exists React.useEffect(() => { if (selectedFile && changedFiles.length > 0) { @@ -390,11 +927,57 @@ export const DiffView: React.FC = () => { } }, [changedFiles, selectedFile]); + const registerSectionRef = React.useCallback((path: string, node: HTMLDivElement | null) => { + const map = fileSectionRefs.current; + if (node) { + map.set(path, node); + } else { + map.delete(path); + } + }, []); + + const scrollToFile = React.useCallback((path: string, behavior: ScrollBehavior = 'smooth') => { + const node = fileSectionRefs.current.get(path); + if (!node) return false; + node.scrollIntoView({ behavior, block: 'start' }); + return true; + }, []); + const handleSelectFile = React.useCallback((value: string) => { setSelectedFile(value); }, []); + const handleSelectFileAndScroll = React.useCallback((value: string) => { + setSelectedFile(value); + if (isStackedView && !scrollToFile(value)) { + pendingScrollTargetRef.current = value; + } + }, [isStackedView, scrollToFile]); + + const handleDiffViewModeChange = React.useCallback((mode: DiffTabViewMode) => { + setDiffViewMode(mode); + if (mode === 'stacked' && selectedFile && !scrollToFile(selectedFile, 'auto')) { + pendingScrollTargetRef.current = selectedFile; + } + }, [scrollToFile, selectedFile, setDiffViewMode]); + + const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => { + const nextLayout: 'inline' | 'side-by-side' = + mode === 'side-by-side' ? 'side-by-side' : 'inline'; + + if (isStackedView) { + changedFiles.forEach((file) => { + setDiffFileLayout(file.path, nextLayout); + }); + return; + } + + if (!selectedFileEntry) return; + setDiffFileLayout(selectedFileEntry.path, nextLayout); + }, [changedFiles, isStackedView, selectedFileEntry, setDiffFileLayout]); + const renderSideBySide = (currentLayoutForSelectedFile ?? 'side-by-side') === 'side-by-side'; + const showFileSelector = !isStackedView || !showFileSidebar; const selectedCachedDiff = useGitStore(React.useCallback((state) => { if (!effectiveDirectory || !selectedFile) return null; @@ -402,9 +985,13 @@ export const DiffView: React.FC = () => { }, [effectiveDirectory, selectedFile])); const hasCurrentDiff = !!selectedCachedDiff; - const isCurrentFileLoading = !!selectedFile && !hasCurrentDiff; + const isCurrentFileLoading = !isStackedView && !!selectedFile && !hasCurrentDiff; React.useEffect(() => { + if (isStackedView) { + return; + } + setDiffLoadError(null); if (!effectiveDirectory || !selectedFile) { @@ -427,7 +1014,7 @@ export const DiffView: React.FC = () => { void (async () => { try { const fetchPromise = git.getGitFileDiff(effectiveDirectory, { path: selectedFile }); - const timeoutMs = 15000; + const timeoutMs = DIFF_REQUEST_TIMEOUT_MS; const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); }); @@ -453,7 +1040,7 @@ export const DiffView: React.FC = () => { lastDiffRequestRef.current = null; } }; - }, [effectiveDirectory, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]); + }, [effectiveDirectory, isStackedView, selectedFile, selectedCachedDiff, git, setDiff, diffRetryNonce]); // Render all diff viewers - they stay mounted const renderAllDiffViewers = () => { @@ -471,7 +1058,52 @@ export const DiffView: React.FC = () => { )); }; + const renderStackedDiffView = () => { + if (!effectiveDirectory) return null; + + return ( +
+ {showFileSidebar && ( +
+
+ Files + {changedFiles.length} +
+ +
+ )} + +
+ {changedFiles.map((file) => ( + + ))} +
+
+
+ ); + }; + const renderContent = () => { + if (!effectiveDirectory) { return (
@@ -505,6 +1137,9 @@ export const DiffView: React.FC = () => { ); } + if (isStackedView) { + return renderStackedDiffView(); + } return (
@@ -555,13 +1190,21 @@ export const DiffView: React.FC = () => {
)} - + {!isMobileLayout && ( + + )} + {showFileSelector && ( + + )}
{selectedFileEntry && (
diff --git a/packages/ui/src/components/views/PierreDiffViewer.tsx b/packages/ui/src/components/views/PierreDiffViewer.tsx index 60dc9daa..fc99cf73 100644 --- a/packages/ui/src/components/views/PierreDiffViewer.tsx +++ b/packages/ui/src/components/views/PierreDiffViewer.tsx @@ -14,6 +14,7 @@ interface PierreDiffViewerProps { fileName?: string; renderSideBySide: boolean; wrapLines?: boolean; + layout?: 'fill' | 'inline'; } // CSS injected into Pierre's Shadow DOM for WebKit scroll optimization @@ -89,6 +90,7 @@ export const PierreDiffViewer: React.FC = ({ fileName = 'file', renderSideBySide, wrapLines = false, + layout = 'fill', }) => { const themeSystem = useOptionalThemeSystem(); const isDark = themeSystem?.currentTheme?.metadata?.variant === 'dark'; @@ -149,14 +151,17 @@ export const PierreDiffViewer: React.FC = ({ unsafeCSS: WEBKIT_SCROLL_FIX_CSS, }), [isDark, renderSideBySide, wrapLines]); + const isInlineLayout = layout === 'inline'; + if (typeof window === 'undefined') { return null; } - + return ( ; diffWrapLines: boolean; + diffViewMode: 'single' | 'stacked'; isTimelineDialogOpen: boolean; nativeNotificationsEnabled: boolean; notificationMode: 'always' | 'hidden-only'; @@ -95,6 +96,7 @@ interface UIStore { setDiffLayoutPreference: (mode: 'dynamic' | 'inline' | 'side-by-side') => void; setDiffFileLayout: (filePath: string, mode: 'inline' | 'side-by-side') => void; setDiffWrapLines: (wrap: boolean) => void; + setDiffViewMode: (mode: 'single' | 'stacked') => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; setNativeNotificationsEnabled: (value: boolean) => void; @@ -141,6 +143,7 @@ export const useUIStore = create()( diffLayoutPreference: 'dynamic', diffFileLayout: {}, diffWrapLines: false, + diffViewMode: 'single', isTimelineDialogOpen: false, nativeNotificationsEnabled: false, notificationMode: 'hidden-only', @@ -365,6 +368,10 @@ export const useUIStore = create()( set({ diffWrapLines: wrap }); }, + setDiffViewMode: (mode) => { + set({ diffViewMode: mode }); + }, + setInputBarOffset: (offset) => { set({ inputBarOffset: offset }); }, @@ -503,6 +510,7 @@ export const useUIStore = create()( recentModels: state.recentModels, diffLayoutPreference: state.diffLayoutPreference, diffWrapLines: state.diffWrapLines, + diffViewMode: state.diffViewMode, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, })