Skip to content

Commit baf889d

Browse files
committed
Add mutually exclusive panel functionality
- Implement `resolveExclusivePanelAction` to manage the opening and closing of the diff panel and code viewer. - Create `useMutuallyExclusivePanels` hook to enforce that only one panel is open at a time. - Add tests for `resolveExclusivePanelAction` to cover various transition scenarios and edge cases.
1 parent 057fa60 commit baf889d

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { resolveExclusivePanelAction } from "./mutuallyExclusivePanels";
4+
5+
describe("resolveExclusivePanelAction", () => {
6+
// ─── Diff opens while code viewer is already open ──────────────────
7+
it("returns 'close-code-viewer' when diff transitions open while code viewer is open", () => {
8+
const result = resolveExclusivePanelAction(
9+
/* prevDiffOpen */ false,
10+
/* diffOpen */ true,
11+
/* prevCodeViewerOpen */ true,
12+
/* codeViewerOpen */ true,
13+
);
14+
expect(result).toBe("close-code-viewer");
15+
});
16+
17+
// ─── Code viewer opens while diff is already open ──────────────────
18+
it("returns 'close-diff' when code viewer transitions open while diff is open", () => {
19+
const result = resolveExclusivePanelAction(
20+
/* prevDiffOpen */ true,
21+
/* diffOpen */ true,
22+
/* prevCodeViewerOpen */ false,
23+
/* codeViewerOpen */ true,
24+
);
25+
expect(result).toBe("close-diff");
26+
});
27+
28+
// ─── No-op cases ──────────────────────────────────────────────────
29+
it("returns null when neither panel is open", () => {
30+
expect(resolveExclusivePanelAction(false, false, false, false)).toBeNull();
31+
});
32+
33+
it("returns null when only diff is open (no transition)", () => {
34+
expect(resolveExclusivePanelAction(true, true, false, false)).toBeNull();
35+
});
36+
37+
it("returns null when only code viewer is open (no transition)", () => {
38+
expect(resolveExclusivePanelAction(false, false, true, true)).toBeNull();
39+
});
40+
41+
it("returns null when diff opens but code viewer is closed", () => {
42+
expect(resolveExclusivePanelAction(false, true, false, false)).toBeNull();
43+
});
44+
45+
it("returns null when code viewer opens but diff is closed", () => {
46+
expect(resolveExclusivePanelAction(false, false, false, true)).toBeNull();
47+
});
48+
49+
it("returns null when diff closes (code viewer still closed)", () => {
50+
expect(resolveExclusivePanelAction(true, false, false, false)).toBeNull();
51+
});
52+
53+
it("returns null when code viewer closes (diff still closed)", () => {
54+
expect(resolveExclusivePanelAction(false, false, true, false)).toBeNull();
55+
});
56+
57+
// ─── Edge: both were already open (no transition) ─────────────────
58+
it("returns null when both were already open (no transition)", () => {
59+
expect(resolveExclusivePanelAction(true, true, true, true)).toBeNull();
60+
});
61+
62+
// ─── Edge: both transition open simultaneously ────────────────────
63+
it("prefers closing code viewer when both open simultaneously (diff wins)", () => {
64+
// Both transition false → true in the same tick. Diff check runs first,
65+
// so code viewer gets closed.
66+
const result = resolveExclusivePanelAction(false, true, false, true);
67+
expect(result).toBe("close-code-viewer");
68+
});
69+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { useEffect, useRef } from "react";
2+
3+
/**
4+
* Given previous and current open states for the diff panel and code viewer,
5+
* returns which panel should be closed to enforce mutual exclusivity, or `null`
6+
* if no action is needed.
7+
*
8+
* The rule is: whichever panel just transitioned from closed → open wins; the
9+
* other panel is closed.
10+
*/
11+
export function resolveExclusivePanelAction(
12+
prevDiffOpen: boolean,
13+
diffOpen: boolean,
14+
prevCodeViewerOpen: boolean,
15+
codeViewerOpen: boolean,
16+
): "close-code-viewer" | "close-diff" | null {
17+
// Diff just opened while code viewer is already open → close code viewer
18+
if (diffOpen && !prevDiffOpen && codeViewerOpen) {
19+
return "close-code-viewer";
20+
}
21+
22+
// Code viewer just opened while diff is already open → close diff
23+
if (codeViewerOpen && !prevCodeViewerOpen && diffOpen) {
24+
return "close-diff";
25+
}
26+
27+
return null;
28+
}
29+
30+
/**
31+
* Ensures that the diff panel and code viewer are never open simultaneously.
32+
* When one panel transitions from closed → open while the other is already open,
33+
* the previously-open panel is closed. This prevents overlapping fixed-position
34+
* sidebars and the phantom gap that results from two sidebar gap divs reserving
35+
* layout space while the fixed containers stack at `right: 0`.
36+
*/
37+
export function useMutuallyExclusivePanels(
38+
diffOpen: boolean,
39+
codeViewerOpen: boolean,
40+
closeDiff: () => void,
41+
closeCodeViewer: () => void,
42+
) {
43+
const prevDiffOpen = useRef(diffOpen);
44+
const prevCodeViewerOpen = useRef(codeViewerOpen);
45+
46+
useEffect(() => {
47+
const wasDiffOpen = prevDiffOpen.current;
48+
const wasCodeViewerOpen = prevCodeViewerOpen.current;
49+
prevDiffOpen.current = diffOpen;
50+
prevCodeViewerOpen.current = codeViewerOpen;
51+
52+
const action = resolveExclusivePanelAction(wasDiffOpen, diffOpen, wasCodeViewerOpen, codeViewerOpen);
53+
if (action === "close-code-viewer") {
54+
closeCodeViewer();
55+
} else if (action === "close-diff") {
56+
closeDiff();
57+
}
58+
}, [diffOpen, codeViewerOpen, closeDiff, closeCodeViewer]);
59+
}

apps/web/src/routes/_chat.$threadId.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
stripDiffSearchParams,
2626
} from "../diffRouteSearch";
2727
import { useCodeViewerStore } from "../codeViewerStore";
28+
import { useMutuallyExclusivePanels } from "../mutuallyExclusivePanels";
2829
import { useMediaQuery } from "../hooks/useMediaQuery";
2930
import { useStore } from "../store";
3031
import { Sheet, SheetPopup } from "../components/ui/sheet";
@@ -308,6 +309,9 @@ function ChatThreadRouteView() {
308309
// No-op — code viewer opens when files are added via the store
309310
}, []);
310311

312+
// Enforce mutual exclusivity: only one right-side panel open at a time.
313+
useMutuallyExclusivePanels(diffOpen, codeViewerOpen, closeDiff, closeCodeViewer);
314+
311315
useEffect(() => {
312316
if (diffOpen) {
313317
setHasOpenedDiff(true);

0 commit comments

Comments
 (0)