Skip to content

fix(desktop): reuse Pierre for large changes lists - #5782

Merged
Kitenite merged 11 commits into
mainfrom
agent/renderer-churn-current-main
Jul 19, 2026
Merged

Kitenite merged 11 commits into
mainfrom
agent/renderer-churn-current-main

Conversation

@Kitenite

@Kitenite Kitenite commented Jul 19, 2026 •

Copy link
Copy Markdown
Member

Summary

  • merge current origin/main through 20cde0296 and rerun the production-shaped 8-workspace / 20k-file Changes workload
  • replace the separate TanStack folder renderer with one shared @pierre/trees renderer for Folders and Tree modes
  • project grouped display paths back to real repository paths for selection, open, editor, context-menu, status, and discard behavior
  • cap the Pierre host at 20 rows so its internal virtualizer retains the full range while mounting a bounded DOM slice
  • upgrade every direct Pierre dependency: @pierre/diffs 1.2.12 and @pierre/trees 1.0.0-beta.5
  • update the reusable runbook, review measurements, and 12-second checkpoint video

Evidence

The original before-fix renderer profile identified eager ChangesFoldersView → FileRow mounting as the application hot path. During the Pierre consolidation, CDP caught that a full-content host mounted 604 rows; the final 528 px Pierre viewport exposes a 14,472 px folder-mode range with only 43 mounted shadow rows. Tree mode also mounts 43 rows across a 19,320 px range, and a real wheel event reached scrollTop=12000.

Churn metric Original before Final shared Pierre
CDP p50 / p95 / max 2.72 / 43.80 / 1,563.21 ms 0.44 / 4.90 / 119.20 ms
Event-loop delay p50 / p95 / max 0.00 / 1.20 / 6,644.00 ms 0.00 / 2.00 / 210.40 ms
Renderer RSS p50 / p95 / max 1,561.66 / 1,709.66 / 1,727.89 MiB 856.00 / 917.66 / 917.94 MiB
2 s CDP timeouts 3 0

The final mutator completed 192,000 tracked-file writes in 59.617 s. All eleven real-pointer transitions landed in 506–681 ms. Folders ↔ Tree, real wheel scrolling, and a projected folder-row file click were exercised over the matched CDP page.

Validation

  • exact synthetic workload: 8 workspaces × 20,000 tracked files; initial 360 modified + 150 untracked + 90 deleted per workspace; 192,000 tracked-file writes over 60 seconds
  • dedicated CDP 127.0.0.1:9520, page F07A1B359DD295ECA14F9C98B3B9428B, renderer 7505, API 7501, renderer PID 29562
  • focused projection tests: 4 pass / 0 fail
  • bun run --cwd apps/desktop typecheck
  • bun run lint:fix
  • bun run lint
  • git diff --check
  • MP4 decoded with ffprobe and all source checkpoints visually inspected

Safety and risk

Only synthetic Git roots and this worktree local host-service DB were used; no production database, migration, repository, or credential literal was touched. The synthetic project/workspace rows, eight worktrees, repository roots, temporary evidence, processes, and ports were cleaned up.

Status freshness remains separate: the cooldown screenshot still showed the cached 600-file result while direct Git status was 800, then refreshed later. The 20-row Pierre viewport also moves long-list scrolling into the tree surface; pointer, wheel, mode-switch, and file-selection checks passed, while multi-section nested-scroll ergonomics remain the main UI risk.

Ready for review. Never merge from this task.

Summary by CodeRabbit

  • Performance Improvements
    • Improved the Changes view’s large-folder/file rendering by standardizing on a tree-style layout for consistent navigation and scrolling.
    • Added a stable scroll-container marker to improve layout reliability with virtualized rendering.
  • Documentation
    • Added a runbook for reproducing production-shaped renderer churn profiling.
    • Updated memory/worker/telemetry and renderer review notes with refreshed metrics, dispositions, and safety-gate results.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The Changes sidebar now renders tree and folder modes through a shared Pierre-backed view with path projection, translated interactions, capped host sizing, and updated git status mapping. Supporting documentation records renderer-churn procedures and latest memory findings, while Pierre dependencies are upgraded.

Changes

Changes sidebar projection and integration

Layer / File(s) Summary
Pierre path projection and sorting
apps/desktop/src/renderer/.../ChangesTreeView/utils/buildPierreProjection/*
Adds tree/folder path projections, unique folder labels, file and directory lookup maps, folder-aware sorting, and tests for projection and ordering behavior.
Projected ChangesTreeView integration
apps/desktop/src/renderer/.../ChangesTreeView/ChangesTreeView.tsx, apps/desktop/src/renderer/.../ChangesTreeView/hooks/*
Passes viewMode through Pierre, maps projected paths to files and directories for selection, editor actions, context menus, and git status, and caps the host height.
Changes list mode wiring
apps/desktop/src/renderer/.../ChangesFileList/ChangesFileList.tsx
Adds a scroll-container marker and routes both view modes through ChangesTreeView.
Renderer churn evidence and supporting updates
apps/desktop/docs/*.md, apps/desktop/package.json
Adds the renderer profiling runbook, updates memory and worker review evidence, and upgrades Pierre dependencies.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChangesFileList
  participant ChangesTreeView
  participant buildPierreProjection
  participant PierreTree
  ChangesFileList->>ChangesTreeView: pass files and viewMode
  ChangesTreeView->>buildPierreProjection: project file paths
  buildPierreProjection-->>ChangesTreeView: return projected paths and lookup maps
  ChangesTreeView->>PierreTree: render projected paths and git status
  PierreTree->>ChangesTreeView: return selected tree path
  ChangesTreeView->>ChangesFileList: translate selection to file path
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise, conventional, and accurately reflects the main Pierre reuse change in the changes list.
Description check ✅ Passed The description covers the change, rationale, testing, and safety notes, though it does not use the repo's exact template headings or checklist format.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/renderer-churn-current-main

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.

@ghost

ghost commented Jul 19, 2026 •

Copy link
Copy Markdown

Ready to review this PR? Stage has broken it down into 6 individual chapters for you:

Title
1 Upgrade Pierre tree and diff dependencies
2 Implement path projection for folder virtualization
3 Consolidate Changes view into virtualized TreeView
4 Remove obsolete non-virtualized components
5 Document performance findings and runbook
6 Other changes
Open in Stage

Chapters generated by Stage for commit ac3c539 on Jul 19, 2026 10:30pm UTC.

@Kitenite
Kitenite force-pushed the agent/renderer-churn-current-main branch from e9032b2 to 2f2ef33 Compare July 19, 2026 19:00
@Kitenite Kitenite changed the title docs(desktop): record latest renderer churn verification fix(desktop): virtualize large changes lists Jul 19, 2026
@Kitenite
Kitenite marked this pull request as ready for review July 19, 2026 21:56

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx:
- Around line 89-92: Add the relative positioning utility to the scroll
container div in ChangesFileList, preserving its existing flex, overflow,
spacing, and data attribute configuration so child virtualizers calculate
offsetTop against this container.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx:
- Around line 110-120: Fix the virtualizer setup in ChangesFoldersView so
scrollMargin and the scroll element are initialized and refreshed reactively
rather than relying on the mount-time listRef value. Track the element’s
offsetTop in React state, observe the relevant layout changes with
ResizeObserver (including sibling-driven repositioning), and update the state
when the element moves or resizes; ensure the virtualizer receives the current
margin and attaches to the scroll container once available.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c2c2b7fe-70bd-4e2b-84fb-ffb9a5ff26dc

📥 Commits

Reviewing files that changed from the base of the PR and between b06e97f and 5ad7a28.

⛔ Files ignored due to path filters (1)
  • apps/desktop/docs/artifacts/renderer-churn-visible-lifecycle-checkpoints.mp4 is excluded by !**/*.mp4
📒 Files selected for processing (4)
  • apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md
  • apps/desktop/docs/RENDERER_CHURN_UI_PROFILE_RUNBOOK.md
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx

Comment on lines +89 to +92
<div
className="min-h-0 flex-1 space-y-2 overflow-y-auto pt-1"
data-changes-scroll-container
>

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.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Add relative positioning to establish the correct offsetParent.

Without relative positioning on this scroll container, listRef.current?.offsetTop in child virtualizers will be incorrectly calculated against a higher-level positioned ancestor (e.g., the sidebar root or document).

This introduces a permanent misalignment between the physical scroll position and the virtualizer's viewport math, causing it to unmount visible rows prematurely and leaving large blank gaps when scrolling.

🐛 Proposed fix
 		<div
-			className="min-h-0 flex-1 space-y-2 overflow-y-auto pt-1"
+			className="min-h-0 flex-1 space-y-2 overflow-y-auto pt-1 relative"
 			data-changes-scroll-container
 		>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
className="min-h-0 flex-1 space-y-2 overflow-y-auto pt-1"
data-changes-scroll-container
>
<div
className="min-h-0 flex-1 space-y-2 overflow-y-auto pt-1 relative"
data-changes-scroll-container
>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx
around lines 89 - 92, Add the relative positioning utility to the scroll
container div in ChangesFileList, preserving its existing flex, overflow,
spacing, and data attribute configuration so child virtualizers calculate
offsetTop against this container.

@greptile-apps

greptile-apps Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR virtualizes the v2 ChangesFoldersView using @tanstack/react-virtual, flattening folder headers and file rows into a single virtual list anchored to the existing scroll container, and updates the MEMORY_AND_WORKERS_REVIEW document with the full before/after 8-workspace × 20k-file churn measurements.

  • ChangesFoldersView.tsx: Replaces the eager full-list render with a virtualizer that bounds mounted rows to the visible/overscan slice (~30 rows vs. 600+), achieving 6× reduction in p50 CDP round-trip and elimination of 2 s CDP timeouts per the profiling run.
  • ChangesFileList.tsx: Adds a data-changes-scroll-container attribute so the virtualizer can locate the shared scroll element via closest().
  • Docs: New RENDERER_CHURN_UI_PROFILE_RUNBOOK.md captures the reproducible synthetic profiling procedure; MEMORY_AND_WORKERS_REVIEW.md is updated with measured before/after results and current shipping status of companion PRs.

Confidence Score: 4/5

Virtualization is a real improvement but the scrollMargin computation is incorrect and may render wrong items in multi-section layouts.

The core useVirtualizer wiring is correct and the benchmark confirms the freeze is gone. The scrollMargin uses the wrong coordinate system and is null on first render, which can produce incorrect visible-range calculations for non-first sections.

ChangesFoldersView.tsx — the scrollMargin computation at line 119 needs to be replaced with a layout-effect-based BoundingClientRect calculation relative to the scroll container.

Important Files Changed

Filename Overview
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx Core virtualization change; correctly flattens folder/file rows and bounds mounted DOM to the visible slice, but scrollMargin is stale on first render and references the wrong coordinate system after mount.
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx Adds data-changes-scroll-container to the scroll wrapper so ChangesFoldersView can locate the scroll element via closest(); straightforward and correct.
apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md Documentation update with before/after churn rerun results and updated shipping status for companion PRs.
apps/desktop/docs/RENDERER_CHURN_UI_PROFILE_RUNBOOK.md New runbook documenting the reproducible synthetic profiling procedure. Documentation only.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    SCR["div[data-changes-scroll-container]\n(overflow-y-auto, flex-1)"]
    SCR --> CS1["ChangesSection (unstaged)"]
    SCR --> CS2["ChangesSection (staged)"]
    CS1 --> CFV1["ChangesFoldersView (listRef div)"]
    CS2 --> CFV2["ChangesFoldersView (listRef div)"]
    CFV1 --> VIRT1["useVirtualizer\nscrollMargin: listRef.current?.offsetTop ?? 0"]
    CFV2 --> VIRT2["useVirtualizer\nscrollMargin: listRef.current?.offsetTop ?? 0"]
    VIRT1 --> ISSUE["null ref on first render → 0\noffsetTop relative to offsetParent\nnot scroll container"]
    VIRT2 --> ISSUE
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    SCR["div[data-changes-scroll-container]\n(overflow-y-auto, flex-1)"]
    SCR --> CS1["ChangesSection (unstaged)"]
    SCR --> CS2["ChangesSection (staged)"]
    CS1 --> CFV1["ChangesFoldersView (listRef div)"]
    CS2 --> CFV2["ChangesFoldersView (listRef div)"]
    CFV1 --> VIRT1["useVirtualizer\nscrollMargin: listRef.current?.offsetTop ?? 0"]
    CFV2 --> VIRT2["useVirtualizer\nscrollMargin: listRef.current?.offsetTop ?? 0"]
    VIRT1 --> ISSUE["null ref on first render → 0\noffsetTop relative to offsetParent\nnot scroll container"]
    VIRT2 --> ISSUE
Loading
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx:110-120
**Stale and misreferenced `scrollMargin` on initial render**

`scrollMargin: listRef.current?.offsetTop ?? 0` has two compounding problems. On the first render pass, `listRef.current` is `null` so `scrollMargin` starts as `0` for every group. For groups physically far from the top of the scroll container (e.g. the "Staged" section when "Unstaged" is large), this places the first ~viewport-height items of that group in the visible range even though they are off-screen, partially defeating the fix for non-first groups until the next scroll event.

After mount, `element.offsetTop` is the distance to the element's `offsetParent`. Because the scroll container (`overflow-y-auto`) has no `position` set, its `offsetParent` is the nearest positioned ancestor above it (likely the sidebar shell or `body`). That reference point is in a different coordinate system than the `scrollTop` the virtualizer reads from the scroll container. The correct offset is `listRef.current.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop`. The 8-row overscan buffers small mismatches, but the error grows with layout depth and the null-ref issue recurs on every mount.

The standard fix is a `useLayoutEffect` that runs after mount and re-runs when the container resizes, storing the computed margin in state and passing it to `useVirtualizer`.

### Issue 2 of 2
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx:1
`defaultRangeExtractor` is the library's built-in default, so importing and explicitly passing it is a no-op. The import can be dropped along with the explicit `rangeExtractor` option to reduce the bundle and avoid misleading readers into thinking a custom extractor is active.

```suggestion
import { useVirtualizer } from "@tanstack/react-virtual";
```

Reviews (1): Last reviewed commit: "docs(desktop): record final latest-main ..." | Re-trigger Greptile

Comment on lines +110 to +120
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () =>
listRef.current?.closest(
"[data-changes-scroll-container]",
) as HTMLElement | null,
estimateSize: () => ESTIMATED_ROW_HEIGHT,
rangeExtractor: defaultRangeExtractor,
overscan: OVERSCAN,
scrollMargin: listRef.current?.offsetTop ?? 0,
});

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.

P1 Stale and misreferenced scrollMargin on initial render

scrollMargin: listRef.current?.offsetTop ?? 0 has two compounding problems. On the first render pass, listRef.current is null so scrollMargin starts as 0 for every group. For groups physically far from the top of the scroll container (e.g. the "Staged" section when "Unstaged" is large), this places the first ~viewport-height items of that group in the visible range even though they are off-screen, partially defeating the fix for non-first groups until the next scroll event.

After mount, element.offsetTop is the distance to the element's offsetParent. Because the scroll container (overflow-y-auto) has no position set, its offsetParent is the nearest positioned ancestor above it (likely the sidebar shell or body). That reference point is in a different coordinate system than the scrollTop the virtualizer reads from the scroll container. The correct offset is listRef.current.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop. The 8-row overscan buffers small mismatches, but the error grows with layout depth and the null-ref issue recurs on every mount.

The standard fix is a useLayoutEffect that runs after mount and re-runs when the container resizes, storing the computed margin in state and passing it to useVirtualizer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx
Line: 110-120

Comment:
**Stale and misreferenced `scrollMargin` on initial render**

`scrollMargin: listRef.current?.offsetTop ?? 0` has two compounding problems. On the first render pass, `listRef.current` is `null` so `scrollMargin` starts as `0` for every group. For groups physically far from the top of the scroll container (e.g. the "Staged" section when "Unstaged" is large), this places the first ~viewport-height items of that group in the visible range even though they are off-screen, partially defeating the fix for non-first groups until the next scroll event.

After mount, `element.offsetTop` is the distance to the element's `offsetParent`. Because the scroll container (`overflow-y-auto`) has no `position` set, its `offsetParent` is the nearest positioned ancestor above it (likely the sidebar shell or `body`). That reference point is in a different coordinate system than the `scrollTop` the virtualizer reads from the scroll container. The correct offset is `listRef.current.getBoundingClientRect().top - scrollEl.getBoundingClientRect().top + scrollEl.scrollTop`. The 8-row overscan buffers small mismatches, but the error grows with layout depth and the null-ref issue recurs on every mount.

The standard fix is a `useLayoutEffect` that runs after mount and re-runs when the container resizes, storing the computed margin in state and passing it to `useVirtualizer`.

How can I resolve this? If you propose a fix, please make it concise.

@@ -1,3 +1,4 @@
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";

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.

P2 defaultRangeExtractor is the library's built-in default, so importing and explicitly passing it is a no-op. The import can be dropped along with the explicit rangeExtractor option to reduce the bundle and avoid misleading readers into thinking a custom extractor is active.

Suggested change
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx
Line: 1

Comment:
`defaultRangeExtractor` is the library's built-in default, so importing and explicitly passing it is a no-op. The import can be dropped along with the explicit `rangeExtractor` option to reduce the bundle and avoid misleading readers into thinking a custom extractor is active.

```suggestion
import { useVirtualizer } from "@tanstack/react-virtual";
```

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@Kitenite Kitenite changed the title fix(desktop): virtualize large changes lists fix(desktop): reuse Pierre for large changes lists Jul 19, 2026
@github-actions

github-actions Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

🧹 Preview Cleanup Complete

The following preview resources have been cleaned up:

  • ✅ Neon database branch

Thank you for your contribution! 🎉

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts (1)

39-42: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cancel the previous animation frame to prevent callback pileup.

During high file-change churn, model.subscribe can fire many times within a single frame. Without cancelling the previous frame, this schedules hundreds or thousands of concurrent requestAnimationFrame callbacks that all execute on the next frame, contributing to event-loop delay and rendering jitter.

⚡ Proposed fix
 		const unsubscribe = model.subscribe(() => {
+			cancelAnimationFrame(raf);
 			raf = requestAnimationFrame(readHeight);
 		});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts
around lines 39 - 42, Update the model subscription callback in
useMeasuredTreeHeight to cancel any previously scheduled animation frame before
assigning a new requestAnimationFrame(readHeight). Preserve the existing cleanup
behavior, ensuring the latest scheduled frame remains active and the returned
unsubscribe cleanup still handles teardown.
♻️ Duplicate comments (1)
apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx (1)

88-91: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Add relative positioning to establish the correct offsetParent.

Without relative positioning on this scroll container, listRef.current?.offsetTop in child virtualizers will be incorrectly calculated against a higher-level positioned ancestor (e.g., the sidebar root or document).

This introduces a permanent misalignment between the physical scroll position and the virtualizer's viewport math, causing it to unmount visible rows prematurely and leaving large blank gaps when scrolling.

🐛 Proposed fix
 		<div
-			className="min-h-0 flex-1 space-y-2 overflow-y-auto pt-1"
+			className="min-h-0 flex-1 space-y-2 overflow-y-auto pt-1 relative"
 			data-changes-scroll-container
 		>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx
around lines 88 - 91, Add the `relative` positioning class to the scroll
container `<div>` in `ChangesFileList`, preserving its existing flex, spacing,
overflow, and padding classes so child virtualizers calculate
`listRef.current?.offsetTop` against this container.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx:
- Around line 428-438: Replace the flatMap implementation in
buildPierreGitStatus with a simple loop that reuses a single result array,
appending entries only when treePathByFilePath contains a path. Preserve the
existing path and status mapping behavior while avoiding per-file temporary
arrays during updates.
- Around line 281-286: Normalize treePath by removing trailing slashes before
the fileByPath and directoryPathByTreePath lookups in openInExternalEditor. Use
the normalized path for both maps while preserving the existing realPath
selection and onOpenInEditor behavior.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts:
- Around line 72-73: The file comparator in buildPierreProjection.ts currently
sorts files descending; change it to compare left.basename against
right.basename so files use A-Z order. Update the expected ordering in
buildPierreProjection.test.ts lines 87-90 to [file("a.ts"), file("z.ts")] to
match the corrected behavior.

---

Outside diff comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts:
- Around line 39-42: Update the model subscription callback in
useMeasuredTreeHeight to cancel any previously scheduled animation frame before
assigning a new requestAnimationFrame(readHeight). Preserve the existing cleanup
behavior, ensuring the latest scheduled frame remains active and the returned
unsubscribe cleanup still handles teardown.

---

Duplicate comments:
In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx:
- Around line 88-91: Add the `relative` positioning class to the scroll
container `<div>` in `ChangesFileList`, preserving its existing flex, spacing,
overflow, and padding classes so child virtualizers calculate
`listRef.current?.offsetTop` against this container.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8d664b18-9a0b-4584-a9d4-a4790ed2acec

📥 Commits

Reviewing files that changed from the base of the PR and between 5ad7a28 and ac3c539.

⛔ Files ignored due to path filters (2)
  • apps/desktop/docs/artifacts/renderer-churn-visible-lifecycle-checkpoints.mp4 is excluded by !**/*.mp4
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (15)
  • apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md
  • apps/desktop/docs/RENDERER_CHURN_UI_PROFILE_RUNBOOK.md
  • apps/desktop/package.json
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/ChangesFileList.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/hooks/useMeasuredTreeHeight/useMeasuredTreeHeight.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.test.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts
💤 Files with no reviewable changes (6)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/components/FolderHeader/FolderHeader.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/FileRow.tsx
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/FileRow/index.ts
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesFoldersView/ChangesFoldersView.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/desktop/docs/RENDERER_CHURN_UI_PROFILE_RUNBOOK.md
  • apps/desktop/docs/MEMORY_AND_WORKERS_REVIEW.md

Comment on lines +281 to 286
openInExternalEditor: (treePath) => {
const filePath = fileByPath.get(treePath)?.path;
const directoryPath = projection.directoryPathByTreePath.get(treePath);
const realPath = filePath ?? directoryPath;
if (realPath !== undefined) onOpenInEditor?.(realPath);
},

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Strip trailing slashes from treePath before map lookups.

When a directory row triggers this action (e.g., via middle-click), Pierre may pass the treePath with a trailing slash. Because directoryPathByTreePath keys do not contain trailing slashes, the lookup will miss and the action will silently fail.

🐛 Proposed fix
-			openInExternalEditor: (treePath) => {
-				const filePath = fileByPath.get(treePath)?.path;
-				const directoryPath = projection.directoryPathByTreePath.get(treePath);
+			openInExternalEditor: (rawTreePath) => {
+				const treePath = stripTrailingSlash(rawTreePath);
+				const filePath = fileByPath.get(treePath)?.path;
+				const directoryPath = projection.directoryPathByTreePath.get(treePath);
 				const realPath = filePath ?? directoryPath;
 				if (realPath !== undefined) onOpenInEditor?.(realPath);
 			},
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
openInExternalEditor: (treePath) => {
const filePath = fileByPath.get(treePath)?.path;
const directoryPath = projection.directoryPathByTreePath.get(treePath);
const realPath = filePath ?? directoryPath;
if (realPath !== undefined) onOpenInEditor?.(realPath);
},
openInExternalEditor: (rawTreePath) => {
const treePath = stripTrailingSlash(rawTreePath);
const filePath = fileByPath.get(treePath)?.path;
const directoryPath = projection.directoryPathByTreePath.get(treePath);
const realPath = filePath ?? directoryPath;
if (realPath !== undefined) onOpenInEditor?.(realPath);
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx
around lines 281 - 286, Normalize treePath by removing trailing slashes before
the fileByPath and directoryPathByTreePath lookups in openInExternalEditor. Use
the normalized path for both maps while preserving the existing realPath
selection and onOpenInEditor behavior.

Comment on lines +428 to 438
function buildPierreGitStatus(
files: ChangesetFile[],
treePathByFilePath: Map<string, string>,
): PierreGitStatusEntry[] {
return files.flatMap((file) => {
const treePath = treePathByFilePath.get(file.path);
return treePath
? [{ path: treePath, status: FILE_STATUS_TO_PIERRE[file.status] }]
: [];
});
}

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.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Optimize buildPierreGitStatus to reduce GC pressure during churn.

Using flatMap with an array of 20,000 files creates 20,000 short-lived, single-item arrays on every update, generating significant memory churn. Given the PR's focus on reducing renderer RSS and event-loop delays during high churn, switching to a simple loop avoids this allocation overhead.

⚡ Proposed fix
 function buildPierreGitStatus(
 	files: ChangesetFile[],
 	treePathByFilePath: Map<string, string>,
 ): PierreGitStatusEntry[] {
-	return files.flatMap((file) => {
-		const treePath = treePathByFilePath.get(file.path);
-		return treePath
-			? [{ path: treePath, status: FILE_STATUS_TO_PIERRE[file.status] }]
-			: [];
-	});
+	const entries: PierreGitStatusEntry[] = [];
+	for (const file of files) {
+		const treePath = treePathByFilePath.get(file.path);
+		if (treePath) {
+			entries.push({ path: treePath, status: FILE_STATUS_TO_PIERRE[file.status] });
+		}
+	}
+	return entries;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function buildPierreGitStatus(
files: ChangesetFile[],
treePathByFilePath: Map<string, string>,
): PierreGitStatusEntry[] {
return files.flatMap((file) => {
const treePath = treePathByFilePath.get(file.path);
return treePath
? [{ path: treePath, status: FILE_STATUS_TO_PIERRE[file.status] }]
: [];
});
}
function buildPierreGitStatus(
files: ChangesetFile[],
treePathByFilePath: Map<string, string>,
): PierreGitStatusEntry[] {
const entries: PierreGitStatusEntry[] = [];
for (const file of files) {
const treePath = treePathByFilePath.get(file.path);
if (treePath) {
entries.push({ path: treePath, status: FILE_STATUS_TO_PIERRE[file.status] });
}
}
return entries;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/ChangesTreeView.tsx
around lines 428 - 438, Replace the flatMap implementation in
buildPierreGitStatus with a simple loop that reuses a single result array,
appending entries only when treePathByFilePath contains a path. Preserve the
existing path and status mapping behavior while avoiding per-file temporary
arrays during updates.

Comment on lines +72 to +73
return right.basename.localeCompare(left.basename);
};

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix file sorting order (Z-A vs A-Z). Directories are sorted ascending, but files are sorted descending (right.basename.localeCompare(left.basename)). This results in an unexpected Z-A file order, likely due to swapped variables, which was then codified into the tests.

  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts#L72-L73: change the return statement to return left.basename.localeCompare(right.basename); so files sort A-Z.
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.test.ts#L87-L90: update the expected test output to [file("a.ts"), file("z.ts")] to match the ascending order.
📍 Affects 2 files
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts#L72-L73 (this comment)
  • apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.test.ts#L87-L90
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/components/WorkspaceSidebar/hooks/useChangesTab/components/ChangesFileList/components/ChangesTreeView/utils/buildPierreProjection/buildPierreProjection.ts
around lines 72 - 73, The file comparator in buildPierreProjection.ts currently
sorts files descending; change it to compare left.basename against
right.basename so files use A-Z order. Update the expected ordering in
buildPierreProjection.test.ts lines 87-90 to [file("a.ts"), file("z.ts")] to
match the corrected behavior.

@Kitenite
Kitenite merged commit 4d298a3 into main Jul 19, 2026
19 checks passed
Kitenite added a commit that referenced this pull request Jul 20, 2026
…nked icons (#5807)

* fix(desktop): revert changes list to pre-Pierre rows, keep icon-sprite fix

Restores the pre-#5782 changes view: folder mode renders the old
FolderHeader + FileRow grouped list (colored +N/-N, status badges, "/"
paths, plain filenames), tree mode returns to the standalone Pierre tree.
Pierre's single text-only row decoration + hardcoded git lane can't
reproduce two-tone diff stats or our StatusIndicator icons, so the
unified-Pierre folder view read as a degraded nested tree.

Keeps the independent icon-sprite fix: fetchSymbolBody now rejects
non-SVG responses. Missing icon assets resolve to the SPA index.html
(HTTP 200), and that HTML, embedded in a <symbol>, aborted Pierre's
innerHTML sprite parse mid-stream and silently dropped ~300 symbols —
blanking .test.ts and many other file icons in the Pierre trees (Files
tab + changes tree). All 535 symbols now parse.

Tradeoff: folder mode is no longer list-virtualized (reverts #5782's
perf change); tree mode keeps Pierre's virtualization.

* perf(desktop): virtualize the folder changes view

Window the folder-mode changes list with @tanstack/react-virtual so large
changesets stay responsive, while keeping the restored FolderHeader +
FileRow look (colored stats, StatusIndicator, "/" paths). Folder-header
and file rows are flattened into one list; each section virtualizes into
the shared [data-changes-scroll-container] via scrollMargin, matching
v1's FileListGroupedVirtualized. Tree mode already virtualizes via Pierre.

Removes the "folder mode no longer virtualized" tradeoff from the revert.

* fix(desktop): stop Pierre file trees from truncating names that fit

Pierre keeps an empty decoration lane (flex: 1 1 0) on every row when the
git lane is active but the row has no custom decoration — every file in
the Files-tab explorer, plus changes with no +/- summary. That empty lane
grows to fill the row and starves the name column, so Pierre's truncation
grid middle-truncates names that easily fit (CLAUDE.md -> CLAUD…d).

Add a shared PIERRE_TREE_UNSAFE_CSS that lets the name column take the free
space and the decoration hug its own content. Pierre wraps unsafeCSS in
@layer unsafe, so it overrides @layer base by cascade order — no !important.

* chore(desktop): apply biome formatting to CommitRow (fix pre-existing CI lint)

This branch was previously deployed

1 inactive deployment
preview — ac3c539f Deployed Jul 19, 2026 by Kitenite via Deploy API #6671
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant