Skip to content

Commit 779c8b5

Browse files
committed
fix(web): allow multiple editor panes
1 parent 1dd2746 commit 779c8b5

16 files changed

Lines changed: 257 additions & 156 deletions

packages/web/src/features/agent-panes/actions/use-pane-actions.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
closeEditorPaneById,
1212
closePaneBySessionId,
1313
convertDraftPaneToEditor,
14-
enforceSingleEditorPaneInvariant,
1514
insertPaneAtEdge as insertPaneNodeAtEdge,
1615
removePaneBySessionId,
1716
replaceSessionInPane,
@@ -31,10 +30,9 @@ export function usePaneActions(workspaceId: string) {
3130
(update: PaneNode | ((current: PaneNode) => PaneNode)) => {
3231
const current = store.get(paneLayoutAtomFamily(workspaceId));
3332
const next = typeof update === "function" ? update(current) : update;
34-
const normalized = enforceSingleEditorPaneInvariant(next);
35-
setPaneLayout(normalized);
36-
void persistUiState({ paneLayout: normalized });
37-
return normalized;
33+
setPaneLayout(next);
34+
void persistUiState({ paneLayout: next });
35+
return next;
3836
},
3937
[persistUiState, setPaneLayout, store, workspaceId]
4038
);

packages/web/src/features/agent-panes/atoms/editor-panes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ export const focusedEditorPaneIdAtomFamily = atomFamily(() => atom<string | null
77

88
export const activeEditorPaneIdAtomFamily = atomFamily(() => atom<string | null>(null));
99

10+
export function getEditorPaneStateKey(workspaceId: string, paneId: string): string {
11+
return `${workspaceId}::${paneId}`;
12+
}
13+
1014
export const editorPaneActiveFilePathAtomFamily = atomFamily(() => atom<string | null>(null));
1115

1216
export const editorPaneModeAtomFamily = atomFamily(() => atom<WorkspaceEditorMode>("preview"));

packages/web/src/features/agent-panes/atoms/pane-layout.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
normalizePaneLayout,
66
readLegacyPaneLayout,
77
readPaneRatio,
8+
toWorkspacePaneLayout,
89
writePaneRatio,
910
} from "./pane-layout";
1011

@@ -52,7 +53,7 @@ describe("pane layout storage helpers", () => {
5253
});
5354
});
5455

55-
it("normalizes persisted layouts back to a single editor pane", () => {
56+
it("preserves multiple editor panes from persisted layouts", () => {
5657
expect(
5758
normalizePaneLayout({
5859
id: "root",
@@ -69,7 +70,7 @@ describe("pane layout storage helpers", () => {
6970
direction: "horizontal",
7071
children: [
7172
{ id: "left", type: "leaf", leafKind: "editor" },
72-
{ id: "right", type: "leaf", leafKind: "draft" },
73+
{ id: "right", type: "leaf", leafKind: "editor" },
7374
],
7475
});
7576
});
@@ -97,4 +98,27 @@ describe("pane layout storage helpers", () => {
9798
],
9899
});
99100
});
101+
102+
it("serializes pane layouts to the strict workspace uiState schema", () => {
103+
expect(
104+
toWorkspacePaneLayout({
105+
id: "root",
106+
type: "split",
107+
direction: "horizontal",
108+
ratio: 0.42,
109+
children: [
110+
{ id: "left", type: "leaf", leafKind: "editor", sessionId: "ignored-session" },
111+
{ id: "right", type: "leaf", leafKind: "session", sessionId: "sess-1" },
112+
],
113+
})
114+
).toEqual({
115+
id: "root",
116+
type: "split",
117+
direction: "horizontal",
118+
children: [
119+
{ id: "left", type: "leaf", leafKind: "editor" },
120+
{ id: "right", type: "leaf", leafKind: "session", sessionId: "sess-1" },
121+
],
122+
});
123+
});
100124
});

packages/web/src/features/agent-panes/atoms/pane-layout.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
* Server-backed pane layout projection owned by the agent-panes feature.
55
*/
66

7-
import type { WorkspacePaneLeafKind, WorkspacePaneNode } from "@coder-studio/core";
7+
import type {
8+
WorkspacePaneLeafKind,
9+
WorkspacePaneNode,
10+
WorkspacePaneSplit,
11+
} from "@coder-studio/core";
812
import { atom } from "jotai";
913
import { atomFamily } from "jotai-family";
10-
import { enforceSingleEditorPaneInvariant } from "../pane-layout-tree";
1114

1215
/**
1316
* Pane layout by workspace (agent pane splits).
@@ -40,7 +43,7 @@ export const defaultPaneLayout: PaneNode = {
4043
leafKind: "draft",
4144
};
4245

43-
export const paneLayoutAtomFamily = atomFamily((workspaceId: string) =>
46+
export const paneLayoutAtomFamily = atomFamily((_workspaceId: string) =>
4447
atom<PaneNode>(defaultPaneLayout)
4548
);
4649

@@ -77,8 +80,53 @@ export function readLegacyPaneLayout(workspaceId: string): PaneNode | null {
7780
export function normalizePaneLayout(
7881
layout: WorkspacePaneNode | PaneNode | null | undefined
7982
): PaneNode | null {
80-
const normalized = normalizePaneLayoutNode(layout);
81-
return normalized ? enforceSingleEditorPaneInvariant(normalized) : null;
83+
return normalizePaneLayoutNode(layout);
84+
}
85+
86+
export function toWorkspacePaneLayout(layout: WorkspacePaneNode | PaneNode): WorkspacePaneNode {
87+
if (layout.type === "leaf") {
88+
const leafKind = "leafKind" in layout ? layout.leafKind : undefined;
89+
const sessionId =
90+
"sessionId" in layout && typeof layout.sessionId === "string" ? layout.sessionId : undefined;
91+
92+
if ((leafKind === "session" || !leafKind) && sessionId) {
93+
return {
94+
id: layout.id,
95+
type: "leaf",
96+
leafKind: "session",
97+
sessionId,
98+
};
99+
}
100+
101+
if (leafKind === "editor") {
102+
return {
103+
id: layout.id,
104+
type: "leaf",
105+
leafKind: "editor",
106+
};
107+
}
108+
109+
return {
110+
id: layout.id,
111+
type: "leaf",
112+
leafKind: "draft",
113+
};
114+
}
115+
116+
const next: WorkspacePaneSplit = {
117+
id: layout.id,
118+
type: "split",
119+
};
120+
121+
if (layout.direction) {
122+
next.direction = layout.direction;
123+
}
124+
125+
if (layout.children) {
126+
next.children = layout.children.map((child) => toWorkspacePaneLayout(child));
127+
}
128+
129+
return next;
82130
}
83131

84132
function normalizePaneLayoutNode(

packages/web/src/features/agent-panes/components/session-card.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ describe("SessionCard", () => {
241241
paneLayout: {
242242
id: "root",
243243
type: "leaf",
244+
leafKind: "session",
244245
sessionId: "sess_654321",
245246
},
246247
})

packages/web/src/features/agent-panes/index.test.tsx

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import {
1818
import { LEGACY_PANE_LAYOUT_STORAGE_KEY_PREFIX, paneLayoutAtomFamily } from "./atoms/pane-layout";
1919
import { AgentPanes } from "./index";
2020

21+
function editorPaneStateKey(workspaceId: string, paneId: string): string {
22+
return `${workspaceId}::${paneId}`;
23+
}
24+
2125
type MockSessionCardProps = {
2226
dragState?: {
2327
isActiveDropTarget: boolean;
@@ -642,10 +646,9 @@ describe("AgentPanes", () => {
642646
id: "root",
643647
type: "split",
644648
direction: "horizontal",
645-
ratio: 0.5,
646649
children: [
647-
{ id: "left", type: "leaf", sessionId: "sess_2" },
648-
{ id: "right", type: "leaf", sessionId: "sess_1" },
650+
{ id: "left", type: "leaf", leafKind: "session", sessionId: "sess_2" },
651+
{ id: "right", type: "leaf", leafKind: "session", sessionId: "sess_1" },
649652
],
650653
},
651654
}),
@@ -732,10 +735,9 @@ describe("AgentPanes", () => {
732735
id: "root",
733736
type: "split",
734737
direction: "horizontal",
735-
ratio: 0.5,
736738
children: [
737-
{ id: "left", type: "leaf" },
738-
{ id: "right", type: "leaf", sessionId: "sess_1" },
739+
{ id: "left", type: "leaf", leafKind: "draft" },
740+
{ id: "right", type: "leaf", leafKind: "session", sessionId: "sess_1" },
739741
],
740742
},
741743
}),
@@ -834,10 +836,19 @@ describe("AgentPanes", () => {
834836
id: expect.stringMatching(/^split-right-left-/),
835837
type: "split",
836838
direction: "horizontal",
837-
ratio: 0.5,
838839
children: [
839-
expect.objectContaining({ id: "left", type: "leaf", sessionId: "sess_1" }),
840-
expect.objectContaining({ id: "right", type: "leaf", sessionId: "sess_2" }),
840+
expect.objectContaining({
841+
id: "left",
842+
type: "leaf",
843+
leafKind: "session",
844+
sessionId: "sess_1",
845+
}),
846+
expect.objectContaining({
847+
id: "right",
848+
type: "leaf",
849+
leafKind: "session",
850+
sessionId: "sess_2",
851+
}),
841852
],
842853
}),
843854
}),
@@ -1865,7 +1876,7 @@ describe("AgentPanes", () => {
18651876
expect(await screen.findByTestId("editor-pane-root")).toBeInTheDocument();
18661877
});
18671878

1868-
it("reuses the existing editor pane when another draft launcher opens a file", async () => {
1879+
it("converts another draft launcher into a new editor pane when it opens a file", async () => {
18691880
const { store } = createAgentPaneStore({
18701881
id: "root",
18711882
type: "split",
@@ -1875,6 +1886,10 @@ describe("AgentPanes", () => {
18751886
{ id: "right", type: "leaf", leafKind: "draft" },
18761887
],
18771888
});
1889+
store.set(
1890+
editorPaneActiveFilePathAtomFamily(editorPaneStateKey("ws-1", "left")),
1891+
"src/left.tsx"
1892+
);
18781893

18791894
render(
18801895
<Provider store={store}>
@@ -1890,12 +1905,17 @@ describe("AgentPanes", () => {
18901905
direction: "horizontal",
18911906
children: [
18921907
{ id: "left", type: "leaf", leafKind: "editor" },
1893-
{ id: "right", type: "leaf", leafKind: "draft" },
1908+
{ id: "right", type: "leaf", leafKind: "editor" },
18941909
],
18951910
});
1896-
expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBe("left");
1897-
expect(store.get(focusedEditorPaneIdAtomFamily("ws-1"))).toBe("left");
1898-
expect(store.get(editorPaneActiveFilePathAtomFamily("ws-1"))).toBe("src/app.tsx");
1911+
expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBe("right");
1912+
expect(store.get(focusedEditorPaneIdAtomFamily("ws-1"))).toBe("right");
1913+
expect(store.get(editorPaneActiveFilePathAtomFamily(editorPaneStateKey("ws-1", "left")))).toBe(
1914+
"src/left.tsx"
1915+
);
1916+
expect(store.get(editorPaneActiveFilePathAtomFamily(editorPaneStateKey("ws-1", "right")))).toBe(
1917+
"src/app.tsx"
1918+
);
18991919
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBeNull();
19001920
});
19011921

@@ -1908,7 +1928,10 @@ describe("AgentPanes", () => {
19081928
store.set(activeEditorPaneIdAtomFamily("ws-1"), "root");
19091929
store.set(focusedEditorPaneIdAtomFamily("ws-1"), "root");
19101930
store.set(activeFilePathAtomFamily("ws-1"), "src/global.tsx");
1911-
store.set(editorPaneActiveFilePathAtomFamily("ws-1"), "src/app.tsx");
1931+
store.set(
1932+
editorPaneActiveFilePathAtomFamily(editorPaneStateKey("ws-1", "root")),
1933+
"src/app.tsx"
1934+
);
19121935
store.set(openFilesAtomFamily("ws-1"), {
19131936
"src/global.tsx": {
19141937
kind: "text",
@@ -1943,7 +1966,9 @@ describe("AgentPanes", () => {
19431966
});
19441967
expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBeNull();
19451968
expect(store.get(focusedEditorPaneIdAtomFamily("ws-1"))).toBeNull();
1946-
expect(store.get(editorPaneActiveFilePathAtomFamily("ws-1"))).toBeNull();
1969+
expect(store.get(editorPaneActiveFilePathAtomFamily(editorPaneStateKey("ws-1", "root")))).toBe(
1970+
null
1971+
);
19471972
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/global.tsx");
19481973
expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({
19491974
"src/global.tsx": expect.objectContaining({
@@ -1962,7 +1987,10 @@ describe("AgentPanes", () => {
19621987
store.set(activeEditorPaneIdAtomFamily("ws-1"), "root");
19631988
store.set(focusedEditorPaneIdAtomFamily("ws-1"), "root");
19641989
store.set(activeFilePathAtomFamily("ws-1"), "src/app.tsx");
1965-
store.set(editorPaneActiveFilePathAtomFamily("ws-1"), "src/app.tsx");
1990+
store.set(
1991+
editorPaneActiveFilePathAtomFamily(editorPaneStateKey("ws-1", "root")),
1992+
"src/app.tsx"
1993+
);
19661994
store.set(openFilesAtomFamily("ws-1"), {
19671995
"src/app.tsx": {
19681996
kind: "text",
@@ -1983,7 +2011,9 @@ describe("AgentPanes", () => {
19832011
fireEvent.click(screen.getByRole("button", { name: "close-editor-root" }));
19842012

19852013
expect(store.get(activeFilePathAtomFamily("ws-1"))).toBe("src/app.tsx");
1986-
expect(store.get(editorPaneActiveFilePathAtomFamily("ws-1"))).toBeNull();
2014+
expect(store.get(editorPaneActiveFilePathAtomFamily(editorPaneStateKey("ws-1", "root")))).toBe(
2015+
null
2016+
);
19872017
expect(store.get(openFilesAtomFamily("ws-1"))).toEqual({
19882018
"src/app.tsx": expect.objectContaining({
19892019
path: "src/app.tsx",

0 commit comments

Comments
 (0)