From da98b19462392e8cf7f7320cc1f6a4e4b24d6e5d Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:39:03 +0800 Subject: [PATCH 01/45] feat: add pane drag tree mutations --- .../agent-panes/pane-layout-tree.test.ts | 124 ++++++++++++- .../features/agent-panes/pane-layout-tree.ts | 174 ++++++++++++++++++ 2 files changed, 297 insertions(+), 1 deletion(-) diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts index 39fe8c6d..113a0deb 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { PaneNode } from "./atoms/pane-layout"; import { appendSessionToLayout, @@ -7,9 +7,12 @@ import { closeDraftPaneById, closePaneBySessionId, createFallbackPaneLayout, + insertPaneAtEdge, + moveSessionToDraftPane, removePaneBySessionId, splitPaneByPaneId, splitPaneBySessionId, + swapPaneSessionsByPaneId, } from "./pane-layout-tree"; describe("pane-layout-tree", () => { @@ -104,6 +107,125 @@ describe("pane-layout-tree", () => { }); }); + it("swaps session ids between two session panes without changing pane ids", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(swapPaneSessionsByPaneId(layout, "left", "right")).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_2" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], + }); + }); + + it("moves a session into a draft leaf and collapses the old source branch", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { + id: "left-split", + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "left-top", type: "leaf", sessionId: "sess_1" }, + { id: "left-bottom", type: "leaf", sessionId: "sess_2" }, + ], + }, + { id: "right", type: "leaf" }, + ], + }; + + expect(moveSessionToDraftPane(layout, "left-bottom", "right")).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left-top", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }); + }); + + it("wraps the target leaf with a horizontal split on left insert", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1700000000000); + + try { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "left")).toEqual({ + id: "split-right-left-1700000000000", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }); + } finally { + nowSpy.mockRestore(); + } + }); + + it("returns the original tree when attempting to drag onto the same pane", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "left", "left")).toBe(layout); + expect(moveSessionToDraftPane(layout, "left", "left")).toBe(layout); + expect(swapPaneSessionsByPaneId(layout, "left", "left")).toBe(layout); + }); + + it("rejects draft edge insertion and preserves the input layout", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "right")).toBe(layout); + }); + it("splits a draft pane by pane id without relying on a session id marker", () => { const layout: PaneNode = { id: "root", diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.ts b/packages/web/src/features/agent-panes/pane-layout-tree.ts index 534ee1b1..bf42f6fe 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.ts @@ -1,6 +1,7 @@ import type { PaneNode } from "./atoms/pane-layout"; type PaneDirection = NonNullable; +type PaneDropPlacement = "left" | "right" | "top" | "bottom" | "center"; function createDraftLeaf(id: string): PaneNode { return { @@ -17,6 +18,101 @@ function createSessionLeaf(id: string, sessionId: string): PaneNode { }; } +function createDragSplitId( + targetPaneId: string, + placement: Exclude +): string { + return `split-${targetPaneId}-${placement}-${Date.now()}`; +} + +function findLeafByPaneId(node: PaneNode, paneId: string): PaneNode | null { + if (node.type === "leaf") { + return node.id === paneId ? node : null; + } + + for (const child of node.children ?? []) { + const match = findLeafByPaneId(child, paneId); + if (match) { + return match; + } + } + + return null; +} + +function replaceLeafByPaneId( + node: PaneNode, + paneId: string, + replace: (leaf: PaneNode) => PaneNode +): PaneNode { + if (node.type === "leaf") { + if (node.id !== paneId) { + return node; + } + + return replace(node); + } + + const children = node.children ?? []; + let changed = false; + const nextChildren = children.map((child) => { + const nextChild = replaceLeafByPaneId(child, paneId, replace); + if (nextChild !== child) { + changed = true; + } + return nextChild; + }); + + if (!changed) { + return node; + } + + return { + ...node, + children: nextChildren, + }; +} + +function removeLeafByPaneId(node: PaneNode, paneId: string): PaneNode | null { + if (node.type === "leaf") { + if (node.id === paneId) { + return null; + } + + return node; + } + + const children = node.children ?? []; + let changed = false; + const nextChildren: PaneNode[] = []; + for (const child of children) { + const nextChild = removeLeafByPaneId(child, paneId); + if (nextChild !== child) { + changed = true; + } + if (nextChild !== null) { + nextChildren.push(nextChild); + } + } + + if (!changed) { + return node; + } + + if (nextChildren.length === 1) { + return nextChildren[0]!; + } + + if (nextChildren.length === 0) { + return null; + } + + return { + ...node, + children: nextChildren, + }; +} + export function splitPaneByPaneId( node: PaneNode, paneId: string, @@ -165,6 +261,84 @@ export function replaceSessionInPane( }; } +export function swapPaneSessionsByPaneId( + node: PaneNode, + sourcePaneId: string, + targetPaneId: string +): PaneNode { + if (sourcePaneId === targetPaneId) { + return node; + } + + const source = findLeafByPaneId(node, sourcePaneId); + const target = findLeafByPaneId(node, targetPaneId); + if (!source?.sessionId || !target?.sessionId) { + return node; + } + + const withSourceSwapped = replaceLeafByPaneId(node, sourcePaneId, (leaf) => ({ + ...leaf, + sessionId: target.sessionId, + })); + + return replaceLeafByPaneId(withSourceSwapped, targetPaneId, (leaf) => ({ + ...leaf, + sessionId: source.sessionId!, + })); +} + +export function moveSessionToDraftPane( + node: PaneNode, + sourcePaneId: string, + targetPaneId: string +): PaneNode { + if (sourcePaneId === targetPaneId) { + return node; + } + + const source = findLeafByPaneId(node, sourcePaneId); + const target = findLeafByPaneId(node, targetPaneId); + if (!source?.sessionId || !target || target.sessionId) { + return node; + } + + const stripped = removeLeafByPaneId(node, sourcePaneId) ?? { id: node.id, type: "leaf" }; + return assignSessionToPane(stripped, targetPaneId, source.sessionId); +} + +export function insertPaneAtEdge( + node: PaneNode, + sourcePaneId: string, + targetPaneId: string, + placement: Exclude +): PaneNode { + if (sourcePaneId === targetPaneId) { + return node; + } + + const source = findLeafByPaneId(node, sourcePaneId); + const target = findLeafByPaneId(node, targetPaneId); + if (!source?.sessionId || !target?.sessionId) { + return node; + } + + const stripped = removeLeafByPaneId(node, sourcePaneId) ?? { id: node.id, type: "leaf" }; + const incomingLeaf: PaneNode = { + id: source.id, + type: "leaf", + sessionId: source.sessionId, + }; + + return replaceLeafByPaneId(stripped, targetPaneId, (leaf) => ({ + id: createDragSplitId(leaf.id, placement), + type: "split", + direction: placement === "left" || placement === "right" ? "horizontal" : "vertical", + ratio: 0.5, + children: + placement === "left" || placement === "top" ? [incomingLeaf, leaf] : [leaf, incomingLeaf], + })); +} + export function closePaneBySessionId(node: PaneNode, sessionId: string): PaneNode { // Handle draft pane closure: __draft__ if (sessionId.startsWith("__draft__")) { From 2f882033d68f08f78b892c5dbcc79a8b4d76519d Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:53:42 +0800 Subject: [PATCH 02/45] test: cover pane drag tree edge cases --- .../agent-panes/pane-layout-tree.test.ts | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) diff --git a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts index 113a0deb..9ab6f97f 100644 --- a/packages/web/src/features/agent-panes/pane-layout-tree.test.ts +++ b/packages/web/src/features/agent-panes/pane-layout-tree.test.ts @@ -131,6 +131,66 @@ describe("pane-layout-tree", () => { }); }); + it("returns the original tree when swap source pane is a draft leaf", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(swapPaneSessionsByPaneId(layout, "left", "right")).toBe(layout); + }); + + it("returns the original tree when swap target pane is a draft leaf", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf" }, + ], + }; + + expect(swapPaneSessionsByPaneId(layout, "left", "right")).toBe(layout); + }); + + it("returns the original tree when swap source pane is missing", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(swapPaneSessionsByPaneId(layout, "missing", "right")).toBe(layout); + }); + + it("returns the original tree when swap target pane is missing", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(swapPaneSessionsByPaneId(layout, "left", "missing")).toBe(layout); + }); + it("moves a session into a draft leaf and collapses the old source branch", () => { const layout: PaneNode = { id: "root", @@ -164,6 +224,66 @@ describe("pane-layout-tree", () => { }); }); + it("returns the original tree when move source pane is missing", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf" }, + ], + }; + + expect(moveSessionToDraftPane(layout, "missing", "right")).toBe(layout); + }); + + it("returns the original tree when move target pane is missing", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf" }, + ], + }; + + expect(moveSessionToDraftPane(layout, "left", "missing")).toBe(layout); + }); + + it("returns the original tree when move target pane is a session leaf", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(moveSessionToDraftPane(layout, "left", "right")).toBe(layout); + }); + + it("returns the original tree when move source pane is a draft leaf", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf" }, + { id: "right", type: "leaf" }, + ], + }; + + expect(moveSessionToDraftPane(layout, "left", "right")).toBe(layout); + }); + it("wraps the target leaf with a horizontal split on left insert", () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1700000000000); @@ -194,6 +314,78 @@ describe("pane-layout-tree", () => { } }); + it("wraps the target leaf with a horizontal split on right insert", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "right")).toEqual({ + id: expect.stringMatching(/^split-right-right-/), + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "right", type: "leaf", sessionId: "sess_2" }, + { id: "left", type: "leaf", sessionId: "sess_1" }, + ], + }); + }); + + it("wraps the target leaf with a vertical split on top insert", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "top")).toEqual({ + id: expect.stringMatching(/^split-right-top-/), + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }); + }); + + it("wraps the target leaf with a vertical split on bottom insert", () => { + const layout: PaneNode = { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }; + + expect(insertPaneAtEdge(layout, "left", "right", "bottom")).toEqual({ + id: expect.stringMatching(/^split-right-bottom-/), + type: "split", + direction: "vertical", + ratio: 0.5, + children: [ + { id: "right", type: "leaf", sessionId: "sess_2" }, + { id: "left", type: "leaf", sessionId: "sess_1" }, + ], + }); + }); + it("returns the original tree when attempting to drag onto the same pane", () => { const layout: PaneNode = { id: "root", From 96c79d63b9601098f7fad399d16cd8d5b71279a2 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 00:53:44 +0800 Subject: [PATCH 03/45] test(web): add semantic color migration guardrails --- .../web/src/styles/color-system.guard.test.ts | 110 ++++++++++++++++++ .../web/src/styles/foundations.guard.test.ts | 3 + 2 files changed, 113 insertions(+) create mode 100644 packages/web/src/styles/color-system.guard.test.ts diff --git a/packages/web/src/styles/color-system.guard.test.ts b/packages/web/src/styles/color-system.guard.test.ts new file mode 100644 index 00000000..a3c0ae82 --- /dev/null +++ b/packages/web/src/styles/color-system.guard.test.ts @@ -0,0 +1,110 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const files = [ + "src/styles/base.css", + "src/styles/components.css", + "src/components/ui/workbench-layer/index.module.css", + "src/components/ui/button/index.module.css", + "src/components/ui/icon-button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/segmented-control/index.module.css", + "src/components/ui/kbd/index.module.css", + "src/components/ui/switch/index.module.css", + "src/components/ui/spinner/index.module.css", + "src/components/ui/action-menu/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/badge/index.module.css", + "src/components/ui/pill/index.module.css", + "src/components/ui/notice/index.module.css", + "src/components/ui/toast/index.module.css", + "src/components/ui/status-dot/index.module.css", + "src/components/ui/tooltip/index.module.css", + "src/components/ui/modal/index.module.css", + "src/components/ui/drawer/index.module.css", + "src/components/ui/local-overlay/index.module.css", + "src/components/ui/popover/index.module.css", + "src/components/ui/progress-bar/index.module.css", + "src/components/ui/empty-state/index.module.css", + "src/components/ui/confirm-dialog/index.module.css", +].map((file) => [file, readFileSync(`${process.cwd()}/${file}`, "utf8")] as const); + +const rawColorPattern = /#[0-9A-Fa-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(|color-mix\(|\bblur\(\d/; +const runtimePattern = /--app-surface-opacity|--app-surface-backdrop-filter|data-appearance-glass/; +const privateRefPattern = /var\(--ref-/; +const legacyPublicPattern = /var\(--(?:bg-|accent-|color-|ws-)/; + +const expectedRawColorConsumers = [ + "src/components/ui/action-menu/index.module.css", + "src/components/ui/button/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/icon-button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/kbd/index.module.css", + "src/components/ui/pill/index.module.css", + "src/components/ui/segmented-control/index.module.css", + "src/components/ui/spinner/index.module.css", + "src/components/ui/status-dot/index.module.css", + "src/components/ui/switch/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/styles/base.css", + "src/styles/components.css", +]; + +const expectedRuntimeConsumers = [ + "src/components/ui/workbench-layer/index.module.css", + "src/styles/base.css", + "src/styles/components.css", +]; + +const expectedLegacyPublicConsumers = [ + "src/components/ui/action-menu/index.module.css", + "src/components/ui/badge/index.module.css", + "src/components/ui/button/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/icon-button/index.module.css", + "src/components/ui/input/index.module.css", + "src/components/ui/kbd/index.module.css", + "src/components/ui/modal/index.module.css", + "src/components/ui/popover/index.module.css", + "src/components/ui/segmented-control/index.module.css", + "src/components/ui/spinner/index.module.css", + "src/components/ui/switch/index.module.css", + "src/components/ui/tabs/index.module.css", + "src/components/ui/tag/index.module.css", + "src/components/ui/textarea/index.module.css", + "src/components/ui/toast/index.module.css", + "src/styles/base.css", + "src/styles/components.css", +]; + +function offenders(pattern: RegExp) { + return files + .filter(([, source]) => pattern.test(source)) + .map(([file]) => file) + .sort(); +} + +describe("color-system migration guard", () => { + it("tracks the remaining raw-color consumers explicitly", () => { + expect(offenders(rawColorPattern)).toEqual(expectedRawColorConsumers); + }); + + it("tracks the remaining runtime appearance consumers explicitly", () => { + expect(offenders(runtimePattern)).toEqual(expectedRuntimeConsumers); + }); + + it("forbids private reference tokens outside tokens.css", () => { + expect(offenders(privateRefPattern)).toEqual([]); + }); + + it("tracks the remaining legacy public token consumers explicitly", () => { + expect(offenders(legacyPublicPattern)).toEqual(expectedLegacyPublicConsumers); + }); +}); diff --git a/packages/web/src/styles/foundations.guard.test.ts b/packages/web/src/styles/foundations.guard.test.ts index b6fa0567..e840faf4 100644 --- a/packages/web/src/styles/foundations.guard.test.ts +++ b/packages/web/src/styles/foundations.guard.test.ts @@ -27,6 +27,9 @@ const sharedUiSources = [ "src/components/ui/progress-bar/index.module.css", "src/components/ui/status-dot/index.module.css", "src/components/ui/empty-state/index.module.css", + "src/components/ui/confirm-dialog/index.module.css", + "src/components/ui/datetime-picker/index.module.css", + "src/components/ui/spinner/index.module.css", ].map((file) => [file, readFileSync(`${process.cwd()}/${file}`, "utf8")] as const); const rawFoundationPattern = From b1e0a16915327ffa0723cbc4d9f57b2000f4a667 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 01:05:08 +0800 Subject: [PATCH 04/45] feat: expose pane drag actions --- .../agent-panes/actions/pane-drag-types.ts | 10 + .../agent-panes/actions/use-pane-actions.ts | 32 ++ .../src/features/agent-panes/index.test.tsx | 368 +++++++++++++++++- .../web/src/features/agent-panes/index.tsx | 31 ++ 4 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 packages/web/src/features/agent-panes/actions/pane-drag-types.ts diff --git a/packages/web/src/features/agent-panes/actions/pane-drag-types.ts b/packages/web/src/features/agent-panes/actions/pane-drag-types.ts new file mode 100644 index 00000000..b18a8193 --- /dev/null +++ b/packages/web/src/features/agent-panes/actions/pane-drag-types.ts @@ -0,0 +1,10 @@ +export type PaneDropPlacement = "left" | "right" | "top" | "bottom" | "center"; + +export type PaneDropTargetType = "session" | "draft"; + +export interface PaneDropIntent { + sourcePaneId: string; + targetPaneId: string; + placement: PaneDropPlacement; + targetType: PaneDropTargetType; +} diff --git a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts index 8e04a470..a4ea6bad 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-actions.ts +++ b/packages/web/src/features/agent-panes/actions/use-pane-actions.ts @@ -9,11 +9,15 @@ import { assignSessionToPane, closeDraftPaneById, closePaneBySessionId, + insertPaneAtEdge, + moveSessionToDraftPane, removePaneBySessionId, replaceSessionInPane, splitPaneByPaneId, splitPaneBySessionId, + swapPaneSessionsByPaneId, } from "../pane-layout-tree"; +import type { PaneDropPlacement } from "./pane-drag-types"; export function usePaneActions(workspaceId: string) { const setPaneLayout = useSetAtom(paneLayoutAtomFamily(workspaceId)); @@ -111,6 +115,31 @@ export function usePaneActions(workspaceId: string) { [applyLayout] ); + const swapPaneSessions = useCallback( + (sourcePaneId: string, targetPaneId: string) => { + applyLayout((current) => swapPaneSessionsByPaneId(current, sourcePaneId, targetPaneId)); + }, + [applyLayout] + ); + + const moveSessionToDraft = useCallback( + (sourcePaneId: string, targetPaneId: string) => { + applyLayout((current) => moveSessionToDraftPane(current, sourcePaneId, targetPaneId)); + }, + [applyLayout] + ); + + const insertSessionPaneAtEdge = useCallback( + ( + sourcePaneId: string, + targetPaneId: string, + placement: Exclude + ) => { + applyLayout((current) => insertPaneAtEdge(current, sourcePaneId, targetPaneId, placement)); + }, + [applyLayout] + ); + return { appendSession, appendSessionToMobileColumn, @@ -122,5 +151,8 @@ export function usePaneActions(workspaceId: string) { replaceWithSession, splitDraftPane, splitSessionPane, + swapPaneSessions, + moveSessionToDraft, + insertSessionPaneAtEdge, }; } diff --git a/packages/web/src/features/agent-panes/index.test.tsx b/packages/web/src/features/agent-panes/index.test.tsx index a30ef44d..234e4a98 100644 --- a/packages/web/src/features/agent-panes/index.test.tsx +++ b/packages/web/src/features/agent-panes/index.test.tsx @@ -7,18 +7,28 @@ import { connectionStatusAtom, wsClientAtom } from "../../atoms/connection"; import { sessionsAtom } from "../../atoms/sessions"; import { activeWorkspaceIdAtom, workspacesLoadStateAtom } from "../../atoms/workspaces"; import { seedReadyWorkspaceState } from "../../test-utils/workspace-state"; +import type { PaneDropIntent } from "./actions/pane-drag-types"; import { LEGACY_PANE_LAYOUT_STORAGE_KEY_PREFIX, paneLayoutAtomFamily } from "./atoms/pane-layout"; import { AgentPanes } from "./index"; type MockSessionCardProps = { + paneId?: string; sessionId: string; onSplitHorizontal?: () => void; onSplitVertical?: () => void; onClose?: () => void; + onPaneDrop?: (intent: PaneDropIntent) => void; }; const mockSessionCard = vi.fn( - ({ sessionId, onSplitHorizontal, onSplitVertical, onClose }: MockSessionCardProps) => ( + ({ + paneId, + sessionId, + onSplitHorizontal, + onSplitVertical, + onClose, + onPaneDrop, + }: MockSessionCardProps) => (
{sessionId} + {paneId && onPaneDrop ? ( + <> + + + + ) : null}
) ); @@ -38,6 +78,46 @@ vi.mock("./views/shared/session-card", () => ({ SessionCard: (props: MockSessionCardProps) => mockSessionCard(props), })); +type MockDraftLauncherProps = { + workspaceId: string; + paneId?: string; + onAssignSession?: (paneId: string, sessionId: string) => void; + onClosePane?: (paneId: string) => void; + onReplaceWithSession?: (sessionId: string) => void; + onSplitPane?: (paneId: string, direction: "horizontal" | "vertical") => void; + onPaneDrop?: (intent: PaneDropIntent) => void; +}; + +vi.mock("./views/shared/draft-launcher", async () => { + const actual = await vi.importActual( + "./views/shared/draft-launcher" + ); + + return { + ...actual, + DraftLauncher: ({ paneId, onPaneDrop, ...props }: MockDraftLauncherProps) => ( +
+ + {paneId && onPaneDrop ? ( + + ) : null} +
+ ), + }; +}); + vi.mock("./views/shared/pane-layout", () => ({ PaneLayout: ({ children, @@ -306,6 +386,292 @@ describe("AgentPanes", () => { expect(sendCommand).toHaveBeenCalledWith("session.stop", { sessionId: "sess_1" }, undefined); }); + it("swaps pane sessions on a center drop over another session pane", async () => { + const sendCommand = vi.fn(async (op: string, args?: Record) => { + if (op === "session.list") { + return [ + { + id: "sess_1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "claude", + state: "running", + capability: "full", + startedAt: Date.now() - 10_000, + lastActiveAt: Date.now() - 1_000, + }, + { + id: "sess_2", + workspaceId: "ws-1", + terminalId: "term-2", + providerId: "codex", + state: "idle", + capability: "full", + startedAt: Date.now() - 8_000, + lastActiveAt: Date.now() - 500, + }, + ]; + } + + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "repo", + path: "/tmp/repo", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: args?.uiState, + }; + } + + return undefined; + }); + const { store } = createAgentPaneStore( + { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }, + sendCommand, + "connected" + ); + + render( + + + + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "drop-center-sess_1" })); + }); + + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_2" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], + }); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ + workspaceId: "ws-1", + uiState: expect.objectContaining({ + paneLayout: { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_2" }, + { id: "right", type: "leaf", sessionId: "sess_1" }, + ], + }, + }), + }), + undefined + ); + }); + + it("moves a session into a draft pane on a center drop over a draft target", async () => { + const sendCommand = vi.fn(async (op: string, args?: Record) => { + if (op === "session.list") { + return [ + { + id: "sess_1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "claude", + state: "running", + capability: "full", + startedAt: Date.now() - 10_000, + lastActiveAt: Date.now() - 1_000, + }, + ]; + } + + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "repo", + path: "/tmp/repo", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: args?.uiState, + }; + } + + return undefined; + }); + const { store } = createAgentPaneStore( + { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf" }, + ], + }, + sendCommand, + "connected" + ); + + render( + + + + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "move-to-draft-right" })); + }); + + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "right", + type: "leaf", + sessionId: "sess_1", + }); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ + workspaceId: "ws-1", + uiState: expect.objectContaining({ + paneLayout: { + id: "right", + type: "leaf", + sessionId: "sess_1", + }, + }), + }), + undefined + ); + }); + + it("inserts a dragged session at the target edge on an edge drop", async () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + + try { + const sendCommand = vi.fn(async (op: string, args?: Record) => { + if (op === "session.list") { + return [ + { + id: "sess_1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "claude", + state: "running", + capability: "full", + startedAt: Date.now() - 10_000, + lastActiveAt: Date.now() - 1_000, + }, + { + id: "sess_2", + workspaceId: "ws-1", + terminalId: "term-2", + providerId: "codex", + state: "idle", + capability: "full", + startedAt: Date.now() - 8_000, + lastActiveAt: Date.now() - 500, + }, + ]; + } + + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "repo", + path: "/tmp/repo", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: args?.uiState, + }; + } + + return undefined; + }); + const { store } = createAgentPaneStore( + { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }, + sendCommand, + "connected" + ); + + render( + + + + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "drop-left-sess_1" })); + }); + + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ + id: "split-right-left-1700000000000", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }); + }); + + expect(sendCommand).toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ + workspaceId: "ws-1", + uiState: expect.objectContaining({ + paneLayout: { + id: "split-right-left-1700000000000", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }, + }), + }), + undefined + ); + } finally { + nowSpy.mockRestore(); + } + }); + it("keeps the remaining draft pane visible after closing the last session pane", async () => { const { store } = createAgentPaneStore({ id: "root", diff --git a/packages/web/src/features/agent-panes/index.tsx b/packages/web/src/features/agent-panes/index.tsx index 654f283a..c555021a 100644 --- a/packages/web/src/features/agent-panes/index.tsx +++ b/packages/web/src/features/agent-panes/index.tsx @@ -10,6 +10,7 @@ import type { FC } from "react"; import { activeWorkspaceAtom } from "../../atoms/workspaces"; import { EmptyState } from "../../components/ui"; import { useTranslation } from "../../lib/i18n"; +import type { PaneDropIntent } from "./actions/pane-drag-types"; import { usePaneActions } from "./actions/use-pane-actions"; import { useSessionActions } from "./actions/use-session-actions"; import { useWorkspaceSessions } from "./actions/use-workspace-sessions"; @@ -52,6 +53,20 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { (hasLayoutSessions || (paneLayout.type === "leaf" && !paneLayout.sessionId && paneLayout.id === "root")); + const handlePaneDrop = (intent: PaneDropIntent) => { + if (intent.placement === "center") { + if (intent.targetType === "draft") { + paneActions.moveSessionToDraft(intent.sourcePaneId, intent.targetPaneId); + return; + } + + paneActions.swapPaneSessions(intent.sourcePaneId, intent.targetPaneId); + return; + } + + paneActions.insertSessionPaneAtEdge(intent.sourcePaneId, intent.targetPaneId, intent.placement); + }; + if (!workspace) { return (
@@ -83,6 +98,7 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { onSplitSession={paneActions.splitSessionPane} onCloseDraftPane={paneActions.closeDraftPane} onAssignSession={paneActions.assignSession} + onPaneDrop={handlePaneDrop} onReplaceWithSession={paneActions.replaceWithSession} onCloseSessionCommand={sessionActions.closeSession} /> @@ -100,6 +116,7 @@ interface PaneNodeRendererProps { sessionId: string, paneDisposition?: "draft" | "remove" ) => Promise; + onPaneDrop: (intent: PaneDropIntent) => void; onReplaceWithSession: (sessionId: string) => void; onSplitDraftPane: (paneId: string, direction: "horizontal" | "vertical") => void; onSplitSession: (sessionId: string, direction: "horizontal" | "vertical") => void; @@ -115,6 +132,7 @@ const PaneNodeRenderer: FC = ({ onCloseDraftPane, onCloseSession, onCloseSessionCommand, + onPaneDrop, onReplaceWithSession, onSplitDraftPane, onSplitSession, @@ -124,6 +142,13 @@ const PaneNodeRenderer: FC = ({ if (node.sessionId) { return ( void; + })} sessionId={node.sessionId} onClose={async () => { onCloseSession(node.sessionId!); @@ -140,6 +165,11 @@ const PaneNodeRenderer: FC = ({ paneId={node.id} onAssignSession={onAssignSession} onClosePane={onCloseDraftPane} + {...({ + onPaneDrop, + } satisfies { + onPaneDrop: (intent: PaneDropIntent) => void; + })} onReplaceWithSession={onReplaceWithSession} onSplitPane={onSplitDraftPane} /> @@ -166,6 +196,7 @@ const PaneNodeRenderer: FC = ({ onCloseDraftPane={onCloseDraftPane} onCloseSession={onCloseSession} onCloseSessionCommand={onCloseSessionCommand} + onPaneDrop={onPaneDrop} onReplaceWithSession={onReplaceWithSession} onSplitDraftPane={onSplitDraftPane} onSplitSession={onSplitSession} From bcef382a70f5d5f1e482cf839a072f53280afeb5 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 01:07:05 +0800 Subject: [PATCH 05/45] test(web): cover select in color migration guards --- packages/web/src/styles/color-system.guard.test.ts | 2 ++ packages/web/src/styles/foundations.guard.test.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/web/src/styles/color-system.guard.test.ts b/packages/web/src/styles/color-system.guard.test.ts index a3c0ae82..05e987b1 100644 --- a/packages/web/src/styles/color-system.guard.test.ts +++ b/packages/web/src/styles/color-system.guard.test.ts @@ -20,6 +20,7 @@ const files = [ "src/components/ui/tag/index.module.css", "src/components/ui/badge/index.module.css", "src/components/ui/pill/index.module.css", + "src/components/ui/select/index.module.css", "src/components/ui/notice/index.module.css", "src/components/ui/toast/index.module.css", "src/components/ui/status-dot/index.module.css", @@ -74,6 +75,7 @@ const expectedLegacyPublicConsumers = [ "src/components/ui/modal/index.module.css", "src/components/ui/popover/index.module.css", "src/components/ui/segmented-control/index.module.css", + "src/components/ui/select/index.module.css", "src/components/ui/spinner/index.module.css", "src/components/ui/switch/index.module.css", "src/components/ui/tabs/index.module.css", diff --git a/packages/web/src/styles/foundations.guard.test.ts b/packages/web/src/styles/foundations.guard.test.ts index e840faf4..7a0e562d 100644 --- a/packages/web/src/styles/foundations.guard.test.ts +++ b/packages/web/src/styles/foundations.guard.test.ts @@ -18,6 +18,7 @@ const sharedUiSources = [ "src/components/ui/tag/index.module.css", "src/components/ui/badge/index.module.css", "src/components/ui/pill/index.module.css", + "src/components/ui/select/index.module.css", "src/components/ui/tooltip/index.module.css", "src/components/ui/notice/index.module.css", "src/components/ui/modal/index.module.css", From 3701037c638d1b3891083285ed7095d725d0b14d Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 01:31:11 +0800 Subject: [PATCH 06/45] fix: tighten pane drag contracts --- .../src/features/agent-panes/index.test.tsx | 180 +++++++++--------- .../web/src/features/agent-panes/index.tsx | 15 +- .../views/shared/draft-launcher.tsx | 3 + .../agent-panes/views/shared/session-card.tsx | 5 + 4 files changed, 99 insertions(+), 104 deletions(-) diff --git a/packages/web/src/features/agent-panes/index.test.tsx b/packages/web/src/features/agent-panes/index.test.tsx index 234e4a98..ece49b00 100644 --- a/packages/web/src/features/agent-panes/index.test.tsx +++ b/packages/web/src/features/agent-panes/index.test.tsx @@ -567,109 +567,105 @@ describe("AgentPanes", () => { }); it("inserts a dragged session at the target edge on an edge drop", async () => { - const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); - - try { - const sendCommand = vi.fn(async (op: string, args?: Record) => { - if (op === "session.list") { - return [ - { - id: "sess_1", - workspaceId: "ws-1", - terminalId: "term-1", - providerId: "claude", - state: "running", - capability: "full", - startedAt: Date.now() - 10_000, - lastActiveAt: Date.now() - 1_000, - }, - { - id: "sess_2", - workspaceId: "ws-1", - terminalId: "term-2", - providerId: "codex", - state: "idle", - capability: "full", - startedAt: Date.now() - 8_000, - lastActiveAt: Date.now() - 500, - }, - ]; - } - - if (op === "workspace.uiState.set") { - return { - id: "ws-1", - name: "repo", - path: "/tmp/repo", - targetRuntime: "native", - openedAt: 1, - lastActiveAt: 1, - uiState: args?.uiState, - }; - } - - return undefined; - }); - const { store } = createAgentPaneStore( - { - id: "root", - type: "split", - direction: "horizontal", - ratio: 0.5, - children: [ - { id: "left", type: "leaf", sessionId: "sess_1" }, - { id: "right", type: "leaf", sessionId: "sess_2" }, - ], - }, - sendCommand, - "connected" - ); + const sendCommand = vi.fn(async (op: string, args?: Record) => { + if (op === "session.list") { + return [ + { + id: "sess_1", + workspaceId: "ws-1", + terminalId: "term-1", + providerId: "claude", + state: "running", + capability: "full", + startedAt: Date.now() - 10_000, + lastActiveAt: Date.now() - 1_000, + }, + { + id: "sess_2", + workspaceId: "ws-1", + terminalId: "term-2", + providerId: "codex", + state: "idle", + capability: "full", + startedAt: Date.now() - 8_000, + lastActiveAt: Date.now() - 500, + }, + ]; + } - render( - - - - ); + if (op === "workspace.uiState.set") { + return { + id: "ws-1", + name: "repo", + path: "/tmp/repo", + targetRuntime: "native", + openedAt: 1, + lastActiveAt: 1, + uiState: args?.uiState, + }; + } - await act(async () => { - fireEvent.click(screen.getByRole("button", { name: "drop-left-sess_1" })); - }); + return undefined; + }); + const { store } = createAgentPaneStore( + { + id: "root", + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + { id: "left", type: "leaf", sessionId: "sess_1" }, + { id: "right", type: "leaf", sessionId: "sess_2" }, + ], + }, + sendCommand, + "connected" + ); + + render( + + + + ); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "drop-left-sess_1" })); + }); - await waitFor(() => { - expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual({ - id: "split-right-left-1700000000000", + await waitFor(() => { + expect(store.get(paneLayoutAtomFamily("ws-1"))).toEqual( + expect.objectContaining({ + id: expect.stringMatching(/^split-right-left-/), type: "split", direction: "horizontal", ratio: 0.5, children: [ - { id: "left", type: "leaf", sessionId: "sess_1" }, - { id: "right", type: "leaf", sessionId: "sess_2" }, + expect.objectContaining({ id: "left", type: "leaf", sessionId: "sess_1" }), + expect.objectContaining({ id: "right", type: "leaf", sessionId: "sess_2" }), ], - }); - }); + }) + ); + }); - expect(sendCommand).toHaveBeenCalledWith( - "workspace.uiState.set", - expect.objectContaining({ - workspaceId: "ws-1", - uiState: expect.objectContaining({ - paneLayout: { - id: "split-right-left-1700000000000", - type: "split", - direction: "horizontal", - ratio: 0.5, - children: [ - { id: "left", type: "leaf", sessionId: "sess_1" }, - { id: "right", type: "leaf", sessionId: "sess_2" }, - ], - }, + expect(sendCommand).toHaveBeenCalledWith( + "workspace.uiState.set", + expect.objectContaining({ + workspaceId: "ws-1", + uiState: expect.objectContaining({ + paneLayout: expect.objectContaining({ + id: expect.stringMatching(/^split-right-left-/), + type: "split", + direction: "horizontal", + ratio: 0.5, + children: [ + expect.objectContaining({ id: "left", type: "leaf", sessionId: "sess_1" }), + expect.objectContaining({ id: "right", type: "leaf", sessionId: "sess_2" }), + ], }), }), - undefined - ); - } finally { - nowSpy.mockRestore(); - } + }), + undefined + ); }); it("keeps the remaining draft pane visible after closing the last session pane", async () => { diff --git a/packages/web/src/features/agent-panes/index.tsx b/packages/web/src/features/agent-panes/index.tsx index c555021a..e80292ff 100644 --- a/packages/web/src/features/agent-panes/index.tsx +++ b/packages/web/src/features/agent-panes/index.tsx @@ -142,13 +142,8 @@ const PaneNodeRenderer: FC = ({ if (node.sessionId) { return ( void; - })} + paneId={node.id} + onPaneDrop={onPaneDrop} sessionId={node.sessionId} onClose={async () => { onCloseSession(node.sessionId!); @@ -165,11 +160,7 @@ const PaneNodeRenderer: FC = ({ paneId={node.id} onAssignSession={onAssignSession} onClosePane={onCloseDraftPane} - {...({ - onPaneDrop, - } satisfies { - onPaneDrop: (intent: PaneDropIntent) => void; - })} + onPaneDrop={onPaneDrop} onReplaceWithSession={onReplaceWithSession} onSplitPane={onSplitDraftPane} /> diff --git a/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx b/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx index 9331751b..cf033243 100644 --- a/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx +++ b/packages/web/src/features/agent-panes/views/shared/draft-launcher.tsx @@ -7,6 +7,7 @@ import { sessionsAtom } from "../../../../atoms/sessions"; import { Button, IconButton, StatusDot, Tag, ThemedIcon, Tooltip } from "../../../../components/ui"; import { useTranslation } from "../../../../lib/i18n"; import { buildDiagnosticsPath } from "../../../diagnostics"; +import type { PaneDropIntent } from "../../actions/pane-drag-types"; import { type ProviderId, useProviderLauncher } from "../../actions/use-provider-launcher"; interface DraftLauncherProps { @@ -14,6 +15,7 @@ interface DraftLauncherProps { paneId?: string; onAssignSession?: (paneId: string, sessionId: string) => void; onClosePane?: (paneId: string) => void; + onPaneDrop?: (intent: PaneDropIntent) => void; onReplaceWithSession?: (sessionId: string) => void; onSplitPane?: (paneId: string, direction: "horizontal" | "vertical") => void; } @@ -23,6 +25,7 @@ export const DraftLauncher: FC = ({ paneId, onAssignSession, onClosePane, + onPaneDrop: _onPaneDrop, onReplaceWithSession, onSplitPane, }) => { diff --git a/packages/web/src/features/agent-panes/views/shared/session-card.tsx b/packages/web/src/features/agent-panes/views/shared/session-card.tsx index 0a32ed4f..13a35cb2 100644 --- a/packages/web/src/features/agent-panes/views/shared/session-card.tsx +++ b/packages/web/src/features/agent-panes/views/shared/session-card.tsx @@ -21,18 +21,21 @@ import { SupervisorCard } from "../../../supervisor/views/shared/supervisor-card import { XtermHost } from "../../../terminal-panel/views/shared/xterm-host"; import { usePersistWorkspaceLastViewedTarget } from "../../../workspace/actions/use-persist-workspace-last-viewed-target"; import { useWorkspaceUiStatePersistence } from "../../../workspace/actions/use-workspace-ui-state-persistence"; +import type { PaneDropIntent } from "../../actions/pane-drag-types"; import { usePaneActions } from "../../actions/use-pane-actions"; import { useSessionActions } from "../../actions/use-session-actions"; type SessionCardAction = () => void | Promise; interface SessionCardProps { + paneId?: string; sessionId: string; showHeaderActions?: boolean; showSupervisorInline?: boolean; terminalReadOnlyOverride?: boolean; headerAccessory?: ReactNode; onClose?: SessionCardAction; + onPaneDrop?: (intent: PaneDropIntent) => void; onSplitHorizontal?: SessionCardAction; onSplitVertical?: SessionCardAction; } @@ -45,12 +48,14 @@ interface SessionCardProps { * - Terminal area (xterm.js) */ export const SessionCard: FC = ({ + paneId: _paneId, sessionId, showHeaderActions = true, showSupervisorInline = true, terminalReadOnlyOverride, headerAccessory, onClose, + onPaneDrop: _onPaneDrop, onSplitHorizontal, onSplitVertical, }) => { From 49c446cb3926fc8608aa4bbdf19abaf776b23e7c Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 01:44:36 +0800 Subject: [PATCH 07/45] feat: add pane drag controller --- .../actions/use-pane-drag-controller.test.tsx | 100 ++++++++ .../actions/use-pane-drag-controller.ts | 236 ++++++++++++++++++ .../web/src/features/agent-panes/index.tsx | 6 + 3 files changed, 342 insertions(+) create mode 100644 packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx create mode 100644 packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts diff --git a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx new file mode 100644 index 00000000..14130922 --- /dev/null +++ b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx @@ -0,0 +1,100 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { usePaneDragController } from "./use-pane-drag-controller"; + +function createPaneElement(rect: { + left: number; + top: number; + width: number; + height: number; +}): HTMLElement { + const element = document.createElement("div"); + const domRect = { + x: rect.left, + y: rect.top, + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + right: rect.left + rect.width, + bottom: rect.top + rect.height, + toJSON: () => rect, + } as DOMRect; + + element.getBoundingClientRect = vi.fn(() => domRect); + return element; +} + +describe("usePaneDragController", () => { + afterEach(() => { + document.body.classList.remove("is-dragging-pane"); + }); + + it("marks a hovered session pane as left when the pointer is inside the left edge band", () => { + const { result } = renderHook(() => usePaneDragController({ onDrop: vi.fn() })); + + act(() => { + result.current.registerPane("target-pane", { + type: "session", + element: createPaneElement({ left: 100, top: 40, width: 400, height: 240 }), + }); + result.current.startDrag({ paneId: "source-pane" }); + result.current.handlePointerMove({ clientX: 130, clientY: 180 } as PointerEvent); + }); + + expect(result.current.state.hoverTargetPaneId).toBe("target-pane"); + expect(result.current.state.hoverPlacement).toBe("left"); + expect(result.current.state.previewPosition).toEqual({ x: 130, y: 180 }); + }); + + it("treats draft panes as center-only targets and dispatches a center drop intent", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => usePaneDragController({ onDrop })); + + act(() => { + result.current.registerPane("draft-pane", { + type: "draft", + element: createPaneElement({ left: 300, top: 80, width: 320, height: 240 }), + }); + result.current.startDrag({ paneId: "source-pane" }); + result.current.handlePointerMove({ clientX: 310, clientY: 140 } as PointerEvent); + }); + + expect(result.current.state.hoverPlacement).toBe("center"); + + act(() => { + result.current.handlePointerUp(); + }); + + expect(onDrop).toHaveBeenCalledWith({ + sourcePaneId: "source-pane", + targetPaneId: "draft-pane", + placement: "center", + targetType: "draft", + }); + expect(result.current.state.isDragging).toBe(false); + }); + + it("does not hover or dispatch a drop when the pointer stays over the source pane", () => { + const onDrop = vi.fn(); + const { result } = renderHook(() => usePaneDragController({ onDrop })); + + act(() => { + result.current.registerPane("pane-1", { + type: "session", + element: createPaneElement({ left: 40, top: 30, width: 360, height: 220 }), + }); + result.current.startDrag({ paneId: "pane-1" }); + result.current.handlePointerMove({ clientX: 120, clientY: 120 } as PointerEvent); + }); + + expect(result.current.state.hoverTargetPaneId).toBeNull(); + expect(result.current.state.hoverPlacement).toBeNull(); + + act(() => { + result.current.handlePointerUp(); + }); + + expect(onDrop).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts new file mode 100644 index 00000000..b5617bee --- /dev/null +++ b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PaneDropIntent, PaneDropPlacement, PaneDropTargetType } from "./pane-drag-types"; + +const EDGE_RATIO = 0.22; +const EDGE_MIN = 48; +const EDGE_MAX = 96; + +export interface PaneDragSourceSnapshot { + paneId: string; + sessionId?: string; + title?: string; + providerLabel?: string; +} + +export interface RegisteredPane { + type: PaneDropTargetType; + element: HTMLElement; +} + +export interface PaneDragPreviewPosition { + x: number; + y: number; +} + +export interface PaneDragState { + isDragging: boolean; + source: PaneDragSourceSnapshot | null; + hoverTargetPaneId: string | null; + hoverPlacement: PaneDropPlacement | null; + previewPosition: PaneDragPreviewPosition; + previewX: number; + previewY: number; +} + +interface UsePaneDragControllerOptions { + onDrop: (intent: PaneDropIntent) => void; +} + +function createIdleState(): PaneDragState { + return { + isDragging: false, + source: null, + hoverTargetPaneId: null, + hoverPlacement: null, + previewPosition: { x: 0, y: 0 }, + previewX: 0, + previewY: 0, + }; +} + +function clampEdgeBand(size: number): number { + return Math.max(EDGE_MIN, Math.min(EDGE_MAX, size * EDGE_RATIO)); +} + +function resolvePlacement( + paneId: string, + pane: RegisteredPane, + clientX: number, + clientY: number, + sourcePaneId: string | undefined +): PaneDropPlacement | null { + const rect = pane.element.getBoundingClientRect(); + + if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) { + return null; + } + + if (sourcePaneId === paneId) { + return null; + } + + if (pane.type === "draft") { + return "center"; + } + + const edgeX = clampEdgeBand(rect.width); + const edgeY = clampEdgeBand(rect.height); + + if (clientX <= rect.left + edgeX) { + return "left"; + } + + if (clientX >= rect.right - edgeX) { + return "right"; + } + + if (clientY <= rect.top + edgeY) { + return "top"; + } + + if (clientY >= rect.bottom - edgeY) { + return "bottom"; + } + + return "center"; +} + +export function usePaneDragController({ onDrop }: UsePaneDragControllerOptions) { + const paneRegistry = useRef(new Map()); + const [state, setState] = useState(() => createIdleState()); + const stateRef = useRef(state); + + const setDragState = useCallback( + (nextState: PaneDragState | ((current: PaneDragState) => PaneDragState)) => { + const resolved = typeof nextState === "function" ? nextState(stateRef.current) : nextState; + + stateRef.current = resolved; + setState(resolved); + }, + [] + ); + + const registerPane = useCallback((paneId: string, entry: RegisteredPane | null) => { + if (!entry) { + paneRegistry.current.delete(paneId); + return; + } + + paneRegistry.current.set(paneId, entry); + }, []); + + const startDrag = useCallback( + (source: PaneDragSourceSnapshot) => { + document.body.classList.add("is-dragging-pane"); + setDragState({ + isDragging: true, + source, + hoverTargetPaneId: null, + hoverPlacement: null, + previewPosition: { x: 0, y: 0 }, + previewX: 0, + previewY: 0, + }); + }, + [setDragState] + ); + + const clearDrag = useCallback(() => { + document.body.classList.remove("is-dragging-pane"); + setDragState(createIdleState()); + }, [setDragState]); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + setDragState((current) => { + if (!current.isDragging) { + return current; + } + + let hoverTargetPaneId: string | null = null; + let hoverPlacement: PaneDropPlacement | null = null; + + for (const [paneId, pane] of paneRegistry.current.entries()) { + const placement = resolvePlacement( + paneId, + pane, + event.clientX, + event.clientY, + current.source?.paneId + ); + + if (!placement) { + continue; + } + + hoverTargetPaneId = paneId; + hoverPlacement = placement; + break; + } + + return { + ...current, + hoverTargetPaneId, + hoverPlacement, + previewPosition: { x: event.clientX, y: event.clientY }, + previewX: event.clientX, + previewY: event.clientY, + }; + }); + }, + [setDragState] + ); + + const handlePointerUp = useCallback(() => { + const current = stateRef.current; + + if ( + current.isDragging && + current.source && + current.hoverTargetPaneId && + current.hoverPlacement + ) { + const target = paneRegistry.current.get(current.hoverTargetPaneId); + + if (target) { + onDrop({ + sourcePaneId: current.source.paneId, + targetPaneId: current.hoverTargetPaneId, + placement: current.hoverPlacement, + targetType: target.type, + }); + } + } + + clearDrag(); + }, [clearDrag, onDrop]); + + useEffect(() => { + if (!state.isDragging) { + return; + } + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + }; + }, [handlePointerMove, handlePointerUp, state.isDragging]); + + useEffect(() => { + return () => { + document.body.classList.remove("is-dragging-pane"); + }; + }, []); + + return { + clearDrag, + handlePointerMove, + handlePointerUp, + registerPane, + startDrag, + state, + }; +} diff --git a/packages/web/src/features/agent-panes/index.tsx b/packages/web/src/features/agent-panes/index.tsx index e80292ff..2a1676ae 100644 --- a/packages/web/src/features/agent-panes/index.tsx +++ b/packages/web/src/features/agent-panes/index.tsx @@ -12,6 +12,7 @@ import { EmptyState } from "../../components/ui"; import { useTranslation } from "../../lib/i18n"; import type { PaneDropIntent } from "./actions/pane-drag-types"; import { usePaneActions } from "./actions/use-pane-actions"; +import { usePaneDragController } from "./actions/use-pane-drag-controller"; import { useSessionActions } from "./actions/use-session-actions"; import { useWorkspaceSessions } from "./actions/use-workspace-sessions"; import { type PaneNode, readPaneRatio, writePaneRatio } from "./atoms/pane-layout"; @@ -66,6 +67,7 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { paneActions.insertSessionPaneAtEdge(intent.sourcePaneId, intent.targetPaneId, intent.placement); }; + const dragController = usePaneDragController({ onDrop: handlePaneDrop }); if (!workspace) { return ( @@ -98,6 +100,7 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { onSplitSession={paneActions.splitSessionPane} onCloseDraftPane={paneActions.closeDraftPane} onAssignSession={paneActions.assignSession} + dragController={dragController} onPaneDrop={handlePaneDrop} onReplaceWithSession={paneActions.replaceWithSession} onCloseSessionCommand={sessionActions.closeSession} @@ -107,6 +110,7 @@ export const AgentPanes: FC = ({ hydrateSessions = true }) => { }; interface PaneNodeRendererProps { + dragController: ReturnType; node: PaneNode; workspaceId: string; onAssignSession: (paneId: string, sessionId: string) => void; @@ -126,6 +130,7 @@ interface PaneNodeRendererProps { * Recursively render pane tree */ const PaneNodeRenderer: FC = ({ + dragController, node, workspaceId, onAssignSession, @@ -181,6 +186,7 @@ const PaneNodeRenderer: FC = ({ {node.children?.map((child) => ( Date: Mon, 25 May 2026 02:01:43 +0800 Subject: [PATCH 08/45] fix: prepare pane leaf drag state --- .../web/src/features/agent-panes/index.tsx | 79 ++++++++++++++----- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/packages/web/src/features/agent-panes/index.tsx b/packages/web/src/features/agent-panes/index.tsx index 2a1676ae..7193a6e7 100644 --- a/packages/web/src/features/agent-panes/index.tsx +++ b/packages/web/src/features/agent-panes/index.tsx @@ -126,6 +126,25 @@ interface PaneNodeRendererProps { onSplitSession: (sessionId: string, direction: "horizontal" | "vertical") => void; } +interface PaneLeafDragState { + isDragging: boolean; + isActiveDropTarget: boolean; + hoverPlacement: ReturnType["state"]["hoverPlacement"]; +} + +function getPaneLeafDragState( + dragState: ReturnType["state"], + paneId: string +): PaneLeafDragState { + const isActiveDropTarget = dragState.hoverTargetPaneId === paneId; + + return { + isDragging: dragState.isDragging, + isActiveDropTarget, + hoverPlacement: isActiveDropTarget ? dragState.hoverPlacement : null, + }; +} + /** * Recursively render pane tree */ @@ -143,32 +162,52 @@ const PaneNodeRenderer: FC = ({ onSplitSession, }) => { if (node.type === "leaf") { + const leafDragState = getPaneLeafDragState(dragController.state, node.id); + // Render session card or draft launcher if (node.sessionId) { return ( - { - onCloseSession(node.sessionId!); - await onCloseSessionCommand(node.sessionId!, "draft"); - }} - onSplitHorizontal={() => onSplitSession(node.sessionId!, "horizontal")} - onSplitVertical={() => onSplitSession(node.sessionId!, "vertical")} - /> +
+ { + onCloseSession(node.sessionId!); + await onCloseSessionCommand(node.sessionId!, "draft"); + }} + onSplitHorizontal={() => onSplitSession(node.sessionId!, "horizontal")} + onSplitVertical={() => onSplitSession(node.sessionId!, "vertical")} + /> +
); } else { return ( - +
+ +
); } } From fb3918315756e1c8e5a79923813f3aafcddf0864 Mon Sep 17 00:00:00 2001 From: pallyoung Date: Mon, 25 May 2026 02:41:13 +0800 Subject: [PATCH 09/45] feat: wire pane drag ui --- .../actions/use-pane-drag-controller.test.tsx | 22 ++ .../actions/use-pane-drag-controller.ts | 9 +- .../components/session-card.test.tsx | 43 ++++ .../src/features/agent-panes/index.test.tsx | 170 ++++++++++++++- .../web/src/features/agent-panes/index.tsx | 198 ++++++++++++------ .../views/shared/draft-launcher.test.tsx | 26 +++ .../views/shared/draft-launcher.tsx | 19 +- .../agent-panes/views/shared/session-card.tsx | 52 ++++- packages/web/src/styles/components.css | 76 +++++++ 9 files changed, 545 insertions(+), 70 deletions(-) diff --git a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx index 14130922..17406738 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx +++ b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.test.tsx @@ -97,4 +97,26 @@ describe("usePaneDragController", () => { expect(onDrop).not.toHaveBeenCalled(); }); + + it("attaches pointer listeners while dragging and removes listeners and body class on unmount", () => { + const addEventListenerSpy = vi.spyOn(window, "addEventListener"); + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); + const { result, unmount } = renderHook(() => usePaneDragController({ onDrop: vi.fn() })); + + act(() => { + result.current.startDrag({ paneId: "source-pane" }); + }); + + expect(document.body).toHaveClass("is-dragging-pane"); + expect(addEventListenerSpy).toHaveBeenCalledWith("pointermove", expect.any(Function)); + expect(addEventListenerSpy).toHaveBeenCalledWith("pointerup", expect.any(Function)); + + act(() => { + unmount(); + }); + + expect(document.body).not.toHaveClass("is-dragging-pane"); + expect(removeEventListenerSpy).toHaveBeenCalledWith("pointermove", expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith("pointerup", expect.any(Function)); + }); }); diff --git a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts index b5617bee..80c1ae1f 100644 --- a/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts +++ b/packages/web/src/features/agent-panes/actions/use-pane-drag-controller.ts @@ -98,6 +98,7 @@ function resolvePlacement( export function usePaneDragController({ onDrop }: UsePaneDragControllerOptions) { const paneRegistry = useRef(new Map()); const [state, setState] = useState(() => createIdleState()); + const onDropRef = useRef(onDrop); const stateRef = useRef(state); const setDragState = useCallback( @@ -140,6 +141,10 @@ export function usePaneDragController({ onDrop }: UsePaneDragControllerOptions) setDragState(createIdleState()); }, [setDragState]); + useEffect(() => { + onDropRef.current = onDrop; + }, [onDrop]); + const handlePointerMove = useCallback( (event: PointerEvent) => { setDragState((current) => { @@ -193,7 +198,7 @@ export function usePaneDragController({ onDrop }: UsePaneDragControllerOptions) const target = paneRegistry.current.get(current.hoverTargetPaneId); if (target) { - onDrop({ + onDropRef.current({ sourcePaneId: current.source.paneId, targetPaneId: current.hoverTargetPaneId, placement: current.hoverPlacement, @@ -203,7 +208,7 @@ export function usePaneDragController({ onDrop }: UsePaneDragControllerOptions) } clearDrag(); - }, [clearDrag, onDrop]); + }, [clearDrag]); useEffect(() => { if (!state.isDragging) { diff --git a/packages/web/src/features/agent-panes/components/session-card.test.tsx b/packages/web/src/features/agent-panes/components/session-card.test.tsx index 9ebd2b44..62cd1d50 100644 --- a/packages/web/src/features/agent-panes/components/session-card.test.tsx +++ b/packages/web/src/features/agent-panes/components/session-card.test.tsx @@ -229,6 +229,49 @@ describe("SessionCard", () => { ); }); + it("renders a pane drag handle button in the header actions", () => { + const { store } = createSessionStore({ + terminalId: "term-live", + state: "running", + endedAt: undefined, + }); + + render( + + + + ); + + expect(screen.getByRole("button", { name: "Drag pane" })).toBeInTheDocument(); + }); + + it("starts pane drag only from the drag handle", () => { + const { store } = createSessionStore({ + terminalId: "term-live", + state: "running", + endedAt: undefined, + }); + const onPaneDragStart = vi.fn(); + + render( + + + + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Drag pane" })); + fireEvent.pointerDown(screen.getByText("SESSION-56")); + + expect(onPaneDragStart).toHaveBeenCalledTimes(1); + expect(onPaneDragStart).toHaveBeenCalledWith( + expect.objectContaining({ + paneId: "pane-1", + sessionId: "sess_123456", + providerLabel: "Codex", + }) + ); + }); + it("passes isActiveSession to XtermHost when the workspace ui state targets this session", () => { const { store } = createSessionStore({ terminalId: "term-live", diff --git a/packages/web/src/features/agent-panes/index.test.tsx b/packages/web/src/features/agent-panes/index.test.tsx index ece49b00..4b9b1599 100644 --- a/packages/web/src/features/agent-panes/index.test.tsx +++ b/packages/web/src/features/agent-panes/index.test.tsx @@ -8,10 +8,17 @@ import { sessionsAtom } from "../../atoms/sessions"; import { activeWorkspaceIdAtom, workspacesLoadStateAtom } from "../../atoms/workspaces"; import { seedReadyWorkspaceState } from "../../test-utils/workspace-state"; import type { PaneDropIntent } from "./actions/pane-drag-types"; +import type { PaneDragSourceSnapshot } from "./actions/use-pane-drag-controller"; import { LEGACY_PANE_LAYOUT_STORAGE_KEY_PREFIX, paneLayoutAtomFamily } from "./atoms/pane-layout"; import { AgentPanes } from "./index"; type MockSessionCardProps = { + dragState?: { + isActiveDropTarget: boolean; + isDragging: boolean; + hoverPlacement: PaneDropIntent["placement"] | null; + }; + onPaneDragStart?: (source: PaneDragSourceSnapshot) => void; paneId?: string; sessionId: string; onSplitHorizontal?: () => void; @@ -24,13 +31,35 @@ const mockSessionCard = vi.fn( ({ paneId, sessionId, + dragState, + onPaneDragStart, onSplitHorizontal, onSplitVertical, onClose, onPaneDrop, }: MockSessionCardProps) => ( -
+
{sessionId} + {paneId && onPaneDragStart ? ( + + ) : null} @@ -79,6 +108,11 @@ vi.mock("./views/shared/session-card", () => ({ })); type MockDraftLauncherProps = { + dragState?: { + isActiveDropTarget: boolean; + isDragging: boolean; + hoverPlacement: "center" | null; + }; workspaceId: string; paneId?: string; onAssignSession?: (paneId: string, sessionId: string) => void; @@ -95,9 +129,14 @@ vi.mock("./views/shared/draft-launcher", async () => { return { ...actual, - DraftLauncher: ({ paneId, onPaneDrop, ...props }: MockDraftLauncherProps) => ( -
- + DraftLauncher: ({ paneId, onPaneDrop, dragState, ...props }: MockDraftLauncherProps) => ( +
+ {paneId && onPaneDrop ? (