refactor: ux performance optimization#1912
Conversation
📝 WalkthroughWalkthroughChangesThe PR adds a Chinese static UX audit and updates renderer interaction behavior. Changes include shared blur and z-index tokens, overscroll containment, platform detection, editor synchronization safeguards, icon loading states, overlay transitions, chat virtualization and scroll measurement coordination, route shell sizing, activity-key handling, and corresponding tests. UX and renderer updates
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested reviewers: Sequence Diagram(s)sequenceDiagram
participant ChatPage
participant MessageListRow
participant useMessageWindow
participant Browser
ChatPage->>Browser: receive scroll and pointer events
Browser-->>ChatPage: report scrolling intent
ChatPage->>MessageListRow: defer row measurements during gestures
MessageListRow->>useMessageWindow: submit measured heights
useMessageWindow-->>ChatPage: flush coalesced layout updates
ChatPage->>Browser: restore viewport anchor when idle
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ 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 |
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (2)
src/renderer/src/components/chat/ChatTopBar.vue (1)
4-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse shared blur and z-index utilities instead of static inline styling.
dc-blur-panelandz-[var(--dc-z-sticky)]already represent these values. This also preserves the shared-webkit-backdrop-filterbehavior.As per coding guidelines,
**/*.{ts,tsx,vue}must use Tailwind for styling.Proposed fix
- class="sticky top-0 flex h-12 items-center justify-between bg-background/60 px-4 window-drag-region transition-[padding] duration-[var(--dc-motion-default)] ease-[var(--dc-ease-out-express)]" - style=" - z-index: var(--dc-z-sticky); - backdrop-filter: blur(var(--dc-blur-panel)); - -webkit-backdrop-filter: blur(var(--dc-blur-panel)); - " + class="sticky top-0 z-[var(--dc-z-sticky)] dc-blur-panel flex h-12 items-center justify-between bg-background/60 px-4 window-drag-region transition-[padding] duration-[var(--dc-motion-default)] ease-[var(--dc-ease-out-express)]"🤖 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 `@src/renderer/src/components/chat/ChatTopBar.vue` around lines 4 - 9, Replace the inline z-index and backdrop-filter styles on the ChatTopBar root element with the shared Tailwind utilities `z-[var(--dc-z-sticky)]` and `dc-blur-panel`, preserving the existing layout and transition classes and removing the entire style attribute.Source: Coding guidelines
src/renderer/src/components/chat/ChatSearchBar.vue (1)
3-7: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReuse
dc-blur-overlayinstead of duplicating the blur inline.The shared utility already defines both
backdrop-filterand-webkit-backdrop-filter. Reusing it keeps this component aligned with the visual token layer.As per coding guidelines,
**/*.{ts,tsx,vue}must use Tailwind for styling.Proposed fix
- class="chat-search-bar flex w-full max-w-[24rem] items-center gap-2 rounded-2xl border bg-background/90 px-2.5 py-2 shadow-lg" - style=" - backdrop-filter: blur(var(--dc-blur-overlay)); - -webkit-backdrop-filter: blur(var(--dc-blur-overlay)); - " + class="chat-search-bar dc-blur-overlay flex w-full max-w-[24rem] items-center gap-2 rounded-2xl border bg-background/90 px-2.5 py-2 shadow-lg"🤖 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 `@src/renderer/src/components/chat/ChatSearchBar.vue` around lines 3 - 7, Replace the inline backdrop-filter declarations on the ChatSearchBar root element with the shared dc-blur-overlay utility class, and remove the accompanying style attribute. Keep the existing layout classes unchanged.Source: Coding guidelines
🤖 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 `@docs/ux-interaction-audit-2026-07-09.md`:
- Around line 93-110: Update section 2.3 to reference the semantic blur tokens
actually used by each component, including --dc-blur-panel in ChatInputBox.vue
and --dc-blur-overlay in AgentProgressFloat.vue, and reflect the separate soft,
panel, and overlay definitions in style.css with their resolved values. Remove
the outdated backdrop-blur utility-class entries and revise the recommendation
to assess consolidation without assuming a single global token or removing
intentional layer distinctions.
- Around line 20-39: The ChatPage.vue finding about ChatInputBox and
ChatStatusBar unmounting is outdated because the current implementation
preserves ChatInputBox with v-show and inert during permission/question states.
Update the referenced finding and P0 recommendation to describe the current
mounted-but-hidden behavior, or explicitly label the unmounting and missing
draft recovery claims as historical/pre-fix evidence.
In `@src/renderer/src/assets/style.css`:
- Around line 1110-1117: Update the dark-mode scrollbar rules near the existing
`.dark *::-webkit-scrollbar-thumb` selectors to also target the root element
itself, including `html.dark` and the corresponding root thumb pseudo-elements.
Add a dark-mode `scrollbar-color` override on the dark root selector for
Firefox, while preserving the existing descendant thumb and hover styling.
In `@src/renderer/src/components/icons/ModelIcon.vue`:
- Around line 124-129: Make the opacity classes mutually exclusive in the
ModelIcon template’s class binding: ensure the inverted state applies
opacity-50, while the non-inverted loaded state applies opacity-100, and retain
opacity-0 for unloaded icons. Update the binding so iconLoaded and invert cannot
add conflicting opacity classes.
In `@src/renderer/src/pages/ChatPage.vue`:
- Around line 9-12: Extend gesture gating in ChatPage’s touch/pointer handling
so it remains active for the entire touch or drag rather than expiring after the
140ms timer. Update markListScrolling and the relevant `@touchstart/`@pointerdown
bindings to refresh or maintain the gate during movement, and clear it only on
the corresponding touchend/touchcancel or pointerup/pointercancel events; apply
the same change to the related handlers around the alternate referenced section.
- Around line 619-684: Recheck scroll ownership inside the requestAnimationFrame
callback in scheduleViewportAnchorRestore before any scrollTop mutation. If
isProgrammaticScrollActive() or hasRecentUserScrollAwayIntent() is true, skip
restoration and avoid applying the pending anchor or above-viewport delta.
---
Nitpick comments:
In `@src/renderer/src/components/chat/ChatSearchBar.vue`:
- Around line 3-7: Replace the inline backdrop-filter declarations on the
ChatSearchBar root element with the shared dc-blur-overlay utility class, and
remove the accompanying style attribute. Keep the existing layout classes
unchanged.
In `@src/renderer/src/components/chat/ChatTopBar.vue`:
- Around line 4-9: Replace the inline z-index and backdrop-filter styles on the
ChatTopBar root element with the shared Tailwind utilities
`z-[var(--dc-z-sticky)]` and `dc-blur-panel`, preserving the existing layout and
transition classes and removing the entire style attribute.
🪄 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: 01fcf7b9-e3c5-450a-ac8b-ab54c89d6730
📒 Files selected for processing (35)
docs/ux-interaction-audit-2026-07-09.mdsrc/renderer/src/App.vuesrc/renderer/src/assets/style.csssrc/renderer/src/components/chat-input/McpIndicator.vuesrc/renderer/src/components/chat/AgentProgressFloat.vuesrc/renderer/src/components/chat/ChatInputBox.vuesrc/renderer/src/components/chat/ChatSearchBar.vuesrc/renderer/src/components/chat/ChatStatusBar.vuesrc/renderer/src/components/chat/ChatToolInteractionOverlay.vuesrc/renderer/src/components/chat/ChatTopBar.vuesrc/renderer/src/components/chat/MemoryTurnDialog.vuesrc/renderer/src/components/chat/MemoryUpdateChip.vuesrc/renderer/src/components/chat/MessageListRow.vuesrc/renderer/src/components/chat/PendingInputLane.vuesrc/renderer/src/components/chat/mentions/SuggestionList.vuesrc/renderer/src/components/editor/mention/PromptParamsDialog.vuesrc/renderer/src/components/icons/ModelIcon.vuesrc/renderer/src/components/message/MessageActionButtons.vuesrc/renderer/src/components/message/MessageBlockActivityGroup.vuesrc/renderer/src/components/message/MessageBlockToolCall.vuesrc/renderer/src/components/message/MessageToolbar.vuesrc/renderer/src/components/message/ReferencePreview.vuesrc/renderer/src/components/popup/TranslatePopup.vuesrc/renderer/src/components/sidepanel/ChatSidePanel.vuesrc/renderer/src/components/sidepanel/WorkspacePanel.vuesrc/renderer/src/components/sidepanel/WorkspaceViewer.vuesrc/renderer/src/components/sidepanel/viewer/WorkspaceInfoPane.vuesrc/renderer/src/components/sidepanel/viewer/WorkspacePreviewPane.vuesrc/renderer/src/components/spotlight/SpotlightOverlay.vuesrc/renderer/src/composables/message/useMessageWindow.tssrc/renderer/src/pages/ChatPage.vuesrc/renderer/src/views/ChatTabView.vuetest/renderer/components/ChatInputBox.test.tstest/renderer/components/ChatPage.test.tstest/renderer/composables/useMessageWindow.test.ts
zerob13
left a comment
There was a problem hiding this comment.
Reviewed the current head (9c4e6717c). The simplified scroll implementation removes several still-required behaviors, and the targeted ChatPage suite does not pass on this head. Inline comments cover the reproducible regressions.
| @@ -5,8 +5,6 @@ | |||
| data-testid="chat-page" | |||
| :data-generating="String(isGenerating)" | |||
| class="message-list-container h-full w-full min-w-0 overflow-y-auto" | |||
There was a problem hiding this comment.
P1 — Restore a trigger for loading older history. Removing the scroll binding also removes the only path that called messageStore.loadOlderMessages() (onScroll → loadOlderMessagesAtTop, both deleted below). Session restore still requests only INITIAL_MESSAGE_RESTORE_COUNT (40), and there is no sentinel/observer or explicit load-more action elsewhere. As a result, conversations longer than 40 messages can never expose their older history.
| <MessageList | ||
| ref="messageListRef" | ||
| :messages="visibleDisplayMessages" | ||
| :messages="displayMessages" |
There was a problem hiding this comment.
P2 — This removes the performance bound the PR is meant to improve. displayMessages mounts the entire loaded history instead of the previous window. The existing bounds mounted message rows for long loaded histories test now receives 300 rows instead of at most 90. Every row also still installs a ResizeObserver in MessageListRow, even though @measure was removed here, so long conversations pay that observer/rAF cost for no consumer. Please either retain windowing or remove the unused measurement machinery and explicitly re-evaluate the long-history performance contract.
| } | ||
|
|
||
| applyRestoredSessionSummary(restoredSession) | ||
| scheduleScrollToBottom() |
There was a problem hiding this comment.
P1 — Preserve Spotlight message jumps during restore. The Spotlight store still sets pendingMessageJump before selecting a message result's session, but this PR removes useSpotlightStore and focusPendingSpotlightMessageJump. Restore now always schedules a bottom scroll here, so selecting a message result lands at the conversation bottom, never highlights the target, and never clears the pending jump. The existing consumes pending spotlight message jumps... test fails on this head.
|
|
||
| remainingFrames -= 1 | ||
| scheduleNextFrame() | ||
| el.scrollTo({ top: el.scrollHeight }) |
There was a problem hiding this comment.
P1 — The documented targeted test command fails on this call. pnpm exec vitest --config vitest.config.renderer.ts test/renderer/components/ChatPage.test.ts --run produces TypeError: el.scrollTo is not a function from this line (46 uncaught exceptions in the current jsdom environment). After locally supplying only a scrollTo polyfill, five behavioral assertions still fail. Using the previous el.scrollTop = Math.max(el.scrollHeight - el.clientHeight, 0) path would keep the production behavior deterministic and avoid requiring a new test-environment API shim.
| } else { | ||
| scheduleScrollMetricsRead() | ||
| if (isGenerating.value && uiSettingsStore.autoScrollEnabled) { | ||
| scheduleScrollToBottom() |
There was a problem hiding this comment.
P1 — Do not override the user's reading position during generation. Every stream revision now schedules a bottom scroll whenever the setting is enabled. The removed implementation tracked isNearBottom/shouldAutoFollow and stopped following after an upward wheel/scroll gesture; with that state gone, a user who scrolls up to read earlier content is pulled back to the bottom on the next token update. Keep a minimal near-bottom/follow-state guard so auto-scroll can be disengaged by user intent.
| await nextTick() | ||
| applyChatSearchHighlights(root, chatSearchQuery.value) | ||
| setActiveChatSearchResult(root, result, { behavior }) | ||
| setActiveChatSearchResult(root, chatSearchResults.value[nextIndex], { scroll: false }) |
There was a problem hiding this comment.
P1 — Search navigation no longer reveals off-screen matches. setActiveChatSearchResult calls scrollIntoView unless options.scroll === false; this argument explicitly disables it. Since revealChatSearchResult and its fallback were also removed, next/previous navigation only changes an off-screen highlight and gives the user no visible result. Removing { scroll: false } restores the helper's built-in scrolling now that all rows are mounted.
| overscroll-behavior: contain; | ||
| overflow-anchor: none; | ||
| scroll-behavior: auto; | ||
| scroll-behavior: smooth; |
There was a problem hiding this comment.
P2 — Avoid smooth behavior for high-frequency auto-follow. This CSS also governs the scrollTo issued for every streaming revision. Each update starts/restarts a smooth animation while scrollHeight keeps growing, so the viewport can visibly lag or crawl behind the generated content. Keep the container at scroll-behavior: auto and request smooth behavior only for one-shot, user-initiated navigation.
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/renderer/src/pages/ChatPage.vue (1)
101-110: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winReserve space for question-only and combined interaction overlays.
The reservation is zero without a plan and otherwise measures only the plan trigger. Question overlays can therefore obscure the final messages beneath the absolute layer.
Proposed fix
function syncPlanFloatReservedHeight() { const layer = planFloatLayer.value - if (!latestPlanSnapshot.value || !layer) { + if ((!latestPlanSnapshot.value && !activePendingInteraction.value) || !layer) { planFloatReservedHeight.value = 0 return } const trigger = layer.querySelector<HTMLElement>('[data-testid="agent-progress-float-trigger"]') const triggerHeight = trigger?.offsetHeight ?? layer.offsetHeight - const next = triggerHeight + PLAN_FLOAT_SAFE_GAP + const contentHeight = activePendingInteraction.value ? layer.offsetHeight : triggerHeight + const next = contentHeight + PLAN_FLOAT_SAFE_GAP- if (!latestPlanSnapshot.value || !layer) { + if ((!latestPlanSnapshot.value && !activePendingInteraction.value) || !layer) {watch( - [latestPlanSnapshot, isPlanFloatCollapsed], + [latestPlanSnapshot, isPlanFloatCollapsed, activePendingInteraction],Also applies to: 528-575, 2237-2250
🤖 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 `@src/renderer/src/pages/ChatPage.vue` around lines 101 - 110, Update the layout reservation associated with the planFloatLayer and its related sections so it accounts for question-only as well as combined plan-and-question overlays, not just latestPlanSnapshot. Use activePendingInteraction when determining the reserved space, and ensure the corresponding sizing logic near the other referenced interaction-overlay sections stays consistent so final messages are not obscured.
🤖 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 `@docs/ux-interaction-audit-2026-07-09.md`:
- Line 246: The audit entry incorrectly attributes focus preservation to inert.
In the row describing ChatPage.vue’s input area, state only that v-show keeps
the editor mounted and preserves draft state; if focus restoration is required,
document an explicit focus action after the panel closes rather than relying on
inert.
In `@src/renderer/src/pages/ChatPage.vue`:
- Around line 1062-1081: Guard the post-load restoration in the history-loading
handler against session changes: capture the active session identifier before
awaiting messageStore.loadOlderMessages(), then verify it is unchanged before
adjusting scrollTop or syncing viewport metrics. Return early when the session
differs, using the handler and messageStore.loadOlderMessages() symbols to
locate the logic.
- Around line 1021-1041: Update the scroll-intent handling around
isSessionRestoreScrollSettleActive and scheduleScrollMetricsRead so keyboard
scrolling is classified as user-initiated, not only wheel, touch, or pointer
input. Track relevant keyboard navigation events (especially PageUp and End),
arm the same user-scroll classification for them, and ensure End at the bottom
re-enables shouldAutoFollow.
- Around line 738-768: Delay flushPendingMeasures until scroll-away intent
expires and anchor restoration has completed, rather than flushing immediately
after the 140ms idle delay; update finishListScrollingAfterIdle to retain or
reschedule pending measurements while the gesture/scroll-away state remains
active. Also make the requestAnimationFrame callback in flushPendingMeasures
pause processing when a new list gesture starts, preserving the remaining queue
for a later flush instead of applying measurements during active scrolling.
---
Outside diff comments:
In `@src/renderer/src/pages/ChatPage.vue`:
- Around line 101-110: Update the layout reservation associated with the
planFloatLayer and its related sections so it accounts for question-only as well
as combined plan-and-question overlays, not just latestPlanSnapshot. Use
activePendingInteraction when determining the reserved space, and ensure the
corresponding sizing logic near the other referenced interaction-overlay
sections stays consistent so final messages are not obscured.
🪄 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: 79eeafcb-d057-41ea-ad7e-c0685c9fad26
📒 Files selected for processing (6)
docs/ux-interaction-audit-2026-07-09.mdsrc/renderer/src/assets/style.csssrc/renderer/src/components/chat/ChatSearchBar.vuesrc/renderer/src/components/chat/ChatTopBar.vuesrc/renderer/src/components/icons/ModelIcon.vuesrc/renderer/src/pages/ChatPage.vue
✅ Files skipped from review due to trivial changes (1)
- src/renderer/src/components/chat/ChatSearchBar.vue
🚧 Files skipped from review as they are similar to previous changes (3)
- src/renderer/src/components/chat/ChatTopBar.vue
- src/renderer/src/components/icons/ModelIcon.vue
- src/renderer/src/assets/style.css
| const previousScrollHeight = el.scrollHeight | ||
| const previousScrollTop = el.scrollTop | ||
| const loadedCount = await messageStore.loadOlderMessages() | ||
| if (loadedCount === 0) { | ||
| return | ||
| } | ||
|
|
||
| await nextTick() | ||
| const nextScrollHeight = el.scrollHeight | ||
| el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight) | ||
| syncMessageViewportMetrics(el) | ||
| const container = scrollContainer.value | ||
| if (!container) { | ||
| return | ||
| } | ||
|
|
||
| // Use the *current* scrollTop after await, not the pre-await snapshot. The | ||
| // user may keep scrolling while history loads; writing back the old value | ||
| // feels like the viewport "snapped to a few seconds ago". | ||
| const heightDelta = container.scrollHeight - previousScrollHeight | ||
| if (heightDelta !== 0) { | ||
| container.scrollTop += heightDelta | ||
| } | ||
| syncMessageViewportMetrics(container) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Guard history restoration against session switches.
If the session changes while loadOlderMessages() is pending, this continuation adjusts the newly mounted session’s scrollTop using the previous session’s height.
Proposed fix
const previousScrollHeight = el.scrollHeight
+ const sessionId = props.sessionId
+ const requestId = sessionRestoreRequestId
const loadedCount = await messageStore.loadOlderMessages()
- if (loadedCount === 0) {
+ if (
+ loadedCount === 0 ||
+ props.sessionId !== sessionId ||
+ sessionRestoreRequestId !== requestId
+ ) {
return
}
await nextTick()
+ if (props.sessionId !== sessionId || sessionRestoreRequestId !== requestId) {
+ return
+ }📝 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.
| const previousScrollHeight = el.scrollHeight | |
| const previousScrollTop = el.scrollTop | |
| const loadedCount = await messageStore.loadOlderMessages() | |
| if (loadedCount === 0) { | |
| return | |
| } | |
| await nextTick() | |
| const nextScrollHeight = el.scrollHeight | |
| el.scrollTop = previousScrollTop + (nextScrollHeight - previousScrollHeight) | |
| syncMessageViewportMetrics(el) | |
| const container = scrollContainer.value | |
| if (!container) { | |
| return | |
| } | |
| // Use the *current* scrollTop after await, not the pre-await snapshot. The | |
| // user may keep scrolling while history loads; writing back the old value | |
| // feels like the viewport "snapped to a few seconds ago". | |
| const heightDelta = container.scrollHeight - previousScrollHeight | |
| if (heightDelta !== 0) { | |
| container.scrollTop += heightDelta | |
| } | |
| syncMessageViewportMetrics(container) | |
| const previousScrollHeight = el.scrollHeight | |
| const sessionId = props.sessionId | |
| const requestId = sessionRestoreRequestId | |
| const loadedCount = await messageStore.loadOlderMessages() | |
| if ( | |
| loadedCount === 0 || | |
| props.sessionId !== sessionId || | |
| sessionRestoreRequestId !== requestId | |
| ) { | |
| return | |
| } | |
| await nextTick() | |
| if (props.sessionId !== sessionId || sessionRestoreRequestId !== requestId) { | |
| return | |
| } | |
| const container = scrollContainer.value | |
| if (!container) { | |
| return | |
| } | |
| // Use the *current* scrollTop after await, not the pre-await snapshot. The | |
| // user may keep scrolling while history loads; writing back the old value | |
| // feels like the viewport "snapped to a few seconds ago". | |
| const heightDelta = container.scrollHeight - previousScrollHeight | |
| if (heightDelta !== 0) { | |
| container.scrollTop += heightDelta | |
| } | |
| syncMessageViewportMetrics(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 `@src/renderer/src/pages/ChatPage.vue` around lines 1062 - 1081, Guard the
post-load restoration in the history-loading handler against session changes:
capture the active session identifier before awaiting
messageStore.loadOlderMessages(), then verify it is unchanged before adjusting
scrollTop or syncing viewport metrics. Return early when the session differs,
using the handler and messageStore.loadOlderMessages() symbols to locate the
logic.
Summary
UI layout
BEFORE:
AFTER:
Tests
pnpm run formatpnpm run i18npnpm run lintpnpm run typecheck:webpnpm exec vitest --config vitest.config.renderer.ts test/renderer/components/ChatInputBox.test.ts test/renderer/components/ChatPage.test.ts test/renderer/composables/useMessageWindow.test.tspnpm exec vitest --config vitest.config.renderer.ts test/renderer/components/message/messageActivityGroups.test.ts test/renderer/components/message/MessageItemAssistant.test.ts --rungit diff --checkReview
Summary by CodeRabbit