Skip to content

feat(canvas): nested group trees and a recursive sessions sidebar - #166

Merged
eneskirca merged 2 commits into
mainfrom
feat/nested-groups
Aug 13, 2026
Merged

feat(canvas): nested group trees and a recursive sessions sidebar#166
eneskirca merged 2 commits into
mainfrom
feat/nested-groups

Conversation

@eneskirca

Copy link
Copy Markdown
Owner

Slice 3 of #112, sliced by us. Credit to @proteus-dev / Corvin corvin@streamlain.com, whose commits in #112 this is derived from (Co-Authored-By on the commit). Sliced from integration/codex-112 (the PR merged onto current main with the ~5,600 lines of Prettier churn removed), never the raw PR branch. S1 is #163, S2 was #162.

What it adds

Group frames can contain other group frames, to any depth, and the sessions sidebar renders that as a tree.

Transformssrc/renderer/state/workspace.ts, pure and unit-tested:

  • groupSelectedNodes wraps objects that share one container — frames included — creating the wrapper inside that container. A mixed-container set, or an ancestor selected together with its own descendant, is refused (positions are only comparable within a container, and the descendant would be torn out of the ancestor being wrapped).
  • ungroupNodes promotes a frame's direct children into its own parent, not to the root (the root would move them by the whole ancestor offset).
  • reparentNode moves a node or a whole frame subtree, keeps its root-space position fixed (rootPosition, replacing the add-one-parent's-origin math), and refuses a cycle.
  • New: selectedRootIds (box-selection catches a frame and its children — structural actions normalize to subtree roots), addSelectionToGroup, reorderGroupWithinParent.
  • groupsFirst is now topological (frames depth-first from the root). A flat "groups first" sort is not enough once two frames compare equal. nodeStatesToFlow runs it, so the persisted order is parent-first at any depth.

The same root-space math and cycle guard land in the projects store (repositionState / moveNodeToGroup), plus a reorderGroup action for non-active projects.

Sidebar (SessionsSidebar.tsx, lib/sessionList.ts) — GroupBucket gains children; buildSessionList builds a recursive tree (a dangling or cyclic parentId is promoted to a root rather than recursing forever), filtering keeps a match's ancestors so the hit stays reachable, and counts/badges aggregate through groupSessionRows/groupSessionCount. Frame rows get a chevron, a drag handle and sibling reorder drop zones; dropping a frame row on another frame's head reparents it (the move path), dropping it on the project head pulls it to the top level.

Elsewhere — worktree/cwd inheritance (cwdForNewNodeIn, new worktreeForGroupChain, TerminalNode's ↪ affordance) and the Source Control scope (selectedScmGroupId) walk the ancestor chain, so a session in a sub-frame of a worktree frame still belongs to that checkout. The canvas-control group / ungroup / move verbs and their agent-facing docs follow the new rules.

One deliberate UX change that rides along: a frame's body is pointer-events: none and its label pill is the node's dragHandle. A frame is a background container, not a giant drag target — its body now passes clicks to the pane (pan / rubber-band), and an outer frame cannot swallow the clicks meant for a frame drawn inside it. Consequence worth knowing: the frame's context menu and drag are reached from its label pill, not from anywhere on its body. multiSelectionKeyCode also gains Shift (the gesture "Add selection to group" is reached by).

The four review findings from the full-PR review

1. Real geometry bug the green tests missed — FIXED, pinned. groupSelectedNodes created a nested wrapper at (minX-28, minY-62) relative to its new parent (routinely negative) with extent:'parent', without growing that parent. React Flow clamps such a node into [0, parentSize − wrapperSize] — an inverted range when the wrapper is bigger than its parent — so the frame snapped hundreds of px away and dragged the whole wrapped subtree with it. The PR's new test asserted only that the offsets add up, which stayed true. Fix: groupSelectedNodes now re-fits every ancestor frame (fitGroupToChildren up the chain, innermost first) around the new wrapper; addSelectionToGroup does the same for the frame it fills. Pinned by two tests that assert the frame fits (0 ≤ x, x + w ≤ parentW, and the same one level further up) — both verified to fail with the re-fit removed and pass with it.

2. Undocumented contract change — made deliberate and documented. Sidebar collapse moves to a persisted settings.sidebarCollapsedItems (keys project:<id> and project:<id>:group:<groupId>), and the documented "a project switch resets manual toggles" effect is removed on purpose: a tree the user shaped by hand should still be that shape after a restart, and one transient rule for projects plus a sticky one for frames would have been two contracts in one list. sidebarAutoCollapse survives with a narrower job — the default for a project row nobody ever toggled (on = active expanded / others collapsed; off = everything expanded) — and its Settings copy now says that. CLAUDE.md's "Projects (tabs)" section is updated in this PR (it is tracked at the repo root; no force-add needed), along with the "group" node-kind bullet and the canvas-control grouping-verbs paragraph.

3. Unbounded growth — FIXED. sidebarCollapsedItems is pruned on every write: liveCollapseKeys(groups) enumerates what the current tree can address (project rows + every frame at any depth) and pruneCollapsedItems drops the rest, keeping the key being written (a filtered tree does not list every frame) and returning the same object when nothing changed, so a no-op cannot dirty settings. Three unit tests.

4. Downgrade — analysed, guarded by construction, stated here. Nested frames add no new fields; only parentId semantics widen. What an older build does with a nested tree:

  • It renders correctly. Its nodeStatesToFlow sorts with a comparator returning 0 for two groups, and Array.prototype.sort is stable (ES2019+), so the parent-before-child order we now emit through groupsFirst is preserved. That order is the downgrade contract and is commented as such in the code.
  • It mangles a nested frame only if the user performs a structural edit there: its ungroupNodes / reparentNode / repositionState add exactly one parent's origin, so ungrouping or moving a nested frame writes a relative position as if it were absolute. A frame at the top level (every frame written by an older build) is unaffected.
  • No cheap guard exists that helps the old build — it is already shipped and would need to refuse an edit it cannot see is nested. The tombstone precedent does not apply: there is no removed node kind to migrate, and flattening the tree on load would delete the feature. So this is the disclosure, not a code change.

Left out (entangled with the Codex identity work in #112)

codexAccountId and its serializers, the scheduler (native Loop) node kind, createCodexAccountLoginNode / createCanvasControlTerminalNode, browserOwnerNodeId, the Accounts UI and settings.ts's codex reconcile, and the codex bits of workspace.test.ts. Also not taken: the integration branch's sessionList.ts / SessionsSidebar.tsx regressions of already-merged main features — it deletes projectHeadClickAction (#157), projectIdAtIndex + projectJump.* (#123) and the project working badge (#124). All three are preserved here; the nested-tree changes were re-applied on top of main's versions by hand rather than cherry-picked.

Three surfaces

  • Desktop — the primary target; everything above.
  • Server Edition — works as-is. All of it is pure renderer state plus workspace.save / settings.save, both already served by src/server. No new IPC, no new window.nodeTerminal member, so no bridge stub to decide on.
  • Mobile companion (~/projects/nodeterm-ios) — read-only degrade, follow-up in that repo. It groups sessions by the frame they sit in and has no canvas; a nested frame's sessions still reach it as ordinary tmux sessions, they simply appear under their immediate frame with no parent chain. Nothing breaks (parentId is a field it already reads); rendering the hierarchy is a separate change there.

Gates

  • npm run typecheck — clean.
  • npx vitest run src/renderer src/shared src/core — 310 files, 4315 passed, 4 skipped.
  • npx vitest run (full) — 396 passed / 1 failed file, 5142 passed, 12 skipped; the only 3 failures are src/main/node-pty-patch.test.ts, environmental in a clone with symlinked node_modules.

Not device-verified: the drag/pointer behaviour of nested frames on a real canvas (frame body pass-through, label-pill drag, sidebar frame reorder) wants a run on the desktop app.

enes and others added 2 commits August 13, 2026 15:18
Group frames may now contain other frames, to any depth, and the sessions
sidebar renders that tree instead of one flat level.

Transforms (src/renderer/state/workspace.ts, all pure + unit-tested):
- `groupSelectedNodes` wraps objects sharing ONE container — frames included
  — creating the wrapper inside that container. Mixed containers and an
  ancestor selected with its own descendant are refused, not scrambled.
- `ungroupNodes` promotes children into the frame's OWN parent (not the root).
- `reparentNode` moves a node or a whole frame subtree, keeps its ROOT-space
  position fixed, and refuses a cycle.
- new `selectedRootIds`, `addSelectionToGroup`, `reorderGroupWithinParent`.
- `groupsFirst` is now topological (depth-first from the root); a flat sort is
  not enough once two frames compare equal. `nodeStatesToFlow` uses it, so the
  persisted order is parent-first at any depth.

Fixes the geometry bug the pure arithmetic hides: a wrapper created at
(minX-28, minY-62) relative to its new parent is routinely negative and, with
`extent:'parent'`, React Flow clamps it into an inverted range — the frame
snaps hundreds of px away and drags the whole wrapped subtree with it. Every
ancestor frame is re-fitted around the new wrapper, pinned by a test that
asserts the frame FITS (and fails without the fix).

Sidebar disclosure moves to a persisted `settings.sidebarCollapsedItems`
(projects AND frames), replacing the "a project switch resets manual toggles"
effect; `sidebarAutoCollapse` now only supplies the default for an untouched
project row. Keys are pruned on every write against the live tree. CLAUDE.md
documents the new contract. `projectHeadClickAction`, the project working
badge and `projectIdAtIndex` are untouched.

Also: a frame's body is `pointer-events: none` with the label pill as its
`dragHandle` (a frame is a background container, not a giant drag target, and
an outer frame must not swallow clicks meant for one drawn inside it),
worktree/cwd inheritance and the Source Control scope walk the ancestor chain,
and the canvas-control `group`/`ungroup`/`move` verbs + docs follow.

Slice 3 of PR #112, sliced by us.

Co-Authored-By: Corvin <corvin@streamlain.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eneskirca
eneskirca merged commit 711f34b into main Aug 13, 2026
4 checks passed
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.

1 participant