diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 016e63c48..96d0088ea 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -586,6 +586,11 @@ pub(crate) struct AppSettings { rename = "notificationSoundsEnabled" )] pub(crate) notification_sounds_enabled: bool, + #[serde( + default = "default_split_chat_diff_view", + rename = "splitChatDiffView" + )] + pub(crate) split_chat_diff_view: bool, #[serde(default = "default_preload_git_diffs", rename = "preloadGitDiffs")] pub(crate) preload_git_diffs: bool, #[serde( @@ -923,6 +928,10 @@ fn default_system_notifications_enabled() -> bool { true } +fn default_split_chat_diff_view() -> bool { + false +} + fn default_preload_git_diffs() -> bool { true } @@ -1184,6 +1193,7 @@ impl Default for AppSettings { code_font_size: default_code_font_size(), notification_sounds_enabled: true, system_notifications_enabled: true, + split_chat_diff_view: default_split_chat_diff_view(), preload_git_diffs: default_preload_git_diffs(), git_diff_ignore_whitespace_changes: default_git_diff_ignore_whitespace_changes(), commit_message_prompt: default_commit_message_prompt(), @@ -1346,6 +1356,7 @@ mod tests { assert_eq!(settings.code_font_size, 11); assert!(settings.notification_sounds_enabled); assert!(settings.system_notifications_enabled); + assert!(!settings.split_chat_diff_view); assert!(settings.preload_git_diffs); assert!(!settings.git_diff_ignore_whitespace_changes); assert!(settings.commit_message_prompt.contains("{diff}")); diff --git a/src/App.tsx b/src/App.tsx index c06b7bb4a..751b1700c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -399,6 +399,7 @@ function MainApp() { activeWorkspace, gitDiffPreloadEnabled: appSettings.preloadGitDiffs, gitDiffIgnoreWhitespaceChanges: appSettings.gitDiffIgnoreWhitespaceChanges, + splitChatDiffView: appSettings.splitChatDiffView, isCompact, isTablet, activeTab, @@ -2326,6 +2327,7 @@ function MainApp() { tabletTab={tabletTab} centerMode={centerMode} preloadGitDiffs={appSettings.preloadGitDiffs} + splitChatDiffView={appSettings.splitChatDiffView} hasActivePlan={hasActivePlan} activeWorkspace={Boolean(activeWorkspace)} sidebarNode={sidebarNode} diff --git a/src/features/app/components/AppLayout.tsx b/src/features/app/components/AppLayout.tsx index 16d2ebdf1..dc887555c 100644 --- a/src/features/app/components/AppLayout.tsx +++ b/src/features/app/components/AppLayout.tsx @@ -12,6 +12,7 @@ type AppLayoutProps = { tabletTab: "codex" | "git" | "log"; centerMode: "chat" | "diff"; preloadGitDiffs: boolean; + splitChatDiffView: boolean; hasActivePlan: boolean; activeWorkspace: boolean; sidebarNode: ReactNode; @@ -48,6 +49,7 @@ export const AppLayout = memo(function AppLayout({ tabletTab, centerMode, preloadGitDiffs, + splitChatDiffView, hasActivePlan, activeWorkspace, sidebarNode, @@ -134,6 +136,7 @@ export const AppLayout = memo(function AppLayout({ topbarLeftNode={desktopTopbarLeftNode} centerMode={centerMode} preloadGitDiffs={preloadGitDiffs} + splitChatDiffView={splitChatDiffView} messagesNode={messagesNode} gitDiffViewerNode={gitDiffViewerNode} gitDiffPanelNode={gitDiffPanelNode} diff --git a/src/features/app/hooks/useGitPanelController.test.tsx b/src/features/app/hooks/useGitPanelController.test.tsx index 2154261f6..3baf58c07 100644 --- a/src/features/app/hooks/useGitPanelController.test.tsx +++ b/src/features/app/hooks/useGitPanelController.test.tsx @@ -38,6 +38,7 @@ function makeProps(overrides?: Partial[ activeWorkspace: workspace, gitDiffPreloadEnabled: false, gitDiffIgnoreWhitespaceChanges: false, + splitChatDiffView: false, isCompact: false, isTablet: false, activeTab: "codex" as const, @@ -142,4 +143,17 @@ describe("useGitPanelController preload behavior", () => { const selectedEnabled = getLastEnabledArg(); expect(selectedEnabled).toBe(true); }); + + it("loads local diffs when split view is enabled and preload is disabled", () => { + renderHook(() => + useGitPanelController( + makeProps({ + splitChatDiffView: true, + }), + ), + ); + + const enabled = getLastEnabledArg(); + expect(enabled).toBe(true); + }); }); diff --git a/src/features/app/hooks/useGitPanelController.ts b/src/features/app/hooks/useGitPanelController.ts index 9d1d29f96..ebd82874f 100644 --- a/src/features/app/hooks/useGitPanelController.ts +++ b/src/features/app/hooks/useGitPanelController.ts @@ -9,6 +9,7 @@ export function useGitPanelController({ activeWorkspace, gitDiffPreloadEnabled, gitDiffIgnoreWhitespaceChanges, + splitChatDiffView, isCompact, isTablet, activeTab, @@ -21,6 +22,7 @@ export function useGitPanelController({ activeWorkspace: WorkspaceInfo | null; gitDiffPreloadEnabled: boolean; gitDiffIgnoreWhitespaceChanges: boolean; + splitChatDiffView: boolean; isCompact: boolean; isTablet: boolean; activeTab: "home" | "projects" | "codex" | "git" | "log"; @@ -104,10 +106,13 @@ export function useGitPanelController({ ); const shouldLoadSelectedLocalDiff = centerMode === "diff" && Boolean(selectedDiffPath); + const shouldLoadLocalDiffsForSplitView = splitChatDiffView && diffSource === "local"; const shouldLoadLocalDiffs = Boolean(activeWorkspace) && (shouldPreloadDiffs || - (gitDiffPreloadEnabled ? diffUiVisible : shouldLoadSelectedLocalDiff)); + (gitDiffPreloadEnabled + ? diffUiVisible + : shouldLoadSelectedLocalDiff || shouldLoadLocalDiffsForSplitView)); const shouldLoadDiffs = Boolean(activeWorkspace) && (diffSource === "local" ? shouldLoadLocalDiffs : diffUiVisible); diff --git a/src/features/git/components/GitDiffViewer.tsx b/src/features/git/components/GitDiffViewer.tsx index caa42448d..81957248e 100644 --- a/src/features/git/components/GitDiffViewer.tsx +++ b/src/features/git/components/GitDiffViewer.tsx @@ -5,6 +5,7 @@ import { FileDiff, WorkerPoolContextProvider } from "@pierre/diffs/react"; import type { FileDiffMetadata } from "@pierre/diffs"; import { parsePatchFiles } from "@pierre/diffs"; import RotateCcw from "lucide-react/dist/esm/icons/rotate-ccw"; +import GitCommitHorizontal from "lucide-react/dist/esm/icons/git-commit-horizontal"; import { workerFactory } from "../../../utils/diffsWorker"; import type { GitHubPullRequest, GitHubPullRequestComment } from "../../../types"; import { formatRelativeTime } from "../../../utils/time"; @@ -594,6 +595,18 @@ export function GitDiffViewer({ } rowVirtualizer.scrollToIndex(0, { align: "start" }); }, [diffs.length, rowVirtualizer]); + const emptyStateCopy = pullRequest + ? { + title: "No file changes in this pull request", + subtitle: + "The pull request loaded, but there are no diff hunks to render for this selection.", + hint: "Try switching to another pull request or commit from the Git panel.", + } + : { + title: "Working tree is clean", + subtitle: "No local changes were detected for the current workspace.", + hint: "Make an edit, stage a file, or select a commit to inspect changes here.", + }; return ( )} {!error && !isLoading && !diffs.length && ( -
No changes detected.
+
+
+ + + +

{emptyStateCopy.title}

+

{emptyStateCopy.subtitle}

+

{emptyStateCopy.hint}

+
)} {!error && diffs.length > 0 && (
(null); const chatLayerRef = useRef(null); - const shouldRenderDiffViewer = preloadGitDiffs || centerMode === "diff"; + const shouldRenderDiffViewer = + splitChatDiffView || preloadGitDiffs || centerMode === "diff"; useEffect(() => { const diffLayer = diffLayerRef.current; const chatLayer = chatLayerRef.current; + if (splitChatDiffView) { + diffLayer?.removeAttribute("inert"); + chatLayer?.removeAttribute("inert"); + return; + } + if (diffLayer) { if (centerMode === "diff") { diffLayer.removeAttribute("inert"); @@ -81,7 +90,7 @@ export function DesktopLayout({ ) { activeElement.blur(); } - }, [centerMode]); + }, [centerMode, splitChatDiffView]); return ( <> @@ -103,21 +112,44 @@ export function DesktopLayout({ <> {approvalToastsNode} -
-
- {shouldRenderDiffViewer ? gitDiffViewerNode : null} -
-
- {messagesNode} -
+
+ {splitChatDiffView ? ( + <> +
+ {messagesNode} +
+
+ {shouldRenderDiffViewer ? gitDiffViewerNode : null} +
+ + ) : ( + <> +
+ {shouldRenderDiffViewer ? gitDiffViewerNode : null} +
+
+ {messagesNode} +
+ + )}
{ }); }); + it("toggles split chat and diff center panes", async () => { + const onUpdateAppSettings = vi.fn().mockResolvedValue(undefined); + renderDisplaySection({ onUpdateAppSettings }); + + const row = screen + .getByText("Split chat and diff center panes") + .closest(".settings-toggle-row") as HTMLElement | null; + if (!row) { + throw new Error("Expected split center panes row"); + } + const toggle = row.querySelector("button.settings-toggle") as HTMLButtonElement | null; + if (!toggle) { + throw new Error("Expected split center panes toggle"); + } + fireEvent.click(toggle); + + await waitFor(() => { + expect(onUpdateAppSettings).toHaveBeenCalledWith( + expect.objectContaining({ splitChatDiffView: true }), + ); + }); + }); + it("toggles reduce transparency", async () => { const onToggleTransparency = vi.fn(); renderDisplaySection({ onToggleTransparency, reduceTransparency: false }); diff --git a/src/features/settings/components/sections/SettingsDisplaySection.tsx b/src/features/settings/components/sections/SettingsDisplaySection.tsx index 9722759da..ac7b0757d 100644 --- a/src/features/settings/components/sections/SettingsDisplaySection.tsx +++ b/src/features/settings/components/sections/SettingsDisplaySection.tsx @@ -128,6 +128,27 @@ export function SettingsDisplaySection({
+
+
+
Split chat and diff center panes
+
+ Show chat and diff side by side instead of swapping between them. +
+
+ +
Auto-generate new thread titles
diff --git a/src/features/settings/hooks/useAppSettings.ts b/src/features/settings/hooks/useAppSettings.ts index 572acfaf7..c9b4c7112 100644 --- a/src/features/settings/hooks/useAppSettings.ts +++ b/src/features/settings/hooks/useAppSettings.ts @@ -72,6 +72,7 @@ function buildDefaultSettings(): AppSettings { codeFontSize: CODE_FONT_SIZE_DEFAULT, notificationSoundsEnabled: true, systemNotificationsEnabled: true, + splitChatDiffView: false, preloadGitDiffs: true, gitDiffIgnoreWhitespaceChanges: false, commitMessagePrompt: DEFAULT_COMMIT_MESSAGE_PROMPT, diff --git a/src/styles/diff-viewer.css b/src/styles/diff-viewer.css index 7710ed7d7..77ba4d923 100644 --- a/src/styles/diff-viewer.css +++ b/src/styles/diff-viewer.css @@ -620,6 +620,75 @@ padding: 8px 0; } +.diff-viewer-empty { + padding: 16px; +} + +.diff-viewer-empty-state { + position: relative; + flex: 1; + min-height: 240px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 24px 16px 30px; + text-align: center; +} + +.diff-viewer-empty-glow { + position: absolute; + width: min(72%, 520px); + height: 180px; + border-radius: 999px; + background: + radial-gradient( + ellipse at center, + color-mix(in srgb, var(--border-accent-soft) 32%, transparent) 0%, + transparent 72% + ); + filter: blur(14px); + pointer-events: none; +} + +.diff-viewer-empty-icon { + position: relative; + width: 34px; + height: 34px; + border-radius: 999px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-emphasis); + border: 1px solid color-mix(in srgb, var(--border-accent-soft) 56%, transparent); + background: color-mix(in srgb, var(--surface-active) 70%, transparent); +} + +.diff-viewer-empty-title { + margin: 2px 0 2px; + font-size: 17px; + line-height: 1.2; + color: var(--text-emphasis); + letter-spacing: 0.01em; +} + +.diff-viewer-empty-subtitle { + margin: 0; + max-width: 560px; + color: var(--text-subtle); + font-size: 13px; + line-height: 1.45; +} + +.diff-viewer-empty-hint { + margin: 0; + max-width: 560px; + color: var(--text-faint); + font-size: 12px; + line-height: 1.4; +} + .diff-viewer-loading { color: var(--text-faint); font-size: 11px; diff --git a/src/styles/main.css b/src/styles/main.css index 0b577a79c..38efd377b 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -851,6 +851,10 @@ position: relative; } +.content-split { + position: relative; +} + .content-layer { position: absolute; inset: 0; @@ -861,6 +865,22 @@ isolation: isolate; } +.content-layer-split { + top: var(--main-topbar-height); + bottom: 0; +} + +.content-layer-chat { + left: 0; + right: 50%; + border-right: 1px solid var(--border-subtle); +} + +.content-layer-diff { + left: 50%; + right: 0; +} + .content-layer.is-hidden { opacity: 0; pointer-events: none; diff --git a/src/types.ts b/src/types.ts index 5cdbe55e8..67c1b9fcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -189,6 +189,7 @@ export type AppSettings = { codeFontSize: number; notificationSoundsEnabled: boolean; systemNotificationsEnabled: boolean; + splitChatDiffView: boolean; preloadGitDiffs: boolean; gitDiffIgnoreWhitespaceChanges: boolean; commitMessagePrompt: string;