Skip to content

feat(sidebar): nest project groups from the group menu - #26194

Open
LINDECKER-Charles wants to merge 6 commits into
stablyai:mainfrom
LINDECKER-Charles:feat/nested-project-groups
Open

LINDECKER-Charles wants to merge 6 commits into
stablyai:mainfrom
LINDECKER-Charles:feat/nested-project-groups

Conversation

@LINDECKER-Charles

@LINDECKER-Charles LINDECKER-Charles commented Oct 7, 2026 •

Copy link
Copy Markdown

ELI5

You can already put projects into groups, but only one level deep. This lets you put a group inside another group (for example a "Clients" group holding one group per client) straight from the group's ⋯ menu: New subgroup creates one inside it, and Move to group moves an existing group under another one, or back to the top level.

What Changed

Before: a group's ⋯ menu only offered Rename and Delete. Nesting existed only when a folder import created it, so people encoded hierarchy in names ("acme / web", "acme / infra"). A project's Move to group submenu was a flat list, so two groups with the same name in different branches were indistinguishable.

After:

  • The group menu gains New subgroup and a Move to group submenu (Top level + every valid parent, indented as a tree). The current parent is shown but disabled; the group itself and its own subgroups are never offered.
  • A project's Move to group submenu shows the same indented tree.
  • New subgroup is disabled at the depth cap (3 levels, top level = 1), as suggested in the issue. The cap applies to manual creates and moves only; deeper trees from folder imports keep working and can still be moved shallower.
  • The delete dialog now says when subgroups will be deleted with the group (the host already cascaded; the dialog just didn't say so).
  • After a create or move, the sidebar reveals the group, so it doesn't vanish into a collapsed parent.

Mechanism:

  • projectGroup.update (IPC + runtime RPC) accepts an optional parentGroupId (null = top level). The host that owns the group validates every move in src/shared/project-group-nesting.ts: no self, no descendant, no other host, no deeper than the cap. A manual subgroup inherits its parent's host (connectionId).
  • Mixed versions: an older host silently strips the unknown field and answers with the unmoved group, so the client only sends a move to remote hosts that advertise the new runtime capability projectGroup.nesting.v1. Otherwise it shows a "could not confirm the move" toast instead of pretending it worked. Creating a subgroup needs no gate, because projectGroup.create has accepted parentGroupId since Add nested repo group imports #2866; the client also sends the parent's connectionId so older hosts keep the subgroup on the right SSH target. This follows Rule 1 of docs/reference/remote-wire-compatibility.md.
  • normalizeProjectGroups now breaks parent cycles in persisted data, since the sidebar renders from roots and a cycle would hide every group in it. The sidebar also resolves parent links inside each host's catalog, so equal group ids on two hosts can't link into a loop.

Why

Folder imports already produce nested groups and the sidebar already renders them; the missing piece was a way to build or change that structure by hand (#8785).

  • Menu-only, no drag-to-nest. Dragging a header already means "reorder among siblings". Overloading that drop zone needs its own hit-testing and UX decisions, and it is the part that made earlier attempts large. The menu path is discoverable, works with keyboard and screen readers, and gives drag-to-nest a validated backend to build on in a follow-up.
  • Validation lives on the host, not only in the menu, so the CLI, mobile and remote clients can't create cycles or cross-host parents.
  • A capability gate rather than a protocol bump, so mixed client/host versions keep working and degrade to a clear message.
  • Depth cap = 3, as the issue suggested: deeper than that, the indentation eats the sidebar width. It is one constant (MAX_PROJECT_GROUP_LEVELS). Folder imports stay exempt, and an imported tree that is already deeper can still be moved shallower, never deeper, so the cap never traps existing data.

Prior work: #8939 (drag-and-drop, by the issue author) has had merge conflicts since July, and #19125 bundles a broader "client" model. This PR is the narrow, menu-only slice that can land on its own. Drag-to-nest from #8939 can build on top of it.

Linked Issue

Fixes #8785

Visual Proof

Headless Electron build, macOS, same seeded data on both sides (left: main at 2584ed9, right: this branch).

Sidebar tree: groups can now nest.

Sidebar tree, before and after

Group ⋯ menu: adds New subgroup and Move to group.

Group menu, before and after

Project → Move to group: an indented tree instead of a flat list.

Project Move to group submenu, before and after

New: Group → Move to group. Top level is offered, the current parent is shown but disabled, and the group's own subtree is hidden.

Group Move to group submenu

Testing

  • pnpm typecheck: passes.

  • electron-vite build (the e2e build of the desktop app): succeeds.

  • pnpm lint: every step that covers this change passes, including verify:rpc-params-catalog, verify:localization-*, check:max-lines-ratchet and the changed-lines code-quality and design-system gate (0 new findings across 40 files). The only failure is 5 existing import/no-cycle warnings in mobile/ files this PR doesn't touch.

  • Unit tests (Vitest, Node) across every directory touched (components/sidebar, renderer/store, shared, main/persistence, main/runtime/rpc, main/ipc, preload): 29,821 pass. The single failure, bounded-output-sink.test.ts, fails identically on main and is unrelated.

  • New tests:

    • shared/project-group-nesting.test.ts: depth, subtree span, move rejections, and the rule that imported deep trees can still move shallower.
    • shared/project-groups.test.ts: parent-cycle breaking.
    • main/persistence-project-group-nesting.test.ts: host-side validation, parent-host inheritance, and renames inside over-deep imported trees.
    • main/runtime/rpc/methods/repo.test.ts, main/ipc/repos-execution-host-catalog.test.ts: parentGroupId round-trip.
    • store/slices/repos-project-group-nesting.test.ts: owner-host routing, capability gate, and the old host that ignores the move.
    • sidebar/project-group-move-targets.test.ts, project-group-header-actions.test.tsx, use-project-group-dialogs-owner-host.test.tsx, ProjectGroupDeleteDialog.test.tsx, worktree-list-groups-nested-project-groups.test.ts: menu targets, tree order, host isolation, delete copy.
    • tests/e2e/project-group-nesting.spec.ts (headless Electron, 4 tests passing): create a subgroup, cap at level 3, move in and out, own subtree never offered (and the submenu disappears when no valid parent is left).
  • Platforms: macOS (unit + headless e2e). No platform-specific code: no shortcuts, paths or process spawning. SSH and remote behaviour is covered by the routing and capability unit tests above.

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

AI coding agents were used for this PR. Claude (Anthropic, Claude Opus 5.5, via Claude Code) verified the change, rebased it onto main, ran the checks and e2e tests, captured the screenshots, did the review below, and drafted this description. All of it was reviewed by me before submission.

Review

AI review summary of the final diff:

  • Cross-platform: no OS-specific code paths, shortcuts, file paths or child processes. Menu indentation uses the existing PROJECT_GROUP_HEADER_INDENT constant (inline style, because computed padding classes on menu primitives are lint-gated).
  • SSH / remote / local: every mutation routes to the group's owner host, not the focused one. Move targets are limited to the moving group's catalog, where local and direct-SSH rows share one catalog and each runtime host has its own. Cross-host parents are rejected on the host. Mixed versions are handled by the projectGroup.nesting.v1 capability gate, and an unreachable host or timeout ends in a "could not confirm" toast, never a false success.
  • Agents and integrations: none involved; this is sidebar organization only, with no agent, git-provider or integration-specific logic.
  • Performance: move checks index the subtree once, then evaluate each candidate parent linearly. The target tree is built only while a menu is open, never per sidebar row. Cycle breaking memoizes walks that reach a root, so it stays linear on large catalogs.
  • UI quality: uses the existing shadcn DropdownMenu* primitives and tokens, with no new colors or restyles (design-system gate: 0 findings). Long trees scroll inside the submenu (max-h-80). New strings go through translate() with hashed auto.* keys, seeded in all six locales.
  • Security: parentGroupId is validated with zod (min(1), nullable, optional) on both IPC and RPC, and existence, host and cycle checks run on the host before anything is persisted. There are no new filesystem, shell or network surfaces.

Suggested reading order (28 source files, +816/−190; tests +1,178; locales +198):

  1. src/shared/project-group-nesting.ts: the rules.
  2. src/main/persistence/tracking-repos/project-group-operations.ts: host-side enforcement.
  3. src/renderer/src/store/project-groups/project-group-owner-update.ts and project-group-mutations.ts: routing and the capability gate.
  4. src/renderer/src/components/sidebar/project-group-move-targets.ts, ProjectGroupMoveSubmenu.tsx and project-group-header-actions.tsx: the UI.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Ensure no issues in: Security, Cross-platform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance. All of these are covered in Review above. Mobile has no group-editing UI; it only reads parentGroupId to resolve group subtrees (agent-history-resume-target.ts), which already handles imported nested trees and is cycle-safe.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

Folder imports already create nested groups and the sidebar renders them,
but nothing let a user build or change that hierarchy by hand. Group
headers now offer "New subgroup" and "Move to group" (capped at five
levels for manual changes), and the project "Move to group" submenu shows
the tree indented.

Moves are validated on the host that owns the group (no self, descendant,
cross-host, or too-deep parents). Clients only send a move to remote hosts
that advertise projectGroup.nesting.v1, because older hosts drop the field
and still return the unmoved group.

Refs stablyai#8785
stablyai#8785 suggested two or three levels: deeper than that, the indentation
eats the sidebar width. Folder imports stay exempt, and imported trees
deeper than the cap can still be moved shallower.
@LINDECKER-Charles
LINDECKER-Charles marked this pull request as ready for review October 7, 2026 11:31
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

We couldn't safely recover the incremental review. No full review was started, and the last reviewed checkpoint was preserved. Retry later, or explicitly request a full review by commenting @coderabbitai full review.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 3bc43aba-0ef3-4919-9e9f-04c883ca05bf
📥 Commits

Reviewing files that changed from the base of the PR and between ebf536d and d7ff26b.

📒 Files selected for processing (2)
  • src/main/persistence-project-group-nesting.test.ts
  • src/main/persistence/tracking-repos/project-group-operations.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/main/persistence-project-group-nesting.test.ts
  • src/main/persistence/tracking-repos/project-group-operations.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.

📝 Walkthrough

Adds manual creation and movement of nested project groups, with a three-level limit for manual nesting and host-aware update routing. Sidebar menus show eligible move targets and subgroup actions. Group-tree resolution is scoped to each host, and delete confirmations report descendant subgroup counts. Folder-imported groups can remain deeper than the manual nesting limit.

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to d7ff2

This change adds nested project-group creation and moves with validation on the host. No actionable merge-blocking issue was identified in the reviewed portion.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.65% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 62 functions across 40 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding nested project groups through the sidebar menu.
Description check ✅ Passed The description covers the required sections with clear user-facing changes, rationale, linked issue, visual proof, testing details, and review notes. It is detailed and mostly complete.
Linked Issues check ✅ Passed Issue #8785 requests manual subgroup creation and changing a group’s parent, with a suggested depth cap. The PR adds “New subgroup” and group “Move to group” menus, including a top-level destination, …
Out of Scope Changes check ✅ Passed The changes support nested sidebar groups. Owner-host routing and host validation enable subgroup creation and group moves. Cycle handling and host-scoped parent resolution support rendering the neste…
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: ba6e4437-3e39-40a4-affe-9a20ecdcecf6
📥 Commits

Reviewing files that changed from the base of the PR and between 2584ed9 and ebf536d.

📒 Files selected for processing (46)
  • src/main/ipc/repos-execution-host-catalog.test.ts
  • src/main/ipc/repos/repo-ipc-arg-schemas.ts
  • src/main/persistence-project-group-nesting.test.ts
  • src/main/persistence/tracking-repos/project-group-operations.ts
  • src/main/runtime/rpc/methods/repo.test.ts
  • src/main/runtime/runtime-project-group-controller.ts
  • src/preload/api/repository-api.ts
  • src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.test.tsx
  • src/renderer/src/components/sidebar/ProjectGroupDeleteDialog.tsx
  • src/renderer/src/components/sidebar/ProjectGroupMoveSubmenu.tsx
  • src/renderer/src/components/sidebar/WorktreeContextMenuView.tsx
  • src/renderer/src/components/sidebar/WorktreeList.tsx
  • src/renderer/src/components/sidebar/project-group-move-targets.test.ts
  • src/renderer/src/components/sidebar/project-group-move-targets.ts
  • src/renderer/src/components/sidebar/worktree-list-groups-nested-project-groups.test.ts
  • src/renderer/src/components/sidebar/worktree-list/grouping/project-group-sections.ts
  • src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx
  • src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx
  • src/renderer/src/components/sidebar/worktree-list/rows/project-group-header-actions.test.tsx
  • src/renderer/src/components/sidebar/worktree-list/rows/project-group-header-actions.tsx
  • src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx
  • src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs-owner-host.test.tsx
  • src/renderer/src/components/sidebar/worktree-list/rows/use-project-group-dialogs.ts
  • src/renderer/src/components/sidebar/worktree-list/viewport/viewport-props.ts
  • src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts
  • src/renderer/src/i18n/locales/en.json
  • src/renderer/src/i18n/locales/es.json
  • src/renderer/src/i18n/locales/fr.json
  • src/renderer/src/i18n/locales/ja.json
  • src/renderer/src/i18n/locales/ko.json
  • src/renderer/src/i18n/locales/zh.json
  • src/renderer/src/store/project-groups/project-group-mutations.ts
  • src/renderer/src/store/project-groups/project-group-owner-update.ts
  • src/renderer/src/store/repos/repo-state.ts
  • src/renderer/src/store/slices/project-group-owner-routing.ts
  • src/renderer/src/store/slices/repos-project-group-nesting.test.ts
  • src/renderer/src/store/slices/repos.ts
  • src/shared/project-group-nesting-capability.ts
  • src/shared/project-group-nesting.test.ts
  • src/shared/project-group-nesting.ts
  • src/shared/project-group-types.ts
  • src/shared/project-groups.test.ts
  • src/shared/project-groups.ts
  • src/shared/protocol-version.ts
  • src/shared/rpc-contract/repo-params.ts
  • tests/e2e/project-group-nesting.spec.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

A create call that named another connection than the parent's stored the
mismatch, so a later move under a valid group on the parent's host failed
with host-mismatch. Reject it up front; an omitted or null connection still
inherits the parent's.
Roughriver74 added a commit to Roughriver74/orca that referenced this pull request Oct 11, 2026
Upstream now routes project group catalog calls by their owner host
(stablyai#27288): createProjectGroup takes the seeding project's host, and rows
are adopted through adoptFromEndpoint. Subgroup creation moves to its own
createProjectSubgroup action, routed by the parent group's owner like
moveProjectGroup, and the update/move helper uses the new owner routing.

protocol-version.ts re-exports the attention capabilities with a star,
as stablyai#27493 did for the resume ones, to stay within the line limit.
@LINDECKER-Charles

Copy link
Copy Markdown
Author

@brennanb2025 friendly ping on this one when you have a moment.

I merged main back in to clear the conflicts with #27288 (project group calls now route by their owner host). The only behavioral adjustment: creating a subgroup is now its own createProjectSubgroup store action, routed by the parent group's owner the same way moveProjectGroup is, since createProjectGroup now takes the seeding project's host. Typecheck, lint and the project-group unit tests pass locally.

Since this comes from a fork, the CI workflows are still waiting for a maintainer to approve the run.

Upstream renamed runtimeTargetForOwnerHostId to
ipcOrRuntimeTargetForHostId and dropped the RuntimeClientTarget re-export
(stablyai#27992), so createProjectGroup and the shared createOnTarget helper use
the new name and import the type from shared. ProjectGroupingModel moved
to shared/project-section-grouping (stablyai#28850). Locale conflicts were
sibling additions; both sides are kept.
The workspace-list builder moved to src/shared/sidebar-list (stablyai#29906), and
the sidebar now draws one header per project group id when two hosts
publish the same group (stablyai#30128). The branch linked each host's copy to a
parent on its own host, which assumed every copy was drawn; with copies
collapsed, parents now resolve by id over the drawn groups, the same links
reveal walks. Same-id copies from two hosts can still close a parent loop,
so section emission and getProjectGroupOccurrencePaths share one parent
map that cuts it with breakProjectGroupParentCycles.

compareProjectGroupSiblings adopts the device-independent sidebar order
from stablyai#29946 instead of localeCompare.

createOnTarget moves to project-group-create-on-host.ts, and two repo RPC
test expectations fold into one, to keep both files under max-lines.
@LINDECKER-Charles

Copy link
Copy Markdown
Author

@brennanb2025 one follow-up since my last ping, no rush.

I merged main again. One change worth knowing about: since #30128 draws a same-id project group once across hosts, subgroups now link to their parent by id over the drawn groups (the same links reveal walks), instead of each host's copy linking to a parent on its own host. If two hosts' copies would close a parent loop, the loop is cut so no group disappears.

main moves fast enough that this branch picks up conflicts within a few days. Would you rather I keep it synced continuously, or leave it and sync once when you're ready to review? Either works for me.

Roughriver74 added a commit to Roughriver74/orca that referenced this pull request Oct 22, 2026
Roughriver74 added a commit to Roughriver74/orca that referenced this pull request Oct 22, 2026
…s C+E)

Two distinct bugs in the project-group rebase stack (PR stablyai#26194 nest-groups
layered on top of upstream's stablyai#30045/stablyai#30128 same-id-across-hosts fix):

1. appendProjectGroupSections() fed getDistinctProjectGroups()'s bare-id
   last-wins dedup straight into the per-host catalogGroupKey nesting walk.
   That dedup is correct for stablyai#30045's real scenario (one group literally
   republished under the same id to a managed server, so only one copy
   should render), but when two DIFFERENT duplicated ids cross-reference
   each other as parents across hosts (host A's copy of X parents host B's
   copy of Y, while host B's copy of Y parents host A's copy of X), the
   dedup silently drops an entire host's half of the hierarchy instead of
   looping. Added findDuplicatedIdsFormingParentCycles() to detect that
   specific cross-reference and exempt only those ids from the dedup, so
   every host keeps its own acyclic copy; ids with no such cross-reference
   keep the existing stablyai#30045 merge-to-one-copy behavior unchanged.

2. host-section-rows.ts's getRowHostId() pinned every project-group header
   to a concrete host id via getProjectGroupExecutionHostIdForRows(), which
   defaults to defaultHostId when a group has no executionHostId/
   connectionId of its own. That conflated 'this group explicitly belongs
   to one host' with 'this group has no host and spans whichever hosts its
   repos live on' — so a catalog-wide group's header got bucketed into the
   default host's section only, instead of floating as a pending header
   repeated in every host section its member repos actually appear under.
   Added projectGroupHasExplicitExecutionHost() and only pin the header to
   a host when the group actually names one.

This branch has not been deployed

No deployments
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.

[Feature]: Allow nesting project groups (drag a group into another group)

1 participant