feat(canvas): nested group trees and a recursive sessions sidebar - #166
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-Byon the commit). Sliced fromintegration/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.
Transforms —
src/renderer/state/workspace.ts, pure and unit-tested:groupSelectedNodeswraps 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).ungroupNodespromotes a frame's direct children into its own parent, not to the root (the root would move them by the whole ancestor offset).reparentNodemoves 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.selectedRootIds(box-selection catches a frame and its children — structural actions normalize to subtree roots),addSelectionToGroup,reorderGroupWithinParent.groupsFirstis now topological (frames depth-first from the root). A flat "groups first" sort is not enough once two frames compare equal.nodeStatesToFlowruns 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 areorderGroupaction for non-active projects.Sidebar (
SessionsSidebar.tsx,lib/sessionList.ts) —GroupBucketgainschildren;buildSessionListbuilds a recursive tree (a dangling or cyclicparentIdis promoted to a root rather than recursing forever), filtering keeps a match's ancestors so the hit stays reachable, and counts/badges aggregate throughgroupSessionRows/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 (themovepath), dropping it on the project head pulls it to the top level.Elsewhere — worktree/cwd inheritance (
cwdForNewNodeIn, newworktreeForGroupChain,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-controlgroup/ungroup/moveverbs and their agent-facing docs follow the new rules.One deliberate UX change that rides along: a frame's body is
pointer-events: noneand its label pill is the node'sdragHandle. 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.multiSelectionKeyCodealso gainsShift(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.
groupSelectedNodescreated a nested wrapper at(minX-28, minY-62)relative to its new parent (routinely negative) withextent:'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:groupSelectedNodesnow re-fits every ancestor frame (fitGroupToChildrenup the chain, innermost first) around the new wrapper;addSelectionToGroupdoes 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(keysproject:<id>andproject:<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.sidebarAutoCollapsesurvives 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.
sidebarCollapsedItemsis pruned on every write:liveCollapseKeys(groups)enumerates what the current tree can address (project rows + every frame at any depth) andpruneCollapsedItemsdrops 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
parentIdsemantics widen. What an older build does with a nested tree:nodeStatesToFlowsorts with a comparator returning0for two groups, andArray.prototype.sortis stable (ES2019+), so the parent-before-child order we now emit throughgroupsFirstis preserved. That order is the downgrade contract and is commented as such in the code.ungroupNodes/reparentNode/repositionStateadd 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.Left out (entangled with the Codex identity work in #112)
codexAccountIdand its serializers, thescheduler(native Loop) node kind,createCodexAccountLoginNode/createCanvasControlTerminalNode,browserOwnerNodeId, the Accounts UI andsettings.ts's codex reconcile, and the codex bits ofworkspace.test.ts. Also not taken: the integration branch'ssessionList.ts/SessionsSidebar.tsxregressions of already-merged main features — it deletesprojectHeadClickAction(#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
workspace.save/settings.save, both already served bysrc/server. No new IPC, no newwindow.nodeTerminalmember, so no bridge stub to decide on.~/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 (parentIdis 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 aresrc/main/node-pty-patch.test.ts, environmental in a clone with symlinkednode_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.