Skip to content

Tile panes in 2D and reposition them by dragging - #55

Merged
BunsDev merged 2 commits into
mainfrom
feat/pane-2d-tiling
Aug 10, 2026
Merged

Tile panes in 2D and reposition them by dragging#55
BunsDev merged 2 commits into
mainfrom
feat/pane-2d-tiling

Conversation

@BunsDev

@BunsDev BunsDev commented Aug 10, 2026

Copy link
Copy Markdown
Member

The pane canvas could only stack vertically. layoutRects split purely on height, insertBelow always appended below, and no operation could move a pane once placed — you could resize a divider or close a pane, and that was the whole vocabulary. Panes now tile in both directions and can be dragged onto another pane's edge to re-tile.

BEFORE (vertical only)        AFTER (2D tiling)
┌───────────────┐             ┌────────┬───────┐
│    agent      │             │ agent  │ shell │
├───────────────┤             │        ├───────┤
│    shell      │             │        │ logs  │
├───────────────┤             ├────────┴───────┤
│    logs       │             │     tests      │
└───────────────┘             └────────────────┘

Pane tree

  • Splits carry an orientation. "column" is the default by omission, so every layout persisted before this change still loads as the vertical stack it was — no migration, no version field.
  • layoutRects is axis-generic, and minimumSize sums along a split's own axis while taking the max across it. The old minimumHeight only ever summed heights, so it had no way to express side-by-side minimums.
  • insertRelative places a pane above/below/left/right. moveLeaf prunes the pane from its old slot before re-inserting, so the branch it vacated collapses instead of leaving an empty slot. Every impossible move — same leaf, unknown leaf, bad position, single-pane canvas — returns the original root by identity, so callers detect a no-op with ===.

Interaction

  • Pointer events, not HTML5 drag-and-drop. The panes host live xterm canvases, where a native drag image reads as a rendering glitch; owning the gesture also lets the drop target be a region of a pane rather than the whole element.
  • Nearest edge wins — four triangular drop zones meeting at the centre. Escape cancels mid-drag, and a lone pane never starts one.
  • Dividers are orientation-aware for pointer drags (clientX vs clientY) and for keys (←/→ on a row split, ↑/↓ on a column). ARIA reports the separator's own orientation, which is the opposite of its drag axis.
  • The drop highlight is position: fixed — client rects are already viewport-space, so it needs no positioned ancestor and no pane's overflow can clip it. It animates between targets so the gesture reads as continuous, and holds still under prefers-reduced-motion.

Verification

  • npx vitest run1447 passed (up from 1440), 11 skipped, 3 failed.
  • tauriPaneTree.test.ts is at 19 passing, covering: orientation-free legacy layouts still loading as columns, row-axis geometry, the minimum-size mirror (646×120 fits a row split, 645 does not), branch collapse on move, mixed row/column trees staying inside their rect, and every no-op path.
  • App rebuilt and restarted via script/build_and_run.sh --verify.

The 3 failures are this machine's known environmental baseline, in release-signing tests untouched by this change: appStoreConnect.test.ts pins pnpm 10.14.0 against a local 10.33.2, and releaseWorkflow.test.ts (×2) calls stat -c %Lp, a BSD format spec that fails because GNU coreutils stat precedes it on PATH.

Not verified

Screenshots and window enumeration are blocked on this machine, so the drag has not been watched, only reasoned about and unit-tested. Three specifics worth a reviewer's hands:

  • Nearest-edge has no centre dead zone, so near a pane's middle small movements can flip the target between edges.
  • cursor: grab covers the entire header, including the title text.
  • Panes have a 320×120 minimum, so on a narrow window a left/right drop can be refused where a top/bottom one succeeds. That is canFit working, but it will read as "the drop didn't take."

Note for reviewers

web/panes.bundle.js is checked-in generated output, regenerated with pnpm build:web. main.js and styles.css are loaded directly and are not bundled. Files under native/ are tracked despite a .gitignore rule, so staging needs -f.

🤖 Generated with Claude Code

The canvas could only stack panes vertically: layoutRects split purely on
height, insertBelow always appended below, and no operation could move a
pane once placed. Panes now tile in both directions and can be dragged
onto another pane's edge to re-tile.

Pane tree:

- Splits carry an `orientation`. "column" is the default by omission, so
  layouts persisted before this change still load as the vertical stacks
  they were.
- layoutRects is axis-generic, and minimumSize sums along a split's own
  axis while taking the max across it. The old minimumHeight only ever
  summed heights, so it could not describe side-by-side minimums.
- insertRelative places a pane above/below/left/right; moveLeaf prunes
  the pane from its old slot before re-inserting, so the branch it leaves
  collapses rather than keeping an empty slot. Impossible moves return
  the original root by identity so callers can detect a no-op cheaply.

Interaction:

- Pointer events, not HTML5 drag-and-drop: panes host live xterm
  canvases, where a native drag image reads as a rendering glitch, and
  owning the gesture lets the drop target be a region of a pane rather
  than the whole element.
- Nearest edge wins, giving four triangular drop zones meeting at the
  centre. Escape cancels; a lone pane never starts a drag.
- Dividers are orientation-aware for pointer drags (clientX vs clientY)
  and keys (left/right on a row, up/down on a column). ARIA reports the
  separator's own orientation, the opposite of its drag axis.

The drop highlight is position:fixed, since client rects are already
viewport-space - no positioned ancestor required and no pane's overflow
can clip it. It animates between targets so the gesture reads as
continuous, and holds still under prefers-reduced-motion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 10, 2026 12:43
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
psyche-build-docs Ready Ready Preview Aug 10, 2026 12:47pm

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds true 2D pane tiling (row/column splits) and enables drag-to-reposition panes by dropping onto a target pane edge, while keeping legacy (orientation-less) layouts loading as vertical stacks.

Changes:

  • Extended the pane tree model with split orientation, axis-generic layout/minimum-size calculations, and a moveLeaf operation for re-tiling.
  • Implemented pointer-based pane drag/reposition interactions and made dividers orientation-aware (cursor, drag axis, keyboard arrows, ARIA orientation).
  • Added styling for row splits and a fixed-position animated drop indicator; updated tests to cover 2D behavior and no-op paths.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
native/macos/psyche-build-tauri/web/styles.css Adds row-split styling, drag cursors, and drop-indicator styling.
native/macos/psyche-build-tauri/web/panes/pane-tree.mjs Introduces orientation-aware pane tree operations, min-size logic, layout, and move semantics.
native/macos/psyche-build-tauri/web/panes/pane-entry.js Re-exports new pane-tree APIs for consumers.
native/macos/psyche-build-tauri/web/panes.bundle.js Regenerated bundled output reflecting pane-tree and interaction changes.
native/macos/psyche-build-tauri/web/main.js Adds pointer-driven drag-to-reposition and updates divider behavior for row/column splits.
tests/tauriPaneTree.test.ts Adds/updates unit tests for 2D tiling, layout geometry, and move/no-op behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1156 to +1158
.terminal-pane-header { cursor: grab; }
body.is-pane-dragging { cursor: grabbing; user-select: none; }
body.is-pane-dragging .terminal-pane-header { cursor: grabbing; }
Comment on lines +1403 to +1416
function paneElementAt(clientX, clientY) {
var ids = canvasThreadIds();
for (var i = 0; i < ids.length; i++) {
var thread = findThread(ids[i]);
var pane = thread && thread.pane;
if (!pane) continue;
var rect = pane.getBoundingClientRect();
if (clientX >= rect.left && clientX <= rect.right &&
clientY >= rect.top && clientY <= rect.bottom) {
return { thread: thread, rect: rect };
}
}
return null;
}
The pane tree is imported dynamically, so its exports are untyped and
`.map((leaf) => ...)` callbacks tripped TS7006 under tsconfig.test.json.
Local `vitest run` never surfaced this - only `pnpm typecheck` does, which
is why CI caught it and the local run did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@BunsDev
BunsDev merged commit f579ccf into main Aug 10, 2026
3 checks passed
@BunsDev
BunsDev deleted the feat/pane-2d-tiling branch August 10, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants