From 033b25dd85bb7e1faf987722d9deeec46efa4eb2 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 00:22:52 -0600 Subject: [PATCH 01/14] Add split pane design spec for terminal/changes area Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-07-09-split-pane-design.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-split-pane-design.md diff --git a/docs/superpowers/specs/2026-07-09-split-pane-design.md b/docs/superpowers/specs/2026-07-09-split-pane-design.md new file mode 100644 index 00000000..46ba2281 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-split-pane-design.md @@ -0,0 +1,122 @@ +# Split Pane for Terminal/Changes Area + +## Overview + +Add a split-pane feature to the center content area that lets users view two tabs side-by-side (e.g. Terminal + Changes) with a resizable divider. The feature is designed to support the current two tabs and a future third tab. + +## Interaction Model + +### Entering Split Mode + +Users drag a tab from the `CenterPaneTabBar` to the left or right side of the content area. + +1. Each tab in the tab bar is `draggable="true"` (HTML5 drag and drop). +2. When a drag starts, the content area shows two drop zones (left half, right half) with a subtle visual indicator — a semi-transparent overlay with a dashed border or background highlight. +3. Hovering over a zone highlights it to confirm the target. +4. On drop, the dragged tab renders in the dropped zone. The currently-active tab renders on the other side. The view enters split mode. +5. If already in split mode, dragging a tab onto an occupied zone replaces that pane's content. + +Edge cases: + +- Dragging the already-active tab onto its own current side: no-op. +- Only 1 tab available: drag is disabled. +- Dragging disabled on mobile. +- Clicking (not dragging) a tab in the center bar while in split mode: exits split mode and shows that tab full-width. Drag is the only gesture that enters or modifies a split. + +### Exiting Split Mode (Unsplit) + +A single button sits centered on the resizable divider between the two panes. The button uses a Lucide split-related icon (e.g. `SplitSquareHorizontal` or `Columns2`). Clicking it returns to single-pane mode, keeping the left pane's tab as the active tab. + +### Resizing + +The divider between panes is draggable to resize. Minimum pane width is ~20% to prevent collapsing too small. + +## Layout + +### Single-Pane Mode (Default) + +No change from current behavior. The center tab bar shows all tabs, one is active, content fills full width. + +### Split Mode + +``` +┌──────────────────────────────────────────────────┐ +│ [◀] [*] (remaining tabs) [⚙] [▶] │ ← main header bar +├──────────────────────────────────────────────────┤ +│ Terminal │⊞│ Changes │ ← pane headers + divider +│ │ │ │ +│ (terminal content) │ │ (changes content) │ +│ │ │ │ +└───────────────────────┘ └────────────────────────┘ +``` + +- Each pane gets a small header showing the tab name, styled to match existing tab text (`text-xs font-semibold uppercase tracking-wide`). +- The `ChangesSettingsPopover` moves into the Changes pane header when in split mode. +- The center tab bar shows only tabs NOT currently in a split pane. If all tabs are split, it hides or shows empty. +- The resizable divider uses `react-resizable-panels` handle styling — a thin vertical bar with the unsplit button centered on it. + +## State Model + +```ts +type CenterTab = "terminal" | "changes"; // extended later with 3rd tab + +type SplitState = { + mode: "single" | "split"; + left: CenterTab; + right: CenterTab; + sizes: [number, number]; // percentage for each pane, e.g. [50, 50] +}; +``` + +Persisted per-agent using `atomFamily` keyed by agent ID, backed by `atomWithLocalStorage`. Key format: `dispatch:split-pane:`. + +## Implementation Approach + +### Library + +Add `react-resizable-panels` and the shadcn `Resizable` component (`ResizablePanelGroup`, `ResizablePanel`, `ResizableHandle`). This is the standard shadcn pattern — accessible, keyboard-resizable, and provides `onLayout` callbacks for persistence. + +### Routing + +Currently tab switching is route-based (`/agents/:id` for terminal, `/agents/:id/changes` for changes). + +In split mode, both tabs are visible simultaneously. The route points at whichever pane was most recently interacted with (for deep-linking and reload). Both panes render regardless of route match. + +### Terminal Lifecycle + +The terminal (`TerminalPane`) is currently hidden via `display:none` when on the changes tab but stays mounted. In split mode it is always visible — no lifecycle change needed. The xterm terminal needs a `fit` resize when pane widths change, triggered by the `onLayout` callback from `react-resizable-panels`. + +### Changes Tab Lifecycle + +Currently `ChangesTab` only mounts when the `/changes` route matches. In split mode it stays mounted regardless of route. Render condition becomes: route matches OR split state includes changes. + +### Mobile + +Split mode is disabled on mobile. If the user is in split mode on desktop and resizes to mobile, it falls back to single-pane showing the left pane's tab. + +## Extensibility (3rd Tab) + +The design supports N tabs with a 2-pane maximum: + +- Add the new tab value to the `CenterTab` union. +- The tab bar shows all tabs not currently in a split pane. +- User can drag any tab into either split slot, replacing what's there. +- No 3-way split — maximum 2 panes at once. +- The 3rd tab lives in the tab bar and can be clicked (single-pane mode) or dragged (to replace a split pane's content). + +## Components + +| Component | Purpose | +| ------------------------- | --------------------------------------------------------------------------- | +| `ResizablePanel` (shadcn) | New UI component wrapping `react-resizable-panels` | +| `SplitPaneContainer` | Orchestrates single vs split mode rendering, drop zones | +| `SplitPaneHeader` | Small header bar for each pane in split mode (tab name + optional controls) | +| `CenterPaneTabBar` | Modified — tabs become draggable, hides split tabs | +| `useSplitPane` | Hook managing split state atom, enter/exit split, resize persistence | + +## Testing + +- Unit tests for `useSplitPane` hook (state transitions, persistence, edge cases). +- E2E tests: drag tab to split, resize divider, click unsplit button, verify persistence across reload. +- Verify xterm terminal resizes correctly when split pane widths change. +- Verify mobile fallback behavior. From 896e9f9a93d113a78553e87eda41aa75e82f4afc Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 09:36:02 -0600 Subject: [PATCH 02/14] Add split pane implementation plan Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-07-09-split-pane.md | 1147 +++++++++++++++++ 1 file changed, 1147 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-split-pane.md diff --git a/docs/superpowers/plans/2026-07-09-split-pane.md b/docs/superpowers/plans/2026-07-09-split-pane.md new file mode 100644 index 00000000..435d9aea --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-split-pane.md @@ -0,0 +1,1147 @@ +# Split Pane Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users drag tabs to split the terminal/changes content area side-by-side with a resizable divider, and unsplit with a single button click. + +**Architecture:** Add `react-resizable-panels` with a shadcn `Resizable` wrapper. A new `useSplitPane` hook manages persisted split state per agent via Jotai `atomFamily`. The `CenterPaneTabBar` gains drag support, and `agents-view.tsx` conditionally renders a `ResizablePanelGroup` in split mode or the current single-pane layout in normal mode. + +**Tech Stack:** react-resizable-panels, Jotai atomFamily + atomWithLocalStorage, HTML5 Drag and Drop API, Lucide icons, Tailwind CSS + +## Global Constraints + +- Use `pnpm` for package management (not npm). +- Follow existing shadcn/ui component patterns in `apps/web/src/components/ui/`. +- Follow existing Jotai + atomWithLocalStorage patterns in `apps/web/src/lib/store.ts`. +- Follow existing test patterns: Vitest with jsdom for unit tests, Playwright for E2E. +- Split mode is desktop-only; disabled on mobile (`isMobile` prop). +- The `CenterTab` type union must be extensible for a future 3rd tab. +- All changes are in `apps/web/`. + +--- + +### Task 1: Install react-resizable-panels and add shadcn Resizable component + +**Files:** + +- Modify: `apps/web/package.json` (add dependency) +- Create: `apps/web/src/components/ui/resizable.tsx` (shadcn component) + +**Interfaces:** + +- Produces: `ResizablePanelGroup`, `ResizablePanel`, `ResizableHandle` components exported from `@/components/ui/resizable` + +- [ ] **Step 1: Install the dependency** + +```bash +cd apps/web && pnpm add react-resizable-panels +``` + +- [ ] **Step 2: Create the shadcn Resizable component** + +Create `apps/web/src/components/ui/resizable.tsx`: + +```tsx +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 }; +``` + +- [ ] **Step 3: Verify the component compiles** + +```bash +cd apps/web && pnpm run check +``` + +Expected: No type errors. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/package.json apps/web/src/components/ui/resizable.tsx pnpm-lock.yaml +git commit -m "feat: add react-resizable-panels and shadcn Resizable component" +``` + +--- + +### Task 2: Add split pane state atom and useSplitPane hook + +**Files:** + +- Modify: `apps/web/src/lib/store.ts` (add split pane atoms + types) +- Create: `apps/web/src/hooks/use-split-pane.ts` (hook) +- Modify: `apps/web/src/lib/store.test.ts` (add reconcile tests) + +**Interfaces:** + +- Consumes: `atomWithLocalStorage`, `atomFamily` from `apps/web/src/lib/store.ts` +- Produces: + - Type `CenterTab = "terminal" | "changes"` (exported from store.ts) + - Type `SplitPaneState = { mode: "single" | "split"; left: CenterTab; right: CenterTab; sizes: [number, number] }` (exported from store.ts) + - `splitPaneStateAtomFamily(agentId: string)` returning `WritableAtom` (exported from store.ts) + - `SPLIT_PANE_STATE_STORAGE_PREFIX` constant (exported from store.ts) + - `reconcileSplitPaneStateStorage(agentIds: Iterable): void` (exported from store.ts) + - `useSplitPane(agentId: string | null, isMobile: boolean)` hook returning `{ splitState, isSplit, enterSplit, exitSplit, updateSizes, handleTabDrop }` (exported from use-split-pane.ts) + +- [ ] **Step 1: Write tests for reconcileSplitPaneStateStorage** + +Add to `apps/web/src/lib/store.test.ts`: + +```ts +import { + // ... existing imports ... + reconcileSplitPaneStateStorage, + SPLIT_PANE_STATE_STORAGE_PREFIX, +} from "./store"; + +describe("reconcileSplitPaneStateStorage", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + }); + + const storeForAgent = (agentId: string) => { + window.localStorage.setItem( + `${SPLIT_PANE_STATE_STORAGE_PREFIX}${agentId}`, + JSON.stringify({ + mode: "single", + left: "terminal", + right: "changes", + sizes: [50, 50], + }) + ); + }; + + 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("handles empty live set gracefully", () => { + storeForAgent("agt_1"); + reconcileSplitPaneStateStorage([]); + expect( + window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_1`) + ).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cd apps/web && pnpm vitest run src/lib/store.test.ts +``` + +Expected: FAIL — `reconcileSplitPaneStateStorage` and `SPLIT_PANE_STATE_STORAGE_PREFIX` not found. + +- [ ] **Step 3: Add split pane types, atoms, and reconcile function to store.ts** + +Add to the end of `apps/web/src/lib/store.ts`: + +```ts +// --------------------------------------------------------------------------- +// 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 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)); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd apps/web && pnpm vitest run src/lib/store.test.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Create the useSplitPane hook** + +Create `apps/web/src/hooks/use-split-pane.ts`: + +```ts +import { useCallback, useMemo } from "react"; +import { useAtom } from "jotai"; + +import { + type CenterTab, + type SplitPaneState, + defaultSplitPaneState, + splitPaneStateAtomFamily, +} from "@/lib/store"; + +const INACTIVE_ATOM = splitPaneStateAtomFamily(""); + +export function useSplitPane(agentId: string | null, isMobile: boolean) { + const atom = agentId ? splitPaneStateAtomFamily(agentId) : INACTIVE_ATOM; + 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(() => { + setState((prev) => ({ + ...prev, + mode: "single", + })); + }, [setState]); + + const updateSizes = useCallback( + (sizes: number[]) => { + if (sizes.length >= 2) { + setState((prev) => ({ + ...prev, + sizes: [sizes[0], sizes[1]] as [number, number], + })); + } + }, + [setState] + ); + + const handleTabDrop = useCallback( + (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { + if (isMobile || !agentId) return; + + if (splitState.mode === "split") { + setState((prev) => ({ + ...prev, + [side]: draggedTab, + })); + return; + } + + enterSplit(draggedTab, side, activeTab); + }, + [agentId, enterSplit, isMobile, setState, splitState.mode] + ); + + return useMemo( + () => ({ + splitState, + isSplit, + enterSplit, + exitSplit, + updateSizes, + handleTabDrop, + }), + [splitState, isSplit, enterSplit, exitSplit, updateSizes, handleTabDrop] + ); +} +``` + +- [ ] **Step 6: Verify compilation** + +```bash +cd apps/web && pnpm run check +``` + +Expected: No type errors. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/lib/store.ts apps/web/src/lib/store.test.ts apps/web/src/hooks/use-split-pane.ts +git commit -m "feat: add split pane state atoms and useSplitPane hook" +``` + +--- + +### Task 3: Make CenterPaneTabBar tabs draggable with drop zones + +**Files:** + +- Modify: `apps/web/src/components/app/center-pane-tab-bar.tsx` (add drag behavior) +- Create: `apps/web/src/components/app/split-drop-zones.tsx` (drop target overlay) + +**Interfaces:** + +- Consumes: `CenterTab` type from `@/lib/store` +- Produces: + - `CenterPaneTabBar` now accepts additional props: `isSplit: boolean`, `splitState: SplitPaneState` (to hide tabs currently in split panes) + - `SplitDropZones` component accepting `{ visible: boolean; onDrop: (tab: string, side: "left" | "right") => void }` + - Drag data transfer uses MIME type `application/x-dispatch-tab` with the tab id as the value + +- [ ] **Step 1: Update CenterPaneTabBar to support draggable tabs and hide split tabs** + +Modify `apps/web/src/components/app/center-pane-tab-bar.tsx`. Replace the full file content: + +```tsx +import { memo, useCallback, useRef } from "react"; + +import type { DiffStats } from "@/components/app/types"; +import { type CenterTab, type SplitPaneState } from "@/lib/store"; +import { cn } from "@/lib/utils"; + +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 ( +
+ {visibleTabs.map((tab) => ( + + ))} +
+ ); +}); +``` + +- [ ] **Step 2: Create the SplitDropZones component** + +Create `apps/web/src/components/app/split-drop-zones.tsx`: + +```tsx +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 + +
+
+ ); +}); +``` + +- [ ] **Step 3: Verify compilation** + +```bash +cd apps/web && pnpm run check +``` + +Expected: Type errors in `agents-view.tsx` because `CenterPaneTabBar` now requires new props. That's expected — Task 4 will fix it. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/app/center-pane-tab-bar.tsx apps/web/src/components/app/split-drop-zones.tsx +git commit -m "feat: make tab bar tabs draggable and add split drop zones" +``` + +--- + +### Task 4: Integrate split pane into agents-view.tsx + +This is the main integration task. Wire up the `useSplitPane` hook, render the `ResizablePanelGroup` in split mode, show drop zones during drag, and add the unsplit button on the resize handle. + +**Files:** + +- Modify: `apps/web/src/components/app/agents-view.tsx` (integrate split pane rendering) +- Modify: `apps/web/src/hooks/use-agents-view-routing.ts` (export `onTabChange` that also exits split) + +**Interfaces:** + +- Consumes: `useSplitPane` hook, `SplitDropZones`, `ResizablePanelGroup`/`ResizablePanel`/`ResizableHandle`, `CenterPaneTabBar` with new props, `CenterTab` and `SplitPaneState` types, `TAB_DRAG_MIME` constant +- Produces: Full split pane rendering in `agents-view.tsx` + +- [ ] **Step 1: Add drag state tracking and useSplitPane to agents-view.tsx** + +At the top of `agents-view.tsx`, add the new imports: + +```ts +import { Columns2 } from "lucide-react"; + +import { SplitDropZones } from "@/components/app/split-drop-zones"; +import { TAB_DRAG_MIME } from "@/components/app/center-pane-tab-bar"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { useSplitPane } from "@/hooks/use-split-pane"; +import { type CenterTab } from "@/lib/store"; +``` + +Inside the `AgentsView` function body, after the existing `useAgentsViewRouting` call, add: + +```ts +const { splitState, isSplit, exitSplit, updateSizes, handleTabDrop } = + useSplitPane(focusedAgentId, isMobile); + +const [isDraggingTab, setIsDraggingTab] = useState(false); + +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 handleDropSide = useCallback( + (side: "left" | "right") => { + const dragEvent = window.__dispatchDragTab as CenterTab | undefined; + if (dragEvent) { + const activeTab: CenterTab = changesMatch ? "changes" : "terminal"; + handleTabDrop(dragEvent, side, activeTab); + } + setIsDraggingTab(false); + }, + [changesMatch, handleTabDrop] +); +``` + +In `agents-view.tsx`, the drop handler: + +```ts +const handleDropOnZone = useCallback( + (tab: string, side: "left" | "right") => { + const activeTab: CenterTab = changesMatch ? "changes" : "terminal"; + handleTabDrop(tab as CenterTab, side, activeTab); + setIsDraggingTab(false); + }, + [changesMatch, handleTabDrop] +); +``` + +- [ ] **Step 2: Update the CenterPaneTabBar invocation** + +Replace the existing `` JSX in agents-view.tsx (around line 527-531) with: + +```tsx + { + if (isSplit) { + exitSplit(); + } + onTabChange(tab); + }} + diffStats={focusedDiffStats} + isSplit={isSplit} + splitState={splitState} + isMobile={isMobile} +/> +``` + +- [ ] **Step 3: Replace the content area with split-aware rendering** + +Replace the content div (the one containing `TerminalPane` and the `` block, around lines 563-601) with: + +```tsx +
+ {isSplit ? ( + + +
+
+ + {splitState.left === "terminal" ? "Terminal" : "Changes"} + +
+
+ {splitState.left === "terminal" ? ( + + ) : ( + + )} +
+
+
+ + + + +
+
+ + {splitState.right === "terminal" ? "Terminal" : "Changes"} + + {splitState.right === "changes" && !isMobile ? ( + + ) : null} + {splitState.left === "changes" ? null : null} +
+
+ {splitState.right === "terminal" ? ( + + ) : ( + + )} +
+
+
+
+ ) : ( + <> +
+ +
+ + + } + /> + + + )} + + {!isMobile ? ( +
+ +
+ ) : null} +
+``` + +- [ ] **Step 4: Move ChangesSettingsPopover into the pane header in split mode** + +In the existing header bar area (around line 536), update the `ChangesSettingsPopover` condition to only show in single-pane mode (it moves to the split pane header in split mode): + +```tsx +{ + changesMatch && !isMobile && !isSplit ? : null; +} +``` + +Also add `ChangesSettingsPopover` in the left pane header when the left pane shows changes: + +In the left pane's header div, after the tab label span, add: + +```tsx +{ + splitState.left === "changes" && !isMobile ? ( + + ) : null; +} +``` + +- [ ] **Step 5: Wire up storage reconciliation** + +In `apps/web/src/hooks/use-media-sidebar-state.ts`, at line 8-9 add the import: + +```ts +import { + reconcileMediaSidebarStateStorage, + reconcileDiffViewStateStorage, + reconcileSplitPaneStateStorage, +} from "@/lib/store"; +``` + +At lines 97-98 (inside the `useEffect` that calls the other reconcile functions), add: + +```ts +reconcileSplitPaneStateStorage(agentIds as string[]); +``` + +- [ ] **Step 6: Verify compilation** + +```bash +cd apps/web && pnpm run check +``` + +Expected: No type errors. + +- [ ] **Step 7: Run the full test suite** + +```bash +pnpm run finalize:web +``` + +Expected: Type check + build pass. + +- [ ] **Step 8: Commit** + +```bash +git add apps/web/src/components/app/agents-view.tsx apps/web/src/components/app/split-drop-zones.tsx apps/web/src/hooks/use-split-pane.ts apps/web/src/components/app/center-pane-tab-bar.tsx +git add -u +git commit -m "feat: integrate split pane rendering into agents view" +``` + +--- + +### Task 5: Handle terminal resize on split pane layout changes + +When pane widths change in split mode, the xterm terminal needs to re-fit. The `useTerminal` hook already handles resize via a combination of `ResizeObserver` and the xterm `FitAddon`. However, the `react-resizable-panels` layout changes trigger DOM size changes that the `ResizeObserver` should pick up automatically. If it doesn't, we need to trigger a fit manually. + +**Files:** + +- Modify: `apps/web/src/hooks/use-terminal.ts` (verify or add resize handling) +- Modify: `apps/web/src/components/app/agents-view.tsx` (pass split state to useTerminal if needed) + +**Interfaces:** + +- Consumes: `useSplitPane`'s `isSplit` flag, `updateSizes` callback from `onLayout` +- Produces: Terminal correctly resizes when split pane divider is dragged + +- [ ] **Step 1: Check how useTerminal handles resize** + +Read `apps/web/src/hooks/use-terminal.ts` and find the resize/fit logic. Look for `ResizeObserver` or `FitAddon` usage. + +- [ ] **Step 2: Verify the existing ResizeObserver handles the resize** + +The `react-resizable-panels` library changes the actual DOM width of the panel containers, which should trigger any `ResizeObserver` watching the terminal host element. Start the dev server, enter split mode, and drag the divider. If the terminal re-fits automatically, no additional work is needed. + +If the terminal does NOT re-fit automatically (the `ResizeObserver` isn't watching the right element, or it debounces too aggressively), add a `splitResizeKey` dependency: + +In `agents-view.tsx`, pass `splitSizes` to `useTerminal` as part of its existing `mediaResizeSettleKey` mechanism, or add a new prop. The `useTerminal` hook accepts `deferMediaResize` / `mediaResizeSettleKey` for a similar purpose — piggyback on that or add `splitLayoutKey: isSplit ? splitState.sizes.join(",") : ""`. + +- [ ] **Step 3: Test resize behavior manually** + +Start dev server via `repo_dev_up`. Enter split mode with Terminal on one side. Drag the divider. Verify the terminal text re-flows correctly. + +- [ ] **Step 4: Commit if changes were needed** + +```bash +git add -u +git commit -m "fix: ensure terminal re-fits when split pane is resized" +``` + +--- + +### Task 6: E2E tests for split pane + +**Files:** + +- Create: `e2e/split-pane.spec.ts` + +**Interfaces:** + +- Consumes: `createAgentViaAPI`, `cleanupE2EAgents` from `e2e/helpers.ts` +- Produces: E2E coverage for split mode entry, unsplit, persistence, and mobile behavior + +- [ ] **Step 1: Create the E2E test file** + +Create `e2e/split-pane.spec.ts`: + +```ts +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" }); +} + +test.describe("Split pane", () => { + test.afterEach(async ({ request }) => { + await cleanupE2EAgents(request); + }); + + test("drag tab to split and unsplit via button", async ({ + page, + request, + }) => { + const agent = await createAgentViaAPI(request, { + name: `e2e-split-${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(); + + // Drag the Changes tab to the right side + const contentArea = page.getByTestId("terminal-pane").locator(".."); + const box = await contentArea.boundingBox(); + expect(box).not.toBeNull(); + + await changesTab.dragTo(contentArea, { + targetPosition: { x: box!.width * 0.75, y: box!.height / 2 }, + }); + + // Should see the unsplit button + const unsplitBtn = page.getByTestId("unsplit-button"); + await expect(unsplitBtn).toBeVisible({ timeout: 3000 }); + + // Click unsplit + await unsplitBtn.click(); + await expect(unsplitBtn).not.toBeVisible(); + + // Tab bar should be restored + await expect(page.getByTestId("center-tab-terminal")).toBeVisible(); + await expect(page.getByTestId("center-tab-changes")).toBeVisible(); + }); + + 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); + + // Enter split mode via localStorage injection (simpler than drag in CI) + await page.evaluate((agentId) => { + window.localStorage.setItem( + `dispatch:splitPane:${agentId}`, + JSON.stringify({ + mode: "split", + left: "terminal", + right: "changes", + sizes: [50, 50], + }) + ); + }, agent.id); + + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForAppShell(page, agent.name); + + // Should be in split mode after reload + const unsplitBtn = page.getByTestId("unsplit-button"); + await expect(unsplitBtn).toBeVisible({ timeout: 5000 }); + }); +}); +``` + +- [ ] **Step 2: Run the E2E tests** + +```bash +pnpm run test:e2e -- --grep "Split pane" +``` + +Expected: Tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add e2e/split-pane.spec.ts +git commit -m "test: add E2E tests for split pane feature" +``` + +--- + +### Task 7: Final validation and cleanup + +**Files:** + +- Possibly modify any files from Tasks 1-6 that need polish. + +**Interfaces:** + +- Consumes: All prior tasks +- Produces: All checks pass, feature works end-to-end + +- [ ] **Step 1: Run type checking** + +```bash +pnpm run check +``` + +Expected: No type errors. + +- [ ] **Step 2: Run web finalization** + +```bash +pnpm run finalize:web +``` + +Expected: Type check + production build pass. + +- [ ] **Step 3: Run unit tests** + +```bash +cd apps/web && pnpm vitest run +``` + +Expected: All tests pass. + +- [ ] **Step 4: Run E2E tests** + +```bash +pnpm run test:e2e +``` + +Expected: All tests pass (including the new split-pane tests). + +- [ ] **Step 5: Visual validation via Playwright** + +Start the dev server with `repo_dev_up`. Use Playwright to: + +1. Navigate to an agent +2. Verify the tab bar shows Terminal and Changes +3. Enter split mode (inject localStorage state) +4. Verify both panes render +5. Click unsplit button +6. Verify single pane mode restores +7. Capture screenshots and share via `dispatch_share` + +- [ ] **Step 6: Clean up and commit any remaining fixes** + +```bash +git add -u +git commit -m "chore: split pane final cleanup and polish" +``` From 78db766f6c746f3c77137f44dc29cf7daf6bd994 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 09:56:25 -0600 Subject: [PATCH 03/14] feat: add react-resizable-panels and shadcn Resizable component - Install react-resizable-panels v4.12.1 dependency - Create Resizable component wrapping Group, Panel, and Separator - Exports ResizablePanelGroup, ResizablePanel, ResizableHandle for split-pane UI Co-Authored-By: Claude Opus 4.6 --- apps/web/package.json | 1 + apps/web/src/components/ui/resizable.tsx | 50 ++++++++++++++++++++++++ pnpm-lock.yaml | 17 ++++++++ 3 files changed, 68 insertions(+) create mode 100644 apps/web/src/components/ui/resizable.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 9bceab81..3ae43dd9 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/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/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 From 22f03db2b203e0d21ab4f2b1d1bbe83d821296c6 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 10:00:52 -0600 Subject: [PATCH 04/14] feat: add split pane state atoms and useSplitPane hook Adds per-agent split-pane state (mode, left/right CenterTab, sizes) backed by localStorage, following the existing atomWithLocalStorage + atomFamily + reconcile*Storage pattern used for media sidebar and diff view state. Also adds the useSplitPane hook that later tasks (tab bar, agents-view integration) will consume for drag-to-split behavior. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/hooks/use-split-pane.ts | 87 +++++++++++++++++++++ apps/web/src/lib/store.test.ts | 113 +++++++++++++++++++++++++++ apps/web/src/lib/store.ts | 49 ++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 apps/web/src/hooks/use-split-pane.ts 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..360e6f0a --- /dev/null +++ b/apps/web/src/hooks/use-split-pane.ts @@ -0,0 +1,87 @@ +import { useCallback, useMemo } from "react"; +import { useAtom } from "jotai"; + +import { + type CenterTab, + type SplitPaneState, + defaultSplitPaneState, + splitPaneStateAtomFamily, +} from "@/lib/store"; + +const INACTIVE_ATOM = splitPaneStateAtomFamily(""); + +export function useSplitPane(agentId: string | null, isMobile: boolean) { + const atom = agentId ? splitPaneStateAtomFamily(agentId) : INACTIVE_ATOM; + 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(() => { + setState((prev) => ({ + ...prev, + mode: "single", + })); + }, [setState]); + + const updateSizes = useCallback( + (sizes: number[]) => { + if (sizes.length >= 2) { + setState((prev) => ({ + ...prev, + sizes: [sizes[0], sizes[1]] as [number, number], + })); + } + }, + [setState] + ); + + const handleTabDrop = useCallback( + (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { + if (isMobile || !agentId) return; + + if (splitState.mode === "split") { + setState((prev) => ({ + ...prev, + [side]: draggedTab, + })); + return; + } + + enterSplit(draggedTab, side, activeTab); + }, + [agentId, enterSplit, isMobile, setState, splitState.mode] + ); + + 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 d5254630..2a928779 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -231,3 +231,52 @@ 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 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)); +} From f4e413c28bed184f35cfc86452f0fb8a1a99b9a4 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 10:05:21 -0600 Subject: [PATCH 05/14] fix: use plain atom for inactive split pane state fallback Replace INACTIVE_ATOM (localStorage-backed with empty string key) with inactiveSplitPaneStateAtom (plain in-memory atom) to avoid unnecessary persistence. Add guards to exitSplit and updateSizes for null agentId. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/hooks/use-split-pane.ts | 13 ++++++++----- apps/web/src/lib/store.ts | 4 ++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/use-split-pane.ts b/apps/web/src/hooks/use-split-pane.ts index 360e6f0a..644af8c1 100644 --- a/apps/web/src/hooks/use-split-pane.ts +++ b/apps/web/src/hooks/use-split-pane.ts @@ -5,13 +5,14 @@ import { type CenterTab, type SplitPaneState, defaultSplitPaneState, + inactiveSplitPaneStateAtom, splitPaneStateAtomFamily, } from "@/lib/store"; -const INACTIVE_ATOM = splitPaneStateAtomFamily(""); - export function useSplitPane(agentId: string | null, isMobile: boolean) { - const atom = agentId ? splitPaneStateAtomFamily(agentId) : INACTIVE_ATOM; + const atom = agentId + ? splitPaneStateAtomFamily(agentId) + : inactiveSplitPaneStateAtom; const [rawState, setState] = useAtom(atom); const splitState: SplitPaneState = @@ -38,14 +39,16 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { ); const exitSplit = useCallback(() => { + if (isMobile || !agentId) return; setState((prev) => ({ ...prev, mode: "single", })); - }, [setState]); + }, [agentId, isMobile, setState]); const updateSizes = useCallback( (sizes: number[]) => { + if (isMobile || !agentId) return; if (sizes.length >= 2) { setState((prev) => ({ ...prev, @@ -53,7 +56,7 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { })); } }, - [setState] + [agentId, isMobile, setState] ); const handleTabDrop = useCallback( diff --git a/apps/web/src/lib/store.ts b/apps/web/src/lib/store.ts index 2a928779..4f670ceb 100644 --- a/apps/web/src/lib/store.ts +++ b/apps/web/src/lib/store.ts @@ -252,6 +252,10 @@ export const defaultSplitPaneState: SplitPaneState = { sizes: [50, 50], }; +export const inactiveSplitPaneStateAtom = atom( + defaultSplitPaneState +); + export const SPLIT_PANE_STATE_STORAGE_PREFIX = "dispatch:splitPane:"; export const splitPaneStateAtomFamily = atomFamily((agentId: string) => From 40aa479faf1c1c1a2c11eb5babfb46dba1720b24 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 10:08:52 -0600 Subject: [PATCH 06/14] feat: make tab bar tabs draggable and add split drop zones - CenterPaneTabBar: tabs are now draggable (application/x-dispatch-tab MIME) and tabs currently shown in split panes are hidden from the bar. - New SplitDropZones overlay component providing left/right drop targets. - agents-view.tsx: pass placeholder isSplit/splitState/isMobile props to CenterPaneTabBar so the repo-wide typecheck (enforced by the pre-commit hook) stays green; Task 4 will replace these with real useSplitPane wiring and mount SplitDropZones. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/agents-view.tsx | 4 + .../components/app/center-pane-tab-bar.tsx | 118 +++++++++++------- .../src/components/app/split-drop-zones.tsx | 95 ++++++++++++++ 3 files changed, 170 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/components/app/split-drop-zones.tsx diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index c763b248..dbd8e2e9 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -44,6 +44,7 @@ import { GlassSidebar } from "@/components/ui/glass-sidebar"; import { uploadAgentMedia } from "@/lib/media-upload"; import { type AgentType, isCliAgentType } from "@/lib/agent-types"; import { type IdeType } from "@/lib/ide-types"; +import { defaultSplitPaneState } from "@/lib/store"; import { cn } from "@/lib/utils"; import { useAgentActions } from "@/hooks/use-agent-actions"; import { useAgents } from "@/hooks/use-agents"; @@ -528,6 +529,9 @@ export function AgentsView({ activeTab={changesMatch ? "changes" : "terminal"} onTabChange={onTabChange} diffStats={focusedDiffStats} + isSplit={false} + splitState={defaultSplitPaneState} + isMobile={isMobile} /> ) : null} 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..4b7bd6c3 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,96 @@ -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 + +
+
+ ); +}); From 7e638fa87efc6bca0b9094d00964794573abcc9a Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 10:18:30 -0600 Subject: [PATCH 07/14] feat: integrate split pane rendering into agents view Wires useSplitPane into AgentsView: renders ResizablePanelGroup with two panes and per-pane headers when split, tracks tab-drag state to show SplitDropZones, moves ChangesSettingsPopover into the pane header in split mode, and exits split mode when a tab is clicked in the center tab bar. Also reconciles split-pane localStorage against live agent ids alongside the existing media/diff-view reconciliation. --- apps/web/src/components/app/agents-view.tsx | 245 +++++++++++++++--- apps/web/src/hooks/use-media-sidebar-state.ts | 2 + 2 files changed, 211 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index dbd8e2e9..f168fc4f 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, useParams } from "react-router-dom"; -import { PanelLeftOpen, PanelRightOpen } from "lucide-react"; +import { Columns2, PanelLeftOpen, PanelRightOpen } 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,10 +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 { defaultSplitPaneState } from "@/lib/store"; +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 +142,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 +229,42 @@ export function AgentsView({ ? (agents.find((agent) => agent.id === focusedAgentId) ?? null) : null; + const { splitState, isSplit, exitSplit, updateSizes, handleTabDrop } = + useSplitPane(focusedAgentId, isMobile); + + 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, @@ -527,17 +574,22 @@ export function AgentsView({ { + if (isSplit) { + exitSplit(); + } + onTabChange(tab); + }} diffStats={focusedDiffStats} - isSplit={false} - splitState={defaultSplitPaneState} + isSplit={isSplit} + splitState={splitState} isMobile={isMobile} /> ) : null}
- {changesMatch && !isMobile ? ( + {changesMatch && !isMobile && !isSplit ? ( ) : null} {hasActiveAgent && (!mediaPanelOpen || isMobile) ? ( @@ -566,37 +618,158 @@ export function AgentsView({
-
- -
- - + +
+
+ + {splitState.left === "terminal" + ? "Terminal" + : "Changes"} + + {splitState.left === "changes" && !isMobile ? ( + + ) : null} +
+
+ {splitState.left === "terminal" ? ( + + ) : ( + + )} +
+
+
+ + + + +
+
+ + {splitState.right === "terminal" + ? "Terminal" + : "Changes"} + + {splitState.right === "changes" && !isMobile ? ( + + ) : null} +
+
+ {splitState.right === "terminal" ? ( + + ) : ( + + )} +
+
+
+ + ) : ( + <> +
+ - } - /> - +
+ + + } + /> + + + )} + {!isMobile ? (
diff --git a/apps/web/src/hooks/use-media-sidebar-state.ts b/apps/web/src/hooks/use-media-sidebar-state.ts index 68d9c0a1..74ca0236 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, type MediaSidebarTab, } from "@/lib/store"; @@ -96,6 +97,7 @@ export function useMediaSidebarState({ if (agentIds.length === 0) return; reconcileMediaSidebarStateStorage(agentIds as string[]); reconcileDiffViewStateStorage(agentIds as string[]); + reconcileSplitPaneStateStorage(agentIds as string[]); }, [agentIds]); useEffect(() => { From 1a7a81101d09efad70a3eb6bc07ae31cbab92c3e Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 10:28:30 -0600 Subject: [PATCH 08/14] fix: use onLayoutChanged and extract duplicated pane elements onLayoutChange fires on every pointer-move during resize, causing a synchronous localStorage write per mousemove. Switch to onLayoutChanged, which only fires when the drag gesture ends, per the library's docs. Also extract the duplicated TerminalPane/ChangesTab JSX (previously repeated across the left split pane, right split pane, and single-pane mode) into terminalElement/changesElement locals computed once per render. --- apps/web/src/components/app/agents-view.tsx | 111 ++++++-------------- 1 file changed, 32 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index f168fc4f..051420da 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -452,6 +452,29 @@ export function AgentsView({ const isAttached = connState === "connected" && Boolean(connectedAgentId); const hasActiveAgent = Boolean(validatedSelectedAgentId); + const terminalElement = ( + + ); + + const changesElement = ( + + ); + return (
@@ -625,7 +648,7 @@ export function AgentsView({ {isSplit ? (
- {splitState.left === "terminal" ? ( - - ) : ( - - )} + {splitState.left === "terminal" + ? terminalElement + : changesElement}
@@ -702,32 +702,9 @@ export function AgentsView({ ) : null}
- {splitState.right === "terminal" ? ( - - ) : ( - - )} + {splitState.right === "terminal" + ? terminalElement + : changesElement}
@@ -735,34 +712,10 @@ export function AgentsView({ ) : ( <>
- + {terminalElement}
- - } - /> + )} From b9d0a132066fc274547b1892d65c7a8db4eda2a6 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 10:58:17 -0600 Subject: [PATCH 09/14] test: add E2E tests for split pane feature Cover split state persistence across reload, unsplit button exit, tab bar hiding during split, post-unsplit tab switching, and drag-and-drop entry via synthesized DragEvents. Co-Authored-By: Claude Opus 4.6 --- e2e/split-pane.spec.ts | 256 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 e2e/split-pane.spec.ts diff --git a/e2e/split-pane.spec.ts b/e2e/split-pane.spec.ts new file mode 100644 index 00000000..2d99a1b0 --- /dev/null +++ b/e2e/split-pane.spec.ts @@ -0,0 +1,256 @@ +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("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(); + }); +}); From 09a3b123ff9dedb04d2c4cd31a20f8548aa2b645 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 11:03:25 -0600 Subject: [PATCH 10/14] test: add mobile viewport test for split pane Co-Authored-By: Claude Opus 4.6 --- e2e/split-pane.spec.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/e2e/split-pane.spec.ts b/e2e/split-pane.spec.ts index 2d99a1b0..cae1e20e 100644 --- a/e2e/split-pane.spec.ts +++ b/e2e/split-pane.spec.ts @@ -154,6 +154,29 @@ test.describe("Split pane", () => { 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, From af983a0260bd51f2c3d97ad76e06f13280c725f6 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 11:13:37 -0600 Subject: [PATCH 11/14] fix: restore typographic minus sign and guard duplicate split panes Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/center-pane-tab-bar.tsx | 5 ++++- apps/web/src/hooks/use-split-pane.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) 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 4b7bd6c3..5114db48 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -86,7 +86,10 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ {tab.id === "changes" && hasChanges ? ( +{diffStats.added} - -{diffStats.deleted} + + {"−"} + {diffStats.deleted} + ) : null} diff --git a/apps/web/src/hooks/use-split-pane.ts b/apps/web/src/hooks/use-split-pane.ts index 644af8c1..ba86a54d 100644 --- a/apps/web/src/hooks/use-split-pane.ts +++ b/apps/web/src/hooks/use-split-pane.ts @@ -64,6 +64,8 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { if (isMobile || !agentId) return; if (splitState.mode === "split") { + const otherSide = side === "left" ? "right" : "left"; + if (splitState[otherSide] === draggedTab) return; setState((prev) => ({ ...prev, [side]: draggedTab, @@ -73,7 +75,7 @@ export function useSplitPane(agentId: string | null, isMobile: boolean) { enterSplit(draggedTab, side, activeTab); }, - [agentId, enterSplit, isMobile, setState, splitState.mode] + [agentId, enterSplit, isMobile, setState, splitState] ); return useMemo( From 21683001515f8f2c1e17b6dc6a3255f4f6cf729a Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 13:03:42 -0600 Subject: [PATCH 12/14] fix: split pane UI polish and remove plan/spec docs - Move unsplit button above resize handle (no cursor conflict) - Use Split icon instead of Columns2 - Bigger button with proper padding - Add left padding to pane headers to clear unsplit button - Disable drag on active tab (no-op drop) - Center tab bar with grid layout (immune to icon changes) - Remove committed plan and spec files Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/agents-view.tsx | 123 +- .../components/app/center-pane-tab-bar.tsx | 6 +- .../plans/2026-07-09-split-pane.md | 1147 ----------------- .../specs/2026-07-09-split-pane-design.md | 122 -- 4 files changed, 65 insertions(+), 1333 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-09-split-pane.md delete mode 100644 docs/superpowers/specs/2026-07-09-split-pane-design.md diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 051420da..de75724f 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Routes, Route, useParams } from "react-router-dom"; -import { Columns2, 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"; @@ -563,7 +563,7 @@ export function AgentsView({ onTransitionEnd={handleFeedbackTransitionEnd} >
-
+
{!leftPanelOpen ? (
-
+
{focusedAgent?.name ? ( <> ) : null}
-
+
{changesMatch && !isMobile && !isSplit ? ( ) : null} @@ -646,69 +646,70 @@ export function AgentsView({ onDrop={handleContentDrop} > {isSplit ? ( - - + + -
-
- + +
+
+ + {splitState.left === "terminal" + ? "Terminal" + : "Changes"} + + {splitState.left === "changes" && !isMobile ? ( + + ) : null} +
+
{splitState.left === "terminal" - ? "Terminal" - : "Changes"} - - {splitState.left === "changes" && !isMobile ? ( - - ) : null} -
-
- {splitState.left === "terminal" - ? terminalElement - : changesElement} + ? terminalElement + : changesElement} +
-
- - - - - -
-
- +
+
+ + {splitState.right === "terminal" + ? "Terminal" + : "Changes"} + + {splitState.right === "changes" && !isMobile ? ( + + ) : null} +
+
{splitState.right === "terminal" - ? "Terminal" - : "Changes"} - - {splitState.right === "changes" && !isMobile ? ( - - ) : null} -
-
- {splitState.right === "terminal" - ? terminalElement - : changesElement} + ? terminalElement + : changesElement} +
-
- - + + +
) : ( <>
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 5114db48..467a6c4e 100644 --- a/apps/web/src/components/app/center-pane-tab-bar.tsx +++ b/apps/web/src/components/app/center-pane-tab-bar.tsx @@ -61,13 +61,13 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({ role="tab" aria-selected={activeTab === tab.id} data-testid={`center-tab-${tab.id}`} - draggable={!isMobile} + draggable={!isMobile && activeTab !== tab.id} onDragStart={(e) => handleDragStart(e, tab.id)} className={cn( - "flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors cursor-grab active:cursor-grabbing", + "flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors", activeTab === tab.id ? "text-foreground" - : "text-muted-foreground hover:text-foreground/80" + : "cursor-grab text-muted-foreground hover:text-foreground/80 active:cursor-grabbing" )} onClick={() => { if (isSplit) { diff --git a/docs/superpowers/plans/2026-07-09-split-pane.md b/docs/superpowers/plans/2026-07-09-split-pane.md deleted file mode 100644 index 435d9aea..00000000 --- a/docs/superpowers/plans/2026-07-09-split-pane.md +++ /dev/null @@ -1,1147 +0,0 @@ -# Split Pane Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let users drag tabs to split the terminal/changes content area side-by-side with a resizable divider, and unsplit with a single button click. - -**Architecture:** Add `react-resizable-panels` with a shadcn `Resizable` wrapper. A new `useSplitPane` hook manages persisted split state per agent via Jotai `atomFamily`. The `CenterPaneTabBar` gains drag support, and `agents-view.tsx` conditionally renders a `ResizablePanelGroup` in split mode or the current single-pane layout in normal mode. - -**Tech Stack:** react-resizable-panels, Jotai atomFamily + atomWithLocalStorage, HTML5 Drag and Drop API, Lucide icons, Tailwind CSS - -## Global Constraints - -- Use `pnpm` for package management (not npm). -- Follow existing shadcn/ui component patterns in `apps/web/src/components/ui/`. -- Follow existing Jotai + atomWithLocalStorage patterns in `apps/web/src/lib/store.ts`. -- Follow existing test patterns: Vitest with jsdom for unit tests, Playwright for E2E. -- Split mode is desktop-only; disabled on mobile (`isMobile` prop). -- The `CenterTab` type union must be extensible for a future 3rd tab. -- All changes are in `apps/web/`. - ---- - -### Task 1: Install react-resizable-panels and add shadcn Resizable component - -**Files:** - -- Modify: `apps/web/package.json` (add dependency) -- Create: `apps/web/src/components/ui/resizable.tsx` (shadcn component) - -**Interfaces:** - -- Produces: `ResizablePanelGroup`, `ResizablePanel`, `ResizableHandle` components exported from `@/components/ui/resizable` - -- [ ] **Step 1: Install the dependency** - -```bash -cd apps/web && pnpm add react-resizable-panels -``` - -- [ ] **Step 2: Create the shadcn Resizable component** - -Create `apps/web/src/components/ui/resizable.tsx`: - -```tsx -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 }; -``` - -- [ ] **Step 3: Verify the component compiles** - -```bash -cd apps/web && pnpm run check -``` - -Expected: No type errors. - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/package.json apps/web/src/components/ui/resizable.tsx pnpm-lock.yaml -git commit -m "feat: add react-resizable-panels and shadcn Resizable component" -``` - ---- - -### Task 2: Add split pane state atom and useSplitPane hook - -**Files:** - -- Modify: `apps/web/src/lib/store.ts` (add split pane atoms + types) -- Create: `apps/web/src/hooks/use-split-pane.ts` (hook) -- Modify: `apps/web/src/lib/store.test.ts` (add reconcile tests) - -**Interfaces:** - -- Consumes: `atomWithLocalStorage`, `atomFamily` from `apps/web/src/lib/store.ts` -- Produces: - - Type `CenterTab = "terminal" | "changes"` (exported from store.ts) - - Type `SplitPaneState = { mode: "single" | "split"; left: CenterTab; right: CenterTab; sizes: [number, number] }` (exported from store.ts) - - `splitPaneStateAtomFamily(agentId: string)` returning `WritableAtom` (exported from store.ts) - - `SPLIT_PANE_STATE_STORAGE_PREFIX` constant (exported from store.ts) - - `reconcileSplitPaneStateStorage(agentIds: Iterable): void` (exported from store.ts) - - `useSplitPane(agentId: string | null, isMobile: boolean)` hook returning `{ splitState, isSplit, enterSplit, exitSplit, updateSizes, handleTabDrop }` (exported from use-split-pane.ts) - -- [ ] **Step 1: Write tests for reconcileSplitPaneStateStorage** - -Add to `apps/web/src/lib/store.test.ts`: - -```ts -import { - // ... existing imports ... - reconcileSplitPaneStateStorage, - SPLIT_PANE_STATE_STORAGE_PREFIX, -} from "./store"; - -describe("reconcileSplitPaneStateStorage", () => { - beforeEach(() => { - window.localStorage.clear(); - }); - - afterEach(() => { - window.localStorage.clear(); - }); - - const storeForAgent = (agentId: string) => { - window.localStorage.setItem( - `${SPLIT_PANE_STATE_STORAGE_PREFIX}${agentId}`, - JSON.stringify({ - mode: "single", - left: "terminal", - right: "changes", - sizes: [50, 50], - }) - ); - }; - - 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("handles empty live set gracefully", () => { - storeForAgent("agt_1"); - reconcileSplitPaneStateStorage([]); - expect( - window.localStorage.getItem(`${SPLIT_PANE_STATE_STORAGE_PREFIX}agt_1`) - ).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -cd apps/web && pnpm vitest run src/lib/store.test.ts -``` - -Expected: FAIL — `reconcileSplitPaneStateStorage` and `SPLIT_PANE_STATE_STORAGE_PREFIX` not found. - -- [ ] **Step 3: Add split pane types, atoms, and reconcile function to store.ts** - -Add to the end of `apps/web/src/lib/store.ts`: - -```ts -// --------------------------------------------------------------------------- -// 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 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)); -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -cd apps/web && pnpm vitest run src/lib/store.test.ts -``` - -Expected: PASS. - -- [ ] **Step 5: Create the useSplitPane hook** - -Create `apps/web/src/hooks/use-split-pane.ts`: - -```ts -import { useCallback, useMemo } from "react"; -import { useAtom } from "jotai"; - -import { - type CenterTab, - type SplitPaneState, - defaultSplitPaneState, - splitPaneStateAtomFamily, -} from "@/lib/store"; - -const INACTIVE_ATOM = splitPaneStateAtomFamily(""); - -export function useSplitPane(agentId: string | null, isMobile: boolean) { - const atom = agentId ? splitPaneStateAtomFamily(agentId) : INACTIVE_ATOM; - 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(() => { - setState((prev) => ({ - ...prev, - mode: "single", - })); - }, [setState]); - - const updateSizes = useCallback( - (sizes: number[]) => { - if (sizes.length >= 2) { - setState((prev) => ({ - ...prev, - sizes: [sizes[0], sizes[1]] as [number, number], - })); - } - }, - [setState] - ); - - const handleTabDrop = useCallback( - (draggedTab: CenterTab, side: "left" | "right", activeTab: CenterTab) => { - if (isMobile || !agentId) return; - - if (splitState.mode === "split") { - setState((prev) => ({ - ...prev, - [side]: draggedTab, - })); - return; - } - - enterSplit(draggedTab, side, activeTab); - }, - [agentId, enterSplit, isMobile, setState, splitState.mode] - ); - - return useMemo( - () => ({ - splitState, - isSplit, - enterSplit, - exitSplit, - updateSizes, - handleTabDrop, - }), - [splitState, isSplit, enterSplit, exitSplit, updateSizes, handleTabDrop] - ); -} -``` - -- [ ] **Step 6: Verify compilation** - -```bash -cd apps/web && pnpm run check -``` - -Expected: No type errors. - -- [ ] **Step 7: Commit** - -```bash -git add apps/web/src/lib/store.ts apps/web/src/lib/store.test.ts apps/web/src/hooks/use-split-pane.ts -git commit -m "feat: add split pane state atoms and useSplitPane hook" -``` - ---- - -### Task 3: Make CenterPaneTabBar tabs draggable with drop zones - -**Files:** - -- Modify: `apps/web/src/components/app/center-pane-tab-bar.tsx` (add drag behavior) -- Create: `apps/web/src/components/app/split-drop-zones.tsx` (drop target overlay) - -**Interfaces:** - -- Consumes: `CenterTab` type from `@/lib/store` -- Produces: - - `CenterPaneTabBar` now accepts additional props: `isSplit: boolean`, `splitState: SplitPaneState` (to hide tabs currently in split panes) - - `SplitDropZones` component accepting `{ visible: boolean; onDrop: (tab: string, side: "left" | "right") => void }` - - Drag data transfer uses MIME type `application/x-dispatch-tab` with the tab id as the value - -- [ ] **Step 1: Update CenterPaneTabBar to support draggable tabs and hide split tabs** - -Modify `apps/web/src/components/app/center-pane-tab-bar.tsx`. Replace the full file content: - -```tsx -import { memo, useCallback, useRef } from "react"; - -import type { DiffStats } from "@/components/app/types"; -import { type CenterTab, type SplitPaneState } from "@/lib/store"; -import { cn } from "@/lib/utils"; - -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 ( -
- {visibleTabs.map((tab) => ( - - ))} -
- ); -}); -``` - -- [ ] **Step 2: Create the SplitDropZones component** - -Create `apps/web/src/components/app/split-drop-zones.tsx`: - -```tsx -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 - -
-
- ); -}); -``` - -- [ ] **Step 3: Verify compilation** - -```bash -cd apps/web && pnpm run check -``` - -Expected: Type errors in `agents-view.tsx` because `CenterPaneTabBar` now requires new props. That's expected — Task 4 will fix it. - -- [ ] **Step 4: Commit** - -```bash -git add apps/web/src/components/app/center-pane-tab-bar.tsx apps/web/src/components/app/split-drop-zones.tsx -git commit -m "feat: make tab bar tabs draggable and add split drop zones" -``` - ---- - -### Task 4: Integrate split pane into agents-view.tsx - -This is the main integration task. Wire up the `useSplitPane` hook, render the `ResizablePanelGroup` in split mode, show drop zones during drag, and add the unsplit button on the resize handle. - -**Files:** - -- Modify: `apps/web/src/components/app/agents-view.tsx` (integrate split pane rendering) -- Modify: `apps/web/src/hooks/use-agents-view-routing.ts` (export `onTabChange` that also exits split) - -**Interfaces:** - -- Consumes: `useSplitPane` hook, `SplitDropZones`, `ResizablePanelGroup`/`ResizablePanel`/`ResizableHandle`, `CenterPaneTabBar` with new props, `CenterTab` and `SplitPaneState` types, `TAB_DRAG_MIME` constant -- Produces: Full split pane rendering in `agents-view.tsx` - -- [ ] **Step 1: Add drag state tracking and useSplitPane to agents-view.tsx** - -At the top of `agents-view.tsx`, add the new imports: - -```ts -import { Columns2 } from "lucide-react"; - -import { SplitDropZones } from "@/components/app/split-drop-zones"; -import { TAB_DRAG_MIME } from "@/components/app/center-pane-tab-bar"; -import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, -} from "@/components/ui/resizable"; -import { useSplitPane } from "@/hooks/use-split-pane"; -import { type CenterTab } from "@/lib/store"; -``` - -Inside the `AgentsView` function body, after the existing `useAgentsViewRouting` call, add: - -```ts -const { splitState, isSplit, exitSplit, updateSizes, handleTabDrop } = - useSplitPane(focusedAgentId, isMobile); - -const [isDraggingTab, setIsDraggingTab] = useState(false); - -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 handleDropSide = useCallback( - (side: "left" | "right") => { - const dragEvent = window.__dispatchDragTab as CenterTab | undefined; - if (dragEvent) { - const activeTab: CenterTab = changesMatch ? "changes" : "terminal"; - handleTabDrop(dragEvent, side, activeTab); - } - setIsDraggingTab(false); - }, - [changesMatch, handleTabDrop] -); -``` - -In `agents-view.tsx`, the drop handler: - -```ts -const handleDropOnZone = useCallback( - (tab: string, side: "left" | "right") => { - const activeTab: CenterTab = changesMatch ? "changes" : "terminal"; - handleTabDrop(tab as CenterTab, side, activeTab); - setIsDraggingTab(false); - }, - [changesMatch, handleTabDrop] -); -``` - -- [ ] **Step 2: Update the CenterPaneTabBar invocation** - -Replace the existing `` JSX in agents-view.tsx (around line 527-531) with: - -```tsx - { - if (isSplit) { - exitSplit(); - } - onTabChange(tab); - }} - diffStats={focusedDiffStats} - isSplit={isSplit} - splitState={splitState} - isMobile={isMobile} -/> -``` - -- [ ] **Step 3: Replace the content area with split-aware rendering** - -Replace the content div (the one containing `TerminalPane` and the `` block, around lines 563-601) with: - -```tsx -
- {isSplit ? ( - - -
-
- - {splitState.left === "terminal" ? "Terminal" : "Changes"} - -
-
- {splitState.left === "terminal" ? ( - - ) : ( - - )} -
-
-
- - - - -
-
- - {splitState.right === "terminal" ? "Terminal" : "Changes"} - - {splitState.right === "changes" && !isMobile ? ( - - ) : null} - {splitState.left === "changes" ? null : null} -
-
- {splitState.right === "terminal" ? ( - - ) : ( - - )} -
-
-
-
- ) : ( - <> -
- -
- - - } - /> - - - )} - - {!isMobile ? ( -
- -
- ) : null} -
-``` - -- [ ] **Step 4: Move ChangesSettingsPopover into the pane header in split mode** - -In the existing header bar area (around line 536), update the `ChangesSettingsPopover` condition to only show in single-pane mode (it moves to the split pane header in split mode): - -```tsx -{ - changesMatch && !isMobile && !isSplit ? : null; -} -``` - -Also add `ChangesSettingsPopover` in the left pane header when the left pane shows changes: - -In the left pane's header div, after the tab label span, add: - -```tsx -{ - splitState.left === "changes" && !isMobile ? ( - - ) : null; -} -``` - -- [ ] **Step 5: Wire up storage reconciliation** - -In `apps/web/src/hooks/use-media-sidebar-state.ts`, at line 8-9 add the import: - -```ts -import { - reconcileMediaSidebarStateStorage, - reconcileDiffViewStateStorage, - reconcileSplitPaneStateStorage, -} from "@/lib/store"; -``` - -At lines 97-98 (inside the `useEffect` that calls the other reconcile functions), add: - -```ts -reconcileSplitPaneStateStorage(agentIds as string[]); -``` - -- [ ] **Step 6: Verify compilation** - -```bash -cd apps/web && pnpm run check -``` - -Expected: No type errors. - -- [ ] **Step 7: Run the full test suite** - -```bash -pnpm run finalize:web -``` - -Expected: Type check + build pass. - -- [ ] **Step 8: Commit** - -```bash -git add apps/web/src/components/app/agents-view.tsx apps/web/src/components/app/split-drop-zones.tsx apps/web/src/hooks/use-split-pane.ts apps/web/src/components/app/center-pane-tab-bar.tsx -git add -u -git commit -m "feat: integrate split pane rendering into agents view" -``` - ---- - -### Task 5: Handle terminal resize on split pane layout changes - -When pane widths change in split mode, the xterm terminal needs to re-fit. The `useTerminal` hook already handles resize via a combination of `ResizeObserver` and the xterm `FitAddon`. However, the `react-resizable-panels` layout changes trigger DOM size changes that the `ResizeObserver` should pick up automatically. If it doesn't, we need to trigger a fit manually. - -**Files:** - -- Modify: `apps/web/src/hooks/use-terminal.ts` (verify or add resize handling) -- Modify: `apps/web/src/components/app/agents-view.tsx` (pass split state to useTerminal if needed) - -**Interfaces:** - -- Consumes: `useSplitPane`'s `isSplit` flag, `updateSizes` callback from `onLayout` -- Produces: Terminal correctly resizes when split pane divider is dragged - -- [ ] **Step 1: Check how useTerminal handles resize** - -Read `apps/web/src/hooks/use-terminal.ts` and find the resize/fit logic. Look for `ResizeObserver` or `FitAddon` usage. - -- [ ] **Step 2: Verify the existing ResizeObserver handles the resize** - -The `react-resizable-panels` library changes the actual DOM width of the panel containers, which should trigger any `ResizeObserver` watching the terminal host element. Start the dev server, enter split mode, and drag the divider. If the terminal re-fits automatically, no additional work is needed. - -If the terminal does NOT re-fit automatically (the `ResizeObserver` isn't watching the right element, or it debounces too aggressively), add a `splitResizeKey` dependency: - -In `agents-view.tsx`, pass `splitSizes` to `useTerminal` as part of its existing `mediaResizeSettleKey` mechanism, or add a new prop. The `useTerminal` hook accepts `deferMediaResize` / `mediaResizeSettleKey` for a similar purpose — piggyback on that or add `splitLayoutKey: isSplit ? splitState.sizes.join(",") : ""`. - -- [ ] **Step 3: Test resize behavior manually** - -Start dev server via `repo_dev_up`. Enter split mode with Terminal on one side. Drag the divider. Verify the terminal text re-flows correctly. - -- [ ] **Step 4: Commit if changes were needed** - -```bash -git add -u -git commit -m "fix: ensure terminal re-fits when split pane is resized" -``` - ---- - -### Task 6: E2E tests for split pane - -**Files:** - -- Create: `e2e/split-pane.spec.ts` - -**Interfaces:** - -- Consumes: `createAgentViaAPI`, `cleanupE2EAgents` from `e2e/helpers.ts` -- Produces: E2E coverage for split mode entry, unsplit, persistence, and mobile behavior - -- [ ] **Step 1: Create the E2E test file** - -Create `e2e/split-pane.spec.ts`: - -```ts -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" }); -} - -test.describe("Split pane", () => { - test.afterEach(async ({ request }) => { - await cleanupE2EAgents(request); - }); - - test("drag tab to split and unsplit via button", async ({ - page, - request, - }) => { - const agent = await createAgentViaAPI(request, { - name: `e2e-split-${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(); - - // Drag the Changes tab to the right side - const contentArea = page.getByTestId("terminal-pane").locator(".."); - const box = await contentArea.boundingBox(); - expect(box).not.toBeNull(); - - await changesTab.dragTo(contentArea, { - targetPosition: { x: box!.width * 0.75, y: box!.height / 2 }, - }); - - // Should see the unsplit button - const unsplitBtn = page.getByTestId("unsplit-button"); - await expect(unsplitBtn).toBeVisible({ timeout: 3000 }); - - // Click unsplit - await unsplitBtn.click(); - await expect(unsplitBtn).not.toBeVisible(); - - // Tab bar should be restored - await expect(page.getByTestId("center-tab-terminal")).toBeVisible(); - await expect(page.getByTestId("center-tab-changes")).toBeVisible(); - }); - - 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); - - // Enter split mode via localStorage injection (simpler than drag in CI) - await page.evaluate((agentId) => { - window.localStorage.setItem( - `dispatch:splitPane:${agentId}`, - JSON.stringify({ - mode: "split", - left: "terminal", - right: "changes", - sizes: [50, 50], - }) - ); - }, agent.id); - - await page.reload({ waitUntil: "domcontentloaded" }); - await waitForAppShell(page, agent.name); - - // Should be in split mode after reload - const unsplitBtn = page.getByTestId("unsplit-button"); - await expect(unsplitBtn).toBeVisible({ timeout: 5000 }); - }); -}); -``` - -- [ ] **Step 2: Run the E2E tests** - -```bash -pnpm run test:e2e -- --grep "Split pane" -``` - -Expected: Tests pass. - -- [ ] **Step 3: Commit** - -```bash -git add e2e/split-pane.spec.ts -git commit -m "test: add E2E tests for split pane feature" -``` - ---- - -### Task 7: Final validation and cleanup - -**Files:** - -- Possibly modify any files from Tasks 1-6 that need polish. - -**Interfaces:** - -- Consumes: All prior tasks -- Produces: All checks pass, feature works end-to-end - -- [ ] **Step 1: Run type checking** - -```bash -pnpm run check -``` - -Expected: No type errors. - -- [ ] **Step 2: Run web finalization** - -```bash -pnpm run finalize:web -``` - -Expected: Type check + production build pass. - -- [ ] **Step 3: Run unit tests** - -```bash -cd apps/web && pnpm vitest run -``` - -Expected: All tests pass. - -- [ ] **Step 4: Run E2E tests** - -```bash -pnpm run test:e2e -``` - -Expected: All tests pass (including the new split-pane tests). - -- [ ] **Step 5: Visual validation via Playwright** - -Start the dev server with `repo_dev_up`. Use Playwright to: - -1. Navigate to an agent -2. Verify the tab bar shows Terminal and Changes -3. Enter split mode (inject localStorage state) -4. Verify both panes render -5. Click unsplit button -6. Verify single pane mode restores -7. Capture screenshots and share via `dispatch_share` - -- [ ] **Step 6: Clean up and commit any remaining fixes** - -```bash -git add -u -git commit -m "chore: split pane final cleanup and polish" -``` diff --git a/docs/superpowers/specs/2026-07-09-split-pane-design.md b/docs/superpowers/specs/2026-07-09-split-pane-design.md deleted file mode 100644 index 46ba2281..00000000 --- a/docs/superpowers/specs/2026-07-09-split-pane-design.md +++ /dev/null @@ -1,122 +0,0 @@ -# Split Pane for Terminal/Changes Area - -## Overview - -Add a split-pane feature to the center content area that lets users view two tabs side-by-side (e.g. Terminal + Changes) with a resizable divider. The feature is designed to support the current two tabs and a future third tab. - -## Interaction Model - -### Entering Split Mode - -Users drag a tab from the `CenterPaneTabBar` to the left or right side of the content area. - -1. Each tab in the tab bar is `draggable="true"` (HTML5 drag and drop). -2. When a drag starts, the content area shows two drop zones (left half, right half) with a subtle visual indicator — a semi-transparent overlay with a dashed border or background highlight. -3. Hovering over a zone highlights it to confirm the target. -4. On drop, the dragged tab renders in the dropped zone. The currently-active tab renders on the other side. The view enters split mode. -5. If already in split mode, dragging a tab onto an occupied zone replaces that pane's content. - -Edge cases: - -- Dragging the already-active tab onto its own current side: no-op. -- Only 1 tab available: drag is disabled. -- Dragging disabled on mobile. -- Clicking (not dragging) a tab in the center bar while in split mode: exits split mode and shows that tab full-width. Drag is the only gesture that enters or modifies a split. - -### Exiting Split Mode (Unsplit) - -A single button sits centered on the resizable divider between the two panes. The button uses a Lucide split-related icon (e.g. `SplitSquareHorizontal` or `Columns2`). Clicking it returns to single-pane mode, keeping the left pane's tab as the active tab. - -### Resizing - -The divider between panes is draggable to resize. Minimum pane width is ~20% to prevent collapsing too small. - -## Layout - -### Single-Pane Mode (Default) - -No change from current behavior. The center tab bar shows all tabs, one is active, content fills full width. - -### Split Mode - -``` -┌──────────────────────────────────────────────────┐ -│ [◀] [*] (remaining tabs) [⚙] [▶] │ ← main header bar -├──────────────────────────────────────────────────┤ -│ Terminal │⊞│ Changes │ ← pane headers + divider -│ │ │ │ -│ (terminal content) │ │ (changes content) │ -│ │ │ │ -└───────────────────────┘ └────────────────────────┘ -``` - -- Each pane gets a small header showing the tab name, styled to match existing tab text (`text-xs font-semibold uppercase tracking-wide`). -- The `ChangesSettingsPopover` moves into the Changes pane header when in split mode. -- The center tab bar shows only tabs NOT currently in a split pane. If all tabs are split, it hides or shows empty. -- The resizable divider uses `react-resizable-panels` handle styling — a thin vertical bar with the unsplit button centered on it. - -## State Model - -```ts -type CenterTab = "terminal" | "changes"; // extended later with 3rd tab - -type SplitState = { - mode: "single" | "split"; - left: CenterTab; - right: CenterTab; - sizes: [number, number]; // percentage for each pane, e.g. [50, 50] -}; -``` - -Persisted per-agent using `atomFamily` keyed by agent ID, backed by `atomWithLocalStorage`. Key format: `dispatch:split-pane:`. - -## Implementation Approach - -### Library - -Add `react-resizable-panels` and the shadcn `Resizable` component (`ResizablePanelGroup`, `ResizablePanel`, `ResizableHandle`). This is the standard shadcn pattern — accessible, keyboard-resizable, and provides `onLayout` callbacks for persistence. - -### Routing - -Currently tab switching is route-based (`/agents/:id` for terminal, `/agents/:id/changes` for changes). - -In split mode, both tabs are visible simultaneously. The route points at whichever pane was most recently interacted with (for deep-linking and reload). Both panes render regardless of route match. - -### Terminal Lifecycle - -The terminal (`TerminalPane`) is currently hidden via `display:none` when on the changes tab but stays mounted. In split mode it is always visible — no lifecycle change needed. The xterm terminal needs a `fit` resize when pane widths change, triggered by the `onLayout` callback from `react-resizable-panels`. - -### Changes Tab Lifecycle - -Currently `ChangesTab` only mounts when the `/changes` route matches. In split mode it stays mounted regardless of route. Render condition becomes: route matches OR split state includes changes. - -### Mobile - -Split mode is disabled on mobile. If the user is in split mode on desktop and resizes to mobile, it falls back to single-pane showing the left pane's tab. - -## Extensibility (3rd Tab) - -The design supports N tabs with a 2-pane maximum: - -- Add the new tab value to the `CenterTab` union. -- The tab bar shows all tabs not currently in a split pane. -- User can drag any tab into either split slot, replacing what's there. -- No 3-way split — maximum 2 panes at once. -- The 3rd tab lives in the tab bar and can be clicked (single-pane mode) or dragged (to replace a split pane's content). - -## Components - -| Component | Purpose | -| ------------------------- | --------------------------------------------------------------------------- | -| `ResizablePanel` (shadcn) | New UI component wrapping `react-resizable-panels` | -| `SplitPaneContainer` | Orchestrates single vs split mode rendering, drop zones | -| `SplitPaneHeader` | Small header bar for each pane in split mode (tab name + optional controls) | -| `CenterPaneTabBar` | Modified — tabs become draggable, hides split tabs | -| `useSplitPane` | Hook managing split state atom, enter/exit split, resize persistence | - -## Testing - -- Unit tests for `useSplitPane` hook (state transitions, persistence, edge cases). -- E2E tests: drag tab to split, resize divider, click unsplit button, verify persistence across reload. -- Verify xterm terminal resizes correctly when split pane widths change. -- Verify mobile fallback behavior. From 0531a0038f8da76d7206cbed4aa51feb5b5b6059 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 13:50:06 -0600 Subject: [PATCH 13/14] fix: unsplit button cursor, clipping, and live resize tracking Move unsplit button outside ResizableHandle to fix cursor showing resize icon instead of pointer. Position button absolutely using ResizeObserver on the left panel for real-time tracking during resize drag. Button is vertically centered in the pane header row. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/agents-view.tsx | 41 ++++++++++++++++----- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index de75724f..37f6ac13 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -232,6 +232,22 @@ export function AgentsView({ 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]); + const handleContentDragOver = useCallback((e: React.DragEvent) => { if (!e.dataTransfer.types.includes(TAB_DRAG_MIME)) return; setIsDraggingTab(true); @@ -647,15 +663,6 @@ export function AgentsView({ > {isSplit ? (
- -
+
{splitState.left === "terminal" @@ -709,6 +719,17 @@ export function AgentsView({
+
) : ( <> From 968f2ecebedf63d526b1276b141b7fe1eafafe1d Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 9 Jul 2026 13:59:41 -0600 Subject: [PATCH 14/14] fix: add dragend safety net for stuck drop zone overlay Add document-level dragend listener to reset isDraggingTab state, preventing drop zones from staying visible if a drag operation ends without a clean dragleave event (browser quirk edge case). Co-Authored-By: Claude Opus 4.6 --- apps/web/src/components/app/agents-view.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index 37f6ac13..e4491b66 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -248,6 +248,12 @@ export function AgentsView({ 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);