Skip to content

fix: prevent renderer memory leak from unbounded expansion state#168

Open
psypeal wants to merge 9 commits intomatt1398:mainfrom
psypeal:fix/renderer-memory-leak
Open

fix: prevent renderer memory leak from unbounded expansion state#168
psypeal wants to merge 9 commits intomatt1398:mainfrom
psypeal:fix/renderer-memory-leak

Conversation

@psypeal
Copy link
Copy Markdown
Contributor

@psypeal psypeal commented Apr 5, 2026

Summary

  • Clear expansion Maps/Sets (aiGroupExpansionLevels, expandedStepIds, expandedDisplayItemIds, expandedAIGroupIds) on session switch in fetchSessionDetail and setActiveTab
  • Add getConversationExpansionResetState() helper to centralize reset logic
  • Add periodic pruning for sessionRefreshGeneration and projectRefreshGeneration Maps (100 cap, prune to 50)
  • Crash log showed renderer at 3029MB -> 3499MB -> crash after 47h; main process was fine at 135MB

Test plan

  • pnpm typecheck passes (pre-existing errors in LinkedToolItem.tsx are unrelated)
  • pnpm test -- all 652 tests pass
  • Manual: switch between 20+ sessions -- renderer memory stays flat
  • Manual: run for 24h+ -- no RENDERER_MEMORY_WARNING in crash log

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added Bash tool execution display with command output viewing
    • Added Markdown preview toggle for supported file types
    • Session refresh (Ctrl+R / Cmd+R) now automatically scrolls chat to the bottom
  • Improvements

    • Tool outputs now display in collapsible sections for improved readability
    • Expanded syntax highlighting support for additional programming languages

psypeal and others added 9 commits February 21, 2026 04:51
# Conflicts:
#	src/renderer/components/chat/ContextBadge.tsx
#	src/renderer/utils/contextTracker.ts
# Conflicts:
#	src/renderer/components/chat/ContextBadge.tsx
sessionAnalyzer now reads pre-computed costUsd from calculateMetrics()
instead of re-computing costs independently. Parent cost uses
detail.metrics.costUsd, subagent cost uses proc.metrics.costUsd.
This ensures the cost analysis panel and chat header always agree.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Electron's before-input-event preventDefault blocks both Chromium's built-in reload AND keydown propagation to the renderer. Route Ctrl+R through IPC instead: main process intercepts and sends session:refresh, preload bridges to renderer, store listener refreshes in place.

Switch all refresh paths (IPC, refresh button, keyboard shortcut) to refreshSessionInPlace to avoid unmounting the scroll container, then dispatch a custom event to smoothly scroll to the bottom.

Fixes matt1398#85
# Conflicts:
#	src/renderer/utils/sessionAnalyzer.ts
#	test/renderer/utils/sessionAnalyzer.test.ts
…hlighting

- Add BashToolViewer with syntax-highlighted command input via CodeBlockViewer
- Add CollapsibleOutputSection (collapsed by default) for tool output
- Apply collapsible output to DefaultToolViewer and BashToolViewer
- Add bash/zsh/fish keyword sets for syntax highlighting
- Add keyword sets for c, cpp, java, kotlin, swift, lua, html, yaml
- Fix markdown/html false type-coloring on capitalized words
- Add markdown preview toggle to ReadToolViewer (defaults to preview)
- Default WriteToolViewer to preview mode for markdown files
- Extend comment detection for zsh, fish, yaml (#) and lua (--)
Clear aiGroupExpansionLevels, expandedStepIds, expandedDisplayItemIds,
and expandedAIGroupIds Maps/Sets when switching sessions. These were
accumulating entries from every session viewed without cleanup, causing
the renderer to grow to 3GB+ after 47 hours of use.

Also add periodic pruning for sessionRefreshGeneration and
projectRefreshGeneration Maps to prevent unbounded growth in the store.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai coderabbitai bot added the bug Something isn't working label Apr 5, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a custom session refresh mechanism for Electron, adds a specialized Bash tool viewer with syntax highlighting and collapsible output, and enhances Read/Write tools with Markdown previews. It also significantly expands syntax highlighting support and introduces state management optimizations to clear UI expansion states. Feedback identifies a critical race condition in the new pruning logic that could cause in-flight requests to be ignored and suggests refactoring the syntax highlighter for improved maintainability.

Comment on lines +513 to +521
if (sessionRefreshGeneration.size > 100) {
const entries = Array.from(sessionRefreshGeneration.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 50);
sessionRefreshGeneration.clear();
for (const [key, value] of entries) {
sessionRefreshGeneration.set(key, value);
}
}
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.

high

The pruning logic for sessionRefreshGeneration has a correctness issue and a logic flaw:

  1. Race Condition / Correctness Bug: If a pruning event occurs (triggered by any session refresh) while a specific session refresh is in flight (awaiting the API call at line 524), the refreshKey for that in-flight request might be evicted from the Map. When the API call returns, the check at line 527 (sessionRefreshGeneration.get(refreshKey) !== generation) will fail because the key is missing (returning undefined), causing the legitimate update to be silently dropped.
  2. Recency vs. Frequency: Sorting by the generation counter (b[1] - a[1]) keeps the most frequently refreshed sessions, not the most recently refreshed ones as stated in the PR description. A session refreshed 100 times yesterday will be prioritized over a session refreshed for the first time just now.

Given that these Maps only store a number per session ID, the memory overhead is negligible (a few hundred KB for 10,000 sessions). It is safer to remove this pruning entirely. If pruning is required, ensure that keys currently in sessionRefreshInFlight are never evicted and use a proper LRU approach (e.g., by re-inserting keys to leverage JS Map's insertion order).

Comment on lines +261 to +269
if (projectRefreshGeneration.size > 100) {
const entries = Array.from(projectRefreshGeneration.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 50);
projectRefreshGeneration.clear();
for (const [key, value] of entries) {
projectRefreshGeneration.set(key, value);
}
}
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.

high

This pruning logic suffers from the same issues identified in sessionDetailSlice.ts. It can cause in-flight project refreshes to be ignored if their key is evicted during the API call, and it incorrectly uses frequency (counter value) as a proxy for recency.

Comment on lines 925 to 928
if (
(language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') &&
(language === 'python' || language === 'bash' || language === 'zsh' || language === 'fish' ||
language === 'r' || language === 'ruby' || language === 'php' || language === 'yaml') &&
remaining.startsWith('#')
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.

medium

The list of languages using # for comments is growing. To improve maintainability and readability, consider defining a Set of languages for each comment style at the top of the file (e.g., const HASH_COMMENT_LANGUAGES = new Set(['python', 'bash', ...])) and checking against that set here.

References
  1. Adhering to general clean code principles for maintainability by avoiding long conditional chains when a Set lookup is more appropriate. (link)

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 5, 2026

📝 Walkthrough

Walkthrough

Implements a Ctrl+R / Cmd+R session refresh flow via IPC that refreshes the active session in-place, fetches updated sessions, and triggers chat history scroll-to-bottom. Adds Bash tool rendering, expands syntax highlighting for multiple languages, introduces markdown preview toggling in file viewers, resets conversation expansion state on session changes, and prunes generation maps to prevent unbounded growth.

Changes

Cohort / File(s) Summary
Keyboard Shortcut & IPC Bridge
src/main/index.ts, src/preload/constants/ipcChannels.ts, src/preload/index.ts, src/shared/types/api.ts
Main process now blocks Ctrl+R / Cmd+R (without Shift) and sends session:refresh IPC event to renderer. Added SESSION_REFRESH constant and onSessionRefresh listener method to preload API and ElectronAPI type.
Browser API Stub
src/renderer/api/httpClient.ts
Added no-op onSessionRefresh method to HttpAPIClient for browser environment compatibility.
State Management & Refresh Logic
src/renderer/store/index.ts, src/renderer/store/slices/sessionSlice.ts, src/renderer/store/slices/sessionDetailSlice.ts, src/renderer/store/slices/tabSlice.ts, src/renderer/store/utils/stateResetHelpers.ts
Added IPC listener in store initialization that triggers in-place session refresh and fetches. Introduced getConversationExpansionResetState() helper and integrated it into session/tab state resets. Added generation map pruning (keep 50 most recent of last 100 entries) in refresh methods to prevent unbounded growth. Tab switching now resets conversation expansion state when loading cached data.
Session Refresh UI Triggers
src/renderer/components/chat/ChatHistory.tsx, src/renderer/components/layout/TabBar.tsx, src/renderer/hooks/useKeyboardShortcuts.ts
Chat history listens for session-refresh-scroll-bottom window event and scrolls to bottom smoothly. TabBar and keyboard shortcuts hook now dispatch this custom event after concurrent session refresh and fetch complete, using refreshSessionInPlace instead of fetchSessionDetail.
Bash Tool & Collapsible Output
src/renderer/components/chat/items/LinkedToolItem.tsx, src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx, src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx, src/renderer/components/chat/items/linkedTool/index.ts
Added Bash tool detection and rendering via new BashToolViewer component. Introduced CollapsibleOutputSection component (collapsed by default) for consistent output display. LinkedToolItem now passes variant prop derived from status and supports Bash viewer selection. Updated exports.
Tool Viewer Refactoring
src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx, src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx, src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx
DefaultToolViewer now uses CollapsibleOutputSection component instead of inline output rendering. ReadToolViewer adds markdown/MDX detection and preview/code toggle. WriteToolViewer initializes view mode based on file type (preview for markdown, code otherwise).
Syntax Highlighting Expansion
src/renderer/components/chat/viewers/syntaxHighlighter.ts
Extended keyword sets for Bash, C, Java, Kotlin, Swift, Lua, HTML, and YAML. Added language aliases (Zsh/Fish→Bash, C++/C++→C, C++h→C++). Updated comment rules for Zsh, Fish, Lua, and YAML. Removed HTML and Markdown from no-highlight fallback list.
Tool Content Utilities
src/renderer/utils/toolRendering/toolContentChecks.ts, src/renderer/utils/toolRendering/index.ts
Added hasBashContent() helper to detect Bash command presence in linked tools and re-exported it.

Possibly related PRs

  • #112: Modifies the same chat linked-tool UI components (CollapsibleOutputSection, DefaultToolViewer, ReadToolViewer, WriteToolViewer) for output display refactoring.
  • #76: Expands syntax highlighting by adding language keyword sets and comment rules in syntaxHighlighter.ts.
  • #99: Modifies LinkedToolItem component rendering and props handling.

Suggested labels

bug

✨ Finishing Touches
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix/renderer-memory-leak

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (5)
src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx (1)

31-57: Consider extracting shared markdown toggle component.

The toggle button UI (lines 31-57) is nearly identical to ReadToolViewer.tsx (lines 62-88). Consider extracting a shared MarkdownToggle component to reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx` around
lines 31 - 57, Extract the duplicated markdown toggle UI into a shared component
(e.g., MarkdownToggle) and use it from both WriteToolViewer and ReadToolViewer:
identify the repeated JSX block that reads/writes viewMode and setViewMode in
WriteToolViewer.tsx and the similar block in ReadToolViewer.tsx, create a new
MarkdownToggle component that accepts props {viewMode, setViewMode} (or onChange
and value) and encapsulates the two buttons and their styling/behavior, then
replace the in-file JSX in both WriteToolViewer and ReadToolViewer with
<MarkdownToggle .../> calls passing the existing viewMode and setViewMode
handlers.
src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx (2)

10-17: Keep the @renderer import block above the relative imports.

This new file currently breaks the repo ordering rule by placing @renderer/types/groups after ../ and ./ imports.

As per coding guidelines, "Organize imports in order: external packages, path aliases (@main, @renderer, @shared, @preload), then relative imports".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx` around
lines 10 - 17, The import ordering in BashToolViewer.tsx is incorrect: move all
`@renderer` path-alias imports (e.g., the LinkedToolItem import from
'@renderer/types/groups' and CodeBlockViewer from
'@renderer/components/chat/viewers') above the relative imports (../BaseItem,
./CollapsibleOutputSection, ./renderHelpers) so the file follows the rule
"external packages, path aliases (`@renderer`, etc.), then relative imports";
update the import block so CodeBlockViewer and LinkedToolItem appear before
ItemStatus, CollapsibleOutputSection, and renderOutput.

24-26: Implement a type guard for Bash tool input to validate string types before casting.

The upstream guard hasBashContent() only checks truthiness of input.command, not whether it's actually a string. A malformed payload with command: 123 or command: {} would pass the guard and then be unsafely cast in BashToolViewer. Following the isXxx naming convention from the coding guidelines, add an isBashToolInput() guard in toolContentChecks.ts that validates both command and description are strings or undefined, then use it in BashToolViewer instead of raw as string casts.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx` around
lines 24 - 26, The BashToolViewer currently casts
linkedTool.input.command/description unsafely (using as string) and relies on
hasBashContent(), which only checks truthiness; add an isBashToolInput() type
guard in toolContentChecks.ts that returns true only when input.command is a
string and input.description is either a string or undefined, then update
BashToolViewer to call isBashToolInput(linkedTool.input) before reading values
and avoid direct casts (handle the invalid case by returning null or a safe
fallback UI). Ensure names match the isXxx convention and reference
isBashToolInput, hasBashContent, BashToolViewer, and
linkedTool.input.command/description when making changes.
src/main/index.ts (1)

487-495: Consider mirroring SESSION_REFRESH in the local constants block.

This file already duplicates preload channel names locally to avoid a main→preload import. Inlining 'session:refresh' at Line 494 makes that one channel easier to drift during a future rename.

🧹 Small cleanup
 const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
+const SESSION_REFRESH = 'session:refresh';
 ...
-      mainWindow.webContents.send('session:refresh');
+      mainWindow.webContents.send(SESSION_REFRESH);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/index.ts` around lines 487 - 495, Replace the inlined channel string
'session:refresh' with a local constant declared alongside the other preload
channel names and use that constant in mainWindow.webContents.send;
specifically, add a constant (e.g., SESSION_REFRESH) in the local constants
block where other channel names are duplicated and change the send call from
mainWindow.webContents.send('session:refresh') to
mainWindow.webContents.send(SESSION_REFRESH) so the channel name mirrors the
existing constants and avoids drift on renames.
src/renderer/store/slices/sessionDetailSlice.ts (1)

407-423: Consider using getConversationExpansionResetState() for consistency.

The inline reset of expansion Maps/Sets duplicates the logic in getConversationExpansionResetState(). If the helper is updated (e.g., new expansion structures added), this inline version could drift out of sync.

♻️ Proposed refactor to use the helper
+import { getConversationExpansionResetState } from '../utils/stateResetHelpers';
+
 // In fetchSessionDetail, around line 407:
-      // Clear expansion Maps/Sets from previous session to prevent unbounded growth over long uptime
-      set({
-        aiGroupExpansionLevels: new Map(),
-        expandedStepIds: new Set(),
-        expandedDisplayItemIds: new Map(),
-        expandedAIGroupIds: new Set(),
-        activeDetailItem: null,
+      set({
+        ...getConversationExpansionResetState(),
         sessionDetail: detail,
         sessionDetailLoading: false,
         // ... rest of the state
       });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/store/slices/sessionDetailSlice.ts` around lines 407 - 423, The
inline reset of expansion Maps/Sets duplicates logic in
getConversationExpansionResetState(); replace the explicit entries
(aiGroupExpansionLevels, expandedStepIds, expandedDisplayItemIds,
expandedAIGroupIds, activeDetailItem) inside the set({...}) call by calling
getConversationExpansionResetState() and spreading its return into the object so
the expansion reset remains centralized and future changes to expansion
structures only need to be made in getConversationExpansionResetState(); ensure
you still assign sessionDetail, sessionDetailLoading, conversation,
conversationLoading, visibleAIGroupId, selectedAIGroup, sessionClaudeMdStats,
sessionContextStats, and sessionPhaseInfo as before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/renderer/components/chat/ChatHistory.tsx`:
- Around line 380-387: The global 'session-refresh-scroll-bottom' listener in
ChatHistory subscribes across all mounted tabs and must be scoped per-tab:
change the useEffect handler in ChatHistory to accept the CustomEvent, read
event.detail.tabId, and only call scrollToBottom('smooth') when that tabId
matches this ChatHistory's tab identifier (e.g., the prop/state holding this
tab's id); also update refresh dispatch sites to send the starting tab id via
window.dispatchEvent(new CustomEvent('session-refresh-scroll-bottom', { detail:
{ tabId: activeTabId } })). Ensure references to scrollToBottom, ChatHistory,
window.addEventListener, and CustomEvent.detail are used so the handler ignores
events for other tabs.

In `@src/renderer/components/chat/items/LinkedToolItem.tsx`:
- Around line 149-150: LinkedToolItem.tsx is passing variant={toolVariant} to
BaseItem but BaseItemProps (in BaseItem.tsx) doesn't declare a variant prop; add
a variant field to the BaseItemProps interface (e.g., variant?: StepVariant) and
update BaseItem component's props type to accept and forward that prop (use the
existing StepVariant type or import it where BaseItem is declared) so BaseItem
can use variant for styling; ensure any usages of BaseItem (including
LinkedToolItem) still type-check and adjust default behavior in BaseItem if
variant is optional.

In `@src/renderer/components/chat/viewers/syntaxHighlighter.ts`:
- Around line 924-929: The current comment detection in the if block that checks
(language === ... ) && remaining.startsWith('#') is too naive; update the check
around the variables language and remaining (the comment-start branch in
syntaxHighlighter.ts) to only treat '#' as a comment when it is at the start of
the token or is preceded by a boundary (e.g., start-of-string or whitespace or
an operator/punctuation like = : , ; ( [ { | & >) and not when embedded in
words/variable expansions or URLs; implement this by replacing
remaining.startsWith('#') with a context-aware check (e.g., a regex or explicit
lookbehind/peek at the preceding character) that excludes cases like `${`#var`}`,
`${var#prefix}` and `http://...#frag` while still matching real comments in
shell/YAML.

In `@src/renderer/store/slices/sessionSlice.ts`:
- Around line 260-269: The pruning logic using projectRefreshGeneration and
per-project generation can evict a just-started refresh because generation is
not a global recency metric; modify the pruning to preserve recency correctly by
either (A) tracking last-touch order separately (e.g., maintain a lastTouched
map or timestamp per key and sort by that when pruning) or (B) explicitly never
prune the current/in-flight key (detect the in-flight project id and always keep
it) instead of sorting by generation; apply the same change to the identical
helper in src/renderer/store/slices/sessionDetailSlice.ts so both slices use
last-touch timestamps or honor the in-flight key when pruning.

---

Nitpick comments:
In `@src/main/index.ts`:
- Around line 487-495: Replace the inlined channel string 'session:refresh' with
a local constant declared alongside the other preload channel names and use that
constant in mainWindow.webContents.send; specifically, add a constant (e.g.,
SESSION_REFRESH) in the local constants block where other channel names are
duplicated and change the send call from
mainWindow.webContents.send('session:refresh') to
mainWindow.webContents.send(SESSION_REFRESH) so the channel name mirrors the
existing constants and avoids drift on renames.

In `@src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx`:
- Around line 10-17: The import ordering in BashToolViewer.tsx is incorrect:
move all `@renderer` path-alias imports (e.g., the LinkedToolItem import from
'@renderer/types/groups' and CodeBlockViewer from
'@renderer/components/chat/viewers') above the relative imports (../BaseItem,
./CollapsibleOutputSection, ./renderHelpers) so the file follows the rule
"external packages, path aliases (`@renderer`, etc.), then relative imports";
update the import block so CodeBlockViewer and LinkedToolItem appear before
ItemStatus, CollapsibleOutputSection, and renderOutput.
- Around line 24-26: The BashToolViewer currently casts
linkedTool.input.command/description unsafely (using as string) and relies on
hasBashContent(), which only checks truthiness; add an isBashToolInput() type
guard in toolContentChecks.ts that returns true only when input.command is a
string and input.description is either a string or undefined, then update
BashToolViewer to call isBashToolInput(linkedTool.input) before reading values
and avoid direct casts (handle the invalid case by returning null or a safe
fallback UI). Ensure names match the isXxx convention and reference
isBashToolInput, hasBashContent, BashToolViewer, and
linkedTool.input.command/description when making changes.

In `@src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx`:
- Around line 31-57: Extract the duplicated markdown toggle UI into a shared
component (e.g., MarkdownToggle) and use it from both WriteToolViewer and
ReadToolViewer: identify the repeated JSX block that reads/writes viewMode and
setViewMode in WriteToolViewer.tsx and the similar block in ReadToolViewer.tsx,
create a new MarkdownToggle component that accepts props {viewMode, setViewMode}
(or onChange and value) and encapsulates the two buttons and their
styling/behavior, then replace the in-file JSX in both WriteToolViewer and
ReadToolViewer with <MarkdownToggle .../> calls passing the existing viewMode
and setViewMode handlers.

In `@src/renderer/store/slices/sessionDetailSlice.ts`:
- Around line 407-423: The inline reset of expansion Maps/Sets duplicates logic
in getConversationExpansionResetState(); replace the explicit entries
(aiGroupExpansionLevels, expandedStepIds, expandedDisplayItemIds,
expandedAIGroupIds, activeDetailItem) inside the set({...}) call by calling
getConversationExpansionResetState() and spreading its return into the object so
the expansion reset remains centralized and future changes to expansion
structures only need to be made in getConversationExpansionResetState(); ensure
you still assign sessionDetail, sessionDetailLoading, conversation,
conversationLoading, visibleAIGroupId, selectedAIGroup, sessionClaudeMdStats,
sessionContextStats, and sessionPhaseInfo as before.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c00556c9-a8c2-46f4-8f34-0af83d41001e

📥 Commits

Reviewing files that changed from the base of the PR and between c0708bb and 106665c.

📒 Files selected for processing (23)
  • src/main/index.ts
  • src/preload/constants/ipcChannels.ts
  • src/preload/index.ts
  • src/renderer/api/httpClient.ts
  • src/renderer/components/chat/ChatHistory.tsx
  • src/renderer/components/chat/items/LinkedToolItem.tsx
  • src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx
  • src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx
  • src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx
  • src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx
  • src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx
  • src/renderer/components/chat/items/linkedTool/index.ts
  • src/renderer/components/chat/viewers/syntaxHighlighter.ts
  • src/renderer/components/layout/TabBar.tsx
  • src/renderer/hooks/useKeyboardShortcuts.ts
  • src/renderer/store/index.ts
  • src/renderer/store/slices/sessionDetailSlice.ts
  • src/renderer/store/slices/sessionSlice.ts
  • src/renderer/store/slices/tabSlice.ts
  • src/renderer/store/utils/stateResetHelpers.ts
  • src/renderer/utils/toolRendering/index.ts
  • src/renderer/utils/toolRendering/toolContentChecks.ts
  • src/shared/types/api.ts

Comment on lines +380 to +387
// Listen for session-refresh-scroll-bottom events (from Ctrl+R / refresh button)
useEffect(() => {
const handler = (): void => {
scrollToBottom('smooth');
};
window.addEventListener('session-refresh-scroll-bottom', handler);
return () => window.removeEventListener('session-refresh-scroll-bottom', handler);
}, [scrollToBottom]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Scope this scroll event to a single tab.

Inactive session tabs stay mounted, so every ChatHistory instance subscribes to this global event. Refreshing one tab will scroll background tabs to bottom too, and if the active tab changes before the async refresh finishes, the wrong tab can jump. Pass the refreshed tabId in CustomEvent.detail and ignore events for other tabs here.

🎯 Suggested fix
  useEffect(() => {
-    const handler = (): void => {
+    const handler = (event: Event): void => {
+      const { tabId: targetTabId } =
+        (event as CustomEvent<{ tabId?: string }>).detail ?? {};
+      if (targetTabId !== effectiveTabId) return;
       scrollToBottom('smooth');
     };
     window.addEventListener('session-refresh-scroll-bottom', handler);
     return () => window.removeEventListener('session-refresh-scroll-bottom', handler);
-  }, [scrollToBottom]);
+  }, [effectiveTabId, scrollToBottom]);

Update the refresh call sites to dispatch the tab id captured when the refresh starts:

window.dispatchEvent(
  new CustomEvent('session-refresh-scroll-bottom', { detail: { tabId: activeTabId } })
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/ChatHistory.tsx` around lines 380 - 387, The
global 'session-refresh-scroll-bottom' listener in ChatHistory subscribes across
all mounted tabs and must be scoped per-tab: change the useEffect handler in
ChatHistory to accept the CustomEvent, read event.detail.tabId, and only call
scrollToBottom('smooth') when that tabId matches this ChatHistory's tab
identifier (e.g., the prop/state holding this tab's id); also update refresh
dispatch sites to send the starting tab id via window.dispatchEvent(new
CustomEvent('session-refresh-scroll-bottom', { detail: { tabId: activeTabId }
})). Ensure references to scrollToBottom, ChatHistory, window.addEventListener,
and CustomEvent.detail are used so the handler ignores events for other tabs.

Comment on lines +149 to +150
// Determine step variant for colored borders/icons
const toolVariant: StepVariant = status === 'error' ? 'tool-error' : 'tool';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify BaseItemProps interface definition
echo "=== Checking BaseItemProps interface ==="
rg -n -A 30 'interface BaseItemProps' --type=ts

echo ""
echo "=== Checking if variant prop exists anywhere in BaseItem ==="
rg -n 'variant' src/renderer/components/chat/items/BaseItem.tsx

Repository: matt1398/claude-devtools

Length of output: 2809


🏁 Script executed:

#!/bin/bash
# Verify LinkedToolItem.tsx around line 175
echo "=== Checking LinkedToolItem.tsx lines 170-180 ==="
sed -n '170,180p' src/renderer/components/chat/items/LinkedToolItem.tsx

echo ""
echo "=== Full BaseItem usage in LinkedToolItem.tsx ==="
rg -n -B 5 -A 5 'BaseItem' src/renderer/components/chat/items/LinkedToolItem.tsx | head -50

Repository: matt1398/claude-devtools

Length of output: 1585


variant prop is not defined on BaseItemProps.

LinkedToolItem.tsx passes variant={toolVariant} to BaseItem (line 175), but the BaseItemProps interface in BaseItem.tsx does not accept a variant prop. This will cause a TypeScript error or the prop will be silently ignored at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/items/LinkedToolItem.tsx` around lines 149 -
150, LinkedToolItem.tsx is passing variant={toolVariant} to BaseItem but
BaseItemProps (in BaseItem.tsx) doesn't declare a variant prop; add a variant
field to the BaseItemProps interface (e.g., variant?: StepVariant) and update
BaseItem component's props type to accept and forward that prop (use the
existing StepVariant type or import it where BaseItem is declared) so BaseItem
can use variant for styling; ensure any usages of BaseItem (including
LinkedToolItem) still type-check and adjust default behavior in BaseItem if
variant is optional.

Comment on lines +924 to 929
// Check for comment (# style for Python/Shell/R/Ruby/PHP/YAML)
if (
(language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') &&
(language === 'python' || language === 'bash' || language === 'zsh' || language === 'fish' ||
language === 'r' || language === 'ruby' || language === 'php' || language === 'yaml') &&
remaining.startsWith('#')
) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the current rule in the file, then show representative inputs that
# would be classified as comments by the same predicate.
sed -n '924,929p' src/renderer/components/chat/viewers/syntaxHighlighter.ts

python <<'PY'
samples = [
    ('bash', 'echo ${`#var`}'),
    ('bash', 'echo ${var#prefix}'),
    ('yaml', 'url: http://example.com/#frag'),
]
for language, line in samples:
    for i in range(len(line)):
        if line[i:].startswith('#'):
            print(f"{language}: {line!r} -> current rule would start a comment at index {i}")
            break
PY

Repository: matt1398/claude-devtools

Length of output: 616


Hash comments are too eager for shell and YAML.

The # check treats any occurrence as a comment start, which mis-highlights valid syntax like echo ${#var}, echo ${var#prefix}, and YAML URLs like http://example.com/#frag. Add context-aware boundary checks (e.g., preceded by whitespace or operator) before treating # as a comment start.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/components/chat/viewers/syntaxHighlighter.ts` around lines 924 -
929, The current comment detection in the if block that checks (language === ...
) && remaining.startsWith('#') is too naive; update the check around the
variables language and remaining (the comment-start branch in
syntaxHighlighter.ts) to only treat '#' as a comment when it is at the start of
the token or is preceded by a boundary (e.g., start-of-string or whitespace or
an operator/punctuation like = : , ; ( [ { | & >) and not when embedded in
words/variable expansions or URLs; implement this by replacing
remaining.startsWith('#') with a context-aware check (e.g., a regex or explicit
lookbehind/peek at the preceding character) that excludes cases like `${`#var`}`,
`${var#prefix}` and `http://...#frag` while still matching real comments in
shell/YAML.

Comment on lines +260 to +269
// Prune to prevent unbounded growth: when exceeding 100 entries, keep only the 50 most recent
if (projectRefreshGeneration.size > 100) {
const entries = Array.from(projectRefreshGeneration.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 50);
projectRefreshGeneration.clear();
for (const [key, value] of entries) {
projectRefreshGeneration.set(key, value);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

This pruning can evict the refresh that just started.

generation is only per-project, so it does not represent global recency. After 101 projects have each refreshed once, the newly touched project still has generation 1 and can be pruned immediately here; then Line 280 reads undefined !== generation and drops its own fresh response. Please track last-touch order separately, or never prune the current/in-flight key, instead of sorting by generation count. The same helper in src/renderer/store/slices/sessionDetailSlice.ts should be updated too.

🧩 One recency-safe approach
+const projectRefreshTouchedAt = new Map<string, number>();
+let projectRefreshSequence = 0;
...
 const generation = (projectRefreshGeneration.get(projectId) ?? 0) + 1;
 projectRefreshGeneration.set(projectId, generation);
+projectRefreshTouchedAt.set(projectId, ++projectRefreshSequence);

 if (projectRefreshGeneration.size > 100) {
-  const entries = Array.from(projectRefreshGeneration.entries())
-    .sort((a, b) => b[1] - a[1])
-    .slice(0, 50);
-  projectRefreshGeneration.clear();
-  for (const [key, value] of entries) {
-    projectRefreshGeneration.set(key, value);
+  const staleKeys = Array.from(projectRefreshTouchedAt.entries())
+    .filter(([key]) => key !== projectId)
+    .sort((a, b) => a[1] - b[1])
+    .slice(0, projectRefreshGeneration.size - 50)
+    .map(([key]) => key);
+  for (const key of staleKeys) {
+    projectRefreshGeneration.delete(key);
+    projectRefreshTouchedAt.delete(key);
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/renderer/store/slices/sessionSlice.ts` around lines 260 - 269, The
pruning logic using projectRefreshGeneration and per-project generation can
evict a just-started refresh because generation is not a global recency metric;
modify the pruning to preserve recency correctly by either (A) tracking
last-touch order separately (e.g., maintain a lastTouched map or timestamp per
key and sort by that when pruning) or (B) explicitly never prune the
current/in-flight key (detect the in-flight project id and always keep it)
instead of sorting by generation; apply the same change to the identical helper
in src/renderer/store/slices/sessionDetailSlice.ts so both slices use last-touch
timestamps or honor the in-flight key when pruning.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant