Skip to content

Refine sidebar worktree group interactions#815

Draft
Mitch515 wants to merge 7 commits intopingdotgg:mainfrom
Mitch515:pr/sidebar-group-interactions
Draft

Refine sidebar worktree group interactions#815
Mitch515 wants to merge 7 commits intopingdotgg:mainfrom
Mitch515:pr/sidebar-group-interactions

Conversation

@Mitch515
Copy link

@Mitch515 Mitch515 commented Mar 10, 2026

What Changed

  • adds grouped project and worktree rendering in the sidebar
  • improves compose affordances, hover behavior, context menu handling, and local drag/reorder interactions for worktree groups
  • refines the local sidebar interaction model around grouped worktrees

Why

  • treating worktrees as first-class sidebar groups makes the sidebar easier to scan and operate
  • compose and drag affordances become clearer when the interaction model is centered on groups instead of flat rows

UI Changes

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes, if applicable

Note

Reorganize sidebar to group threads by branch/worktree with drag-reorder and context menus

  • Replaces @dnd-kit drag-and-drop with custom pointer-based drag logic for reordering both projects and per-project thread groups, with CSS transform animations and drop indicators.
  • Threads are now grouped by branch or worktree path within each project; groups support collapse/expand, context menus (open workspace, copy paths, copy branch, open PR, new chat, delete group+chats+worktree), and group-aware draft thread creation.
  • Adds sortSidebarThreadEntries and related utilities in sidebarThreadOrder.ts to sort threads by recent activity or creation time, configurable via a new Settings page control.
  • Adds sidebarGroupInteractions.ts with ~15 pure utility functions covering drag thresholds, drop validity, collapse keys, chevron/header/indicator class names, and selection suppression.
  • Extends ComposerDraftStore to track draft threads per project group (branch/worktree) with migration from the flat per-project mapping.
  • Risk: removes @dnd-kit entirely; any downstream code relying on sortable context or DnD sensor behavior will break.
📊 Macroscope summarized 7032a72. 14 files reviewed, 17 issues evaluated, 1 issue filtered, 4 comments posted

🗂️ Filtered Issues

apps/web/src/threadGroups.ts — 2 comments posted, 5 evaluated, 1 filtered
  • line 135: In reorderProjectThreadGroupOrder, if input.beforeGroupId equals input.movedGroupId (e.g. dropping an item on itself), the item is removed from withoutMoved on line 128. Consequently, withoutMoved.indexOf(input.beforeGroupId) on line 135 returns -1 because the target ID is no longer in the array. This triggers the condition on line 136, causing the item to jump to the start of the list (line 137) rather than staying in its original position. [ Cross-file consolidated ]

@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 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: d0c77aa1-9670-406a-8056-093e5e4a840e

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
  • Post copyable unit tests in a comment

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 the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Mar 10, 2026
Comment on lines +79 to +89
groups.set(id, {
id,
branch: thread.branch,
worktreePath: thread.worktreePath,
label: threadGroupLabel({
branch: thread.branch,
worktreePath: thread.worktreePath,
}),
latestActivityAt: thread.createdAt,
});
continue;
Copy link

Choose a reason for hiding this comment

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

🟢 Low src/threadGroups.ts:79

buildThreadGroupId normalizes branch and worktreePath (trimming whitespace, treating empty strings as falsy), but the raw unnormalized values are stored in OrderedProjectThreadGroup. This breaks resolveProjectThreadGroupPrById in two ways: (1) when worktreePath is "", the stored "" is used as cwd instead of falling back to projectCwd, causing Git status lookup to fail; (2) when values have leading/trailing whitespace, status.branch === group.branch and status-by-cwd lookups fail due to mismatched strings, resulting in lost PR status. Consider storing the normalized values used to build the ID.

      groups.set(id, {
        id,
-        branch: thread.branch,
-        worktreePath: thread.worktreePath,
+        branch: input.branch,
+        worktreePath: input.worktreePath,
        label: threadGroupLabel({
          branch: thread.branch,
          worktreePath: thread.worktreePath,
        }),
        latestActivityAt: thread.createdAt,
      });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/threadGroups.ts around lines 79-89:

`buildThreadGroupId` normalizes `branch` and `worktreePath` (trimming whitespace, treating empty strings as falsy), but the raw unnormalized values are stored in `OrderedProjectThreadGroup`. This breaks `resolveProjectThreadGroupPrById` in two ways: (1) when `worktreePath` is `""`, the stored `""` is used as `cwd` instead of falling back to `projectCwd`, causing Git status lookup to fail; (2) when values have leading/trailing whitespace, `status.branch === group.branch` and status-by-cwd lookups fail due to mismatched strings, resulting in lost PR status. Consider storing the normalized values used to build the ID.

Evidence trail:
apps/web/src/threadGroups.ts lines 26-32 (normalization functions trim whitespace), lines 45-53 (buildThreadGroupId normalizes values), lines 79-81 (raw values stored in OrderedProjectThreadGroup), lines 153-155 in resolveProjectThreadGroupPrById (uses ?? which doesn't treat empty string as fallback, and does direct equality comparison with potentially unnormalized stored values)

Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 155728d. Group metadata is now stored from normalized identity values so PR lookup uses the same normalized branch/worktree data as the group id.

Comment on lines +67 to +94
export function orderProjectThreadGroups<T extends ThreadGroupSeed>(input: {
threads: T[];
orderedGroupIds?: readonly ThreadGroupId[] | null | undefined;
}): OrderedProjectThreadGroup[] {
const groups = new Map<string, OrderedProjectThreadGroup>();
for (const thread of input.threads) {
const id = buildThreadGroupId({
branch: thread.branch,
worktreePath: thread.worktreePath,
});
const existing = groups.get(id);
if (!existing) {
groups.set(id, {
id,
branch: thread.branch,
worktreePath: thread.worktreePath,
label: threadGroupLabel({
branch: thread.branch,
worktreePath: thread.worktreePath,
}),
latestActivityAt: thread.createdAt,
});
continue;
}
if (thread.createdAt > existing.latestActivityAt) {
existing.latestActivityAt = thread.createdAt;
}
}
Copy link

Choose a reason for hiding this comment

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

🟢 Low src/threadGroups.ts:67

When multiple threads map to the same ThreadGroupId, the group retains the branch, label, and worktreePath from the first thread processed, even when a newer thread has different metadata. This causes stale UI labels and breaks resolveProjectThreadGroupPrById, which relies on group.branch matching the current git status to display PR information. Consider updating all group metadata when latestActivityAt changes.

-    if (thread.createdAt > existing.latestActivityAt) {
-      existing.latestActivityAt = thread.createdAt;
+    if (thread.createdAt > existing.latestActivityAt) {
+      existing.latestActivityAt = thread.createdAt;
+      existing.branch = thread.branch;
+      existing.worktreePath = thread.worktreePath;
+      existing.label = threadGroupLabel({
+        branch: thread.branch,
+        worktreePath: thread.worktreePath,
+      });
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/threadGroups.ts around lines 67-94:

When multiple threads map to the same `ThreadGroupId`, the group retains the `branch`, `label`, and `worktreePath` from the *first* thread processed, even when a newer thread has different metadata. This causes stale UI labels and breaks `resolveProjectThreadGroupPrById`, which relies on `group.branch` matching the current git status to display PR information. Consider updating all group metadata when `latestActivityAt` changes.

Evidence trail:
apps/web/src/threadGroups.ts lines 67-92: `orderProjectThreadGroups` function - when existing group found, only `latestActivityAt` is updated (lines 89-92), not `branch`, `label`, or `worktreePath` (commit REVIEWED_COMMIT)

apps/web/src/threadGroups.ts lines 146-167: `resolveProjectThreadGroupPrById` function - line 160 compares `group.branch` against `status?.branch` to determine if PR info should be displayed

apps/web/src/threadGroups.ts lines 46-56: `buildThreadGroupId` function shows that `worktreePath` takes precedence in ID generation, meaning two threads with same worktreePath but different branches get the same `ThreadGroupId`

Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 155728d. When a newer thread wins the group, the stored branch, worktree path, and label now refresh with it.

Comment on lines +68 to +78
const insertIndex = withoutMoved.indexOf(input.beforeProjectId);
if (insertIndex === -1) {
return [input.movedProjectId, ...withoutMoved];
}

return [
...withoutMoved.slice(0, insertIndex),
input.movedProjectId,
...withoutMoved.slice(insertIndex),
];
}
Copy link

Choose a reason for hiding this comment

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

🟢 Low src/projectOrder.ts:68

When beforeProjectId === movedProjectId, the function incorrectly moves the item to the start of the list. Line 62 removes movedProjectId from the array, so withoutMoved.indexOf(input.beforeProjectId) returns -1, triggering the fallback on line 70. The item should stay in its original position when dropped on itself.

  const insertIndex = withoutMoved.indexOf(input.beforeProjectId);
  if (insertIndex === -1) {
    return [...withoutMoved, input.movedProjectId];
  }
+  if (input.beforeProjectId === input.movedProjectId) {
+    return input.currentOrder.slice();
+  }

  return [
    ...withoutMoved.slice(0, insertIndex),
Also found in 1 other location(s)

apps/web/src/threadGroups.ts:135

In reorderProjectThreadGroupOrder, if input.beforeGroupId equals input.movedGroupId (e.g. dropping an item on itself), the item is removed from withoutMoved on line 128. Consequently, withoutMoved.indexOf(input.beforeGroupId) on line 135 returns -1 because the target ID is no longer in the array. This triggers the condition on line 136, causing the item to jump to the start of the list (line 137) rather than staying in its original position.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/projectOrder.ts around lines 68-78:

When `beforeProjectId === movedProjectId`, the function incorrectly moves the item to the start of the list. Line 62 removes `movedProjectId` from the array, so `withoutMoved.indexOf(input.beforeProjectId)` returns `-1`, triggering the fallback on line 70. The item should stay in its original position when dropped on itself.

Evidence trail:
apps/web/src/projectOrder.ts lines 60-76 (commit REVIEWED_COMMIT): Line 62 filters out movedProjectId from currentOrder into withoutMoved. Line 68 does `withoutMoved.indexOf(input.beforeProjectId)`. When beforeProjectId === movedProjectId, this returns -1 since movedProjectId was removed. Line 69-71 handles insertIndex === -1 by returning `[input.movedProjectId, ...withoutMoved]`, moving the item to the start instead of keeping it in place.

Also found in 1 other location(s):
- apps/web/src/threadGroups.ts:135 -- In `reorderProjectThreadGroupOrder`, if `input.beforeGroupId` equals `input.movedGroupId` (e.g. dropping an item on itself), the item is removed from `withoutMoved` on line 128. Consequently, `withoutMoved.indexOf(input.beforeGroupId)` on line 135 returns `-1` because the target ID is no longer in the array. This triggers the condition on line 136, causing the item to jump to the start of the list (line 137) rather than staying in its original position.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 155728d. Dropping a project or group onto itself now returns the existing order unchanged.

Comment on lines +16 to +24
export function shouldClearOptimisticProjectOrder<T extends string>(input: {
optimisticOrder: readonly T[] | null | undefined;
currentOrder: readonly T[] | null | undefined;
}): boolean {
if (!input.optimisticOrder || input.optimisticOrder.length === 0) {
return false;
}
return projectOrdersEqual(input.optimisticOrder, input.currentOrder);
}
Copy link

Choose a reason for hiding this comment

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

🟢 Low src/projectOrder.ts:16

shouldClearOptimisticProjectOrder returns false when optimisticOrder is an empty array [], even if currentOrder is also [] and the sync has completed. This prevents the UI from exiting the optimistic state when all items are deleted, leaving it stuck in 'Saving...'. Consider removing the length === 0 check so empty arrays are compared via projectOrdersEqual.

  export function shouldClearOptimisticProjectOrder<T extends string>(input: {
    optimisticOrder: readonly T[] | null | undefined;
    currentOrder: readonly T[] | null | undefined;
  }): boolean {
-   if (!input.optimisticOrder || input.optimisticOrder.length === 0) {
+   if (!input.optimisticOrder) {
      return false;
    }
    return projectOrdersEqual(input.optimisticOrder, input.currentOrder);
  }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/projectOrder.ts around lines 16-24:

`shouldClearOptimisticProjectOrder` returns `false` when `optimisticOrder` is an empty array `[]`, even if `currentOrder` is also `[]` and the sync has completed. This prevents the UI from exiting the optimistic state when all items are deleted, leaving it stuck in 'Saving...'. Consider removing the `length === 0` check so empty arrays are compared via `projectOrdersEqual`.

Evidence trail:
apps/web/src/projectOrder.ts lines 16-23: `shouldClearOptimisticProjectOrder` function definition showing the `length === 0` check returning `false` before comparing arrays. apps/web/src/projectOrder.ts lines 3-13: `projectOrdersEqual` function that correctly returns `true` for two empty arrays. apps/web/src/components/Sidebar.tsx lines 494-503: Usage showing that when `shouldClearOptimisticProjectOrder` returns `false`, `setOptimisticProjectOrder(null)` is never called, confirming the consequence.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 155728d. Empty optimistic project order now clears correctly instead of leaving the UI stuck in saving state.

@Mitch515
Copy link
Author

Assets for review:

Grouped sidebar screenshot:

Grouped sidebar

Compose affordance screenshot:

Compose affordance

Muted video:

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

Labels

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.

1 participant