---
name: Drag Pane
slug: drag-pane
category: Frontend
description: Drag Pane implements drag-to-resize behavior for two-pane layouts. Use it when adding or fixing divider dragging, width clamping, mouse hit regions, or saved pane widths.
github: "https://github.com/marcus/sidecar/tree/main/.claude/skills/drag-pane"
language: Go
stars: 1049
forks: 79
install: "npx degit https://github.com/marcus/sidecar/tree/main/.claude/skills/drag-pane ~/.claude/skills/drag-pane"
installs_to: ~/.claude/skills/drag-pane
source_path: .claude/skills/drag-pane/SKILL.md
collection_size: 16
category_size: 567
collection_url: "https://dirskills.com/collections/marcus/sidecar"
added: 2026-08-21T05:13:51.991Z
last_synced: 2026-08-21T05:13:51.991Z
canonical_url: "https://dirskills.com/skills/drag-pane"
---

# Drag Pane

Drag Pane implements drag-to-resize behavior for two-pane layouts. Use it when adding or fixing divider dragging, width clamping, mouse hit regions, or saved pane widths.

**Install:**

```bash
npx degit https://github.com/marcus/sidecar/tree/main/.claude/skills/drag-pane ~/.claude/skills/drag-pane
```

## README

# Drag-to-Resize Pane Implementation

## Overview

Add drag-to-resize support for two-pane plugin layouts (sidebar + main content). Users click and drag the divider between panes to resize them.

## Prerequisites

- Plugin already has a two-pane layout (sidebar + main content)
- State persistence functions exist in `internal/state/state.go` (each plugin has its own getter/setter)
- Familiarity with `internal/mouse` package

## Existing Implementations

| Plugin | State Functions | Mouse File |
|--------|----------------|------------|
| FileBrowser | `GetFileBrowserTreeWidth()` / `SetFileBrowserTreeWidth()` | `internal/plugins/filebrowser/mouse.go` |
| GitStatus | `GetGitStatusSidebarWidth()` / `SetGitStatusSidebarWidth()` | `internal/plugins/gitstatus/mouse.go` |
| Conversations | `GetConversationsSideWidth()` / `SetConversationsSideWidth()` | `internal/plugins/conversations/mouse.go` |
| Workspace (project) | `GetWorkspaceSidebarWidth()` / `SetWorkspaceSidebarWidth()` | `internal/plugins/workspace/view_list.go` |
| Sessions (global workspace) | `GetWorkspaceSidebarWidth()` / `SetWorkspaceSidebarWidth()` | `internal/overview/workspaces.go` |

## Windowing parity: project and global workspaces are one feature

The project workspace (`internal/plugins/workspace`) and the global Workspaces
browser shown as **Sessions** in the navbar (`internal/overview`) are two
projections of the same windowing model. They are not independent surfaces that
happen to look similar.

**If a change affects panes, splits, drag handles, pane borders, focus chrome,
or pane hit regions in one of them, it affects the other.** The rule is
structural, not a habit to remember:

- `internal/panelayout` owns pane-tree structure and geometry.
- `internal/paneframe` owns presentation: chrome geometry (`Inset`, `Geometry`),
  the leaf border states (`Chrome`, `WrapLeaf`), the drag handle
  (`RenderDividerHandle`, `DividerHitBox`, `HandleStateFor`), the compositor
  (`Compose`, `ComposeLeaf`, `RenderContent`), the chrome-aware floors
  (`ChromeFloors`), and the one order hit regions are registered in
  (`RegisterRegions`), and click-to-focus (`LeafAt`, `FocusLeafAt`).
- Each surface implements `paneframe.Host` and `paneframe.RegionSink` in exactly
  one file: `internal/plugins/workspace/pane_host.go` and
  `internal/overview/pane_host.go`.

**Focus is one value, answered from geometry.** `Host.Focus()` draws the ring and
`Host.SetFocus()` moves it; there is no third place a surface may record who is
being typed into, so a surface whose live terminal holds the keyboard separately
gives it up inside its own focus setter (`workspace.setFocusTarget`,
`overview.focusPreviewLeaf`). A pointer moves focus through
`paneframe.FocusLeafAt`, which resolves the leaf from its OUTER **box**, not from
the hit region the press landed on — a terminal leaf owns no click-to-focus
region, because its presses belong to the live pane and are forwarded to tmux.
`FocusLeafAt` moves focus and nothing else, so the press still reaches whichever
region claimed it, and it declines the divider's widened target so a press one
cell off a handle resizes without also re-focusing. Hanging focus off the region
handlers instead is td-43db92: one focus call per leaf kind, and the ring drawn
on a neighbour for the kind nobody remembered.

`Host.Layout()` must answer the tree the surface last **drew**, not one it could
place. A view that replaces the preview — the kanban board, a modal — draws no
tree, and geometry that outlives the frame lets a click on whatever is drawn
there move pane focus instead. The project plugin records the layout beside the
hit regions it earned (`paneFrame`/`paneFrameDrawn`, cleared with the hit map at
the top of `View`); the global browser's `previewPeerBox()` already refuses when
the preview is not drawn.

**Do not add a second compositor, a second border rule, or a second divider
renderer.** If a behaviour belongs to windowing, it goes in `paneframe`; if it
belongs to one surface's content, it goes in that surface's host file. Both
surfaces then get it at once.

Tests that hold this: `internal/paneframe/paneframe_test.go`,
`internal/plugins/workspace/pane_peer_chrome_test.go`, and
`internal/overview/pane_peer_chrome_test.go`.

## Implementation Steps

### Step 1: Add Mouse Handler to Plugin Struct

```go
import "github.com/marcus/sidecar/internal/mouse"

type Plugin struct {
    // ... other fields
    mouseHandler *mouse.Handler
    sidebarWidth int  // Current sidebar width (persisted)
}

func New() *Plugin {
    return &Plugin{
        mouseHandler: mouse.NewHandler(),
    }
}
```

### Step 2: Define Hit Region Constants

```go
const (
    regionSidebar     = "sidebar"
    regionMainPane    = "main-pane"
    regionPaneDivider = "pane-divider"
    dividerWidth      = 1  // Visual divider width
)
```

### Step 3: Initialize Width on First Render (NOT in Init)

**Important:** Do NOT load width in `Init()` - plugin dimensions (`p.width`) are not available yet. Initialize lazily on first render:

```go
func (p *Plugin) renderTwoPane() string {
    p.mouseHandler.HitMap.Clear() // CRITICAL: clear every render

    if p.sidebarWidth == 0 {
        p.sidebarWidth = state.GetYourPluginSidebarWidth()
        if p.sidebarWidth == 0 {
            available := p.width - dividerWidth
            p.sidebarWidth = available * 30 / 100 // Default 30%
        }
    }
    // ... rest of render
}
```

### Step 4: Handle MouseMsg in Update

```go
func (p *Plugin) Update(msg tea.Msg) (plugin.Plugin, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.MouseMsg:
        return p.handleMouse(msg)
    }
}
```

### Step 5: Create mouse.go with Handlers

```go
func (p *Plugin) handleMouse(msg tea.MouseMsg) (*Plugin, tea.Cmd) {
    action := p.mouseHandler.HandleMouse(msg)
    switch action.Type {
    case mouse.ActionClick:
        return p.handleMouseClick(action)
    case mouse.ActionDrag:
        return p.handleMouseDrag(action)
    case mouse.ActionDragEnd:
        return p.handleMouseDragEnd()
    }
    return p, nil
}

func (p *Plugin) handleMouseClick(action mouse.MouseAction) (*Plugin, tea.Cmd) {
    if action.Region == nil {
        return p, nil
    }
    switch action.Region.ID {
    case regionSidebar:
        p.activePane = PaneSidebar
    case regionMainPane:
        p.activePane = PaneMain
    case regionPaneDivider:
        p.mouseHandler.StartDrag(action.X, action.Y, regionPaneDivider, p.sidebarWidth)
    }
    return p, nil
}

func (p *Plugin) handleMouseDrag(action mouse.MouseAction) (*Plugin, tea.Cmd) {
    if p.mouseHandler.DragRegion() != regionPaneDivider {
        return p, nil
    }
    startValue := p.mouseHandler.DragStartValue()
    newWidth := startValue + action.DragDX

    // Clamp to bounds
    // NOTE: Offset varies by plugin (border styling differences):
    // GitStatus: -5, FileBrowser: -6, Conversations: -5, Workspace: just dividerWidth
    available := p.width - 5 - dividerWidth
    minWidth := 25
    maxWidth := available - 40
    if newWidth < minWidth {
        newWidth = minWidth
    } else if newWidth > maxWidth {
        newWidth = maxWidth
    }
    p.sidebarWidth = newWidth
    return p, nil
}

func (p *Plugin) handleMouseDragEnd() (*Plugin, tea.Cmd) {
    _ = state.SetYourPluginSidebarWidth(p.sidebarWidth)
    return p, nil
}
```

### Step 6: Register Hit Regions in Render

**This is where most bugs occur.** Follow this pattern exactly:

```go
func (p *Plugin) renderTwoPane() string {
    p.mouseHandler.HitMap.Clear() // CRITICAL: clear every render

    available := p.width - 5 - dividerWidth
    sidebarWidth := p.sidebarWidth
    if sidebarWidth == 0 {
        sidebarWidth = available * 30 / 100
    }
    if sidebarWidth < 25 {
        sidebarWidth = 25
    }
    if sidebarWidth > available-40 {
        sidebarWidth = available - 40
    }
    mainWidth := available - sidebarWidth
    p.sidebarWidth = sidebarWidth

    // ... render panes and divider ...

    // CRITICAL: Register in priority order (last = highest priority)
    p.mouseHandler.HitMap.AddRect(regionSidebar, 0, 0, sidebarWidth, p.height, nil)
    mainX := sidebarWidth + dividerWidth
    p.mouseHandler.HitMap.AddRect(regionMainPane, mainX, 0, mainWidth, p.height, nil)
    // Divider LAST = highest priority
    dividerX := sidebarWidth
    dividerHitWidth := 3 // Wider than visual for easier clicking
    p.mouseHandler.HitMap.AddRect(regionPaneDivider, dividerX, 0, dividerHitWidth, p.height, nil)

    return content
}
```

### Step 7: Render Visible Divider

Never hand-roll a divider. Use the shared handle so hover and drag colouring,
the one-cell inset at each end, and the theme blend are the same everywhere:

```go
divider := ui.RenderHandle(paneHeight, true, ui.HandleStateFrom(p.hoverDivider, dragging))
```

Inside a pane tree, go through the frame instead, which picks the axis and the
per-split state for you:

```go
handle := paneframe.RenderDividerHandle(divider, host.HandleState(divider.SplitID))
hit := paneframe.DividerHitBox(divider)
```

### Step 8: Add State Persistence

Add plugin-specific functions to `internal/state/state.go`:

```go
// In State struct
YourPluginSidebarWidth int `json:"yourPluginSidebarWidth,omitempty"`

// Getter
func GetYourPluginSidebarWidth() int {
    mu.RLock()
    defer mu.RUnlock()
    if current == nil { return 0 }
    return current.YourPluginSidebarWidth
}

// Setter
func SetYourPluginSidebarWidth(width int) error {
    mu.Lock()
    if current == nil { current = &State{} }
    current.YourPluginSidebarWidth = width
    mu.Unlock()
    return Save()
}
```

## Critical Rules

### Rule 1: Never Reset Width in View()

**WRONG:**
```go
func (p *Plugin) View(width, height int) string {
    p.sidebarWidth = width * 30 / 100 // BUG: Overwrites drag changes every render!
}
```

**CORRECT:** Width is only set when `sidebarWidth == 0`. All other code paths must not unconditionally overwrite it.

### Rule 2: Hit Region X Coordinates

Divider X position = `sidebarWidth`, NOT `sidebarWidth + borderWidth`.

When lipgloss renders `Width(sidebarWidth)`, the pane occupies columns 0 to sidebarWidth-1. The divider starts at column sidebarWidth.

### Rule 3: Hit Region Priority (Registration Order)

`HitMap.Test()` checks regions in **reverse order** - last added = checked first.

The divider region MUST be registered LAST so it takes priority over overlapping pane regions.

```go
// CORRECT ORDER:
p.mouseHandler.HitMap.AddRect(regionSidebar, ...)      // Lowest priority
p.mouseHandler.HitMap.AddRect(regionMainPane, ...)     // Medium priority
p.mouseHandler.HitMap.AddRect(regionPaneDivider, ...)  // HIGHEST priority (last)
```

### Rule 4: Divider Hit Width

Use `dividerHitWidth := 3` (wider than the visual 1-character divider) to make
clicking easier. Inside a pane tree, call `paneframe.DividerHitBox` rather than
widening by hand: a row divider must widen only *upward*, or it masks the header
row — tabs and close button — of the leaf stacked below it.

### Rule 5: Height for Hit Regions

Use `p.height` for hit region height, not `paneHeight` or `paneHeight + 2`.

## Performance Optimization: Hit Region Caching

For plugins with many hit regions, use a dirty flag to avoid rebuilding every render:

```go
type Plugin struct {
    hitRegionsDirty bool
    prevWidth       int
    prevHeight      int
    prevScrollOff   int
}

func (p *Plugin) renderTwoPane() string {
    if p.width != p.prevWidth || p.height != p.prevHeight {
        p.hitRegionsDirty = true
        p.prevWidth = p.width
        p.prevHeight = p.height
    }
    if p.scrollOffset != p.prevScrollOff {
        p.hitRegionsDirty = true
        p.prevScrollOff = p.scrollOffset
    }

    // ... render content ...

    if p.hitRegionsDirty {
        p.mouseHandler.HitMap.Clear()
        // Register all hit regions...
        p.hitRegionsDirty = false
    }
    return content
}
```

Also mark `hitRegionsDirty = true` when:
- View mode changes (toggling list/detail)
- Content changes (items loaded, expanded/collapsed)
- Sidebar visibility toggles

See `internal/plugins/conversations/view_layout.go` and `plugin_input.go` for a complete implementation.

## Debugging

If drag is not working, add temporary logging:

```go
func (p *Plugin) handleMouseClick(action mouse.MouseAction) (*Plugin, tea.Cmd) {
    log.Printf("CLICK x=%d y=%d region=%v", action.X, action.Y, action.Region)
}
```

Common issues:
- **Region is nil or wrong pane:** Check X coordinate calculation and registration order
- **Drag starts but width does not change:** Check that `handleMouseDrag` is being called
- **Width resets after drag:** Search for code that sets `sidebarWidth` unconditionally
