feat(fork): group sidebar v2 threads by project - #24
Conversation
Upstream's V2 list is one flat stream and stays the default. Grouping answers the question scoping cannot — "everything, arranged by repo" rather than "only this repo" — so it ships as a switch at the top of the same project scope menu, persisted device-locally rather than as a fork-shaped key in a schema upstream owns. Only the active cards group. The snoozed and settled shelves are time-ordered tails whose value is being short, and slicing a tail by project turns one header into a dozen. Grouping is skipped while a scope is set, where the header would only repeat the scope row's own label. orderedThreads is rebuilt from the grouped order: it backs the ⌘-jump labels, arrow navigation and range selection, and a flat ordered list under a grouped DOM points every jump hint at the wrong row. A grouped card also drops its project name, since the header two rows up already carries it, and the branch — what actually tells two threads on one project apart — takes the line instead. Alongside it, a card is now three lines only when it has something to put on the third. That line carries the PR badge and the diff counts; with neither it is a blank strip, so the model and runtime move up beside the branch and the card closes at the design's two-line 64px. The contain-intrinsic-size hint follows the same predicate the component renders from, so it cannot claim a height the row does not have. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
Thermo-nuclear code quality — REQUEST_CHANGES
Behavior is fine. Structure is not. The approval bar fails on a visible code-judo miss and spaghetti growth inside an already oversized SidebarV2.tsx.
1. Null dual-mode is the wrong model — collapse to one list shape
activeThreadProjectGroups === null means “flat.” That sentinel is then re-checked in three places that must stay synchronized:
- ordered-thread memo (
orderedActiveThreads) projectTitlesuppression (!== null && section === "active")- active-items render (
.mapvs.flatMap+ headers)
That is mode-flag spaghetti, not a preference feature.
Code judo: in custom/sidebarV2ProjectGrouping.ts, return one section list always:
type ActiveSection<T> = {
header: { projectKey: string; displayName: string } | null;
threads: readonly T[];
};
// flat / scoped → [{ header: null, threads }]
// grouped → groups.map(g => ({ header: {...}, threads: g.threads }))Then SidebarV2 becomes one path:
orderedActiveThreads = sections.flatMap(s => s.threads)- render:
sections.flatMapwith optional header hideProjectTitle = section.header !== null
Delete the null sentinel, the second ternary memo, the dual render branch, and the projectTitle special case. Fork-friendly: shrinks fenced hunks by pushing the model into custom/ (same move as ChromeRows / ThreadCardMeta).
2. More dual-mode control flow in a 2655-line file
SidebarV2.tsx was already far past 1k. Fenced hunks are allowed, but this PR still grows the busy render IIFE with a second control-flow universe. The fix is not “rewrite upstream ownership” — it is extract the list model so the fence is a call site, not three coordinated branches.
3. Guard tests duplicate unit behavior and ossify the dual-mode shape
__fork_guards__/sidebarV2ProjectGrouping.test.ts re-tests orphan retention and project/thread order already covered by custom/sidebarV2ProjectGrouping.test.ts, and it pins the exact string activeThreadProjectGroups !== null && section === "active". Guards should only pin the seam (ordered list wiring, menu toggle, active-only grouping). Behavior stays once in the unit file; stop locking the null-sentinel design into the guard.
What is fine
- Pure
groupThreadsByProject+ structural interfaces + localStorage preference (correct fork boundary) SidebarV2ProjectGroupHeaderas a small presentational extractthreadCardShowsMetaRow+ two-line card collapse (clean, shared with contain-intrinsic-size)
Ship after the list-model collapse. Do not approve the dual-mode as-is.
Sent by Cursor Automation: Thermo-nuclear PR review
| const activeThreadProjectGroups = useMemo( | ||
| () => | ||
| groupByProject && projectScopeKey === null | ||
| ? groupThreadsByProject(activeThreads, projectGroups) | ||
| : null, | ||
| [activeThreads, groupByProject, projectGroups, projectScopeKey], | ||
| ); | ||
| // Grouping reorders the cards, and this list is what the ⌘-jump labels, | ||
| // arrow navigation and shift-range selection read. It has to be the order | ||
| // the rows are actually painted in, or a jump hint points at another row. | ||
| const orderedActiveThreads = useMemo( | ||
| () => | ||
| activeThreadProjectGroups === null | ||
| ? activeThreads | ||
| : activeThreadProjectGroups.flatMap((group) => [...group.threads]), | ||
| [activeThreadProjectGroups, activeThreads], |
There was a problem hiding this comment.
this null-as-mode dual model is the structural problem. null means flat, and that sentinel has to stay synchronized with orderedActiveThreads, the projectTitle branch, and the active-items ternary below — three coordinated special cases in an already 2.6k-line file.
code-judo: extract a single section model in custom/sidebarV2ProjectGrouping.ts that always returns ReadonlyArray<{ header: { projectKey; displayName } | null; threads }> — flat/scoped is one section with header: null; grouped is one section per project with a header. Then orderedActiveThreads = sections.flatMap(s => s.threads) and the render path stops branching on null. that deletes this second memo's ternary entirely and shrinks the fenced hunk to a call site (same extraction pattern as ChromeRows / ThreadCardMeta).
| projectTitle={ | ||
| projectDisplayNameByKey.get( | ||
| `${thread.environmentId}:${thread.projectId}`, | ||
| ) ?? null | ||
| activeThreadProjectGroups !== null && section === "active" | ||
| ? null | ||
| : (projectDisplayNameByKey.get( | ||
| `${thread.environmentId}:${thread.projectId}`, | ||
| ) ?? null) | ||
| } |
There was a problem hiding this comment.
this activeThreadProjectGroups !== null && section === "active" branch is the same dual-mode leaking into row props. under a section model, hide-project-title is just section.header !== null at the call site that already knows which section it is painting — no null-sentinel check inside renderThreadRow, and no section === "active" coupling to grouping state.
also: the fork guard currently pins this exact expression, which will fight the refactor. drop that string assert when you collapse the model.
| const items: ReactNode[] = | ||
| activeThreadProjectGroups === null | ||
| ? activeThreads.map((thread) => renderThreadRow(thread, "active")) | ||
| : activeThreadProjectGroups.flatMap((group, groupIndex) => [ | ||
| <SidebarV2ProjectGroupHeader | ||
| key={`project-group-header:${group.projectKey}`} | ||
| label={group.displayName} | ||
| isFirst={groupIndex === 0} | ||
| />, | ||
| ...group.threads.map((thread) => renderThreadRow(thread, "active")), | ||
| ]); |
There was a problem hiding this comment.
dual render path is the symptom of the null mode above. once sections always exist, this is one flatMap: optional <SidebarV2ProjectGroupHeader /> when header !== null, then the thread rows. no .map vs .flatMap fork, and no need to keep activeThreads and activeThreadProjectGroups both live in this IIFE.
minor while you are here: flatMap((group) => [...group.threads]) above copies for nothing — group.threads is already a readonly array.
| it("keeps every thread when a project no longer resolves to a group", () => { | ||
| const groups = groupThreadsByProject( | ||
| [ | ||
| { environmentId: "local", projectId: "p1" }, | ||
| { environmentId: "local", projectId: "deleted" }, | ||
| ], | ||
| [ | ||
| { | ||
| projectKey: "first", | ||
| displayName: "First", | ||
| memberProjectRefs: [{ environmentId: "local", projectId: "p1" }], | ||
| }, | ||
| ], | ||
| ); | ||
|
|
||
| expect(groups.flatMap((group) => group.threads)).toHaveLength(2); | ||
| expect(groups.at(-1)?.projectKey).toBe(UNGROUPED_PROJECT_KEY); | ||
| }); | ||
|
|
||
| it("keeps the caller's project order and each group's thread order", () => { | ||
| // Grouping must not become a second sort: upstream already ordered both | ||
| // the projects and the threads. | ||
| const groups = groupThreadsByProject( | ||
| [ | ||
| { id: "a", environmentId: "local", projectId: "p1" }, | ||
| { id: "b", environmentId: "local", projectId: "p2" }, | ||
| { id: "c", environmentId: "local", projectId: "p1" }, | ||
| ], | ||
| [ | ||
| { | ||
| projectKey: "second", | ||
| displayName: "Second", | ||
| memberProjectRefs: [{ environmentId: "local", projectId: "p2" }], | ||
| }, | ||
| { | ||
| projectKey: "first", | ||
| displayName: "First", | ||
| memberProjectRefs: [{ environmentId: "local", projectId: "p1" }], | ||
| }, | ||
| ], | ||
| ); | ||
|
|
||
| expect(groups.map((group) => group.projectKey)).toEqual(["second", "first"]); | ||
| expect(groups[1]?.threads.map((thread) => thread.id)).toEqual(["a", "c"]); | ||
| }); |
There was a problem hiding this comment.
these two cases re-test groupThreadsByProject orphan retention and project/thread order — already covered (better) in custom/sidebarV2ProjectGrouping.test.ts. the guard's job is the SidebarV2 seam (ordered list wiring, menu toggle, active-only / no-scope gating), not a second copy of the pure function's behavior.
delete the duplicated behavior tests from the guard. keeping them here also encourages pinning implementation strings like the null-sentinel projectTitle check, which locks in the dual-mode design we should be deleting.
ReviewRead the whole diff plus the surrounding upstream wiring in The problems are all at the seams — one real regression in the card change, and a guard that does not guard the thing it was written for. 1. The two-line collapse makes card height depend on an async query — every PR card grows 15px after mount
Before this change the card was a fixed three rows, so the query resolving just filled the badge into a slot that was already reserved. Now it reflows the list. With N PR-carrying threads you get up to N separate 15px shifts on every sidebar mount and every environment reconnect, staggered as each per-row query lands. Browser scroll anchoring will absorb some of it, but not the in-viewport reflow, and rows can move under the pointer between mousedown and click. This is also structurally worse than it looks today. The predicate needs a third state: PR unknown. A thread that has a branch and a pending git-status query should reserve the row rather than collapse and then grow. Something like passing Worth adding a guard for once fixed, because "collapses only when we know there is no PR" is exactly the kind of invariant a later refactor flattens back into 2. The ordering guard cannot fail on the divergence it exists to catch
Concretely: delete only the fenced
Green suite, and the sidebar now paints flat while The fix I'd argue for is structural rather than another Two related corrections to the claim itself, which matters because the manifest intent is what a future merge reasons from: (a) Jump labels cannot misaddress. (b) There is a fourth consumer, and it silently changes behavior. 3.
|
…rd height through the PR query Six findings from review, plus the nits. The dual-mode model is gone. `groups | null` meant flat, and that sentinel had to stay synchronized across the ordered-thread memo, the projectTitle prop and the render — three coordinated special cases in an already 2.6k-line file, any two of which could disagree about what order the list is in. buildActiveThreadSections now returns sections in both modes, flat being the single headerless one, so the render maps one sequence and the keyboard order flattens the same one. Divergence stops being representable rather than merely being asserted against. Card height no longer depends on an async query resolving. It was derived from `prBadge !== null`, which is false both for "no PR" and for "the VCS query has not answered yet", so every PR-carrying card would have painted at two lines and grown to three as its query landed — reflowing the list under the pointer, once per PR thread, on every mount. The predicate takes an explicit unknown state, and unknown means "has never answered" rather than "is polling": the query re-enters waiting on each 30s refresh, and reading that alone would flip the height on a loop. Verified in the browser against repos whose remote status refresh fails, which is exactly the case that exposed it. The rest: - The group header is a heading in a presentational li, and grouped cards keep their project name as sr-only rather than dropping it. Grouped mode was strictly worse than flat mode for a screen reader: the header is a visual adjacency, so a non-visual user lost the project association entirely. - Grouping is skipped with one project group, for the same reason it is skipped under a scope, and the switch is disabled with a stated reason in both states rather than accepting a click that visibly does nothing. - The ref -> project-key index is memoized on the project list alone; it was rebuilt on every activeThreads identity, which churns on the clock, on capability descriptors, and on each row reporting its PR state up. - The guard test drops the behaviour it duplicated from the unit file and the implementation strings that would fight this refactor, and covers the seam instead — including the render hunk, which it could not fail on before. - contain-intrinsic-size measures the li, so 90/68 rather than 86/64; the ungrouped section's label moves to the render site as "Unknown project"; the manifest records which entry owns which part of SidebarV2ChromeRows. Two corrections to claims the manifest was making. Jump labels cannot misaddress — they are keyed by thread, so a stale order only numbers them out of sequence; the three positional consumers are arrow navigation, shift-range select and planForwardNavigation. That last one is a fourth consumer the PR never mentioned: under grouping, settling the thread you are viewing lands you on the next card in grouped order, which may be the next project's first. That is intended — forward should mean the next visible row — and it is now written down. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both reviews addressed in 7ffa914. The two independent findings converged on the same fix, so that is where most of the change is. The dual-mode model is goneCursor's finding 1 and Noah's finding 2 are the same defect seen from two sides:
The guard also stops re-testing what the unit file covers (orphan retention, project/thread order) and covers the seam instead — including the render hunk, with an assertion that the header comes from the same section whose threads follow it. Card height no longer waits on a queryFinding 1 was a real regression and it was worse than described. The predicate takes an explicit unknown state — but unknown is The rest
Corrections you asked for(a) Fixed in the manifest and above: jump labels are keyed by thread and cannot misaddress — a stale order only numbers them out of sequence down the screen. The three positional consumers are (b) Confirmed intended, and now written into both the manifest intent and the decisions note: under grouping, settling the thread you are viewing lands on the next card in grouped order, which may be the next project's first. Forward should mean the next visible row. Verification163 tests across 21 files ( |


Adds an optional Group by project mode to Sidebar V2, and collapses thread cards to the design's two-line form when there is nothing to put on the third line.
Group by project
A switch at the top of the fork's project scope row. Upstream's flat stream stays the default; grouping answers the question scoping cannot — "everything, arranged by repo" rather than "only this repo" — which is why the two live in the same menu.
projectGroups' own order, already sorted by the user's project sort order and already folded per the environment-grouping mode. Thread order inside a group is untouched, so grouping introduces no second notion of sorting.orderedThreadsis rebuilt from the grouped order. It backs the ⌘-jump labels, arrow navigation and shift-range selection — a flat ordered list under a grouped DOM misaddresses all three, and nothing about the render looks wrong when it does.packages/contractsfield — no fork-shaped key in a schema upstream owns.Two-line cards
The third card line exists to carry the PR badge and the diff counts. With neither, it is a blank 15px strip, so the model and runtime move up beside the branch and the card closes at the design's two-line 64px (Figma). Nothing else varies the height — an absent branch or model leaves its half of a row empty rather than closing the row up. The
contain-intrinsic-sizehint is chosen by the same predicate the component renders from, so it cannot claim a height the row does not have.Worth knowing:
latestTurnDiff()is still an upstream stub returningnull, so until shells carry checkpoint summaries the three-line form only appears on threads with a PR.Fork placement
New Tier-1 files under
apps/web/src/custom/;SidebarV2.tsxcarries four small fenced hunks under a newsidebar-v2-project-groupingmanifest id with its own guard test. Intents are invariants-only per the convention that landed in #21, with the narrative in.fork/notes/FORK-CUSTOMIZATION-DECISIONS.md.Verification
vp test run apps/web/src/__fork_guards__ apps/web/src/custom— 21 files, 157 tests passing, including a new render test for the card's two-line collapse and unit tests for the bucketing (order, folded member projects, empty groups, unresolved projects, key collisions).vp fmt,vp lint,node .fork/lint-owned.mjs(46 fork-owned files, no warnings), web typecheck all clean.🤖 Generated with Claude Code