Skip to content

Persist sidebar project and worktree order#816

Draft
Mitch515 wants to merge 8 commits intopingdotgg:mainfrom
Mitch515:pr/sidebar-order-persistence
Draft

Persist sidebar project and worktree order#816
Mitch515 wants to merge 8 commits intopingdotgg:mainfrom
Mitch515:pr/sidebar-order-persistence

Conversation

@Mitch515
Copy link

@Mitch515 Mitch515 commented Mar 10, 2026

What Changed

  • persist manual project ordering and worktree-group ordering instead of keeping reorder state only in local UI memory
  • add the required contracts, projector, snapshot-query, persistence, migration, and web-store wiring for saved sidebar order
  • restore saved sidebar ordering after reload and restart

Why

  • manual reordering is much less useful when it disappears on refresh or restart
  • saved sidebar order needs to be represented consistently across the contracts, server projection layer, persistence layer, and web state

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

Persist sidebar project and thread group order via project.meta.update

  • Projects now carry threadGroupOrder and sortOrder fields through contracts, read model, projector, projection DB, and the web store; two new migrations (014, 015) add the columns to projection_projects.
  • The sidebar replaces dnd-kit with a custom pointer-driven drag-and-drop that persists reorder operations by dispatching project.meta.update with updated sortOrder or threadGroupOrder, then refreshes the server snapshot with animated transitions.
  • Threads are now grouped by branch/worktree identity in the sidebar; draft threads participate in groups, git status, and ordering alongside server threads.
  • A new group context menu supports opening workspace in editor, copying paths/branch, opening PR, creating a new chat, and deleting a worktree group and its chats.
  • derivePendingUserInputs now filters prompts when the session is in error/closed or the owning turn has settled; sidebar status pills gain 'Planning', 'Plan Submitted', and 'Errored' states.
  • Risk: reorderProjects is removed from the store and project order is no longer read from or written to localStorage; clients that relied on persisted local order will fall back to server sortOrder.
📊 Macroscope summarized 74d3be2. 27 files reviewed, 21 issues evaluated, 5 issues filtered, 3 comments posted

🗂️ Filtered Issues

apps/server/src/orchestration/Layers/ProjectionPipeline.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 1046: In applyThreadTurnsProjection, the handling of thread.turn-diff-completed events creates a potential "zombie turn" state due to a logic regression. [ Out of scope ]
apps/server/src/persistence/Migrations/014_ProjectionProjectsThreadGroupOrder.ts — 0 comments posted, 1 evaluated, 1 filtered
  • line 10: There are multiple blocking issues with how the SQL query result is handled that will cause this migration to crash: [ Out of scope ]
apps/web/src/appSettings.ts — 0 comments posted, 2 evaluated, 1 filtered
  • line 22: Schema.Literals is not a standard export of the effect Schema module. The correct function is Schema.Literal, and it accepts variadic arguments (e.g. Schema.Literal("recent-activity", "created-at")), not an array. Passing an array ["recent-activity", "created-at"] to Schema.Literal (if corrected) would create a schema that matches the array instance itself, not a union of the strings. This will cause a runtime crash (undefined function) or validation errors for all string inputs. [ Out of scope (triage) ]
apps/web/src/components/ChatView.tsx — 0 comments posted, 1 evaluated, 1 filtered
  • line 2462: sendInFlightRef tracks pending message submissions to prevent double-sends, but it is attached to the component instance (via useRef) and is not reset when the active thread changes. Because ChatView is reused by the router when switching threads, navigating away from a thread while a message is sending (e.g., waiting for worktree creation or network response) keeps sendInFlightRef.current as true. This effectively locks the chat input on the destination thread, preventing the user from sending messages until the previous thread's operation completes. To fix this, sendInFlightRef.current should be reset to false in the useEffect that handles threadId changes (around line 2030). [ Out of scope ]
apps/web/src/threadGroups.ts — 1 comment posted, 2 evaluated, 1 filtered
  • line 138: The function reorderProjectThreadGroupOrder handles a "drop on self" scenario incorrectly, treating a no-op as a move to the start of the list. If movedGroupId is equal to beforeGroupId, the item is first removed from withoutMoved (line 130). Consequently, withoutMoved.indexOf(beforeGroupId) returns -1 (line 137). This triggers the fallback logic at line 139, which places the moved group at the beginning of the array. The expected behavior for dropping an item before itself is to return the list order unchanged. [ 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: 9b7470c3-5cc8-4939-860c-cd0e28f619be

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
get().clearProjectGroupDraftThreadId(projectId, fallbackGroupId.slice(projectPrefix.length));
}
},
clearProjectDraftThreadById: (projectId, threadId) => {
Copy link

Choose a reason for hiding this comment

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

🟢 Low src/composerDraftStore.ts:892

clearProjectDraftThreadById returns after clearing only the first matching project-group mapping, so when a thread is mapped to multiple groups it leaves orphaned mappings and partial thread state. Consider iterating through all mappings to remove every instance of the thread.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/composerDraftStore.ts around line 892:

`clearProjectDraftThreadById` returns after clearing only the first matching project-group mapping, so when a thread is mapped to multiple groups it leaves orphaned mappings and partial thread state. Consider iterating through all mappings to remove every instance of the thread.

Evidence trail:
apps/web/src/composerDraftStore.ts lines 892-903 (REVIEWED_COMMIT) - `clearProjectDraftThreadById` function with `return` at line 901 inside the for loop after clearing first match. Contrast with `clearDraftThread` at lines 904-926 which properly filters all matching entries.

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 b958328. Clearing a project draft thread now removes every matching group mapping instead of stopping after the first one.

Comment on lines +76 to +81
export function reorderProjectOrder(input: {
currentOrder: readonly ProjectId[];
movedProjectId: ProjectId;
beforeProjectId: ProjectId | null;
}): ProjectId[] {
const withoutMoved = input.currentOrder.filter((projectId) => projectId !== input.movedProjectId);
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:76

When movedProjectId equals beforeProjectId (e.g., dropping an item onto itself), the function incorrectly moves the item to the start of the list. Line 81 removes movedProjectId from withoutMoved, so withoutMoved.indexOf(beforeProjectId) returns -1 at line 87, triggering the fallback at line 88 that prepends the item. Consider returning input.currentOrder unchanged when the IDs are equal.

  if (input.movedProjectId === input.beforeProjectId) {
+    return [...input.currentOrder];
+  }
+
   const withoutMoved = input.currentOrder.filter((projectId) => projectId !== input.movedProjectId);
Also found in 1 other location(s)

apps/web/src/threadGroups.ts:138

The function reorderProjectThreadGroupOrder handles a "drop on self" scenario incorrectly, treating a no-op as a move to the start of the list. If movedGroupId is equal to beforeGroupId, the item is first removed from withoutMoved (line 130). Consequently, withoutMoved.indexOf(beforeGroupId) returns -1 (line 137). This triggers the fallback logic at line 139, which places the moved group at the beginning of the array. The expected behavior for dropping an item before itself is to return the list order unchanged.

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

When `movedProjectId` equals `beforeProjectId` (e.g., dropping an item onto itself), the function incorrectly moves the item to the start of the list. Line 81 removes `movedProjectId` from `withoutMoved`, so `withoutMoved.indexOf(beforeProjectId)` returns `-1` at line 87, triggering the fallback at line 88 that prepends the item. Consider returning `input.currentOrder` unchanged when the IDs are equal.

Evidence trail:
apps/web/src/projectOrder.ts lines 76-98 at REVIEWED_COMMIT: Line 81 filters out `movedProjectId`, line 87 searches for `beforeProjectId` in the filtered list (returns -1 when they're equal), line 88-90 prepends the item when index is -1.

Also found in 1 other location(s):
- apps/web/src/threadGroups.ts:138 -- The function `reorderProjectThreadGroupOrder` handles a "drop on self" scenario incorrectly, treating a no-op as a move to the start of the list. If `movedGroupId` is equal to `beforeGroupId`, the item is first removed from `withoutMoved` (line 130). Consequently, `withoutMoved.indexOf(beforeGroupId)` returns `-1` (line 137). This triggers the fallback logic at line 139, which places the moved group at the beginning of the array. The expected behavior for dropping an item before itself is to return the list order unchanged.

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 b958328. Self-drop reorder is now treated as a no-op for both project and group ordering.

Comment on lines +91 to +93
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.

🟡 Medium src/threadGroups.ts:91

When orderProjectThreadGroups aggregates threads into groups, it only updates latestActivityAt when a newer thread is encountered, leaving branch, worktreePath, and label set to the values of the first thread processed. Since input.threads is not guaranteed to be ordered by time, this causes the group to retain stale identity data. Later, resolveProjectThreadGroupPrById compares the stale group.branch against the current git status, so PR status lookups fail whenever the branch name has changed. Update the group's metadata fields when a newer thread is found.

-    if (thread.createdAt > existing.latestActivityAt) {
-      existing.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 91-93:

When `orderProjectThreadGroups` aggregates threads into groups, it only updates `latestActivityAt` when a newer thread is encountered, leaving `branch`, `worktreePath`, and `label` set to the values of the first thread processed. Since `input.threads` is not guaranteed to be ordered by time, this causes the group to retain stale identity data. Later, `resolveProjectThreadGroupPrById` compares the stale `group.branch` against the current git status, so PR status lookups fail whenever the branch name has changed. Update the group's metadata fields when a newer thread is found.

Evidence trail:
apps/web/src/threadGroups.ts lines 47-55: buildThreadGroupId uses only worktreePath for ID when set (branch not included in ID); lines 72-76: builds group ID from branch/worktreePath; lines 77-93: when existing group found, only latestActivityAt is updated (lines 91-93), not branch/worktreePath/label; lines 161-164: resolveProjectThreadGroupPrById compares status?.branch against group.branch at line 163, returning null PR status when they don't match.

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 b958328. Group metadata now refreshes when a newer thread becomes the latest activity source.

@Mitch515
Copy link
Author

Assets for review:

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