diff --git a/apps/web/package.json b/apps/web/package.json index f4f8da04..249a3c5d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "react-diff-view": "^3.3.3", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.12.1", "react-router-dom": "^7.14.0", "react-slot-counter": "^3.3.3", "recharts": "^3.8.1", diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 25fcd57b..ff67de27 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -1,11 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Routes, Route, useNavigate, useParams } from "react-router-dom"; -import { PanelLeftOpen, PanelRightOpen } from "lucide-react"; +import { PanelLeftOpen, PanelRightOpen, Split } from "lucide-react"; import { ChangesTab } from "@/components/app/changes-tab"; import { ChangesSettingsPopover } from "@/components/app/changes-settings-popover"; -import { CenterPaneTabBar } from "@/components/app/center-pane-tab-bar"; +import { + CenterPaneTabBar, + TAB_DRAG_MIME, +} from "@/components/app/center-pane-tab-bar"; +import { SplitDropZones } from "@/components/app/split-drop-zones"; import { useAgentDiffStats } from "@/hooks/use-agent-diff-stats"; +import { useSplitPane } from "@/hooks/use-split-pane"; import { AgentListContent } from "@/components/app/agent-sidebar"; import { @@ -41,9 +46,15 @@ import { } from "@/components/app/types"; import { Button } from "@/components/ui/button"; import { GlassSidebar } from "@/components/ui/glass-sidebar"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; import { uploadAgentMedia } from "@/lib/media-upload"; import { type AgentType, isCliAgentType } from "@/lib/agent-types"; import { type IdeType } from "@/lib/ide-types"; +import { type CenterTab } from "@/lib/store"; import { cn } from "@/lib/utils"; import { useAgentActions } from "@/hooks/use-agent-actions"; import { useAgents } from "@/hooks/use-agents"; @@ -132,6 +143,7 @@ export function AgentsView({ }); const [createOpen, setCreateOpen] = useState(false); + const [isDraggingTab, setIsDraggingTab] = useState(false); const [requestedCreateType, setRequestedCreateType] = useState(null); const [lastUsedAgentType, setLastUsedAgentType] = useState( @@ -218,6 +230,64 @@ export function AgentsView({ ? (agents.find((agent) => agent.id === focusedAgentId) ?? null) : null; + const { splitState, isSplit, exitSplit, updateSizes, handleTabDrop } = + useSplitPane(focusedAgentId, isMobile); + + const splitLeftRef = useRef(null); + const splitButtonRef = useRef(null); + + useEffect(() => { + if (!isSplit) return; + const panel = splitLeftRef.current; + const btn = splitButtonRef.current; + if (!panel || !btn) return; + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width; + if (width != null) btn.style.left = `${width}px`; + }); + observer.observe(panel); + return () => observer.disconnect(); + }, [isSplit]); + + useEffect(() => { + const reset = () => setIsDraggingTab(false); + document.addEventListener("dragend", reset); + return () => document.removeEventListener("dragend", reset); + }, []); + + const handleContentDragOver = useCallback((e: React.DragEvent) => { + if (!e.dataTransfer.types.includes(TAB_DRAG_MIME)) return; + setIsDraggingTab(true); + }, []); + + const handleContentDragLeave = useCallback((e: React.DragEvent) => { + if (e.currentTarget.contains(e.relatedTarget as Node)) return; + setIsDraggingTab(false); + }, []); + + const handleContentDrop = useCallback(() => { + setIsDraggingTab(false); + }, []); + + const handleDropOnZone = useCallback( + (tab: string, side: "left" | "right") => { + const activeTab: CenterTab = changesMatch ? "changes" : "terminal"; + handleTabDrop(tab as CenterTab, side, activeTab); + setIsDraggingTab(false); + }, + [changesMatch, handleTabDrop] + ); + + const handleSplitLayoutChange = useCallback( + (layout: Record) => { + const left = layout["split-left"]; + const right = layout["split-right"]; + if (typeof left !== "number" || typeof right !== "number") return; + updateSizes([left, right]); + }, + [updateSizes] + ); + const { mediaFiles, animatingMediaKeys, @@ -430,6 +500,34 @@ export function AgentsView({ const isAttached = connState === "connected" && Boolean(connectedAgentId); const hasActiveAgent = Boolean(validatedSelectedAgentId); + const terminalElement = ( + + ); + + const changesElement = ( + + ); + return (
@@ -518,7 +616,7 @@ export function AgentsView({ onTransitionEnd={handleFeedbackTransitionEnd} >
-
+
{!leftPanelOpen ? (
-
+
{focusedAgent?.name ? ( <> { + if (isSplit) { + exitSplit(); + } + onTabChange(tab); + }} diffStats={focusedDiffStats} + isSplit={isSplit} + splitState={splitState} + isMobile={isMobile} /> ) : null}
-
- {changesMatch && !isMobile ? ( +
+ {changesMatch && !isMobile && !isSplit ? ( ) : null} {hasActiveAgent && (!mediaPanelOpen || isMobile) ? ( @@ -588,38 +694,94 @@ export function AgentsView({
-
- -
- - - } - /> - + {isSplit ? ( +
+ + +
+
+ + {splitState.left === "terminal" + ? "Terminal" + : "Changes"} + + {splitState.left === "changes" && !isMobile ? ( + + ) : null} +
+
+ {splitState.left === "terminal" + ? terminalElement + : changesElement} +
+
+
+ + +
+
+ + {splitState.right === "terminal" + ? "Terminal" + : "Changes"} + + {splitState.right === "changes" && !isMobile ? ( + + ) : null} +
+
+ {splitState.right === "terminal" + ? terminalElement + : changesElement} +
+
+
+
+ +
+ ) : ( + <> +
+ {terminalElement} +
+ + + + + )} + {!isMobile ? (
diff --git a/apps/web/src/components/app/center-pane-tab-bar.tsx b/apps/web/src/components/app/center-pane-tab-bar.tsx index ab17e863..467a6c4e 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -1,72 +1,99 @@ -import { memo } from "react"; +import { memo, useCallback } from "react"; import type { DiffStats } from "@/components/app/types"; +import { type CenterTab, type SplitPaneState } from "@/lib/store"; import { cn } from "@/lib/utils"; -type CenterTab = "terminal" | "changes"; +export const TAB_DRAG_MIME = "application/x-dispatch-tab"; + +type TabDef = { + id: CenterTab; + label: string; +}; + +const TABS: TabDef[] = [ + { id: "terminal", label: "Terminal" }, + { id: "changes", label: "Changes" }, +]; type CenterPaneTabBarProps = { activeTab: CenterTab; onTabChange: (tab: CenterTab) => void; diffStats: DiffStats | null | undefined; + isSplit: boolean; + splitState: SplitPaneState; + isMobile: boolean; }; export const CenterPaneTabBar = memo(function CenterPaneTabBar({ activeTab, onTabChange, diffStats, + isSplit, + splitState, + isMobile, }: CenterPaneTabBarProps): JSX.Element { const hasChanges = diffStats && (diffStats.added > 0 || diffStats.deleted > 0); + const splitTabs = isSplit + ? new Set([splitState.left, splitState.right]) + : new Set(); + + const visibleTabs = TABS.filter((t) => !splitTabs.has(t.id)); + + const handleDragStart = useCallback( + (e: React.DragEvent, tabId: CenterTab) => { + e.dataTransfer.setData(TAB_DRAG_MIME, tabId); + e.dataTransfer.effectAllowed = "move"; + }, + [] + ); + + if (visibleTabs.length === 0) return
; + return (
- - + {tab.id === "changes" && hasChanges ? ( + + +{diffStats.added} + + {"−"} + {diffStats.deleted} + + + ) : null} + + ))}
); }); diff --git a/apps/web/src/components/app/split-drop-zones.tsx b/apps/web/src/components/app/split-drop-zones.tsx new file mode 100644 index 00000000..4a7829fa --- /dev/null +++ b/apps/web/src/components/app/split-drop-zones.tsx @@ -0,0 +1,95 @@ +import { memo, useCallback, useState } from "react"; + +import { TAB_DRAG_MIME } from "@/components/app/center-pane-tab-bar"; +import { cn } from "@/lib/utils"; + +type SplitDropZonesProps = { + visible: boolean; + onDrop: (tab: string, side: "left" | "right") => void; +}; + +export const SplitDropZones = memo(function SplitDropZones({ + visible, + onDrop, +}: SplitDropZonesProps): JSX.Element | null { + const [activeSide, setActiveSide] = useState<"left" | "right" | null>(null); + + const handleDragOver = useCallback( + (e: React.DragEvent, side: "left" | "right") => { + if (!e.dataTransfer.types.includes(TAB_DRAG_MIME)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setActiveSide(side); + }, + [] + ); + + const handleDragLeave = useCallback(() => { + setActiveSide(null); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent, side: "left" | "right") => { + e.preventDefault(); + setActiveSide(null); + const tabId = e.dataTransfer.getData(TAB_DRAG_MIME); + if (tabId) { + onDrop(tabId, side); + } + }, + [onDrop] + ); + + if (!visible) return null; + + return ( +
+
handleDragOver(e, "left")} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, "left")} + data-testid="split-drop-left" + > + + Left + +
+
handleDragOver(e, "right")} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, "right")} + data-testid="split-drop-right" + > + + Right + +
+
+ ); +}); diff --git a/apps/web/src/components/ui/resizable.tsx b/apps/web/src/components/ui/resizable.tsx new file mode 100644 index 00000000..822b86ca --- /dev/null +++ b/apps/web/src/components/ui/resizable.tsx @@ -0,0 +1,50 @@ +import { GripVertical } from "lucide-react"; +import * as ResizablePrimitive from "react-resizable-panels"; + +import { cn } from "@/lib/utils"; + +function ResizablePanelGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +const ResizablePanel = ResizablePrimitive.Panel; + +function ResizableHandle({ + withHandle, + className, + children, + ...props +}: React.ComponentProps & { + withHandle?: boolean; + children?: React.ReactNode; +}) { + return ( + + {withHandle ? ( +
+ +
+ ) : null} + {children} +
+ ); +} + +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }; diff --git a/apps/web/src/hooks/use-media-sidebar-state.ts b/apps/web/src/hooks/use-media-sidebar-state.ts index 8baddae4..09682a54 100644 --- a/apps/web/src/hooks/use-media-sidebar-state.ts +++ b/apps/web/src/hooks/use-media-sidebar-state.ts @@ -7,6 +7,7 @@ import { mediaSidebarStateAtomFamily, reconcileMediaSidebarStateStorage, reconcileDiffViewStateStorage, + reconcileSplitPaneStateStorage, reconcileReviewDraftStorage, type MediaSidebarTab, } from "@/lib/store"; @@ -97,6 +98,7 @@ export function useMediaSidebarState({ if (agentIds.length === 0) return; reconcileMediaSidebarStateStorage(agentIds as string[]); reconcileDiffViewStateStorage(agentIds as string[]); + reconcileSplitPaneStateStorage(agentIds as string[]); reconcileReviewDraftStorage(agentIds as string[]); }, [agentIds]); diff --git a/apps/web/src/hooks/use-split-pane.ts b/apps/web/src/hooks/use-split-pane.ts new file mode 100644 index 00000000..ba86a54d --- /dev/null +++ b/apps/web/src/hooks/use-split-pane.ts @@ -0,0 +1,92 @@ +import { useCallback, useMemo } from "react"; +import { useAtom } from "jotai"; + +import { + type CenterTab, + type SplitPaneState, + defaultSplitPaneState, + inactiveSplitPaneStateAtom, + splitPaneStateAtomFamily, +} from "@/lib/store"; + +export function useSplitPane(agentId: string | null, isMobile: boolean) { + const atom = agentId + ? splitPaneStateAtomFamily(agentId) + : inactiveSplitPaneStateAtom; + const [rawState, setState] = useAtom(atom); + + const splitState: SplitPaneState = + isMobile || !agentId ? defaultSplitPaneState : rawState; + + const isSplit = splitState.mode === "split" && !isMobile; + + const enterSplit = useCallback( + (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { + if (isMobile || !agentId) return; + if (draggedTab === activeTab) return; + + const left = side === "left" ? draggedTab : activeTab; + const right = side === "right" ? draggedTab : activeTab; + + setState({ + mode: "split", + left, + right, + sizes: [50, 50], + }); + }, + [agentId, isMobile, setState] + ); + + const exitSplit = useCallback(() => { + if (isMobile || !agentId) return; + setState((prev) => ({ + ...prev, + mode: "single", + })); + }, [agentId, isMobile, setState]); + + const updateSizes = useCallback( + (sizes: number[]) => { + if (isMobile || !agentId) return; + if (sizes.length >= 2) { + setState((prev) => ({ + ...prev, + sizes: [sizes[0], sizes[1]] as [number, number], + })); + } + }, + [agentId, isMobile, setState] + ); + + const handleTabDrop = useCallback( + (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { + if (isMobile || !agentId) return; + + if (splitState.mode === "split") { + const otherSide = side === "left" ? "right" : "left"; + if (splitState[otherSide] === draggedTab) return; + setState((prev) => ({ + ...prev, + [side]: draggedTab, + })); + return; + } + + enterSplit(draggedTab, side, activeTab); + }, + [agentId, enterSplit, isMobile, setState, splitState] + ); + + return useMemo( + () => ({ + splitState, + isSplit, + enterSplit, + exitSplit, + updateSizes, + handleTabDrop, + }), + [splitState, isSplit, enterSplit, exitSplit, updateSizes, handleTabDrop] + ); +} diff --git a/apps/web/src/lib/store.test.ts b/apps/web/src/lib/store.test.ts index df0fe029..7b9c3ee2 100644 --- a/apps/web/src/lib/store.test.ts +++ b/apps/web/src/lib/store.test.ts @@ -8,6 +8,8 @@ import { defaultMediaSidebarState, reconcileDiffViewStateStorage, DIFF_VIEW_STATE_STORAGE_PREFIX, + reconcileSplitPaneStateStorage, + SPLIT_PANE_STATE_STORAGE_PREFIX, } from "./store"; describe("reconcileAgentSidebarOrder", () => { @@ -222,3 +224,114 @@ describe("reconcileDiffViewStateStorage", () => { ).toBeNull(); }); }); + +describe("reconcileSplitPaneStateStorage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + const defaultSplitState = JSON.stringify({ + mode: "single", + left: "terminal", + right: "changes", + sizes: [50, 50], + }); + + const storeForAgent = (agentId: string) => { + window.localStorage.setItem( + `${SPLIT_PANE_STATE_STORAGE_PREFIX}${agentId}`, + defaultSplitState + ); + }; + + it("removes keys for agents not in the live set", () => { + storeForAgent("agt_1"); + storeForAgent("agt_2"); + storeForAgent("agt_3"); + + reconcileSplitPaneStateStorage(["agt_1", "agt_3"]); + + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_1`) + ).not.toBeNull(); + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_2`) + ).toBeNull(); + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_3`) + ).not.toBeNull(); + }); + + it("does nothing when all stored agents are live", () => { + storeForAgent("agt_1"); + storeForAgent("agt_2"); + + reconcileSplitPaneStateStorage(["agt_1", "agt_2"]); + + expect(window.localStorage.length).toBe(2); + }); + + it("removes all split pane keys when live set is empty", () => { + storeForAgent("agt_1"); + storeForAgent("agt_2"); + + reconcileSplitPaneStateStorage([]); + + expect(window.localStorage.length).toBe(0); + }); + + it("handles empty live set gracefully", () => { + storeForAgent("agt_1"); + reconcileSplitPaneStateStorage([]); + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_1`) + ).toBeNull(); + }); + + it("does nothing when localStorage is empty", () => { + reconcileSplitPaneStateStorage(["agt_1"]); + + expect(window.localStorage.length).toBe(0); + }); + + it("does not affect non-split-pane keys", () => { + window.localStorage.setItem("dispatch:leftSidebarOpen", "true"); + window.localStorage.setItem( + `${MEDIA_SIDEBAR_STATE_STORAGE_PREFIX}agt_live`, + "{}" + ); + storeForAgent("agt_dead"); + + reconcileSplitPaneStateStorage([]); + + expect(window.localStorage.getItem("dispatch:leftSidebarOpen")).toBe( + "true" + ); + expect( + window.localStorage.getItem( + `${MEDIA_SIDEBAR_STATE_STORAGE_PREFIX}agt_live` + ) + ).toBe("{}"); + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_dead`) + ).toBeNull(); + }); + + it("accepts a Set as agentIds", () => { + storeForAgent("agt_1"); + storeForAgent("agt_2"); + + reconcileSplitPaneStateStorage(new Set(["agt_1"])); + + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_1`) + ).not.toBeNull(); + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_2`) + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index dacf9795..55020051 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -274,3 +274,56 @@ export function reconcileDiffViewStateStorage( keysToDelete.forEach((key) => window.localStorage.removeItem(key)); } + +// --------------------------------------------------------------------------- +// Split pane state — per-agent split/single mode and pane sizes +// --------------------------------------------------------------------------- + +export type CenterTab = "terminal" | "changes"; + +export type SplitPaneState = { + mode: "single" | "split"; + left: CenterTab; + right: CenterTab; + sizes: [number, number]; +}; + +export const defaultSplitPaneState: SplitPaneState = { + mode: "single", + left: "terminal", + right: "changes", + sizes: [50, 50], +}; + +export const inactiveSplitPaneStateAtom = atom( + defaultSplitPaneState +); + +export const SPLIT_PANE_STATE_STORAGE_PREFIX = "dispatch:splitPane:"; + +export const splitPaneStateAtomFamily = atomFamily((agentId: string) => + atomWithLocalStorage( + `${SPLIT_PANE_STATE_STORAGE_PREFIX}${agentId}`, + defaultSplitPaneState + ) +); + +export function reconcileSplitPaneStateStorage( + agentIds: Iterable +): void { + if (typeof window === "undefined") return; + + const liveAgentIds = new Set(agentIds); + const keysToDelete: string[] = []; + + for (let i = 0; i < window.localStorage.length; i += 1) { + const key = window.localStorage.key(i); + if (!key?.startsWith(SPLIT_PANE_STATE_STORAGE_PREFIX)) continue; + + const agentId = key.slice(SPLIT_PANE_STATE_STORAGE_PREFIX.length).trim(); + if (!agentId || liveAgentIds.has(agentId)) continue; + keysToDelete.push(key); + } + + keysToDelete.forEach((key) => window.localStorage.removeItem(key)); +} diff --git a/e2e/split-pane.spec.ts b/e2e/split-pane.spec.ts new file mode 100644 index 00000000..cae1e20e --- /dev/null +++ b/e2e/split-pane.spec.ts @@ -0,0 +1,279 @@ +import { expect, test } from "@playwright/test"; +import { cleanupE2EAgents, createAgentViaAPI } from "./helpers"; + +async function waitForAppShell( + page: import("@playwright/test").Page, + agentName: string +): Promise { + await page.getByTestId("agent-sidebar").waitFor({ state: "visible" }); + await page.getByTestId("terminal-pane").waitFor({ state: "visible" }); + await page + .getByTestId("agent-sidebar") + .getByText(agentName) + .first() + .waitFor({ state: "visible" }); +} + +/** Seed split-pane state directly in localStorage — deterministic, avoids + * flaky HTML5 drag simulation in headless CI. */ +async function seedSplitState( + page: import("@playwright/test").Page, + agentId: string +): Promise { + await page.evaluate((id) => { + window.localStorage.setItem( + `dispatch:splitPane:${id}`, + JSON.stringify({ + mode: "split", + left: "terminal", + right: "changes", + sizes: [50, 50], + }) + ); + }, agentId); +} + +test.describe("Split pane", () => { + test.afterEach(async ({ request }) => { + await cleanupE2EAgents(request); + }); + + test("split state persists across page reload", async ({ page, request }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-split-persist-${Date.now()}`, + }); + + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + // Sanity check: starts in single-pane mode. + await expect(page.getByTestId("unsplit-button")).not.toBeVisible(); + + await seedSplitState(page, agent.id); + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + // Split mode should be restored from localStorage. + await expect(page.getByTestId("unsplit-button")).toBeVisible({ + timeout: 5000, + }); + await expect(page.getByTestId("terminal-pane")).toBeVisible(); + }); + + test("unsplit button exits split mode", async ({ page, request }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-split-unsplit-${Date.now()}`, + }); + + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + await seedSplitState(page, agent.id); + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + const unsplitBtn = page.getByTestId("unsplit-button"); + await expect(unsplitBtn).toBeVisible({ timeout: 5000 }); + + // While split, both tabs are rendered as panes — tab bar is empty. + await expect(page.getByTestId("center-tab-terminal")).not.toBeVisible(); + await expect(page.getByTestId("center-tab-changes")).not.toBeVisible(); + + await unsplitBtn.click(); + + await expect(unsplitBtn).not.toBeVisible(); + await expect(page.getByTestId("center-tab-terminal")).toBeVisible(); + await expect(page.getByTestId("center-tab-changes")).toBeVisible(); + }); + + test("tab bar hides tabs that are currently in split panes", async ({ + page, + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-split-tabbar-${Date.now()}`, + }); + + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + // Before splitting, both tabs are visible in the center bar. + await expect(page.getByTestId("center-tab-terminal")).toBeVisible(); + await expect(page.getByTestId("center-tab-changes")).toBeVisible(); + + await seedSplitState(page, agent.id); + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + await expect(page.getByTestId("unsplit-button")).toBeVisible({ + timeout: 5000, + }); + + // Terminal and Changes are both placed in split panes (left + right), + // so neither appears as a tab in the center bar. + await expect(page.getByTestId("center-tab-terminal")).not.toBeVisible(); + await expect(page.getByTestId("center-tab-changes")).not.toBeVisible(); + }); + + test("tabs work normally after exiting split mode", async ({ + page, + request, + }) => { + // With only two center tabs (terminal/changes), a split always places + // both of them in panes, leaving the tab bar empty — so there is no + // visible tab to click "while split" to exercise the exitSplit() path + // through the tab bar's onClick handler. Instead, verify that once + // split mode is exited, the tab bar is restored and switching between + // tabs navigates as expected. + const agent = await createAgentViaAPI(request, { + name: `e2e-split-tabswitch-${Date.now()}`, + }); + + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + await seedSplitState(page, agent.id); + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + const unsplitBtn = page.getByTestId("unsplit-button"); + await expect(unsplitBtn).toBeVisible({ timeout: 5000 }); + await unsplitBtn.click(); + await expect(unsplitBtn).not.toBeVisible(); + + const terminalTab = page.getByTestId("center-tab-terminal"); + const changesTab = page.getByTestId("center-tab-changes"); + await expect(terminalTab).toBeVisible(); + await expect(changesTab).toBeVisible(); + + await changesTab.click(); + await expect(page).toHaveURL(new RegExp(`/agents/${agent.id}/changes$`)); + await expect(changesTab).toHaveAttribute("aria-selected", "true"); + + await terminalTab.click(); + await expect(page).toHaveURL(new RegExp(`/agents/${agent.id}$`)); + await expect(terminalTab).toHaveAttribute("aria-selected", "true"); + }); + + test("split mode is ignored on mobile viewport", async ({ + page, + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-split-mobile-${Date.now()}`, + }); + + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + // Seed split state in localStorage — on mobile this should be ignored. + await seedSplitState(page, agent.id); + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + // The unsplit button should NOT appear — mobile forces single-pane mode. + await expect(page.getByTestId("unsplit-button")).not.toBeVisible(); + // Terminal pane should still be visible in single-pane mode. + await expect(page.getByTestId("terminal-pane")).toBeVisible(); + }); + + test("drag tab to split and unsplit via button", async ({ + page, + request, + }) => { + // Supplementary coverage for the drag-and-drop entry path. Playwright's + // mouse-based locator.dragTo() simulates real OS-level mouse movement, + // which is flaky for HTML5 drag-and-drop (dragstart/dragover/drop) in + // headless Chromium — it depends on timing of intermediate mousemoves + // producing dragover events. Dispatching native DragEvents directly is + // deterministic and avoids that flakiness, so it's used here instead. + // The localStorage-injection tests above remain the primary, reliable + // coverage for split-mode entry/exit — this test only exercises the + // drag interaction itself. + const agent = await createAgentViaAPI(request, { + name: `e2e-split-drag-${Date.now()}`, + }); + + await page.goto(`/agents/${agent.id}`, { waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + const changesTab = page.getByTestId("center-tab-changes"); + await expect(changesTab).toBeVisible(); + + // Step 1: dragstart on the "Changes" tab — populates a DataTransfer + // with the drag MIME type, stashed on window for later steps. + await page.evaluate(() => { + const source = document.querySelector( + '[data-testid="center-tab-changes"]' + ) as HTMLElement; + const rect = source.getBoundingClientRect(); + const dt = new DataTransfer(); + (window as unknown as { __e2eDragDt: DataTransfer }).__e2eDragDt = dt; + const event = new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + clientX: rect.x + rect.width / 2, + clientY: rect.y + rect.height / 2, + }); + Object.defineProperty(event, "dataTransfer", { value: dt }); + source.dispatchEvent(event); + }); + + // Step 2: dragenter/dragover on the content area — flips isDraggingTab + // to true, which mounts the split drop zone overlay. + await page.evaluate(() => { + const dt = (window as unknown as { __e2eDragDt: DataTransfer }) + .__e2eDragDt; + // Content area: two levels up from terminal-pane — past the + // tab-content wrapper to the container with the onDragOver handler. + const contentArea = document.querySelector( + '[data-testid="terminal-pane"]' + )?.parentElement?.parentElement as HTMLElement; + const rect = contentArea.getBoundingClientRect(); + for (const type of ["dragenter", "dragover"]) { + const event = new DragEvent(type, { + bubbles: true, + cancelable: true, + clientX: rect.x + rect.width * 0.75, + clientY: rect.y + rect.height / 2, + }); + Object.defineProperty(event, "dataTransfer", { value: dt }); + contentArea.dispatchEvent(event); + } + }); + + const dropRight = page.getByTestId("split-drop-right"); + await expect(dropRight).toBeVisible({ timeout: 3000 }); + + // Step 3: dragover + drop on the right drop zone. + await page.evaluate(() => { + const dt = (window as unknown as { __e2eDragDt: DataTransfer }) + .__e2eDragDt; + const zone = document.querySelector( + '[data-testid="split-drop-right"]' + ) as HTMLElement; + const rect = zone.getBoundingClientRect(); + const clientX = rect.x + rect.width / 2; + const clientY = rect.y + rect.height / 2; + for (const type of ["dragover", "drop"]) { + const event = new DragEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + }); + Object.defineProperty(event, "dataTransfer", { value: dt }); + zone.dispatchEvent(event); + } + }); + + const unsplitBtn = page.getByTestId("unsplit-button"); + await expect(unsplitBtn).toBeVisible({ timeout: 3000 }); + + await unsplitBtn.click(); + await expect(unsplitBtn).not.toBeVisible(); + + await expect(page.getByTestId("center-tab-terminal")).toBeVisible(); + await expect(page.getByTestId("center-tab-changes")).toBeVisible(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55af6f2c..fc467e22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,6 +176,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.28)(react@18.3.1) + react-resizable-panels: + specifier: ^4.12.1 + version: 4.12.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: specifier: ^7.14.0 version: 7.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -7958,6 +7961,15 @@ packages: "@types/react": optional: true + react-resizable-panels@4.12.1: + resolution: + { + integrity: sha512-ElE/UpOvMLRWtAqbCgyizHXcbws8RPMyN3cBqmdY17Nxr5f01+DEwzOLqhgcy68GSnjtIUFgKWKl8aIgx5aypQ==, + } + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + react-router-dom@7.14.0: resolution: { @@ -14784,6 +14796,11 @@ snapshots: optionalDependencies: "@types/react": 18.3.28 + react-resizable-panels@4.12.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router-dom@7.14.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: react: 18.3.1