Skip to content

Commit 19529bc

Browse files
committed
Unify agent pane drag behavior
1 parent d83611a commit 19529bc

12 files changed

Lines changed: 450 additions & 145 deletions

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export type PaneDropPlacement = "left" | "right" | "top" | "bottom" | "center";
22

3-
export type PaneDropTargetType = "session" | "draft";
3+
export type PaneDropTargetType = "session" | "draft" | "editor";
44

55
export interface PaneDropIntent {
66
sourcePaneId: string;

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ import {
1212
closePaneBySessionId,
1313
convertDraftPaneToEditor,
1414
enforceSingleEditorPaneInvariant,
15-
insertPaneAtEdge,
16-
moveSessionToDraftPane,
15+
insertPaneAtEdge as insertPaneNodeAtEdge,
1716
removePaneBySessionId,
1817
replaceSessionInPane,
1918
splitPaneByPaneId,
2019
splitPaneBySessionId,
20+
swapPaneLeavesByPaneId,
2121
swapPaneSessionsByPaneId,
2222
} from "../pane-layout-tree";
2323
import type { PaneDropPlacement } from "./pane-drag-types";
@@ -141,20 +141,22 @@ export function usePaneActions(workspaceId: string) {
141141
[applyLayout]
142142
);
143143

144-
const moveSessionToDraft = useCallback(
144+
const swapPaneLeaves = useCallback(
145145
(sourcePaneId: string, targetPaneId: string) => {
146-
applyLayout((current) => moveSessionToDraftPane(current, sourcePaneId, targetPaneId));
146+
applyLayout((current) => swapPaneLeavesByPaneId(current, sourcePaneId, targetPaneId));
147147
},
148148
[applyLayout]
149149
);
150150

151-
const insertSessionPaneAtEdge = useCallback(
151+
const insertPaneAtEdge = useCallback(
152152
(
153153
sourcePaneId: string,
154154
targetPaneId: string,
155155
placement: Exclude<PaneDropPlacement, "center">
156156
) => {
157-
applyLayout((current) => insertPaneAtEdge(current, sourcePaneId, targetPaneId, placement));
157+
applyLayout((current) =>
158+
insertPaneNodeAtEdge(current, sourcePaneId, targetPaneId, placement)
159+
);
158160
},
159161
[applyLayout]
160162
);
@@ -172,8 +174,8 @@ export function usePaneActions(workspaceId: string) {
172174
replaceWithSession,
173175
splitDraftPane,
174176
splitSessionPane,
177+
swapPaneLeaves,
175178
swapPaneSessions,
176-
moveSessionToDraft,
177-
insertSessionPaneAtEdge,
179+
insertPaneAtEdge,
178180
};
179181
}

packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("usePaneDragController", () => {
4747
expect(result.current.state.previewPosition).toEqual({ x: 130, y: 180 });
4848
});
4949

50-
it("treats draft panes as center-only targets and dispatches a center drop intent", () => {
50+
it("treats draft panes like other pane targets and dispatches edge drop intents", () => {
5151
const onDrop = vi.fn();
5252
const { result } = renderHook(() => usePaneDragController({ onDrop }));
5353

@@ -60,7 +60,7 @@ describe("usePaneDragController", () => {
6060
result.current.handlePointerMove({ clientX: 310, clientY: 140 } as PointerEvent);
6161
});
6262

63-
expect(result.current.state.hoverPlacement).toBe("center");
63+
expect(result.current.state.hoverPlacement).toBe("left");
6464

6565
act(() => {
6666
result.current.handlePointerUp();
@@ -69,7 +69,7 @@ describe("usePaneDragController", () => {
6969
expect(onDrop).toHaveBeenCalledWith({
7070
sourcePaneId: "source-pane",
7171
targetPaneId: "draft-pane",
72-
placement: "center",
72+
placement: "left",
7373
targetType: "draft",
7474
});
7575
expect(result.current.state.isDragging).toBe(false);

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,6 @@ function resolvePlacement(
7070
return null;
7171
}
7272

73-
if (pane.type === "draft") {
74-
return "center";
75-
}
76-
7773
const edgeX = clampEdgeBand(rect.width);
7874
const edgeY = clampEdgeBand(rect.height);
7975

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

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ type MockDraftLauncherProps = {
113113
dragState?: {
114114
isActiveDropTarget: boolean;
115115
isDragging: boolean;
116-
hoverPlacement: "center" | null;
116+
hoverPlacement: PaneDropIntent["placement"] | null;
117117
};
118118
workspaceId: string;
119119
paneId?: string;
@@ -168,14 +168,32 @@ vi.mock("./views/shared/draft-launcher", async () => {
168168
const mockEditorPaneCard = vi.fn(
169169
({
170170
paneId,
171+
dragState,
171172
onClosePane,
173+
onPaneDragStart,
172174
}: {
173175
paneId: string;
174176
workspaceId: string;
177+
dragState?: {
178+
isActiveDropTarget: boolean;
179+
isDragging: boolean;
180+
hoverPlacement: PaneDropIntent["placement"] | null;
181+
};
175182
onClosePane: (paneId: string) => void;
183+
onPaneDragStart?: (source: PaneDragSourceSnapshot) => void;
176184
onSplitPane: (paneId: string, direction: "horizontal" | "vertical") => void;
177185
}) => (
178-
<div data-testid={`editor-pane-${paneId}`}>
186+
<div
187+
data-testid={`editor-pane-${paneId}`}
188+
data-dragging={dragState?.isDragging ? "true" : undefined}
189+
data-drop-target={dragState?.isActiveDropTarget ? "true" : undefined}
190+
data-hover-placement={dragState?.hoverPlacement ?? undefined}
191+
>
192+
{onPaneDragStart ? (
193+
<button type="button" onPointerDown={() => onPaneDragStart({ paneId, title: paneId })}>
194+
drag-{paneId}
195+
</button>
196+
) : null}
179197
<button type="button" onClick={() => onClosePane(paneId)}>
180198
close-editor-{paneId}
181199
</button>
@@ -187,7 +205,13 @@ vi.mock("./views/shared/editor-pane-card", () => ({
187205
EditorPaneCard: (props: {
188206
paneId: string;
189207
workspaceId: string;
208+
dragState?: {
209+
isActiveDropTarget: boolean;
210+
isDragging: boolean;
211+
hoverPlacement: PaneDropIntent["placement"] | null;
212+
};
190213
onClosePane: (paneId: string) => void;
214+
onPaneDragStart?: (source: PaneDragSourceSnapshot) => void;
191215
onSplitPane: (paneId: string, direction: "horizontal" | "vertical") => void;
192216
}) => mockEditorPaneCard(props),
193217
}));
@@ -586,7 +610,7 @@ describe("AgentPanes", () => {
586610
);
587611
});
588612

589-
it("moves a session into a draft pane on a center drop over a draft target", async () => {
613+
it("swaps a session with a draft pane on a center drop over a draft target", async () => {
590614
const sendCommand = vi.fn(async (op: string, args?: Record<string, unknown>) => {
591615
if (op === "session.list") {
592616
return [
@@ -644,9 +668,14 @@ describe("AgentPanes", () => {
644668

645669
await waitFor(() => {
646670
expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({
647-
id: "right",
648-
type: "leaf",
649-
sessionId: "sess_1",
671+
id: "root",
672+
type: "split",
673+
direction: "horizontal",
674+
ratio: 0.5,
675+
children: [
676+
{ id: "left", type: "leaf" },
677+
{ id: "right", type: "leaf", sessionId: "sess_1" },
678+
],
650679
});
651680
});
652681

@@ -656,9 +685,14 @@ describe("AgentPanes", () => {
656685
workspaceId: "ws-1",
657686
uiState: expect.objectContaining({
658687
paneLayout: {
659-
id: "right",
660-
type: "leaf",
661-
sessionId: "sess_1",
688+
id: "root",
689+
type: "split",
690+
direction: "horizontal",
691+
ratio: 0.5,
692+
children: [
693+
{ id: "left", type: "leaf" },
694+
{ id: "right", type: "leaf", sessionId: "sess_1" },
695+
],
662696
},
663697
}),
664698
}),
@@ -865,6 +899,103 @@ describe("AgentPanes", () => {
865899
expect(document.body).not.toHaveClass("is-dragging-pane");
866900
});
867901

902+
it("registers editor pane wrappers as drop targets and swaps with a session through pointer drag", async () => {
903+
const { store } = createAgentPaneStore({
904+
id: "root",
905+
type: "split",
906+
direction: "horizontal",
907+
ratio: 0.5,
908+
children: [
909+
{ id: "left", type: "leaf", leafKind: "session", sessionId: "sess_1" },
910+
{ id: "right", type: "leaf", leafKind: "editor" },
911+
],
912+
});
913+
store.set(activeEditorPaneIdAtomFamily("ws-1"), "right");
914+
store.set(focusedEditorPaneIdAtomFamily("ws-1"), "right");
915+
916+
render(
917+
<Provider store={store}>
918+
<AgentPanes hydrateSessions={false} />
919+
</Provider>
920+
);
921+
922+
const editorPane = setPaneRect("right", { left: 260, top: 0, width: 220, height: 180 });
923+
setPaneRect("left", { left: 0, top: 0, width: 220, height: 180 });
924+
925+
fireEvent.pointerDown(screen.getByRole("button", { name: "drag-sess_1" }));
926+
fireEvent.pointerMove(window, { clientX: 370, clientY: 90 });
927+
928+
await waitFor(() => {
929+
expect(editorPane).toHaveAttribute("data-pane-drop-target", "true");
930+
expect(editorPane).toHaveAttribute("data-pane-hover-placement", "center");
931+
});
932+
933+
fireEvent.pointerUp(window, { clientX: 370, clientY: 90 });
934+
935+
await waitFor(() => {
936+
expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({
937+
id: "root",
938+
type: "split",
939+
direction: "horizontal",
940+
ratio: 0.5,
941+
children: [
942+
{ id: "left", type: "leaf", leafKind: "editor" },
943+
{ id: "right", type: "leaf", leafKind: "session", sessionId: "sess_1" },
944+
],
945+
});
946+
expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBe("left");
947+
});
948+
});
949+
950+
it("keeps editor focus attached when the editor pane is dragged over a session", async () => {
951+
const { store } = createAgentPaneStore({
952+
id: "root",
953+
type: "split",
954+
direction: "horizontal",
955+
ratio: 0.5,
956+
children: [
957+
{ id: "left", type: "leaf", leafKind: "editor" },
958+
{ id: "right", type: "leaf", leafKind: "session", sessionId: "sess_1" },
959+
],
960+
});
961+
store.set(activeEditorPaneIdAtomFamily("ws-1"), "left");
962+
store.set(focusedEditorPaneIdAtomFamily("ws-1"), "left");
963+
964+
render(
965+
<Provider store={store}>
966+
<AgentPanes hydrateSessions={false} />
967+
</Provider>
968+
);
969+
970+
setPaneRect("left", { left: 0, top: 0, width: 220, height: 180 });
971+
const sessionPane = setPaneRect("right", { left: 260, top: 0, width: 220, height: 180 });
972+
973+
fireEvent.pointerDown(screen.getByRole("button", { name: "drag-left" }));
974+
fireEvent.pointerMove(window, { clientX: 370, clientY: 90 });
975+
976+
await waitFor(() => {
977+
expect(sessionPane).toHaveAttribute("data-pane-drop-target", "true");
978+
expect(sessionPane).toHaveAttribute("data-pane-hover-placement", "center");
979+
});
980+
981+
fireEvent.pointerUp(window, { clientX: 370, clientY: 90 });
982+
983+
await waitFor(() => {
984+
expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({
985+
id: "root",
986+
type: "split",
987+
direction: "horizontal",
988+
ratio: 0.5,
989+
children: [
990+
{ id: "left", type: "leaf", leafKind: "session", sessionId: "sess_1" },
991+
{ id: "right", type: "leaf", leafKind: "editor" },
992+
],
993+
});
994+
expect(store.get(activeEditorPaneIdAtomFamily("ws-1"))).toBe("right");
995+
expect(store.get(focusedEditorPaneIdAtomFamily("ws-1"))).toBe("right");
996+
});
997+
});
998+
868999
it("keeps the remaining draft pane visible after closing the last session pane", async () => {
8691000
const { store } = createAgentPaneStore({
8701001
id: "root",

0 commit comments

Comments
 (0)