From bfaa64df6b5179c3fe0d1bdff75be2f4f768bf21 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 19 Oct 2025 13:33:23 -0500 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=A4=96=20Persist=20FileTree=20expand/?= =?UTF-8?q?collapse=20state=20per=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use existing usePersistedState pattern to remember directory expand/collapse preferences across workspace switches. State is stored in localStorage with workspace-scoped key 'fileTreeExpandState:{workspaceId}'. Implementation follows the same pattern as hunk expand state (PR #332): - Store expand state as Record keyed by node path - Default to expanded for first 2 levels if no manual state exists - Use { listener: true } for cross-component synchronization - State is copied on workspace fork and deleted on workspace removal Benefits: - Users don't need to re-expand folders after switching workspaces - Reduces cognitive load when navigating between workspaces - Consistent UX with hunk expand/collapse behavior --- .../RightSidebar/CodeReview/FileTree.tsx | 37 +++++++++++++++++-- .../RightSidebar/CodeReview/ReviewPanel.tsx | 1 + src/constants/storage.ts | 12 +++++- 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index 89f07f03c..05dd6186e 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -2,9 +2,11 @@ * FileTree - Displays file hierarchy with diff statistics */ -import React, { useState } from "react"; +import React from "react"; import styled from "@emotion/styled"; import type { FileTreeNode } from "@/utils/git/numstatParser"; +import { usePersistedState } from "@/hooks/usePersistedState"; +import { getFileTreeExpandStateKey } from "@/constants/storage"; const TreeContainer = styled.div` flex: 1; @@ -194,8 +196,33 @@ const TreeNodeContent: React.FC<{ onSelectFile: (path: string | null) => void; commonPrefix: string | null; getFileReadStatus?: (filePath: string) => { total: number; read: number } | null; -}> = ({ node, depth, selectedPath, onSelectFile, commonPrefix, getFileReadStatus }) => { - const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels + workspaceId: string; +}> = ({ + node, + depth, + selectedPath, + onSelectFile, + commonPrefix, + getFileReadStatus, + workspaceId, +}) => { + // Use persisted state for expand/collapse per workspace + const [expandStateMap, setExpandStateMap] = usePersistedState>( + getFileTreeExpandStateKey(workspaceId), + {}, + { listener: true } + ); + + // Check if user has manually set expand state for this directory + const hasManualState = node.path in expandStateMap; + const isOpen = hasManualState ? expandStateMap[node.path] : depth < 2; // Default: auto-expand first 2 levels + + const setIsOpen = (open: boolean) => { + setExpandStateMap((prev) => ({ + ...prev, + [node.path]: open, + })); + }; const handleClick = (e: React.MouseEvent) => { if (node.isDirectory) { @@ -295,6 +322,7 @@ const TreeNodeContent: React.FC<{ onSelectFile={onSelectFile} commonPrefix={commonPrefix} getFileReadStatus={getFileReadStatus} + workspaceId={workspaceId} /> ))} @@ -308,6 +336,7 @@ interface FileTreeExternalProps { isLoading?: boolean; commonPrefix?: string | null; getFileReadStatus?: (filePath: string) => { total: number; read: number } | null; + workspaceId: string; } export const FileTree: React.FC = ({ @@ -317,6 +346,7 @@ export const FileTree: React.FC = ({ isLoading = false, commonPrefix = null, getFileReadStatus, + workspaceId, }) => { // Find the node at the common prefix path to start rendering from const startNode = React.useMemo(() => { @@ -355,6 +385,7 @@ export const FileTree: React.FC = ({ onSelectFile={onSelectFile} commonPrefix={commonPrefix} getFileReadStatus={getFileReadStatus} + workspaceId={workspaceId} /> )) ) : ( diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index d02bb2f0e..01c62a578 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -735,6 +735,7 @@ export const ReviewPanel: React.FC = ({ isLoading={isLoadingTree} commonPrefix={commonPrefix} getFileReadStatus={getFileReadStatus} + workspaceId={workspaceId} /> )} diff --git a/src/constants/storage.ts b/src/constants/storage.ts index 7873f7eb7..2173f6b22 100644 --- a/src/constants/storage.ts +++ b/src/constants/storage.ts @@ -93,6 +93,15 @@ export function getReviewExpandStateKey(workspaceId: string): string { return `reviewExpandState:${workspaceId}`; } +/** + * Get the localStorage key for FileTree expand/collapse state in Review tab + * Stores directory expand/collapse preferences per workspace + * Format: "fileTreeExpandState:{workspaceId}" + */ +export function getFileTreeExpandStateKey(workspaceId: string): string { + return `fileTreeExpandState:${workspaceId}`; +} + /** * List of workspace-scoped key functions that should be copied on fork and deleted on removal * Note: Excludes ephemeral keys like getCompactContinueMessageKey @@ -105,6 +114,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getAutoRetryKey, getRetryStateKey, getReviewExpandStateKey, + getFileTreeExpandStateKey, ]; /** @@ -117,7 +127,7 @@ const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> /** * Copy all workspace-specific localStorage keys from source to destination workspace - * This includes: model, input, mode, thinking level, auto-retry, retry state, review expand state + * This includes: model, input, mode, thinking level, auto-retry, retry state, review expand state, file tree expand state */ export function copyWorkspaceStorage(sourceWorkspaceId: string, destWorkspaceId: string): void { for (const getKey of PERSISTENT_WORKSPACE_KEY_FUNCTIONS) { From d43933879bd5f77ade7e98aeb84d933a7f8c9abe Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 19 Oct 2025 13:38:35 -0500 Subject: [PATCH 2/4] Fix: Lift expand state to FileTree to avoid O(n) re-renders Previously every TreeNodeContent subscribed to the same storage key, causing O(total nodes) re-renders on each toggle. Now FileTree owns the persisted state and passes it down, so only affected branches re-render on expand/collapse. Addresses Codex P1 review comment. --- .../RightSidebar/CodeReview/FileTree.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index 05dd6186e..bec1c2435 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -196,7 +196,10 @@ const TreeNodeContent: React.FC<{ onSelectFile: (path: string | null) => void; commonPrefix: string | null; getFileReadStatus?: (filePath: string) => { total: number; read: number } | null; - workspaceId: string; + expandStateMap: Record; + setExpandStateMap: ( + value: Record | ((prev: Record) => Record) + ) => void; }> = ({ node, depth, @@ -204,15 +207,9 @@ const TreeNodeContent: React.FC<{ onSelectFile, commonPrefix, getFileReadStatus, - workspaceId, + expandStateMap, + setExpandStateMap, }) => { - // Use persisted state for expand/collapse per workspace - const [expandStateMap, setExpandStateMap] = usePersistedState>( - getFileTreeExpandStateKey(workspaceId), - {}, - { listener: true } - ); - // Check if user has manually set expand state for this directory const hasManualState = node.path in expandStateMap; const isOpen = hasManualState ? expandStateMap[node.path] : depth < 2; // Default: auto-expand first 2 levels @@ -322,7 +319,8 @@ const TreeNodeContent: React.FC<{ onSelectFile={onSelectFile} commonPrefix={commonPrefix} getFileReadStatus={getFileReadStatus} - workspaceId={workspaceId} + expandStateMap={expandStateMap} + setExpandStateMap={setExpandStateMap} /> ))} @@ -348,6 +346,13 @@ export const FileTree: React.FC = ({ getFileReadStatus, workspaceId, }) => { + // Use persisted state for expand/collapse per workspace (lifted to parent to avoid O(n) re-renders) + const [expandStateMap, setExpandStateMap] = usePersistedState>( + getFileTreeExpandStateKey(workspaceId), + {}, + { listener: true } + ); + // Find the node at the common prefix path to start rendering from const startNode = React.useMemo(() => { if (!commonPrefix || !root) return root; @@ -385,7 +390,8 @@ export const FileTree: React.FC = ({ onSelectFile={onSelectFile} commonPrefix={commonPrefix} getFileReadStatus={getFileReadStatus} - workspaceId={workspaceId} + expandStateMap={expandStateMap} + setExpandStateMap={setExpandStateMap} /> )) ) : ( From 56a90ab7e34394badf1ef331984b9f91fd70dd13 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 19 Oct 2025 13:45:31 -0500 Subject: [PATCH 3/4] Fix double background in diff display Removed redundant background from DiffContainer. The individual diff lines (DiffLineWrapper) already have appropriate backgrounds for add/remove lines, so the container background was creating a double-layer grey effect. Also added 'background: transparent' to pre/code elements in prism-syntax.css and updated the generation script for future regenerations. Resolves double-grey background issue in FileEditToolCall diffs. --- scripts/generate_prism_css.ts | 16 +++++++++++----- src/components/shared/DiffRenderer.tsx | 1 - src/styles/prism-syntax.css | 1 + 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/scripts/generate_prism_css.ts b/scripts/generate_prism_css.ts index d95ea9366..9ac077d33 100755 --- a/scripts/generate_prism_css.ts +++ b/scripts/generate_prism_css.ts @@ -25,8 +25,8 @@ for (const [key, value] of Object.entries(vscDarkPlus as Record } // Convert CSS properties object to CSS string -function cssPropertiesToString(props: CSSProperties): string { - return Object.entries(props) +function cssPropertiesToString(props: CSSProperties, selector: string): string { + const entries = Object.entries(props) .filter(([key]) => { // Skip font-family and font-size - we want to inherit these return key !== "fontFamily" && key !== "fontSize"; @@ -35,8 +35,14 @@ function cssPropertiesToString(props: CSSProperties): string { // Convert camelCase to kebab-case const cssKey = key.replace(/([A-Z])/g, "-$1").toLowerCase(); return ` ${cssKey}: ${value};`; - }) - .join("\n"); + }); + + // Add background: transparent to pre/code elements to prevent double backgrounds + if (selector.startsWith("pre") || selector.startsWith("code")) { + entries.push(" background: transparent;"); + } + + return entries.join("\n"); } // Generate CSS content @@ -55,7 +61,7 @@ function generateCSS(): string { ]; for (const [selector, props] of Object.entries(syntaxStyleNoBackgrounds)) { - const cssRules = cssPropertiesToString(props); + const cssRules = cssPropertiesToString(props, selector); if (cssRules.trim().length > 0) { // Handle selectors that need .token prefix let cssSelector = selector; diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index db5e89a1f..121a8892a 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -111,7 +111,6 @@ export const DiffIndicator = styled.span<{ type: DiffLineType }>` export const DiffContainer = styled.div<{ fontSize?: string; maxHeight?: string }>` margin: 0; padding: 6px 0; - background: rgba(0, 0, 0, 0.2); border-radius: 3px; font-size: ${({ fontSize }) => fontSize ?? "12px"}; line-height: 1.4; diff --git a/src/styles/prism-syntax.css b/src/styles/prism-syntax.css index ee31f9fbe..8c53b6e51 100644 --- a/src/styles/prism-syntax.css +++ b/src/styles/prism-syntax.css @@ -27,6 +27,7 @@ pre[class*="language-"] { padding: 1em; margin: .5em 0; overflow: auto; + background: transparent; } code[class*="language-"] { From 0fcc420b246dd894ca2edc4e5431320ad2bf2abb Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 19 Oct 2025 13:52:54 -0500 Subject: [PATCH 4/4] Centralize code background color as CSS variable Added --color-code-bg variable (hsl(0deg 6.43% 8.04%)) in colors.tsx and replaced all hardcoded rgba(0, 0, 0, 0.2) code backgrounds with it. Ensures consistent appearance across: - DiffContainer (file edits, review diffs) - HunkContent (review panel hunks) - Tool call displays (file read, bash, etc.) - Markdown code blocks - Mermaid diagrams Single source of truth prevents inconsistent shades of grey. --- src/components/Messages/AssistantMessage.tsx | 2 +- src/components/Messages/MarkdownComponents.tsx | 2 +- src/components/Messages/Mermaid.tsx | 6 +++--- .../RightSidebar/CodeReview/HunkViewer.tsx | 2 +- src/components/shared/DiffRenderer.tsx | 1 + src/components/tools/BashToolCall.tsx | 2 +- src/components/tools/FileReadToolCall.tsx | 4 ++-- src/components/tools/ProposePlanToolCall.tsx | 2 +- src/components/tools/shared/ToolPrimitives.tsx | 2 +- src/styles/colors.tsx | 5 +++++ src/styles/prism-syntax.css | 14 +++++++------- 11 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/components/Messages/AssistantMessage.tsx b/src/components/Messages/AssistantMessage.tsx index 831f382de..8e3f9af0d 100644 --- a/src/components/Messages/AssistantMessage.tsx +++ b/src/components/Messages/AssistantMessage.tsx @@ -21,7 +21,7 @@ const RawContent = styled.pre` word-break: break-word; margin: 0; padding: 8px; - background: rgba(0, 0, 0, 0.2); + background: var(--color-code-bg); border-radius: 3px; `; diff --git a/src/components/Messages/MarkdownComponents.tsx b/src/components/Messages/MarkdownComponents.tsx index e38ab9490..af5079bd2 100644 --- a/src/components/Messages/MarkdownComponents.tsx +++ b/src/components/Messages/MarkdownComponents.tsx @@ -38,7 +38,7 @@ export const markdownComponents = { padding: "0.25em 0.5em", border: "1px solid rgba(255, 255, 255, 0.1)", borderRadius: "4px", - background: "rgba(0, 0, 0, 0.2)", + background: "var(--color-code-bg)", }} > {children} diff --git a/src/components/Messages/Mermaid.tsx b/src/components/Messages/Mermaid.tsx index 7b418eb57..39ffc2d19 100644 --- a/src/components/Messages/Mermaid.tsx +++ b/src/components/Messages/Mermaid.tsx @@ -165,7 +165,7 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => {
= ({ chart }) => { style={{ position: "relative", margin: "1em 0", - background: "rgba(0, 0, 0, 0.2)", + background: "var(--color-code-bg)", borderRadius: "4px", padding: "16px", }} @@ -243,7 +243,7 @@ export const Mermaid: React.FC<{ chart: string }> = ({ chart }) => { ref={modalContainerRef} className="mermaid-container mermaid-modal" style={{ - background: "rgba(0, 0, 0, 0.2)", + background: "var(--color-code-bg)", padding: "24px", borderRadius: "8px", minWidth: "80vw", diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index d1f1c6774..85e48b59c 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -107,7 +107,7 @@ const HunkContent = styled.div` font-size: 11px; line-height: 1.4; overflow-x: auto; - background: rgba(0, 0, 0, 0.2); + background: var(--color-code-bg); /* CSS Grid ensures all diff lines span the same width (width of longest line) */ display: grid; diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 121a8892a..9f7e0134f 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -111,6 +111,7 @@ export const DiffIndicator = styled.span<{ type: DiffLineType }>` export const DiffContainer = styled.div<{ fontSize?: string; maxHeight?: string }>` margin: 0; padding: 6px 0; + background: var(--color-code-bg); border-radius: 3px; font-size: ${({ fontSize }) => fontSize ?? "12px"}; line-height: 1.4; diff --git a/src/components/tools/BashToolCall.tsx b/src/components/tools/BashToolCall.tsx index 265b2c26d..901d0235f 100644 --- a/src/components/tools/BashToolCall.tsx +++ b/src/components/tools/BashToolCall.tsx @@ -30,7 +30,7 @@ const ScriptPreview = styled.span` const OutputBlock = styled.pre` margin: 0; padding: 6px 8px; - background: rgba(0, 0, 0, 0.2); + background: var(--color-code-bg); border-radius: 3px; border-left: 2px solid #4caf50; font-size: 11px; diff --git a/src/components/tools/FileReadToolCall.tsx b/src/components/tools/FileReadToolCall.tsx index 53be4ce1e..3e3a99cde 100644 --- a/src/components/tools/FileReadToolCall.tsx +++ b/src/components/tools/FileReadToolCall.tsx @@ -35,7 +35,7 @@ const MetadataText = styled.span` const ContentBlock = styled.div` margin: 0; padding: 6px 8px; - background: rgba(0, 0, 0, 0.2); + background: var(--color-code-bg); border-radius: 3px; font-size: 11px; line-height: 1.4; @@ -79,7 +79,7 @@ const FileInfoRow = styled.div` flex-wrap: wrap; gap: 16px; padding: 6px 8px; - background: rgba(0, 0, 0, 0.2); + background: var(--color-code-bg); border-radius: 3px; font-size: 11px; line-height: 1.4; diff --git a/src/components/tools/ProposePlanToolCall.tsx b/src/components/tools/ProposePlanToolCall.tsx index 57b3fc89a..c2eb6082f 100644 --- a/src/components/tools/ProposePlanToolCall.tsx +++ b/src/components/tools/ProposePlanToolCall.tsx @@ -96,7 +96,7 @@ const RawContent = styled.pre` word-break: break-word; margin: 0; padding: 8px; - background: rgba(0, 0, 0, 0.2); + background: var(--color-code-bg); border-radius: 3px; `; diff --git a/src/components/tools/shared/ToolPrimitives.tsx b/src/components/tools/shared/ToolPrimitives.tsx index bea2793ea..deda8d39d 100644 --- a/src/components/tools/shared/ToolPrimitives.tsx +++ b/src/components/tools/shared/ToolPrimitives.tsx @@ -95,7 +95,7 @@ export const DetailLabel = styled.div` export const DetailContent = styled.pre` margin: 0; padding: 6px 8px; - background: rgba(0, 0, 0, 0.2); + background: var(--color-code-bg); border-radius: 3px; font-size: 11px; line-height: 1.4; diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index 5e45bdfba..2951f8cff 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -67,6 +67,11 @@ export const GlobalColors = () => ( --color-text: hsl(0 0% 83%); --color-text-secondary: hsl(0 0% 42%); + /* Code Block Background */ + --color-code-bg: hsl( + 0deg 6.43% 8.04% + ); /* Slightly darker than main background for code/diff containers */ + /* Button Colors */ --color-button-bg: hsl(0 0% 24%); --color-button-text: hsl(0 0% 80%); diff --git a/src/styles/prism-syntax.css b/src/styles/prism-syntax.css index 8c53b6e51..a0ebbf57d 100644 --- a/src/styles/prism-syntax.css +++ b/src/styles/prism-syntax.css @@ -25,7 +25,7 @@ pre[class*="language-"] { ms-hyphens: none; hyphens: none; padding: 1em; - margin: .5em 0; + margin: 0.5em 0; overflow: auto; background: transparent; } @@ -65,17 +65,17 @@ code[class*="language-"] *::selection { } :not(pre) > code[class*="language-"] { - padding: .1em .3em; - border-radius: .3em; + padding: 0.1em 0.3em; + border-radius: 0.3em; color: #db4c69; } .namespace { - -opacity: .7; + -opacity: 0.7; } doctype.doctype-tag { - color: #569CD6; + color: #569cd6; } doctype.name { @@ -171,7 +171,7 @@ doctype.name { } operator.arrow { - color: #569CD6; + color: #569cd6; } .token.atrule { @@ -195,7 +195,7 @@ atrule.url.punctuation { } .token.keyword { - color: #569CD6; + color: #569cd6; } keyword.module {