Skip to content

feat: configurable project grouping#2055

Open
tlh38 wants to merge 3 commits intopingdotgg:mainfrom
tlh38:fix/nested-project-directories
Open

feat: configurable project grouping#2055
tlh38 wants to merge 3 commits intopingdotgg:mainfrom
tlh38:fix/nested-project-directories

Conversation

@tlh38
Copy link
Copy Markdown

@tlh38 tlh38 commented Apr 16, 2026

What changed

This changes sidebar project grouping so projects are no longer grouped only by repository identity and fixes #2054 & fixes #1912

Previously, any project inside the same git repository shared the same logical sidebar key. That caused a parent directory project and a nested child project to collapse into one sidebar entry.

Now the logical project key uses:

  • repository canonical key
  • plus the project path relative to the repository root

This keeps cross-environment grouping for the same project path, but stops parent and nested directories from overlapping.

Why this should exist

If a user adds both:

  • a parent project directory
  • and a nested project inside it

both should appear as separate entries in the Projects sidebar immediately.

The old behavior hid one project behind the other until the first project was removed, which made the sidebar state incorrect and confusing.

Scope

This PR only changes nested project grouping behavior.

Tests

  • added logical-project grouping regression coverage for root vs nested project paths
  • added repository identity resolver coverage for returning the git top-level root path

Note

Medium Risk
Changes how projects are keyed/grouped and how draft threads are reused, which can alter sidebar ordering/group membership and persisted UI state for existing users. Also expands context menu handling to nested menus, which could introduce edge-case UI regressions across Electron vs web fallback.

Overview
Fixes sidebar project grouping collisions by changing logical project keys to incorporate the repository root path (and optionally repo-relative subpaths), so parent and nested projects no longer collapse into a single sidebar row.

Introduces SidebarProjectGroupingMode and per-project grouping overrides in client settings, updates key derivation/usage across Sidebar, ChatView, and new-thread/draft reuse flows, and adds sidebar UI to change the global grouping mode plus a per-project grouping/rename workflow.

Extends RepositoryIdentity to include rootPath (resolved via git rev-parse --show-toplevel) and upgrades context menus to support nested submenu items in both Electron IPC (main.ts) and the browser fallback (contextMenuFallback.ts), with accompanying test coverage updates.

Reviewed by Cursor Bugbot for commit ce291f5. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Scope sidebar project grouping by repository root with per-project overrides

  • Adds SidebarProjectGroupingMode setting (repository, repository_path, separate) that controls how projects are grouped in the sidebar, defaulting to repository.
  • Extends RepositoryIdentity with a rootPath field (resolved in RepositoryIdentityResolver.ts) so logical project keys can be scoped to the git top-level directory.
  • Rewrites logicalProject.ts to derive logical project keys from grouping settings and repository root, with physical project keys as fallback.
  • Updates the sidebar (Sidebar.tsx) to expose grouping mode controls, per-project overrides, and submenus for multi-member project groups (rename, copy path, delete with emptiness check).
  • Adds nested submenu support to the desktop context menu IPC handler and the web fallback renderer (contextMenuFallback.ts).
  • Behavioral Change: draft thread reuse and new-thread creation now key off the resolved logical project key from settings, so projects that were previously grouped separately may merge, or vice versa, after the settings default is applied.

Macroscope summarized ce291f5.

- Preserve git top-level root paths in repository identity
- Distinguish nested workspaces within the same repo when deriving project grouping keys
- Add coverage for nested-root resolution and cross-environment grouping
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 16, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d72a0c64-3506-4b6b-affa-b84ec7d631c6

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions bot added size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Apr 16, 2026
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 16, 2026

Approvability

Verdict: Needs human review

2 blocking correctness issues found. This PR introduces a significant new feature for configurable sidebar project grouping, including new settings, new UI dialogs, nested context menu support, and substantial new logic in sidebarProjectGrouping.ts. New user-facing features of this scope warrant human review regardless of implementation quality. Additionally, the author appears to be a less frequent contributor to these core files, and there are unresolved medium-severity review comments about memoization and positioning bugs.

You can customize Macroscope's approvability policy. Learn more.

- Group projects by repository path with per-project overrides
- Add nested context menu actions for grouped project members
- Update rename/delete flows for multi-project sidebar rows
@github-actions github-actions bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:M 30-99 changed lines (additions + deletions). labels Apr 16, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 41e5a90. Configure here.

return (
project.repositoryIdentity?.canonicalKey ??
deriveRepositoryScopedKey(project, groupingMode) ??
derivePhysicalProjectKey(project) ??
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.

Unreachable fallback due to always-truthy return value

Low Severity

derivePhysicalProjectKey always returns a non-empty string (${environmentId}:${normalizedCwd}), so the ?? fallback to scopedProjectKey(scopeProjectRef(...)) on the next line is unreachable dead code. This makes the fallback chain misleading — a reader might think the scopedProjectKey path is reachable, but it never executes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 41e5a90. Configure here.

const projectGroupingSettings = useSettings((settings) => ({
sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
}));
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.

New object reference every render defeats downstream memoization

Medium Severity

useSettings with an inline selector that returns a new object produces a fresh reference on every render. Internally, useSettings uses useMemo with [merged, selector] as deps — since the inline arrow function is a new reference each render, the memo always recomputes, returning a new object. This projectGroupingSettings object is then used as a dependency in multiple useMemo/useCallback hooks (e.g. physicalToLogicalKey, sidebarProjects, threadsByProjectKey), causing them all to needlessly recompute on every render.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 41e5a90. Configure here.

): string {
return project?.repositoryIdentity?.canonicalKey ?? scopedProjectKey(projectRef);
return project ? deriveLogicalProjectKey(project, options) : scopedProjectKey(projectRef);
}
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.

Exported function deriveLogicalProjectKeyFromRef is now unused

Low Severity

deriveLogicalProjectKeyFromRef is exported but no longer imported or called anywhere in the codebase after this PR refactored all callers to use deriveLogicalProjectKeyFromSettings instead. This is dead exported code.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 41e5a90. Configure here.

- lets users change project grouping from the project sort menu
- removes the duplicate setting from General settings

Co-authored-by: codex <codex@users.noreply.github.com>
Comment thread apps/desktop/src/main.ts
Comment on lines +165 to +166
for (const sourceItem of source) {
if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
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.

🟢 Low src/main.ts:165

When source contains null or undefined elements (possible via untrusted IPC input), accessing sourceItem.id on line 166 throws a TypeError instead of being filtered out. Consider adding a guard to skip non-object elements before property access.

   for (const sourceItem of source) {
+    if (!sourceItem || typeof sourceItem !== 'object') {
+      continue;
+    }
     if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
🤖 Copy this AI Prompt to have your agent fix this:
In file apps/desktop/src/main.ts around lines 165-166:

When `source` contains `null` or `undefined` elements (possible via untrusted IPC input), accessing `sourceItem.id` on line 166 throws a `TypeError` instead of being filtered out. Consider adding a guard to skip non-object elements before property access.

Evidence trail:
apps/desktop/src/main.ts lines 162-167 (normalizeContextMenuItems function with typeof sourceItem.id check), apps/desktop/src/main.ts lines 1734-1737 (ipcMain.handle calling normalizeContextMenuItems with untrusted IPC items)


if (!isDisabled) {
if (hasChildren) {
button.addEventListener("mouseenter", () => {
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.

🟡 Medium src/contextMenuFallback.ts:96

When a submenu overflows the right viewport edge, the mouseenter handler (lines 96-110) calls clampMenuPosition to flip it to the left side, but openMenu's requestAnimationFrame callback (lines 132-134) immediately re-clamps it using the original preferredLeft (which caused the overflow). The menu ends up pinned to the right edge instead of the intended left-side position, making the flip logic non-functional.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/web/src/contextMenuFallback.ts around line 96:

When a submenu overflows the right viewport edge, the `mouseenter` handler (lines 96-110) calls `clampMenuPosition` to flip it to the left side, but `openMenu`'s `requestAnimationFrame` callback (lines 132-134) immediately re-clamps it using the original `preferredLeft` (which caused the overflow). The menu ends up pinned to the right edge instead of the intended left-side position, making the flip logic non-functional.

Evidence trail:
apps/web/src/contextMenuFallback.ts lines 96-110 (mouseenter handler with flip logic), lines 131-133 (requestAnimationFrame callback in openMenu), lines 3-15 (clampMenuPosition function). The rAF callback scheduled in openMenu uses the original preferredLeft parameter, which overwrites any position changes made after openMenu returns but before the rAF callback executes.

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.

🟡 Medium components/Sidebar.tsx:2689

The inline selector at lines 2689-2692 creates a new object reference on every render. Since useSettings includes the selector function in its useMemo dependency array, projectGroupingSettings becomes a new reference each render. This causes the useMemo hooks at lines 2729-2734 and 2746-2763 to recompute unnecessarily, defeating memoization. Consider extracting the selector to a stable reference with useCallback or defining it outside the component.

  const projectGroupingSettings = useSettings(useCallback((settings) => ({
    sidebarProjectGroupingMode: settings.sidebarProjectGroupingMode,
    sidebarProjectGroupingOverrides: settings.sidebarProjectGroupingOverrides,
  }), []));
Also found in 1 other location(s)

apps/web/src/hooks/useHandleNewThread.ts:22

The inline selector arrow function passed to useSettings creates a new function reference on every render. Since useSettings uses useMemo with the selector in its dependency array, projectGroupingSettings will be a new object reference on every render even when the values haven't changed. This object is included in the useCallback dependency array at line 137, causing handleNewThread to be recreated on every render and defeating the purpose of useCallback. Consumers that depend on handleNewThread being stable (e.g., in effect dependencies or child component memoization) will see unnecessary re-renders or potentially infinite update loops.

🤖 Copy this AI Prompt to have your agent fix this:
In file apps/web/src/components/Sidebar.tsx around lines 2689-2692:

The inline selector at lines 2689-2692 creates a new object reference on every render. Since `useSettings` includes the `selector` function in its `useMemo` dependency array, `projectGroupingSettings` becomes a new reference each render. This causes the `useMemo` hooks at lines 2729-2734 and 2746-2763 to recompute unnecessarily, defeating memoization. Consider extracting the selector to a stable reference with `useCallback` or defining it outside the component.

Evidence trail:
apps/web/src/components/Sidebar.tsx lines 2689-2692 (inline selector), lines ~2729-2734 (physicalToLogicalKey useMemo with projectGroupingSettings dependency), lines ~2746-2763 (sidebarProjects useMemo with projectGroupingSettings dependency) at REVIEWED_COMMIT; apps/web/src/hooks/useSettings.ts line 141 shows `return useMemo(() => (selector ? selector(merged) : (merged as T)), [merged, selector]);` - the selector is in the dependency array at REVIEWED_COMMIT.

Also found in 1 other location(s):
- apps/web/src/hooks/useHandleNewThread.ts:22 -- The inline selector arrow function passed to `useSettings` creates a new function reference on every render. Since `useSettings` uses `useMemo` with the `selector` in its dependency array, `projectGroupingSettings` will be a new object reference on every render even when the values haven't changed. This object is included in the `useCallback` dependency array at line 137, causing `handleNewThread` to be recreated on every render and defeating the purpose of `useCallback`. Consumers that depend on `handleNewThread` being stable (e.g., in effect dependencies or child component memoization) will see unnecessary re-renders or potentially infinite update loops.

@juliusmarminge juliusmarminge changed the title fix: Scope logical project keys by repository root feat: configurable project grouping Apr 16, 2026
@juliusmarminge
Copy link
Copy Markdown
Member

Extended this a bit to solve #1912 as well:

CleanShot.2026-04-16.at.00.01.22.mp4

Can you verify that it still solves your problem?

@tlh38
Copy link
Copy Markdown
Author

tlh38 commented Apr 16, 2026

Can confirm this would fix the issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Nested Projects Overlap in Monorepo feat: configurable logical project keying

2 participants