feat(web): group sidebar v2 by worktree and scope panes to the checkout#4521
feat(web): group sidebar v2 by worktree and scope panes to the checkout#4521jakeleventhal wants to merge 2 commits into
Conversation
Sidebar v2 cards now represent a worktree rather than a thread. Threads
sharing a checkout collapse into one card, where each thread's summary is
its own clickable row carrying that thread's provider logo; the branch,
PR badge and repository row stay at the worktree level. Settle and snooze
(and un-settle/wake) act on every thread in the worktree, and the v1
activity indicators — pulsing terminal and dev-server globe — return on
the card's top row, where they belong now that terminals and dev servers
are worktree-scoped.
The right-panel surfaces (terminal, browser, diff, files) follow the same
rule: switching between sibling threads keeps the same terminal session,
dev server, browser tabs and diff state, and only changes when the
checkout does. There is no worktree entity in the data model, so identity
is composed client-side in worktreeScope.ts (environment + project +
worktreePath, with null paths meaning the project's local checkout).
Server terminal and preview sessions remain keyed by threadId on the
wire, so every thread in a worktree routes those calls through a stable
canonical thread id.
Also adds chat.newInWorktree (default mod+t): the keyboard equivalent of
a card's "New thread on {branch}" action.
Persisted panel state is re-keyed, so right-panel (v8), terminal UI (v5)
and diff-panel (v2) drop their old thread-keyed entries on migrate.
Server-side, archiving a thread no longer closes terminals that sibling
threads in the same checkout still use — that would kill a shared dev
server.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
| @@ -240,7 +242,7 @@ function isValidTerminalId(terminalId: string): boolean { | |||
| } | |||
|
|
|||
| function terminalThreadKey(threadRef: ScopedThreadRef): string { | |||
There was a problem hiding this comment.
🟡 Medium src/terminalUiStateStore.ts:244
When a terminal UI action is performed before the thread's shell is bootstrapped, terminalThreadKey resolves to the fallback scoped thread key (via resolveWorktreeScopeKeyForThreadRef). Once readThreadShell becomes available, the same thread ref resolves to the worktree scope key instead. State written under the fallback key is never migrated, so the drawer layout appears reset and the old entry becomes stranded in storage. Consider migrating the fallback-keyed entry to the worktree key when the shell becomes available, or document this bootstrap-window behavior if it is intentional.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/terminalUiStateStore.ts around line 244:
When a terminal UI action is performed before the thread's shell is bootstrapped, `terminalThreadKey` resolves to the fallback scoped thread key (via `resolveWorktreeScopeKeyForThreadRef`). Once `readThreadShell` becomes available, the same thread ref resolves to the worktree scope key instead. State written under the fallback key is never migrated, so the drawer layout appears reset and the old entry becomes stranded in storage. Consider migrating the fallback-keyed entry to the worktree key when the shell becomes available, or document this bootstrap-window behavior if it is intentional.
| function normalizeWorktreePath(worktreePath: string | null | undefined): string | null { | ||
| const trimmed = worktreePath?.trim(); | ||
| return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| } |
There was a problem hiding this comment.
🟡 Medium src/worktreeScope.ts:16
normalizeWorktreePath trims leading and trailing whitespace from worktreePath, so distinct worktrees whose paths differ only by surrounding spaces (e.g. /repo/wt and /repo/wt ) produce the same scope key. Their terminal/preview panes are then merged, and canonical RPC resolution can route operations through the wrong thread. Trimming discards information that is semantically meaningful in filesystem paths — consider using the raw value (only mapping empty/whitespace-only strings to null) so distinct checkout paths stay distinct.
| function normalizeWorktreePath(worktreePath: string | null | undefined): string | null { | |
| const trimmed = worktreePath?.trim(); | |
| return trimmed && trimmed.length > 0 ? trimmed : null; | |
| } | |
| +function normalizeWorktreePath(worktreePath: string | null | undefined): string | null { | |
| + return worktreePath && worktreePath.length > 0 ? worktreePath : null; | |
| +} |
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/worktreeScope.ts around lines 16-19:
`normalizeWorktreePath` trims leading and trailing whitespace from `worktreePath`, so distinct worktrees whose paths differ only by surrounding spaces (e.g. `/repo/wt` and `/repo/wt `) produce the same scope key. Their terminal/preview panes are then merged, and canonical RPC resolution can route operations through the wrong thread. Trimming discards information that is semantically meaningful in filesystem paths — consider using the raw value (only mapping empty/whitespace-only strings to `null`) so distinct checkout paths stay distinct.
| // worktree's PR state applies to every member thread of the group. | ||
| const prState = pr?.state ?? null; | ||
| const { changeRequestThreadKeys } = props; | ||
| useEffect(() => { |
There was a problem hiding this comment.
🟡 Medium components/SidebarV2.tsx:484
The useEffect in SidebarV2Row pushes the representative thread's prState to every member via changeRequestThreadKeys, but for local-checkout groups resolveThreadPr returns null whenever the representative's thread.branch doesn't match the checked-out branch. When the representative isn't the thread on that branch (e.g. a sibling is), this writes null over the real PR state for all members, hiding the PR badge and clearing closed/merged-PR auto-settlement for the whole group.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/SidebarV2.tsx around line 484:
The `useEffect` in `SidebarV2Row` pushes the representative thread's `prState` to every member via `changeRequestThreadKeys`, but for local-checkout groups `resolveThreadPr` returns `null` whenever the representative's `thread.branch` doesn't match the checked-out branch. When the representative isn't the thread on that branch (e.g. a sibling is), this writes `null` over the real PR state for all members, hiding the PR badge and clearing closed/merged-PR auto-settlement for the whole group.
| let anyWoke = false; | ||
| const wokeAtByKey = new Map<string, string | null>(); | ||
| threads.forEach((thread, index) => { | ||
| const lastVisitedAt = visited[index] === "" ? undefined : visited[index]; |
There was a problem hiding this comment.
Card visit stamps are corrupted
High Severity
The visitedSignature processing incorrectly breaks lastVisitedAt timestamps into individual characters. This corrupts the timestamp values, causing inaccurate "Done/Woke" status and recede styling on worktree cards.
Reviewed by Cursor Bugbot for commit eebafbb. Configure here.
| return null; | ||
| } | ||
| return selectCanonicalShell(ref, shell, shells); | ||
| }, [ref, shell, shells]); |
There was a problem hiding this comment.
Canonical ref shifts after archive
High Severity
The client's canonical thread selection for a worktree, which keys shared resources like terminals, incorrectly excludes archived threads. This causes the canonical thread ID to shift when the oldest thread is archived, breaking shared terminal/session access for active sibling threads. It also leads to terminal leaks, as the server's closing logic may miss terminals associated with an earlier-archived canonical thread when the last active sibling is archived.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit eebafbb. Configure here.
| // sit inline with the repository row because terminals and dev servers | ||
| // belong to the checkout, not to an individual thread. | ||
| const runningTerminals = useRunningTerminalsForThreads({ environmentId, threadIds }); | ||
| const discoveredPorts = useDiscoveredPortsForThreads({ environmentId, threadIds }); |
There was a problem hiding this comment.
Indicators miss canonical sessions
Medium Severity
Worktree terminal and dev-server indicators query only current card member thread ids. Sessions opened through the worktree canonical id disappear from those indicators whenever that canonical thread is no longer a visible member, so the restored v1 activity glyphs can stay off while a process is still running.
Reviewed by Cursor Bugbot for commit eebafbb. Configure here.
|
|
||
| function terminalThreadKey(threadRef: ScopedThreadRef): string { | ||
| return scopedThreadKey(threadRef); | ||
| return resolveWorktreeScopeKeyForThreadRef(threadRef); |
There was a problem hiding this comment.
Delete wipes shared terminal UI
Medium Severity
clearTerminalUiState is now keyed by worktree scope, but delete still clears it for the deleted thread ref. Removing one thread in a multi-thread checkout resets the shared drawer layout for every remaining sibling. Delete also closes terminals by the deleted threadId with no shared-worktree guard, so deleting the current canonical member can kill sessions siblings still use.
Reviewed by Cursor Bugbot for commit eebafbb. Configure here.
ApprovabilityVerdict: Needs human review 3 blocking correctness issues found. This PR introduces a significant new feature (worktree-based sidebar grouping) with cross-cutting changes to multiple state stores and server-side terminal logic. Multiple high-severity bugs identified in review comments (corrupted timestamps, canonical ref shifts) require resolution before approval. You can customize Macroscope's approvability policy. Learn more. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
There are 5 total unresolved issues (including 4 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 48a7020. Configure here.
| error: error.message, | ||
| }), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
Archive closes wrong terminal owner
High Severity
Terminal closure and UI state clearing logic incorrectly targets the specific archived or deleted thread ID. Client terminal sessions, however, are shared across a worktree and associated with a canonical thread ID. This mismatch can lead to PTY and dev server leaks when a worktree is fully parked, as the canonical session may not be closed. It can also cause unintended UI state resets for other active threads in the same worktree.
Reviewed by Cursor Bugbot for commit 48a7020. Configure here.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eebafbb2a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| update: (current: ThreadPreviewState) => ThreadPreviewState, | ||
| ): void { | ||
| const threadKey = scopedThreadKey(ref); | ||
| const threadKey = previewScopeKey(ref); |
There was a problem hiding this comment.
Preserve the canonical preview ref in the aggregate index
When a known thread opens an Electron preview, this now stores a worktree key such as environment:project:path in the active-preview index, but ElectronBrowserHost still passes every index key to parseScopedThreadKey. That parser accepts the value as a thread ref whose thread ID is project:path, so usePreviewBridge sends preview.reportStatus under a nonexistent thread ID while the session was opened under the canonical thread ID. Consequently navigation, loading, and failure updates cannot find the server session; retain the canonical thread ref alongside the worktree-keyed state instead of interpreting the state key as a thread ref.
Useful? React with 👍 / 👎.
| readEnvironmentThreadRefs(ref.environmentId).map((candidateRef) => | ||
| readThreadShell(candidateRef), |
There was a problem hiding this comment.
Retain a canonical owner across thread deletion
When the oldest thread in a shared checkout is deleted while a sibling remains, deleted threads disappear from readEnvironmentThreadRefs, so this resolver immediately elects a different canonical thread. All shared terminal and preview RPCs were previously routed through the deleted thread ID; the delete flow closes those terminals, while preview sessions remain keyed to an ID the sibling no longer queries. Thus deleting one conversation resets or orphans checkout-level panes for the surviving conversation. The canonical owner must remain durable across deletion or the server sessions must be migrated before changing it.
Useful? React with 👍 / 👎.
| thread.id !== archivedThread.id && | ||
| thread.projectId === archivedThread.projectId && | ||
| normalizeWorktreePathForArchive(thread.worktreePath) === | ||
| normalizeWorktreePathForArchive(archivedThread.worktreePath), |
There was a problem hiding this comment.
Close terminals after the last live sibling is archived
When the final unarchived thread in a checkout is archived after any sibling was archived earlier, shell snapshots still contain that earlier thread with a non-null archivedAt, and this predicate counts it as continued use. The close branch is therefore skipped, leaving the canonical checkout PTYs and subprocesses—including dev servers—running indefinitely with no live thread from which to access them. This check must consider only non-archived siblings and, when none remain, close the checkout's canonical terminal owner rather than assuming the just-archived thread owns the sessions.
Useful? React with 👍 / 👎.
| const runningTerminals = useRunningTerminalsForThreads({ environmentId, threadIds }); | ||
| const discoveredPorts = useDiscoveredPortsForThreads({ environmentId, threadIds }); |
There was a problem hiding this comment.
Include the archived canonical owner in activity lookup
When the oldest thread is archived while a sibling remains active, the canonical-thread rule deliberately keeps all terminal and preview RPCs keyed to that archived thread. Sidebar groups, however, contain only unarchived members, so threadIds excludes the actual owner and both hooks return no matches. The active worktree card then loses its running-terminal and dev-server indicators even though those sessions remain available in its shared panes. Resolve the canonical owner for the group or query activity by worktree identity rather than only visible member IDs.
Useful? React with 👍 / 👎.
| const settleable = pending.filter((thread) => canSettle(thread, { now })); | ||
| const targets = settleable.length > 0 ? settleable : [pending[0]!]; |
There was a problem hiding this comment.
Reject settling a worktree when any member is blocked
When a worktree contains both an idle thread and a running, starting, approval-blocked, or queued thread, the card still offers “Settle worktree,” but this filtering silently settles only the eligible members. The blocked member keeps the card active, so the advertised worktree-level action only partially applies and appears ineffective while mutating sibling state. As already done for worktree snoozing, disable or reject the action unless every affected non-settled member can settle.
Useful? React with 👍 / 👎.
| const pr = resolveThreadPr({ | ||
| threadBranch: newest.branch, | ||
| gitStatus: gitStatus.data, | ||
| hasDedicatedWorktree: worktreePath !== null, |
There was a problem hiding this comment.
Derive local-checkout branch state from the checkout
When local-mode sibling threads were created or last used on different branches, the newest-created thread is not necessarily the branch currently checked out in their shared workspace. Using newest.branch here makes the worktree card show a stale branch and causes resolveThreadPr to discard the checkout's valid PR whenever gitStatus.refName differs; the null PR state is then fanned out to every member and can also prevent PR-driven settlement. For a local-checkout group, derive the worktree-level branch and PR from the queried checkout status rather than the newest thread.
Useful? React with 👍 / 👎.
| const shell = readThreadShell(ref); | ||
| return shell === null ? scopedThreadKey(ref) : threadWorktreeScopeKey(shell); |
There was a problem hiding this comment.
Migrate draft pane state when its shell materializes
When a user opens a terminal, preview, diff, or right-panel surface on an unsent draft, the shell is absent and all of those stores write under the plain scoped thread key. After the first send creates the server shell, the same ref resolves to a worktree key here, but no state is transferred from the fallback key. The promoted thread therefore appears to lose its open panes, terminal layout, and preview sessions even though the draft was deliberately promoted with the same preallocated thread ID. Promotion must migrate the fallback entry or use a stable scope key throughout bootstrap.
Useful? React with 👍 / 👎.
| reconcileTurnSelection: (ref, availableTurnIds) => | ||
| set((state) => { | ||
| const threadKey = scopedThreadKey(ref); | ||
| const threadKey = diffScopeKey(ref); |
There was a problem hiding this comment.
Clear stale turn selection for siblings without turn diffs
When thread A leaves the shared diff store in a turn selection and the user switches to sibling B with no completed turn diffs, B reads A's worktree-keyed selection. reconcileTurnSelection returns early when availableTurnIds is empty, so selectedTurnId remains non-null while no selectedTurn exists; DiffPanel consequently disables both the checkpoint request and the branch/working-tree queries and renders no useful diff. Turn selections must remain thread-specific or fall back to the checkout's git scope when the active sibling has no matching turns.
Useful? React with 👍 / 👎.


Demo
Video Demo: https://cap.so/s/re2f8y944mb8vfr
What
Sidebar v2 rows now represent a worktree instead of a thread, and the right-panel surfaces follow the checkout rather than the thread.
Sidebar
Settle worktree (N threads)when it applies to more than one) and multi-select. A snoozed worktree gets one Undo toast, not one per thread.Panes
Terminal, browser, diff and files are keyed to the worktree, so switching between sibling threads keeps the same terminal session (including a running dev server), the same browser tabs, and the same diff/files state. They change only when the checkout does.
Keybinding
New
chat.newInWorktree, default ⌘T: the keyboard equivalent of a card's "New thread on {branch}" action. Falls back to a normal new thread when there is no checkout to join.How
There is no worktree entity in the data model — a worktree is only
thread.worktreePath— so identity is composed client-side inapps/web/src/worktreeScope.tsasenvironment + project + worktreePath, with a null path meaning the project's local checkout.Server terminal and preview sessions stay keyed by
threadIdon the wire, so every thread in a worktree routes those calls through a stable canonical thread ref (the oldest thread ever in the checkout; archived threads stay eligible so the ref cannot shift out from under live sessions).rightPanelStore,terminalUiStateStore,diffPanelStoreandpreviewStateStorestill take aScopedThreadRefpublicly but key internally by worktree scope key.Notes for reviewers
apps/server/src/ws.ts): archiving a thread no longer closes its terminals while another non-archived thread shares the same checkout. Without this, archiving one thread kills a dev server its siblings are still using. Covered by a new test.Testing
pnpm typecheckclean inapps/web,apps/server,packages/contracts,packages/sharedpnpm testinapps/web: 1527 passing, including 14 new unit tests for the worktree grouping/bucketing/ordering logicpnpm testinapps/server: passing, including a new test for the shared-worktree archive guard. One unrelatedGitManagercommit-hook test fails on my machine on a clean checkout too — pre-existing, not from this change.🤖 Generated with Claude Code
Note
Medium Risk
Touches orchestration archive/terminal lifecycle and re-keys persisted UI state (intentional data loss on upgrade); behavior changes when multiple threads share a checkout.
Overview
Threads that share the same checkout are treated as one worktree in the UI: client-side scope keys (
environment + project + worktreePath) drive sidebar grouping and pane state, while terminal/preview RPCs still use a stable canonical thread id (oldest thread in that checkout).Sidebar v2 collapses sibling threads into a single card with one row per thread (provider logo per row), worktree-level branch/PR/activity (terminal pulse, dev-server globe), and settle/snooze that apply to every member thread; snoozed/settled shelves show one slim row per worktree.
Chat and panes route terminal open/close, preview open/close/automation, and mounted terminal drawers through the canonical ref and worktree scope keys so switching threads in the same checkout keeps one terminal session, one set of browser tabs, and shared diff panel selection (
diffPanelStore/previewStateStorere-keyed with version bumps that drop old thread-keyed persistence).Server:
thread.archiveno longer callsterminalManager.closewhen another non-archived thread in the same project shares the normalized worktree path (avoids killing a shared dev server).Reviewed by Cursor Bugbot for commit eebafbb. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
[!NOTE]
Group sidebar v2 threads by worktree and scope UI state to checkout
buildSidebarWorktreeGroupsin SidebarV2.logic.ts to group threads sharing a checkout into a singleSidebarV2WorktreeCardin the active list, with slim representative rows for snoozed/settled worktrees.resolveWorktreeScopeKeyForThreadRef) so sibling threads in the same checkout share state; all affected stores bump their persisted version and drop older entries on upgrade.resolveWorktreeCanonicalThreadRef.thread.archivewhen a sibling thread shares the same worktree.chat.newInWorktreecommand bound tomod+tthat creates a new thread in the active thread's worktree/branch.terminalUiStateStore(v<5),rightPanelStore(v<8), anddiffPanelStore(v<2) is dropped on upgrade.📊 Macroscope summarized 48a7020. 18 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
> ### 🗂️ Filtered Issues No issues evaluated. > >