Skip to content

Commit f7f3ed8

Browse files
committed
feat(layout): add draggable chat/diff split resize support
1 parent d90e168 commit f7f3ed8

11 files changed

Lines changed: 310 additions & 38 deletions

File tree

src/App.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,10 @@ function MainApp() {
263263
});
264264
const {
265265
sidebarWidth,
266+
chatDiffSplitPositionPercent,
266267
rightPanelWidth,
267268
onSidebarResizeStart,
269+
onChatDiffSplitPositionResizeStart,
268270
onRightPanelResizeStart,
269271
planPanelHeight,
270272
onPlanPanelResizeStart,
@@ -1634,6 +1636,7 @@ function MainApp() {
16341636
showComposer,
16351637
activeThreadId,
16361638
sidebarWidth,
1639+
chatDiffSplitPositionPercent,
16371640
rightPanelWidth,
16381641
planPanelHeight,
16391642
terminalPanelHeight,
@@ -1895,6 +1898,7 @@ function MainApp() {
18951898
onFilePanelModeChange: setFilePanelMode,
18961899
fileTreeLoading: isFilesLoading,
18971900
centerMode,
1901+
splitChatDiffView: appSettings.splitChatDiffView,
18981902
onExitDiff: () => {
18991903
setCenterMode("chat");
19001904
setSelectedDiffPath(null);
@@ -2276,6 +2280,7 @@ function MainApp() {
22762280
compactEmptyGitNode={compactEmptyGitNode}
22772281
compactGitBackNode={compactGitBackNode}
22782282
onSidebarResizeStart={onSidebarResizeStart}
2283+
onChatDiffSplitPositionResizeStart={onChatDiffSplitPositionResizeStart}
22792284
onRightPanelResizeStart={onRightPanelResizeStart}
22802285
onPlanPanelResizeStart={onPlanPanelResizeStart}
22812286
/>

src/features/app/components/AppLayout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type AppLayoutProps = {
3636
compactEmptyGitNode: ReactNode;
3737
compactGitBackNode: ReactNode;
3838
onSidebarResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
39+
onChatDiffSplitPositionResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
3940
onRightPanelResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
4041
onPlanPanelResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
4142
};
@@ -73,6 +74,7 @@ export const AppLayout = memo(function AppLayout({
7374
compactEmptyGitNode,
7475
compactGitBackNode,
7576
onSidebarResizeStart,
77+
onChatDiffSplitPositionResizeStart,
7678
onRightPanelResizeStart,
7779
onPlanPanelResizeStart,
7880
}: AppLayoutProps) {
@@ -146,6 +148,7 @@ export const AppLayout = memo(function AppLayout({
146148
debugPanelNode={debugPanelNode}
147149
hasActivePlan={hasActivePlan}
148150
onSidebarResizeStart={onSidebarResizeStart}
151+
onChatDiffSplitPositionResizeStart={onChatDiffSplitPositionResizeStart}
149152
onRightPanelResizeStart={onRightPanelResizeStart}
150153
onPlanPanelResizeStart={onPlanPanelResizeStart}
151154
/>

src/features/app/hooks/useLayoutController.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ export function useLayoutController({
2020
const {
2121
sidebarWidth,
2222
rightPanelWidth,
23+
chatDiffSplitPositionPercent,
2324
onSidebarResizeStart,
25+
onChatDiffSplitPositionResizeStart,
2426
onRightPanelResizeStart,
2527
planPanelHeight,
2628
onPlanPanelResizeStart,
@@ -71,10 +73,12 @@ export function useLayoutController({
7173
isPhone,
7274
sidebarWidth,
7375
rightPanelWidth,
76+
chatDiffSplitPositionPercent,
7477
planPanelHeight,
7578
terminalPanelHeight,
7679
debugPanelHeight,
7780
onSidebarResizeStart,
81+
onChatDiffSplitPositionResizeStart,
7882
onRightPanelResizeStart,
7983
onPlanPanelResizeStart,
8084
onTerminalPanelResizeStart,

src/features/app/orchestration/useLayoutOrchestration.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type UseAppShellOrchestrationOptions = {
1515
activeThreadId: string | null;
1616
sidebarWidth: number;
1717
rightPanelWidth: number;
18+
chatDiffSplitPositionPercent: number;
1819
planPanelHeight: number;
1920
terminalPanelHeight: number;
2021
debugPanelHeight: number;
@@ -35,6 +36,7 @@ export function useAppShellOrchestration({
3536
activeThreadId,
3637
sidebarWidth,
3738
rightPanelWidth,
39+
chatDiffSplitPositionPercent,
3840
planPanelHeight,
3941
terminalPanelHeight,
4042
debugPanelHeight,
@@ -57,6 +59,7 @@ export function useAppShellOrchestration({
5759
"--right-panel-width": `${
5860
isCompact ? rightPanelWidth : rightPanelCollapsed ? 0 : rightPanelWidth
5961
}px`,
62+
"--chat-diff-split-position-percent": `${chatDiffSplitPositionPercent}%`,
6063
"--plan-panel-height": `${planPanelHeight}px`,
6164
"--terminal-panel-height": `${terminalPanelHeight}px`,
6265
"--debug-panel-height": `${debugPanelHeight}px`,
@@ -68,6 +71,7 @@ export function useAppShellOrchestration({
6871
appSettings.codeFontFamily,
6972
appSettings.codeFontSize,
7073
appSettings.uiFontFamily,
74+
chatDiffSplitPositionPercent,
7175
debugPanelHeight,
7276
isCompact,
7377
planPanelHeight,

src/features/layout/components/DesktopLayout.tsx

Lines changed: 95 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,58 @@
11
import { useEffect, useRef, type MouseEvent, type ReactNode } from "react";
22
import { MainTopbar } from "../../app/components/MainTopbar";
33

4+
type CenterMode = "chat" | "diff";
5+
6+
function shouldRenderDiffViewer({
7+
splitChatDiffView,
8+
preloadGitDiffs,
9+
centerMode,
10+
}: {
11+
splitChatDiffView: boolean;
12+
preloadGitDiffs: boolean;
13+
centerMode: CenterMode;
14+
}) {
15+
return splitChatDiffView || preloadGitDiffs || centerMode === "diff";
16+
}
17+
18+
function isActiveLayer(centerMode: CenterMode, layer: CenterMode) {
19+
return centerMode === layer;
20+
}
21+
22+
function layerClassName({
23+
splitChatDiffView,
24+
layer,
25+
isActive,
26+
}: {
27+
splitChatDiffView: boolean;
28+
layer: CenterMode;
29+
isActive: boolean;
30+
}) {
31+
if (splitChatDiffView) {
32+
return `content-layer content-layer-split content-layer-${layer}${
33+
isActive ? " is-active" : ""
34+
}`;
35+
}
36+
return `content-layer ${isActive ? "is-active" : "is-hidden"}`;
37+
}
38+
39+
function setLayerInert(
40+
layer: HTMLDivElement | null,
41+
isActive: boolean,
42+
splitChatDiffView: boolean,
43+
) {
44+
if (!layer) {
45+
return;
46+
}
47+
48+
if (splitChatDiffView || isActive) {
49+
layer.removeAttribute("inert");
50+
return;
51+
}
52+
53+
layer.setAttribute("inert", "");
54+
}
55+
456
type DesktopLayoutProps = {
557
sidebarNode: ReactNode;
658
updateToastNode: ReactNode;
@@ -22,6 +74,7 @@ type DesktopLayoutProps = {
2274
debugPanelNode: ReactNode;
2375
hasActivePlan: boolean;
2476
onSidebarResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
77+
onChatDiffSplitPositionResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
2578
onRightPanelResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
2679
onPlanPanelResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
2780
};
@@ -49,39 +102,29 @@ export function DesktopLayout({
49102
onSidebarResizeStart,
50103
onRightPanelResizeStart,
51104
onPlanPanelResizeStart,
105+
onChatDiffSplitPositionResizeStart,
52106
}: DesktopLayoutProps) {
53107
const diffLayerRef = useRef<HTMLDivElement | null>(null);
54108
const chatLayerRef = useRef<HTMLDivElement | null>(null);
55-
const shouldRenderDiffViewer =
56-
splitChatDiffView || preloadGitDiffs || centerMode === "diff";
109+
const diffLayerActive = isActiveLayer(centerMode, "diff");
110+
const chatLayerActive = isActiveLayer(centerMode, "chat");
111+
const showDiffViewer = shouldRenderDiffViewer({
112+
splitChatDiffView,
113+
preloadGitDiffs,
114+
centerMode,
115+
});
57116

58117
useEffect(() => {
59118
const diffLayer = diffLayerRef.current;
60119
const chatLayer = chatLayerRef.current;
120+
setLayerInert(diffLayer, diffLayerActive, splitChatDiffView);
121+
setLayerInert(chatLayer, chatLayerActive, splitChatDiffView);
61122

62123
if (splitChatDiffView) {
63-
diffLayer?.removeAttribute("inert");
64-
chatLayer?.removeAttribute("inert");
65124
return;
66125
}
67126

68-
if (diffLayer) {
69-
if (centerMode === "diff") {
70-
diffLayer.removeAttribute("inert");
71-
} else {
72-
diffLayer.setAttribute("inert", "");
73-
}
74-
}
75-
76-
if (chatLayer) {
77-
if (centerMode === "chat") {
78-
chatLayer.removeAttribute("inert");
79-
} else {
80-
chatLayer.setAttribute("inert", "");
81-
}
82-
}
83-
84-
const hiddenLayer = centerMode === "diff" ? chatLayer : diffLayer;
127+
const hiddenLayer = diffLayerActive ? chatLayer : diffLayer;
85128
const activeElement = document.activeElement;
86129
if (
87130
hiddenLayer &&
@@ -116,34 +159,53 @@ export function DesktopLayout({
116159
{splitChatDiffView ? (
117160
<>
118161
<div
119-
className={`content-layer content-layer-split content-layer-chat${
120-
centerMode === "chat" ? " is-active" : ""
121-
}`}
162+
className={layerClassName({
163+
splitChatDiffView,
164+
layer: "chat",
165+
isActive: chatLayerActive,
166+
})}
122167
ref={chatLayerRef}
123168
>
124169
{messagesNode}
125170
</div>
126171
<div
127-
className={`content-layer content-layer-split content-layer-diff${
128-
centerMode === "diff" ? " is-active" : ""
129-
}`}
172+
className="content-split-resizer"
173+
role="separator"
174+
aria-orientation="vertical"
175+
aria-label="Resize chat/diff split"
176+
onMouseDown={onChatDiffSplitPositionResizeStart}
177+
/>
178+
<div
179+
className={layerClassName({
180+
splitChatDiffView,
181+
layer: "diff",
182+
isActive: diffLayerActive,
183+
})}
130184
ref={diffLayerRef}
131185
>
132-
{shouldRenderDiffViewer ? gitDiffViewerNode : null}
186+
{showDiffViewer ? gitDiffViewerNode : null}
133187
</div>
134188
</>
135189
) : (
136190
<>
137191
<div
138-
className={`content-layer ${centerMode === "diff" ? "is-active" : "is-hidden"}`}
139-
aria-hidden={centerMode !== "diff"}
192+
className={layerClassName({
193+
splitChatDiffView,
194+
layer: "diff",
195+
isActive: diffLayerActive,
196+
})}
197+
aria-hidden={!splitChatDiffView ? !diffLayerActive : undefined}
140198
ref={diffLayerRef}
141199
>
142-
{shouldRenderDiffViewer ? gitDiffViewerNode : null}
200+
{showDiffViewer ? gitDiffViewerNode : null}
143201
</div>
144202
<div
145-
className={`content-layer ${centerMode === "chat" ? "is-active" : "is-hidden"}`}
146-
aria-hidden={centerMode !== "chat"}
203+
className={layerClassName({
204+
splitChatDiffView,
205+
layer: "chat",
206+
isActive: chatLayerActive,
207+
})}
208+
aria-hidden={!splitChatDiffView ? !chatLayerActive : undefined}
147209
ref={chatLayerRef}
148210
>
149211
{messagesNode}

src/features/layout/hooks/layoutNodes/buildGitNodes.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,22 @@ import type { LayoutNodesOptions, LayoutNodesResult } from "./types";
66

77
type GitLayoutNodes = Pick<LayoutNodesResult, "gitDiffPanelNode" | "gitDiffViewerNode">;
88

9+
function resolveGitDiffStyle({
10+
isPhone,
11+
splitChatDiffView,
12+
centerMode,
13+
userPreference,
14+
}: {
15+
isPhone: boolean;
16+
splitChatDiffView: boolean;
17+
centerMode: LayoutNodesOptions["centerMode"];
18+
userPreference: LayoutNodesOptions["gitDiffViewStyle"];
19+
}): LayoutNodesOptions["gitDiffViewStyle"] {
20+
const shouldForceSingleColumn =
21+
isPhone || (splitChatDiffView && centerMode === "chat");
22+
return shouldForceSingleColumn ? "unified" : userPreference;
23+
}
24+
925
export function buildGitNodes(options: LayoutNodesOptions): GitLayoutNodes {
1026
const sidebarSelectedDiffPath =
1127
options.centerMode === "diff" ? options.selectedDiffPath : null;
@@ -153,7 +169,12 @@ export function buildGitNodes(options: LayoutNodesOptions): GitLayoutNodes {
153169
scrollRequestId={options.diffScrollRequestId}
154170
isLoading={options.gitDiffLoading}
155171
error={options.gitDiffError}
156-
diffStyle={options.isPhone ? "unified" : options.gitDiffViewStyle}
172+
diffStyle={resolveGitDiffStyle({
173+
isPhone: options.isPhone,
174+
splitChatDiffView: options.splitChatDiffView,
175+
centerMode: options.centerMode,
176+
userPreference: options.gitDiffViewStyle,
177+
})}
157178
ignoreWhitespaceChanges={options.gitDiffIgnoreWhitespaceChanges}
158179
pullRequest={options.selectedPullRequest}
159180
pullRequestComments={options.selectedPullRequestComments}

src/features/layout/hooks/layoutNodes/buildPrimaryNodes.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,9 +278,10 @@ export function buildPrimaryNodes(options: LayoutNodesOptions): PrimaryLayoutNod
278278
/>
279279
) : null;
280280

281+
const showDesktopBackToChat = options.centerMode === "diff";
281282
const desktopTopbarLeftNode = (
282283
<>
283-
{options.centerMode === "diff" && (
284+
{showDesktopBackToChat && (
284285
<button
285286
className="icon-button back-button"
286287
onClick={options.onExitDiff}

src/features/layout/hooks/layoutNodes/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export type LayoutNodesOptions = {
229229
launchScriptsState?: WorkspaceLaunchScriptsState;
230230
mainHeaderActionsNode?: ReactNode;
231231
centerMode: "chat" | "diff";
232+
splitChatDiffView: boolean;
232233
onExitDiff: () => void;
233234
activeTab: "home" | "projects" | "codex" | "git" | "log";
234235
onSelectTab: (tab: "home" | "projects" | "codex" | "git" | "log") => void;

0 commit comments

Comments
 (0)