From 34c6dfac2f75487ddbf031790ad18db8b1d966c0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:16:48 -0500 Subject: [PATCH 01/26] =?UTF-8?q?=F0=9F=A4=96=20Implement=20read-only=20hu?= =?UTF-8?q?nk=20tracking=20with=20content-based=20IDs=20and=20LRU=20evicti?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Content-based hunk IDs: Hash file path + diff content for rebase stability - New useReviewState hook with simplified API (isRead, markAsRead, toggleRead) - LRU eviction: Automatically keeps newest 1024 read states per workspace - Visual indicators: Green checkmark and background tint for read hunks - Auto-collapse: Read hunks collapse by default - Filter toggle: Show/hide read hunks with per-workspace persistence - Keyboard shortcut: 'm' key toggles read state of selected hunk - Stats display: Shows 'X read / Y total' in review controls Generated with `cmux` --- .../RightSidebar/CodeReview/HunkViewer.tsx | 66 +++++- .../CodeReview/ReviewControls.tsx | 11 +- .../RightSidebar/CodeReview/ReviewPanel.tsx | 60 +++-- src/hooks/useReviewState.test.ts | 103 ++++++++ src/hooks/useReviewState.ts | 220 +++++++++--------- src/types/review.ts | 32 ++- src/utils/git/diffParser.ts | 18 +- src/utils/ui/keybinds.ts | 3 + 8 files changed, 351 insertions(+), 162 deletions(-) create mode 100644 src/hooks/useReviewState.test.ts diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index e5baff1d6..8383700ba 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -10,11 +10,13 @@ import { SelectableDiffRenderer } from "../../shared/DiffRenderer"; interface HunkViewerProps { hunk: DiffHunk; isSelected?: boolean; + isRead?: boolean; onClick?: () => void; + onToggleRead?: () => void; onReviewNote?: (note: string) => void; } -const HunkContainer = styled.div<{ isSelected: boolean }>` +const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` background: #1e1e1e; border: 1px solid #3e3e42; border-radius: 4px; @@ -23,6 +25,13 @@ const HunkContainer = styled.div<{ isSelected: boolean }>` cursor: pointer; transition: all 0.2s ease; + ${(props) => + props.isRead && + ` + background: rgba(74, 222, 128, 0.05); + border-color: rgba(74, 222, 128, 0.2); + `} + ${(props) => props.isSelected && ` @@ -44,6 +53,7 @@ const HunkHeader = styled.div` align-items: center; font-family: var(--font-monospace); font-size: 12px; + gap: 8px; `; const FilePath = styled.div` @@ -124,19 +134,64 @@ const RenameInfo = styled.div` } `; +const ReadIndicator = styled.span` + display: inline-flex; + align-items: center; + color: #4ade80; + font-size: 14px; + margin-right: 4px; +`; + +const ToggleReadButton = styled.button` + background: transparent; + border: 1px solid #3e3e42; + border-radius: 3px; + padding: 2px 6px; + color: #888; + font-size: 11px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 4px; + + &:hover { + background: rgba(255, 255, 255, 0.05); + border-color: #4ade80; + color: #4ade80; + } + + &:active { + transform: scale(0.95); + } +`; + export const HunkViewer: React.FC = ({ hunk, isSelected, + isRead = false, onClick, + onToggleRead, onReviewNote, }) => { - const [isExpanded, setIsExpanded] = useState(true); + // Collapse by default if marked as read + const [isExpanded, setIsExpanded] = useState(!isRead); + + // Update expanded state when read state changes + React.useEffect(() => { + setIsExpanded(!isRead); + }, [isRead]); const handleToggleExpand = (e: React.MouseEvent) => { e.stopPropagation(); setIsExpanded(!isExpanded); }; + const handleToggleRead = (e: React.MouseEvent) => { + e.stopPropagation(); + onToggleRead?.(); + }; + // Parse diff lines const diffLines = hunk.content.split("\n").filter((line) => line.length > 0); const lineCount = diffLines.length; @@ -153,6 +208,7 @@ export const HunkViewer: React.FC = ({ return ( = ({ }} > + {isRead && } {hunk.filePath} {!isPureRename && ( @@ -175,6 +232,11 @@ export const HunkViewer: React.FC = ({ ({lineCount} {lineCount === 1 ? "line" : "lines"}) + {onToggleRead && ( + + {isRead ? "○" : "◉"} + + )} diff --git a/src/components/RightSidebar/CodeReview/ReviewControls.tsx b/src/components/RightSidebar/CodeReview/ReviewControls.tsx index d7f6c2587..afe0192c4 100644 --- a/src/components/RightSidebar/CodeReview/ReviewControls.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewControls.tsx @@ -166,6 +166,10 @@ export const ReviewControls: React.FC = ({ onFiltersChange({ ...filters, includeDirty: e.target.checked }); }; + const handleShowReadToggle = (e: React.ChangeEvent) => { + onFiltersChange({ ...filters, showReadHunks: e.target.checked }); + }; + const handleSetDefault = () => { setDefaultBase(filters.diffBase); }; @@ -206,6 +210,11 @@ export const ReviewControls: React.FC = ({ Dirty + + + Show read + + = ({ - {stats.total} {stats.total === 1 ? "hunk" : "hunks"} + {stats.read} read / {stats.total} total ); diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index fda0ec552..24becd738 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -9,6 +9,7 @@ import { HunkViewer } from "./HunkViewer"; import { ReviewControls } from "./ReviewControls"; import { FileTree } from "./FileTree"; import { usePersistedState } from "@/hooks/usePersistedState"; +import { useReviewState } from "@/hooks/useReviewState"; import { parseDiff, extractAllHunks } from "@/utils/git/diffParser"; import { parseNumstat, @@ -301,9 +302,17 @@ export const ReviewPanel: React.FC = ({ false ); + // Persist showReadHunks flag per workspace + const [showReadHunks, setShowReadHunks] = usePersistedState( + `review-show-read:${workspaceId}`, + true + ); + + // Initialize review state hook + const reviewState = useReviewState(workspaceId); + const [filters, setFilters] = useState({ - showReviewed: true, - statusFilter: "all", + showReadHunks: showReadHunks, diffBase: diffBase, includeDirty: includeDirty, }); @@ -450,19 +459,29 @@ export const ReviewPanel: React.FC = ({ setIncludeDirty(filters.includeDirty); }, [filters.includeDirty, setIncludeDirty]); - // For MVP: No review state tracking, just show all hunks - const filteredHunks = hunks; - - // Simple stats for display - const stats = useMemo( - () => ({ - total: hunks.length, - accepted: 0, - rejected: 0, - unreviewed: hunks.length, - }), - [hunks] - ); + // Persist showReadHunks when it changes + useEffect(() => { + setShowReadHunks(filters.showReadHunks); + }, [filters.showReadHunks, setShowReadHunks]); + + // Filter hunks based on read state + const filteredHunks = useMemo(() => { + if (filters.showReadHunks) { + return hunks; + } + return hunks.filter((hunk) => !reviewState.isRead(hunk.id)); + }, [hunks, filters.showReadHunks, reviewState]); + + // Calculate stats + const stats = useMemo(() => { + const total = hunks.length; + const read = hunks.filter((h) => reviewState.isRead(h.id)).length; + return { + total, + read, + unread: total - read, + }; + }, [hunks, reviewState]); // Keyboard navigation (j/k or arrow keys) - only when panel is focused useEffect(() => { @@ -482,7 +501,7 @@ export const ReviewPanel: React.FC = ({ const currentIndex = filteredHunks.findIndex((h) => h.id === selectedHunkId); if (currentIndex === -1) return; - // Navigation only + // Navigation if (e.key === "j" || e.key === "ArrowDown") { e.preventDefault(); if (currentIndex < filteredHunks.length - 1) { @@ -493,12 +512,16 @@ export const ReviewPanel: React.FC = ({ if (currentIndex > 0) { setSelectedHunkId(filteredHunks[currentIndex - 1].id); } + } else if (matchesKeybind(e, KEYBINDS.TOGGLE_HUNK_READ)) { + // Toggle read state of selected hunk + e.preventDefault(); + reviewState.toggleRead(selectedHunkId); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isPanelFocused, selectedHunkId, filteredHunks]); + }, [isPanelFocused, selectedHunkId, filteredHunks, reviewState]); // Global keyboard shortcut for refresh (Ctrl+R / Cmd+R) useEffect(() => { @@ -586,13 +609,16 @@ export const ReviewPanel: React.FC = ({ ) : ( filteredHunks.map((hunk) => { const isSelected = hunk.id === selectedHunkId; + const isRead = reviewState.isRead(hunk.id); return ( setSelectedHunkId(hunk.id)} + onToggleRead={() => reviewState.toggleRead(hunk.id)} onReviewNote={onReviewNote} /> ); diff --git a/src/hooks/useReviewState.test.ts b/src/hooks/useReviewState.test.ts new file mode 100644 index 000000000..d5e1ad29f --- /dev/null +++ b/src/hooks/useReviewState.test.ts @@ -0,0 +1,103 @@ +/** + * Tests for useReviewState hook + * + * Note: Hook integration tests are omitted because they require jsdom setup. + * The eviction logic is the critical piece and is tested here. + * The hook itself is a thin wrapper around usePersistedState with manual testing. + */ + +import { describe, it, expect } from "bun:test"; +import type { HunkReadState } from "@/types/review"; + +/** + * Evict oldest read states if count exceeds max + * (Extracted from hook for testing) + */ +function evictOldestReviews( + readState: Record, + maxCount: number +): Record { + const entries = Object.entries(readState); + if (entries.length <= maxCount) return readState; + + // Sort by timestamp descending (newest first) + entries.sort((a, b) => b[1].timestamp - a[1].timestamp); + + // Keep only the newest maxCount + return Object.fromEntries(entries.slice(0, maxCount)); +} + +describe("evictOldestReviews", () => { + it("should not evict when under limit", () => { + const readState: Record = { + "hunk-1": { hunkId: "hunk-1", isRead: true, timestamp: 1 }, + "hunk-2": { hunkId: "hunk-2", isRead: true, timestamp: 2 }, + }; + + const result = evictOldestReviews(readState, 10); + expect(Object.keys(result).length).toBe(2); + }); + + it("should evict oldest entries when exceeding limit", () => { + const readState: Record = { + "hunk-1": { hunkId: "hunk-1", isRead: true, timestamp: 1 }, + "hunk-2": { hunkId: "hunk-2", isRead: true, timestamp: 2 }, + "hunk-3": { hunkId: "hunk-3", isRead: true, timestamp: 3 }, + "hunk-4": { hunkId: "hunk-4", isRead: true, timestamp: 4 }, + "hunk-5": { hunkId: "hunk-5", isRead: true, timestamp: 5 }, + }; + + const result = evictOldestReviews(readState, 3); + expect(Object.keys(result).length).toBe(3); + + // Should keep the newest 3 (timestamps 3, 4, 5) + expect(result["hunk-3"]).toBeDefined(); + expect(result["hunk-4"]).toBeDefined(); + expect(result["hunk-5"]).toBeDefined(); + + // Should evict oldest 2 (timestamps 1, 2) + expect(result["hunk-1"]).toBeUndefined(); + expect(result["hunk-2"]).toBeUndefined(); + }); + + it("should handle exactly at limit", () => { + const readState: Record = { + "hunk-1": { hunkId: "hunk-1", isRead: true, timestamp: 1 }, + "hunk-2": { hunkId: "hunk-2", isRead: true, timestamp: 2 }, + "hunk-3": { hunkId: "hunk-3", isRead: true, timestamp: 3 }, + }; + + const result = evictOldestReviews(readState, 3); + expect(Object.keys(result).length).toBe(3); + expect(result).toEqual(readState); + }); + + it("should handle empty state", () => { + const readState: Record = {}; + const result = evictOldestReviews(readState, 10); + expect(Object.keys(result).length).toBe(0); + }); + + it("should evict to exact limit with many entries", () => { + const readState: Record = {}; + // Create 1100 entries + for (let i = 0; i < 1100; i++) { + readState[`hunk-${i}`] = { + hunkId: `hunk-${i}`, + isRead: true, + timestamp: i, + }; + } + + const result = evictOldestReviews(readState, 1024); + expect(Object.keys(result).length).toBe(1024); + + // Should keep newest 1024 (timestamps 76-1099) + expect(result["hunk-1099"]).toBeDefined(); + expect(result["hunk-76"]).toBeDefined(); + + // Should evict oldest (timestamps 0-75) + expect(result["hunk-0"]).toBeUndefined(); + expect(result["hunk-75"]).toBeUndefined(); + }); +}); diff --git a/src/hooks/useReviewState.ts b/src/hooks/useReviewState.ts index 22ffe1182..aa09e2a34 100644 --- a/src/hooks/useReviewState.ts +++ b/src/hooks/useReviewState.ts @@ -1,56 +1,109 @@ /** - * Hook for managing code review state - * Provides interface for reading/updating hunk reviews with localStorage persistence + * Hook for managing hunk read state + * Provides interface for tracking which hunks have been reviewed with localStorage persistence */ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useEffect, useState } from "react"; import { usePersistedState } from "./usePersistedState"; -import type { ReviewState, HunkReview, ReviewStats, DiffHunk } from "@/types/review"; +import type { ReviewState, HunkReadState } from "@/types/review"; + +/** + * Maximum number of read states to keep per workspace (LRU eviction) + */ +const MAX_READ_STATES = 1024; /** * Get the localStorage key for review state */ function getReviewStateKey(workspaceId: string): string { - return `code-review:${workspaceId}`; + return `review-state:${workspaceId}`; } /** - * Hook for managing code review state for a workspace - * Persists reviews to localStorage and provides helpers for common operations + * Evict oldest read states if count exceeds max + * Keeps the newest MAX_READ_STATES entries */ -export function useReviewState(workspaceId: string) { +function evictOldestReviews( + readState: Record, + maxCount: number +): Record { + const entries = Object.entries(readState); + if (entries.length <= maxCount) return readState; + + // Sort by timestamp descending (newest first) + entries.sort((a, b) => b[1].timestamp - a[1].timestamp); + + // Keep only the newest maxCount + return Object.fromEntries(entries.slice(0, maxCount)); +} + +export interface UseReviewStateReturn { + /** Check if a hunk is marked as read */ + isRead: (hunkId: string) => boolean; + /** Mark a hunk as read */ + markAsRead: (hunkId: string) => void; + /** Mark a hunk as unread */ + markAsUnread: (hunkId: string) => void; + /** Toggle read state of a hunk */ + toggleRead: (hunkId: string) => void; + /** Clear all read states */ + clearAll: () => void; + /** Number of hunks marked as read */ + readCount: number; +} + +/** + * Hook for managing hunk read state for a workspace + * Persists read states to localStorage with automatic LRU eviction + */ +export function useReviewState(workspaceId: string): UseReviewStateReturn { const [reviewState, setReviewState] = usePersistedState( getReviewStateKey(workspaceId), { workspaceId, - reviews: {}, + readState: {}, lastUpdated: Date.now(), } ); + // Apply LRU eviction on initial load + const [hasAppliedEviction, setHasAppliedEviction] = useState(false); + useEffect(() => { + if (!hasAppliedEviction) { + setHasAppliedEviction(true); + const evicted = evictOldestReviews(reviewState.readState, MAX_READ_STATES); + if (Object.keys(evicted).length !== Object.keys(reviewState.readState).length) { + setReviewState((prev) => ({ + ...prev, + readState: evicted, + lastUpdated: Date.now(), + })); + } + } + }, [hasAppliedEviction, reviewState.readState, setReviewState]); + /** - * Get review for a specific hunk + * Check if a hunk is marked as read */ - const getReview = useCallback( - (hunkId: string): HunkReview | undefined => { - return reviewState.reviews[hunkId]; + const isRead = useCallback( + (hunkId: string): boolean => { + return reviewState.readState[hunkId]?.isRead ?? false; }, - [reviewState.reviews] + [reviewState.readState] ); /** - * Set or update a review for a hunk + * Mark a hunk as read */ - const setReview = useCallback( - (hunkId: string, status: "accepted" | "rejected", note?: string) => { + const markAsRead = useCallback( + (hunkId: string) => { setReviewState((prev) => ({ ...prev, - reviews: { - ...prev.reviews, + readState: { + ...prev.readState, [hunkId]: { hunkId, - status, - note, + isRead: true, timestamp: Date.now(), }, }, @@ -61,15 +114,15 @@ export function useReviewState(workspaceId: string) { ); /** - * Delete a review for a hunk + * Mark a hunk as unread */ - const deleteReview = useCallback( + const markAsUnread = useCallback( (hunkId: string) => { setReviewState((prev) => { - const { [hunkId]: _, ...rest } = prev.reviews; + const { [hunkId]: _, ...rest } = prev.readState; return { ...prev, - reviews: rest, + readState: rest, lastUpdated: Date.now(), }; }); @@ -78,106 +131,43 @@ export function useReviewState(workspaceId: string) { ); /** - * Clear all reviews - */ - const clearAllReviews = useCallback(() => { - setReviewState((prev) => ({ - ...prev, - reviews: {}, - lastUpdated: Date.now(), - })); - }, [setReviewState]); - - /** - * Remove stale reviews (hunks that no longer exist in the diff) + * Toggle read state of a hunk */ - const removeStaleReviews = useCallback( - (currentHunkIds: string[]) => { - const currentIdSet = new Set(currentHunkIds); - setReviewState((prev) => { - const cleanedReviews: Record = {}; - let changed = false; - - for (const [hunkId, review] of Object.entries(prev.reviews)) { - if (currentIdSet.has(hunkId)) { - cleanedReviews[hunkId] = review; - } else { - changed = true; - } - } - - if (!changed) return prev; - - return { - ...prev, - reviews: cleanedReviews, - lastUpdated: Date.now(), - }; - }); + const toggleRead = useCallback( + (hunkId: string) => { + if (isRead(hunkId)) { + markAsUnread(hunkId); + } else { + markAsRead(hunkId); + } }, - [setReviewState] + [isRead, markAsRead, markAsUnread] ); /** - * Calculate review statistics - */ - const stats = useMemo((): ReviewStats => { - const reviews = Object.values(reviewState.reviews); - return { - total: reviews.length, - accepted: reviews.filter((r) => r.status === "accepted").length, - rejected: reviews.filter((r) => r.status === "rejected").length, - unreviewed: 0, // Will be calculated by consumer based on total hunks - }; - }, [reviewState.reviews]); - - /** - * Check if there are any stale reviews (reviews for hunks not in current set) + * Clear all read states */ - const hasStaleReviews = useCallback( - (currentHunkIds: string[]): boolean => { - const currentIdSet = new Set(currentHunkIds); - return Object.keys(reviewState.reviews).some((hunkId) => !currentIdSet.has(hunkId)); - }, - [reviewState.reviews] - ); + const clearAll = useCallback(() => { + setReviewState((prev) => ({ + ...prev, + readState: {}, + lastUpdated: Date.now(), + })); + }, [setReviewState]); /** - * Calculate stats for a specific set of hunks + * Calculate number of read hunks */ - const calculateStats = useCallback( - (hunks: DiffHunk[]): ReviewStats => { - const total = hunks.length; - let accepted = 0; - let rejected = 0; - - for (const hunk of hunks) { - const review = reviewState.reviews[hunk.id]; - if (review) { - if (review.status === "accepted") accepted++; - else if (review.status === "rejected") rejected++; - } - } - - return { - total, - accepted, - rejected, - unreviewed: total - accepted - rejected, - }; - }, - [reviewState.reviews] - ); + const readCount = useMemo(() => { + return Object.values(reviewState.readState).filter((state) => state.isRead).length; + }, [reviewState.readState]); return { - reviewState, - getReview, - setReview, - deleteReview, - clearAllReviews, - removeStaleReviews, - hasStaleReviews, - stats, - calculateStats, + isRead, + markAsRead, + markAsUnread, + toggleRead, + clearAll, + readCount, }; } diff --git a/src/types/review.ts b/src/types/review.ts index ebfe642d3..49c7cebae 100644 --- a/src/types/review.ts +++ b/src/types/review.ts @@ -45,16 +45,14 @@ export interface FileDiff { } /** - * User's review of a hunk + * Read state for a single hunk */ -export interface HunkReview { - /** ID of the hunk being reviewed */ +export interface HunkReadState { + /** ID of the hunk */ hunkId: string; - /** Review status */ - status: "accepted" | "rejected"; - /** Optional comment/note */ - note?: string; - /** Timestamp when review was created/updated */ + /** Whether this hunk has been marked as read */ + isRead: boolean; + /** Timestamp when read state was last updated */ timestamp: number; } @@ -64,8 +62,8 @@ export interface HunkReview { export interface ReviewState { /** Workspace ID this review belongs to */ workspaceId: string; - /** Reviews keyed by hunk ID */ - reviews: Record; + /** Read state keyed by hunk ID */ + readState: Record; /** Timestamp of last update */ lastUpdated: number; } @@ -74,10 +72,8 @@ export interface ReviewState { * Filter options for review panel */ export interface ReviewFilters { - /** Whether to show already-reviewed hunks */ - showReviewed: boolean; - /** Status filter */ - statusFilter: "all" | "accepted" | "rejected" | "unreviewed"; + /** Whether to show hunks marked as read */ + showReadHunks: boolean; /** File path filter (regex or glob pattern) */ filePathFilter?: string; /** Base reference to diff against (e.g., "HEAD", "main", "origin/main") */ @@ -90,8 +86,10 @@ export interface ReviewFilters { * Review statistics */ export interface ReviewStats { + /** Total number of hunks */ total: number; - accepted: number; - rejected: number; - unreviewed: number; + /** Number of hunks marked as read */ + read: number; + /** Number of unread hunks */ + unread: number; } diff --git a/src/utils/git/diffParser.ts b/src/utils/git/diffParser.ts index 692cc0278..c8a6ff236 100644 --- a/src/utils/git/diffParser.ts +++ b/src/utils/git/diffParser.ts @@ -5,11 +5,12 @@ import type { DiffHunk, FileDiff } from "@/types/review"; /** - * Generate a stable ID for a hunk based on file path and line ranges + * Generate a stable content-based ID for a hunk + * Uses file path + diff content (not line numbers) to survive rebases */ -function generateHunkId(filePath: string, oldStart: number, newStart: number): string { - // Simple hash: combine file path with line numbers - const str = `${filePath}:${oldStart}:${newStart}`; +function generateHunkId(filePath: string, content: string): string { + // Hash file path + actual diff content for rebase stability + const str = `${filePath}:${content}`; let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); @@ -54,16 +55,13 @@ export function parseDiff(diffOutput: string): FileDiff[] { const finishHunk = () => { if (currentHunk && currentFile && hunkLines.length > 0) { - const hunkId = generateHunkId( - currentFile.filePath, - currentHunk.oldStart!, - currentHunk.newStart! - ); + const content = hunkLines.join("\n"); + const hunkId = generateHunkId(currentFile.filePath, content); currentFile.hunks.push({ ...currentHunk, id: hunkId, filePath: currentFile.filePath, - content: hunkLines.join("\n"), + content, changeType: currentFile.changeType, oldPath: currentFile.oldPath, } as DiffHunk); diff --git a/src/utils/ui/keybinds.ts b/src/utils/ui/keybinds.ts index 6078101d8..ad1aabd0c 100644 --- a/src/utils/ui/keybinds.ts +++ b/src/utils/ui/keybinds.ts @@ -254,4 +254,7 @@ export const KEYBINDS = { /** Refresh diff in Code Review panel */ // macOS: Cmd+R, Win/Linux: Ctrl+R REFRESH_REVIEW: { key: "r", ctrl: true }, + + /** Mark selected hunk as read/unread in Code Review panel */ + TOGGLE_HUNK_READ: { key: "m" }, } as const; From 412ceb0ff64e3dddfa04038dda9a3d5b75f93093 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:20:43 -0500 Subject: [PATCH 02/26] =?UTF-8?q?=F0=9F=A4=96=20Add=20scroll-into-view=20f?= =?UTF-8?q?or=20j/k=20hunk=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When navigating hunks with j/k keys, automatically scroll the viewport to keep the selected hunk visible using smooth scrolling behavior. - Added data-hunk-id attribute to HunkViewer container - Added useEffect to scroll selected hunk into view on change - Uses scrollIntoView with 'nearest' block positioning Generated with `cmux` --- .../RightSidebar/CodeReview/HunkViewer.tsx | 1 + .../RightSidebar/CodeReview/ReviewPanel.tsx | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 8383700ba..13907066b 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -212,6 +212,7 @@ export const HunkViewer: React.FC = ({ onClick={onClick} role="button" tabIndex={0} + data-hunk-id={hunk.id} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index 24becd738..c3982a02c 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -483,6 +483,21 @@ export const ReviewPanel: React.FC = ({ }; }, [hunks, reviewState]); + // Scroll selected hunk into view + useEffect(() => { + if (!selectedHunkId) return; + + // Find the hunk container element by data attribute + const hunkElement = document.querySelector(`[data-hunk-id="${selectedHunkId}"]`); + if (hunkElement) { + hunkElement.scrollIntoView({ + behavior: "smooth", + block: "nearest", + inline: "nearest", + }); + } + }, [selectedHunkId]); + // Keyboard navigation (j/k or arrow keys) - only when panel is focused useEffect(() => { if (!isPanelFocused) return; From d86248c7d41722be45d08fd91b1d72794878e872 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:35:15 -0500 Subject: [PATCH 03/26] =?UTF-8?q?=F0=9F=A4=96=20Add=20mark-all-as-read=20b?= =?UTF-8?q?utton=20for=20files=20in=20FileTree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplified useReviewState.markAsRead to accept string | string[] - Added onMarkFileAsRead handler to mark all hunks in a file - Added getFileReadStatus to show read/total counts per file - Visual indicator: Show ✓ button when file has unread hunks - Shows filled ✓ when all hunks in file are read - Button tooltip: 'Mark all hunks as read' Generated with `cmux` --- .../RightSidebar/CodeReview/FileTree.tsx | 70 ++++++++++++++++++- .../RightSidebar/CodeReview/ReviewPanel.tsx | 27 ++++++- src/hooks/useReviewState.ts | 35 ++++++---- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index ce214f2b7..48049dcb7 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -117,13 +117,54 @@ const EmptyState = styled.div` text-align: center; `; +const MarkReadButton = styled.button` + background: transparent; + border: 1px solid rgba(74, 222, 128, 0.3); + border-radius: 3px; + padding: 2px 6px; + color: #4ade80; + font-size: 10px; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 3px; + opacity: 0.7; + + &:hover { + opacity: 1; + background: rgba(74, 222, 128, 0.1); + border-color: #4ade80; + } + + &:active { + transform: scale(0.95); + } +`; + +const ReadStatus = styled.span` + font-size: 10px; + color: #4ade80; + opacity: 0.7; +`; + const TreeNodeContent: React.FC<{ node: FileTreeNode; depth: number; selectedPath: string | null; onSelectFile: (path: string | null) => void; commonPrefix: string | null; -}> = ({ node, depth, selectedPath, onSelectFile, commonPrefix }) => { + onMarkFileAsRead?: (filePath: string) => void; + getFileReadStatus?: (filePath: string) => { total: number; read: number }; +}> = ({ + node, + depth, + selectedPath, + onSelectFile, + commonPrefix, + onMarkFileAsRead, + getFileReadStatus, +}) => { const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels const handleClick = (e: React.MouseEvent) => { @@ -152,7 +193,15 @@ const TreeNodeContent: React.FC<{ setIsOpen(!isOpen); }; + const handleMarkAsRead = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onMarkFileAsRead) { + onMarkFileAsRead(node.path); + } + }; + const isSelected = selectedPath === node.path; + const readStatus = !node.isDirectory && getFileReadStatus ? getFileReadStatus(node.path) : null; return ( <> @@ -191,6 +240,17 @@ const TreeNodeContent: React.FC<{ {node.stats.deletions > 0 && -{node.stats.deletions}} )} + {readStatus && readStatus.total > 0 && ( + <> + {readStatus.read === readStatus.total ? ( + + ) : ( + + ✓ + + )} + + )} )} @@ -205,6 +265,8 @@ const TreeNodeContent: React.FC<{ selectedPath={selectedPath} onSelectFile={onSelectFile} commonPrefix={commonPrefix} + onMarkFileAsRead={onMarkFileAsRead} + getFileReadStatus={getFileReadStatus} /> ))} @@ -217,6 +279,8 @@ interface FileTreeExternalProps { onSelectFile: (path: string | null) => void; isLoading?: boolean; commonPrefix?: string | null; + onMarkFileAsRead?: (filePath: string) => void; + getFileReadStatus?: (filePath: string) => { total: number; read: number }; } export const FileTree: React.FC = ({ @@ -225,6 +289,8 @@ export const FileTree: React.FC = ({ onSelectFile, isLoading = false, commonPrefix = null, + onMarkFileAsRead, + getFileReadStatus, }) => { // Find the node at the common prefix path to start rendering from const startNode = React.useMemo(() => { @@ -262,6 +328,8 @@ export const FileTree: React.FC = ({ selectedPath={selectedPath} onSelectFile={onSelectFile} commonPrefix={commonPrefix} + onMarkFileAsRead={onMarkFileAsRead} + getFileReadStatus={getFileReadStatus} /> )) ) : ( diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index c3982a02c..90e4e3348 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -3,7 +3,7 @@ * Displays diff hunks for viewing changes in the workspace */ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import styled from "@emotion/styled"; import { HunkViewer } from "./HunkViewer"; import { ReviewControls } from "./ReviewControls"; @@ -464,6 +464,29 @@ export const ReviewPanel: React.FC = ({ setShowReadHunks(filters.showReadHunks); }, [filters.showReadHunks, setShowReadHunks]); + // Handler to mark all hunks in a file as read + const handleMarkFileAsRead = useCallback( + (filePath: string) => { + const fileHunks = hunks.filter((h) => h.filePath === filePath); + const hunkIds = fileHunks.map((h) => h.id); + if (hunkIds.length > 0) { + reviewState.markAsRead(hunkIds); + } + }, + [hunks, reviewState] + ); + + // Get read status for a file + const getFileReadStatus = useCallback( + (filePath: string) => { + const fileHunks = hunks.filter((h) => h.filePath === filePath); + const total = fileHunks.length; + const read = fileHunks.filter((h) => reviewState.isRead(h.id)).length; + return { total, read }; + }, + [hunks, reviewState] + ); + // Filter hunks based on read state const filteredHunks = useMemo(() => { if (filters.showReadHunks) { @@ -651,6 +674,8 @@ export const ReviewPanel: React.FC = ({ onSelectFile={setSelectedFilePath} isLoading={isLoadingTree} commonPrefix={commonPrefix} + onMarkFileAsRead={handleMarkFileAsRead} + getFileReadStatus={getFileReadStatus} /> )} diff --git a/src/hooks/useReviewState.ts b/src/hooks/useReviewState.ts index aa09e2a34..bc85721f9 100644 --- a/src/hooks/useReviewState.ts +++ b/src/hooks/useReviewState.ts @@ -40,8 +40,8 @@ function evictOldestReviews( export interface UseReviewStateReturn { /** Check if a hunk is marked as read */ isRead: (hunkId: string) => boolean; - /** Mark a hunk as read */ - markAsRead: (hunkId: string) => void; + /** Mark one or more hunks as read */ + markAsRead: (hunkIds: string | string[]) => void; /** Mark a hunk as unread */ markAsUnread: (hunkId: string) => void; /** Toggle read state of a hunk */ @@ -93,22 +93,29 @@ export function useReviewState(workspaceId: string): UseReviewStateReturn { ); /** - * Mark a hunk as read + * Mark one or more hunks as read */ const markAsRead = useCallback( - (hunkId: string) => { - setReviewState((prev) => ({ - ...prev, - readState: { - ...prev.readState, - [hunkId]: { + (hunkIds: string | string[]) => { + const ids = Array.isArray(hunkIds) ? hunkIds : [hunkIds]; + if (ids.length === 0) return; + + const timestamp = Date.now(); + setReviewState((prev) => { + const newReadState = { ...prev.readState }; + for (const hunkId of ids) { + newReadState[hunkId] = { hunkId, isRead: true, - timestamp: Date.now(), - }, - }, - lastUpdated: Date.now(), - })); + timestamp, + }; + } + return { + ...prev, + readState: newReadState, + lastUpdated: timestamp, + }; + }); }, [setReviewState] ); From 51b9a5b5d1c367cf543b2c321a1968d9be041006 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:36:36 -0500 Subject: [PATCH 04/26] =?UTF-8?q?=F0=9F=A4=96=20Fix:=20Respect=20manual=20?= =?UTF-8?q?expand/collapse=20after=20marking=20as=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track hasManuallyToggled state to prevent auto-collapse from overriding user's explicit expand actions. Once user manually toggles expansion, that preference persists even when read state changes. Generated with `cmux` --- src/components/RightSidebar/CodeReview/HunkViewer.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 13907066b..c3a63cb38 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -176,14 +176,19 @@ export const HunkViewer: React.FC = ({ }) => { // Collapse by default if marked as read const [isExpanded, setIsExpanded] = useState(!isRead); + // Track if user has manually toggled expansion + const [hasManuallyToggled, setHasManuallyToggled] = useState(false); - // Update expanded state when read state changes + // Auto-collapse when marked as read, but only if user hasn't manually toggled React.useEffect(() => { - setIsExpanded(!isRead); - }, [isRead]); + if (!hasManuallyToggled) { + setIsExpanded(!isRead); + } + }, [isRead, hasManuallyToggled]); const handleToggleExpand = (e: React.MouseEvent) => { e.stopPropagation(); + setHasManuallyToggled(true); setIsExpanded(!isExpanded); }; From f9102ea6974bf4ab0bcb9a93a91b7236012e3eea Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:38:20 -0500 Subject: [PATCH 05/26] =?UTF-8?q?=F0=9F=A4=96=20Simplify:=20Always=20colla?= =?UTF-8?q?pse/expand=20on=20read=20state=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed hasManuallyToggled complexity. Now simply: - Mark as read → always collapses - Unmark as read → always expands - Manual toggle → persists until next read state change This is the expected behavior: marking as read should collapse regardless of previous manual expansion. Generated with `cmux` --- src/components/RightSidebar/CodeReview/HunkViewer.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index c3a63cb38..0fbcd37e9 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -176,19 +176,14 @@ export const HunkViewer: React.FC = ({ }) => { // Collapse by default if marked as read const [isExpanded, setIsExpanded] = useState(!isRead); - // Track if user has manually toggled expansion - const [hasManuallyToggled, setHasManuallyToggled] = useState(false); - // Auto-collapse when marked as read, but only if user hasn't manually toggled + // Auto-collapse when marked as read, auto-expand when unmarked React.useEffect(() => { - if (!hasManuallyToggled) { - setIsExpanded(!isRead); - } - }, [isRead, hasManuallyToggled]); + setIsExpanded(!isRead); + }, [isRead]); const handleToggleExpand = (e: React.MouseEvent) => { e.stopPropagation(); - setHasManuallyToggled(true); setIsExpanded(!isExpanded); }; From 4d3fb04294e467af5821c2e83b2e58d20cece353 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:44:22 -0500 Subject: [PATCH 06/26] =?UTF-8?q?=F0=9F=A4=96=20Improve=20visual=20distinc?= =?UTF-8?q?tion:=20Green=20header=20for=20read=20hunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved read state indicator from container border/background to header: - Removed: Green tinted background and border on container - Added: Green background (rgba(74, 222, 128, 0.15)) on header - Keeps: Gray border on container for clean, minimal look This makes read state more visually distinct from collapsed state and provides better visual separation in the hunk list. Generated with `cmux` --- .../RightSidebar/CodeReview/HunkViewer.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 0fbcd37e9..7c48bd17e 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -25,13 +25,6 @@ const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` cursor: pointer; transition: all 0.2s ease; - ${(props) => - props.isRead && - ` - background: rgba(74, 222, 128, 0.05); - border-color: rgba(74, 222, 128, 0.2); - `} - ${(props) => props.isSelected && ` @@ -44,8 +37,8 @@ const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` } `; -const HunkHeader = styled.div` - background: #252526; +const HunkHeader = styled.div<{ isRead: boolean }>` + background: ${(props) => (props.isRead ? "rgba(74, 222, 128, 0.15)" : "#252526")}; padding: 8px 12px; border-bottom: 1px solid #3e3e42; display: flex; @@ -54,6 +47,7 @@ const HunkHeader = styled.div` font-family: var(--font-monospace); font-size: 12px; gap: 8px; + transition: background 0.2s ease; `; const FilePath = styled.div` @@ -220,7 +214,7 @@ export const HunkViewer: React.FC = ({ } }} > - + {isRead && } {hunk.filePath} From 309b6295f576cb036739e50951fb39c0d3eee96c Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:46:27 -0500 Subject: [PATCH 07/26] =?UTF-8?q?=F0=9F=A4=96=20Use=20plan=20blue=20for=20?= =?UTF-8?q?read=20state=20indicators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed from green to plan blue to avoid confusion with addition indicators: - Read checkmark: plan blue instead of green - Toggle read button hover: plan blue border/text - FileTree mark-as-read button: plan blue accents - Added comment: Keep header grayscale to avoid clashing with LoC Plan blue is already established in the UI for other review states, making it a natural choice for read status indication. Generated with `cmux` --- .../RightSidebar/CodeReview/FileTree.tsx | 10 +++++----- .../RightSidebar/CodeReview/HunkViewer.tsx | 14 +++++++------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index 48049dcb7..e03c74e22 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -119,10 +119,10 @@ const EmptyState = styled.div` const MarkReadButton = styled.button` background: transparent; - border: 1px solid rgba(74, 222, 128, 0.3); + border: 1px solid rgba(31, 107, 184, 0.3); border-radius: 3px; padding: 2px 6px; - color: #4ade80; + color: var(--color-plan-mode); font-size: 10px; cursor: pointer; transition: all 0.2s ease; @@ -133,8 +133,8 @@ const MarkReadButton = styled.button` &:hover { opacity: 1; - background: rgba(74, 222, 128, 0.1); - border-color: #4ade80; + background: var(--color-plan-mode-alpha); + border-color: var(--color-plan-mode); } &:active { @@ -144,7 +144,7 @@ const MarkReadButton = styled.button` const ReadStatus = styled.span` font-size: 10px; - color: #4ade80; + color: var(--color-plan-mode); opacity: 0.7; `; diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 7c48bd17e..24f013c8b 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -37,8 +37,9 @@ const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` } `; -const HunkHeader = styled.div<{ isRead: boolean }>` - background: ${(props) => (props.isRead ? "rgba(74, 222, 128, 0.15)" : "#252526")}; +const HunkHeader = styled.div` + /* Keep grayscale to avoid clashing with green/red LoC indicators */ + background: #252526; padding: 8px 12px; border-bottom: 1px solid #3e3e42; display: flex; @@ -47,7 +48,6 @@ const HunkHeader = styled.div<{ isRead: boolean }>` font-family: var(--font-monospace); font-size: 12px; gap: 8px; - transition: background 0.2s ease; `; const FilePath = styled.div` @@ -131,7 +131,7 @@ const RenameInfo = styled.div` const ReadIndicator = styled.span` display: inline-flex; align-items: center; - color: #4ade80; + color: var(--color-plan-mode); font-size: 14px; margin-right: 4px; `; @@ -151,8 +151,8 @@ const ToggleReadButton = styled.button` &:hover { background: rgba(255, 255, 255, 0.05); - border-color: #4ade80; - color: #4ade80; + border-color: var(--color-plan-mode); + color: var(--color-plan-mode); } &:active { @@ -214,7 +214,7 @@ export const HunkViewer: React.FC = ({ } }} > - + {isRead && } {hunk.filePath} From 39a334631efde1140f27d14602d6875a58b3a63e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:48:04 -0500 Subject: [PATCH 08/26] =?UTF-8?q?=F0=9F=A4=96=20Add=20plan=20blue=20border?= =?UTF-8?q?=20and=20improved=20collapsed=20text=20for=20read=20hunks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added plan blue border to read hunks for better visual distinction - Updated collapsed text: 'Hunk marked as read. Click to expand (N lines)' - Border provides clear visual indicator even when collapsed - Text clarifies why hunk is collapsed Generated with `cmux` --- src/components/RightSidebar/CodeReview/HunkViewer.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 24f013c8b..737dae442 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -25,6 +25,12 @@ const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` cursor: pointer; transition: all 0.2s ease; + ${(props) => + props.isRead && + ` + border-color: var(--color-plan-mode); + `} + ${(props) => props.isSelected && ` @@ -251,7 +257,7 @@ export const HunkViewer: React.FC = ({ ) : ( - Click to expand ({lineCount} lines) + {isRead && "Hunk marked as read. "}Click to expand ({lineCount} lines) )} From 6a813d1cbe371716ef836158595c0ce68148dfee Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:49:40 -0500 Subject: [PATCH 09/26] =?UTF-8?q?=F0=9F=A4=96=20Change=20selected=20hunk?= =?UTF-8?q?=20border=20to=20amber=20to=20avoid=20clash=20with=20read=20sta?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed selected/focused hunk border from cyan blue (#007acc) to amber (#f59e0b) to prevent visual clash with plan blue read state borders. - Selected/hover: Amber border - Read state: Plan blue border - Clear visual distinction between states Generated with `cmux` --- src/components/RightSidebar/CodeReview/HunkViewer.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 737dae442..f82a2914f 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -34,12 +34,12 @@ const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` ${(props) => props.isSelected && ` - border-color: #007acc; - box-shadow: 0 0 0 1px #007acc; + border-color: #f59e0b; + box-shadow: 0 0 0 1px #f59e0b; `} &:hover { - border-color: #007acc; + border-color: #f59e0b; } `; From 1f17bfa54e9f41877ac0b8d15b20939c85c0a71b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:52:30 -0500 Subject: [PATCH 10/26] =?UTF-8?q?=F0=9F=A4=96=20Centralize=20read=20color?= =?UTF-8?q?=20and=20use=20strikethrough=20for=20fully-read=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added --color-read and --color-read-alpha to colors.tsx - Updated all read state indicators to use centralized color - Removed mark-as-read button from FileTree (saves space) - Files with all hunks read now show blue strikethrough - Cleaner, more compact FileTree UI Generated with `cmux` --- .../RightSidebar/CodeReview/FileTree.tsx | 78 ++++--------------- .../RightSidebar/CodeReview/HunkViewer.tsx | 8 +- .../RightSidebar/CodeReview/ReviewPanel.tsx | 13 ---- src/styles/colors.tsx | 4 + 4 files changed, 21 insertions(+), 82 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index e03c74e22..73b4eaee9 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -33,9 +33,16 @@ const TreeNode = styled.div<{ depth: number; isSelected: boolean }>` } `; -const FileName = styled.span` +const FileName = styled.span<{ isFullyRead?: boolean }>` color: #ccc; flex: 1; + ${(props) => + props.isFullyRead && + ` + text-decoration: line-through; + text-decoration-color: var(--color-read); + text-decoration-thickness: 2px; + `} `; const DirectoryName = styled.span` @@ -117,54 +124,14 @@ const EmptyState = styled.div` text-align: center; `; -const MarkReadButton = styled.button` - background: transparent; - border: 1px solid rgba(31, 107, 184, 0.3); - border-radius: 3px; - padding: 2px 6px; - color: var(--color-plan-mode); - font-size: 10px; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - gap: 3px; - opacity: 0.7; - - &:hover { - opacity: 1; - background: var(--color-plan-mode-alpha); - border-color: var(--color-plan-mode); - } - - &:active { - transform: scale(0.95); - } -`; - -const ReadStatus = styled.span` - font-size: 10px; - color: var(--color-plan-mode); - opacity: 0.7; -`; - const TreeNodeContent: React.FC<{ node: FileTreeNode; depth: number; selectedPath: string | null; onSelectFile: (path: string | null) => void; commonPrefix: string | null; - onMarkFileAsRead?: (filePath: string) => void; getFileReadStatus?: (filePath: string) => { total: number; read: number }; -}> = ({ - node, - depth, - selectedPath, - onSelectFile, - commonPrefix, - onMarkFileAsRead, - getFileReadStatus, -}) => { +}> = ({ node, depth, selectedPath, onSelectFile, commonPrefix, getFileReadStatus }) => { const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels const handleClick = (e: React.MouseEvent) => { @@ -193,15 +160,11 @@ const TreeNodeContent: React.FC<{ setIsOpen(!isOpen); }; - const handleMarkAsRead = (e: React.MouseEvent) => { - e.stopPropagation(); - if (onMarkFileAsRead) { - onMarkFileAsRead(node.path); - } - }; - const isSelected = selectedPath === node.path; const readStatus = !node.isDirectory && getFileReadStatus ? getFileReadStatus(node.path) : null; + const isFullyRead = readStatus + ? readStatus.read === readStatus.total && readStatus.total > 0 + : false; return ( <> @@ -233,24 +196,13 @@ const TreeNodeContent: React.FC<{ ) : ( <> - {node.name} + {node.name} {node.stats && ( {node.stats.additions > 0 && +{node.stats.additions}} {node.stats.deletions > 0 && -{node.stats.deletions}} )} - {readStatus && readStatus.total > 0 && ( - <> - {readStatus.read === readStatus.total ? ( - - ) : ( - - ✓ - - )} - - )} )} @@ -265,7 +217,6 @@ const TreeNodeContent: React.FC<{ selectedPath={selectedPath} onSelectFile={onSelectFile} commonPrefix={commonPrefix} - onMarkFileAsRead={onMarkFileAsRead} getFileReadStatus={getFileReadStatus} /> ))} @@ -279,7 +230,6 @@ interface FileTreeExternalProps { onSelectFile: (path: string | null) => void; isLoading?: boolean; commonPrefix?: string | null; - onMarkFileAsRead?: (filePath: string) => void; getFileReadStatus?: (filePath: string) => { total: number; read: number }; } @@ -289,7 +239,6 @@ export const FileTree: React.FC = ({ onSelectFile, isLoading = false, commonPrefix = null, - onMarkFileAsRead, getFileReadStatus, }) => { // Find the node at the common prefix path to start rendering from @@ -328,7 +277,6 @@ export const FileTree: React.FC = ({ selectedPath={selectedPath} onSelectFile={onSelectFile} commonPrefix={commonPrefix} - onMarkFileAsRead={onMarkFileAsRead} getFileReadStatus={getFileReadStatus} /> )) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index f82a2914f..009ea4085 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -28,7 +28,7 @@ const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` ${(props) => props.isRead && ` - border-color: var(--color-plan-mode); + border-color: var(--color-read); `} ${(props) => @@ -137,7 +137,7 @@ const RenameInfo = styled.div` const ReadIndicator = styled.span` display: inline-flex; align-items: center; - color: var(--color-plan-mode); + color: var(--color-read); font-size: 14px; margin-right: 4px; `; @@ -157,8 +157,8 @@ const ToggleReadButton = styled.button` &:hover { background: rgba(255, 255, 255, 0.05); - border-color: var(--color-plan-mode); - color: var(--color-plan-mode); + border-color: var(--color-read); + color: var(--color-read); } &:active { diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index 90e4e3348..60323f133 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -464,18 +464,6 @@ export const ReviewPanel: React.FC = ({ setShowReadHunks(filters.showReadHunks); }, [filters.showReadHunks, setShowReadHunks]); - // Handler to mark all hunks in a file as read - const handleMarkFileAsRead = useCallback( - (filePath: string) => { - const fileHunks = hunks.filter((h) => h.filePath === filePath); - const hunkIds = fileHunks.map((h) => h.id); - if (hunkIds.length > 0) { - reviewState.markAsRead(hunkIds); - } - }, - [hunks, reviewState] - ); - // Get read status for a file const getFileReadStatus = useCallback( (filePath: string) => { @@ -674,7 +662,6 @@ export const ReviewPanel: React.FC = ({ onSelectFile={setSelectedFilePath} isLoading={isLoadingTree} commonPrefix={commonPrefix} - onMarkFileAsRead={handleMarkFileAsRead} getFileReadStatus={getFileReadStatus} /> diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index 25de72911..bfa77347c 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -37,6 +37,10 @@ export const GlobalColors = () => ( --color-edit-mode-alpha: hsl(from var(--color-edit-mode) h s l / 0.1); --color-edit-mode-alpha-hover: hsl(from var(--color-edit-mode) h s l / 0.15); + /* Read State Colors (Blue - reuses plan mode color for consistency) */ + --color-read: var(--color-plan-mode); + --color-read-alpha: var(--color-plan-mode-alpha); + /* Editing Mode Colors */ --color-editing-mode: hsl(30 100% 50%); --color-editing-mode-alpha: hsl(from var(--color-editing-mode) h s l / 0.1); From a438ce29329743c61e62845f94a34db867ca0e1a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:55:30 -0500 Subject: [PATCH 11/26] =?UTF-8?q?=F0=9F=A4=96=20Dim=20filenames=20with=20s?= =?UTF-8?q?trikethrough=20when=20fully=20read=20in=20FileTree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RightSidebar/CodeReview/FileTree.tsx | 1 + src/components/RightSidebar/CodeReview/HunkViewer.tsx | 8 ++------ src/styles/colors.tsx | 5 +++++ 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index 73b4eaee9..0e7aa7b78 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -39,6 +39,7 @@ const FileName = styled.span<{ isFullyRead?: boolean }>` ${(props) => props.isFullyRead && ` + color: #666; text-decoration: line-through; text-decoration-color: var(--color-read); text-decoration-thickness: 2px; diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 009ea4085..472838e59 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -34,13 +34,9 @@ const HunkContainer = styled.div<{ isSelected: boolean; isRead: boolean }>` ${(props) => props.isSelected && ` - border-color: #f59e0b; - box-shadow: 0 0 0 1px #f59e0b; + border-color: var(--color-review-accent); + box-shadow: 0 0 0 1px var(--color-review-accent); `} - - &:hover { - border-color: #f59e0b; - } `; const HunkHeader = styled.div` diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index bfa77347c..ab47c1c82 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -116,6 +116,11 @@ export const GlobalColors = () => ( --color-interrupted: hsl(38 92% 50%); /* #f59e0b */ --color-interrupted-alpha: hsl(from var(--color-interrupted) h s l / 0.3); + /* Review/Selection Colors (Yellow/Orange) */ + --color-review-accent: hsl(48 100% 50%); /* rgb(255, 200, 0) - used for review notes and active hunks */ + --color-review-accent-alpha: hsl(from var(--color-review-accent) h s l / 0.4); + --color-review-accent-alpha-hover: hsl(from var(--color-review-accent) h s l / 0.6); + /* Git Dirty/Uncommitted Changes Colors */ --color-git-dirty: hsl(38 92% 50%); /* Same as interrupted - orange warning color */ From 70e6b53c8d507ffdf83eff2017832666091c34b2 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:56:17 -0500 Subject: [PATCH 12/26] =?UTF-8?q?=F0=9F=A4=96=20Centralize=20review=20acce?= =?UTF-8?q?nt=20color=20and=20remove=20hover=20border=20clash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --color-review-accent to colors.tsx (yellow/orange for review notes) - Add alpha variants (10%, 20%, 30%, 40%, 60%) for different use cases - Update HunkViewer to use new color variables - Remove hover border on HunkContainer to prevent clash with active border - Update DiffRenderer to use centralized color variables instead of hardcoded rgba values This ensures consistent styling across review components and makes the active hunk more visually distinct without hover interference. --- src/components/shared/DiffRenderer.tsx | 10 +++++----- src/styles/colors.tsx | 3 +++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 216c5ddd1..39c304c76 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -227,14 +227,14 @@ const SelectableDiffLineWrapper = styled(DiffLineWrapper)<{ ${({ isSelected }) => isSelected && ` - background: rgba(255, 200, 0, 0.2) !important; + background: var(--color-review-accent-alpha-20) !important; `} &:hover { ${({ isSelecting }) => isSelecting && ` - background: rgba(255, 200, 0, 0.1); + background: var(--color-review-accent-alpha-10); `} } `; @@ -242,7 +242,7 @@ const SelectableDiffLineWrapper = styled(DiffLineWrapper)<{ const InlineNoteContainer = styled.div` padding: 10px 8px 8px 8px; background: #252526; - border-top: 1px solid rgba(255, 200, 0, 0.3); + border-top: 1px solid var(--color-review-accent-alpha-30); margin: 0; `; @@ -253,14 +253,14 @@ const NoteTextarea = styled.textarea` font-family: var(--font-sans); font-size: 11px; background: #1e1e1e; - border: 1px solid rgba(255, 200, 0, 0.4); + border: 1px solid var(--color-review-accent-alpha); border-radius: 2px; color: var(--color-text); resize: vertical; &:focus { outline: none; - border-color: rgba(255, 200, 0, 0.6); + border-color: var(--color-review-accent-alpha-hover); } &::placeholder { diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index ab47c1c82..15d1d8b32 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -118,6 +118,9 @@ export const GlobalColors = () => ( /* Review/Selection Colors (Yellow/Orange) */ --color-review-accent: hsl(48 100% 50%); /* rgb(255, 200, 0) - used for review notes and active hunks */ + --color-review-accent-alpha-10: hsl(from var(--color-review-accent) h s l / 0.1); + --color-review-accent-alpha-20: hsl(from var(--color-review-accent) h s l / 0.2); + --color-review-accent-alpha-30: hsl(from var(--color-review-accent) h s l / 0.3); --color-review-accent-alpha: hsl(from var(--color-review-accent) h s l / 0.4); --color-review-accent-alpha-hover: hsl(from var(--color-review-accent) h s l / 0.6); From 57bdb15cf1105b9a638208eea20f5bb905d307b9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 20:58:25 -0500 Subject: [PATCH 13/26] =?UTF-8?q?=F0=9F=A4=96=20Dim=20files=20with=20unkno?= =?UTF-8?q?wn=20read=20state=20in=20FileTree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a file is selected, we only have hunk data for that file. Other files don't have their read status available, creating an ambiguous state. Now we distinguish between three states: - Known fully read: dimmed + strikethrough (blue) - Known not fully read: normal color, no strikethrough - Unknown state (no hunk data): dimmed, no strikethrough This makes it clear which files have unknown read status when filtering to a specific file. --- .../RightSidebar/CodeReview/FileTree.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index 0e7aa7b78..1f27b0601 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -33,7 +33,7 @@ const TreeNode = styled.div<{ depth: number; isSelected: boolean }>` } `; -const FileName = styled.span<{ isFullyRead?: boolean }>` +const FileName = styled.span<{ isFullyRead?: boolean; isUnknownState?: boolean }>` color: #ccc; flex: 1; ${(props) => @@ -44,6 +44,12 @@ const FileName = styled.span<{ isFullyRead?: boolean }>` text-decoration-color: var(--color-read); text-decoration-thickness: 2px; `} + ${(props) => + props.isUnknownState && + !props.isFullyRead && + ` + color: #666; + `} `; const DirectoryName = styled.span` @@ -166,6 +172,8 @@ const TreeNodeContent: React.FC<{ const isFullyRead = readStatus ? readStatus.read === readStatus.total && readStatus.total > 0 : false; + // Dim files when we don't have hunk data (unknown state) + const isUnknownState = !node.isDirectory && getFileReadStatus && readStatus === null; return ( <> @@ -197,7 +205,9 @@ const TreeNodeContent: React.FC<{ ) : ( <> - {node.name} + + {node.name} + {node.stats && ( {node.stats.additions > 0 && +{node.stats.additions}} From ce3d4f4ba0d8e9f72ecc3186681abddee4e089eb Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:02:44 -0500 Subject: [PATCH 14/26] =?UTF-8?q?=F0=9F=A4=96=20Fix=20FileTree=20dimming?= =?UTF-8?q?=20for=20unknown=20read=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return null from getFileReadStatus when no hunks are loaded for a file, allowing FileTree to properly distinguish between: - Known fully read (has data, all read) - Known partially read (has data, some unread) - Unknown state (no data loaded) This fixes the dimming not working when a file is selected and other files don't have their hunks loaded. --- src/components/RightSidebar/CodeReview/FileTree.tsx | 4 ++-- src/components/RightSidebar/CodeReview/ReviewPanel.tsx | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index 1f27b0601..8925cceb4 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -137,7 +137,7 @@ const TreeNodeContent: React.FC<{ selectedPath: string | null; onSelectFile: (path: string | null) => void; commonPrefix: string | null; - getFileReadStatus?: (filePath: string) => { total: number; read: number }; + 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 @@ -241,7 +241,7 @@ interface FileTreeExternalProps { onSelectFile: (path: string | null) => void; isLoading?: boolean; commonPrefix?: string | null; - getFileReadStatus?: (filePath: string) => { total: number; read: number }; + getFileReadStatus?: (filePath: string) => { total: number; read: number } | null; } export const FileTree: React.FC = ({ diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index 60323f133..2823f6c47 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -468,6 +468,9 @@ export const ReviewPanel: React.FC = ({ const getFileReadStatus = useCallback( (filePath: string) => { const fileHunks = hunks.filter((h) => h.filePath === filePath); + if (fileHunks.length === 0) { + return null; // Unknown state - no hunks loaded for this file + } const total = fileHunks.length; const read = fileHunks.filter((h) => reviewState.isRead(h.id)).length; return { total, read }; From 8c071b4f24f8f2c6cf935f3010897f1cedd821d9 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:03:04 -0500 Subject: [PATCH 15/26] =?UTF-8?q?=F0=9F=A4=96=20Reduce=20intensity=20of=20?= =?UTF-8?q?review=20accent=20color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lower saturation from 100% to 70% for a more muted yellow/orange. This makes the active hunk border less visually jarring while still providing clear selection feedback. --- src/styles/colors.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index 15d1d8b32..83391588f 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -117,7 +117,7 @@ export const GlobalColors = () => ( --color-interrupted-alpha: hsl(from var(--color-interrupted) h s l / 0.3); /* Review/Selection Colors (Yellow/Orange) */ - --color-review-accent: hsl(48 100% 50%); /* rgb(255, 200, 0) - used for review notes and active hunks */ + --color-review-accent: hsl(48 70% 50%); /* Muted yellow/orange - used for review notes and active hunks */ --color-review-accent-alpha-10: hsl(from var(--color-review-accent) h s l / 0.1); --color-review-accent-alpha-20: hsl(from var(--color-review-accent) h s l / 0.2); --color-review-accent-alpha-30: hsl(from var(--color-review-accent) h s l / 0.3); From 507381183aa0afddc557f3857c80aaa94e8f5ebf Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:05:53 -0500 Subject: [PATCH 16/26] =?UTF-8?q?=F0=9F=A4=96=20Use=20alpha=200.75=20and?= =?UTF-8?q?=20remove=20alpha=20variant=20anti-pattern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change review accent color to use 0.75 alpha for better intensity - Remove all -alpha-10, -20, -30, etc. variants from colors.tsx - Update DiffRenderer to use inline alpha with variable reference: hsl(from var(--color-review-accent) h s l / 0.2) - Add comment documenting this as the preferred pattern to avoid cluttering color definitions with every possible alpha variant --- src/components/shared/DiffRenderer.tsx | 10 +++++----- src/styles/colors.tsx | 10 ++++------ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 39c304c76..876e5037f 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -227,14 +227,14 @@ const SelectableDiffLineWrapper = styled(DiffLineWrapper)<{ ${({ isSelected }) => isSelected && ` - background: var(--color-review-accent-alpha-20) !important; + background: hsl(from var(--color-review-accent) h s l / 0.2) !important; `} &:hover { ${({ isSelecting }) => isSelecting && ` - background: var(--color-review-accent-alpha-10); + background: hsl(from var(--color-review-accent) h s l / 0.1); `} } `; @@ -242,7 +242,7 @@ const SelectableDiffLineWrapper = styled(DiffLineWrapper)<{ const InlineNoteContainer = styled.div` padding: 10px 8px 8px 8px; background: #252526; - border-top: 1px solid var(--color-review-accent-alpha-30); + border-top: 1px solid hsl(from var(--color-review-accent) h s l / 0.3); margin: 0; `; @@ -253,14 +253,14 @@ const NoteTextarea = styled.textarea` font-family: var(--font-sans); font-size: 11px; background: #1e1e1e; - border: 1px solid var(--color-review-accent-alpha); + border: 1px solid hsl(from var(--color-review-accent) h s l / 0.4); border-radius: 2px; color: var(--color-text); resize: vertical; &:focus { outline: none; - border-color: var(--color-review-accent-alpha-hover); + border-color: hsl(from var(--color-review-accent) h s l / 0.6); } &::placeholder { diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index 83391588f..b5dc11a59 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -117,12 +117,10 @@ export const GlobalColors = () => ( --color-interrupted-alpha: hsl(from var(--color-interrupted) h s l / 0.3); /* Review/Selection Colors (Yellow/Orange) */ - --color-review-accent: hsl(48 70% 50%); /* Muted yellow/orange - used for review notes and active hunks */ - --color-review-accent-alpha-10: hsl(from var(--color-review-accent) h s l / 0.1); - --color-review-accent-alpha-20: hsl(from var(--color-review-accent) h s l / 0.2); - --color-review-accent-alpha-30: hsl(from var(--color-review-accent) h s l / 0.3); - --color-review-accent-alpha: hsl(from var(--color-review-accent) h s l / 0.4); - --color-review-accent-alpha-hover: hsl(from var(--color-review-accent) h s l / 0.6); + --color-review-accent: hsl(48 70% 50% / 0.75); /* Muted yellow/orange - used for review notes and active hunks */ + /* ANTI-PATTERN: Don't create variants like --color-review-accent-alpha-10, -20, -30, etc. + Instead, use inline alpha: hsl(from var(--color-review-accent) h s l / 0.2) directly in components. + This avoids cluttering color definitions with every possible alpha variant. */ /* Git Dirty/Uncommitted Changes Colors */ --color-git-dirty: hsl(38 92% 50%); /* Same as interrupted - orange warning color */ From b6256dc675fb494547615c5cf5c9b1e855794f72 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:07:16 -0500 Subject: [PATCH 17/26] =?UTF-8?q?=F0=9F=A4=96=20Add=20keybind=20hints=20to?= =?UTF-8?q?=20review=20note=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add second line to the review note textarea placeholder showing: 'J, K to iterate through hunks, M to toggle as read' This helps users discover navigation and marking keybinds while composing review notes. --- src/components/shared/DiffRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 876e5037f..8ea72dde6 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -439,7 +439,7 @@ export const SelectableDiffRenderer: React.FC = ({ setNoteText(e.target.value)} onClick={(e) => e.stopPropagation()} From 1ed256638210b1a056f9f9017d3d822d704d039e Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:08:56 -0500 Subject: [PATCH 18/26] =?UTF-8?q?=F0=9F=A4=96=20Lowercase=20keybinds=20in?= =?UTF-8?q?=20review=20note=20placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/shared/DiffRenderer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index 8ea72dde6..ea68d8b75 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -439,7 +439,7 @@ export const SelectableDiffRenderer: React.FC = ({ setNoteText(e.target.value)} onClick={(e) => e.stopPropagation()} From 145a87ab425b785151ef7ee8e7ee83e6ccdd6809 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:12:46 -0500 Subject: [PATCH 19/26] =?UTF-8?q?=F0=9F=A4=96=20Make=20hunk=20active=20whe?= =?UTF-8?q?n=20clicking=20line=20to=20start=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user clicks on a line in a hunk to start selecting/commenting, that hunk now becomes the active one. This provides better visual feedback and ensures the active border appears on the hunk being interacted with. Added onLineClick callback that flows from SelectableDiffRenderer through HunkViewer to activate the parent hunk on line interaction. --- src/components/RightSidebar/CodeReview/HunkViewer.tsx | 1 + src/components/shared/DiffRenderer.tsx | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/src/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/components/RightSidebar/CodeReview/HunkViewer.tsx index 472838e59..568a9d776 100644 --- a/src/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -249,6 +249,7 @@ export const HunkViewer: React.FC = ({ oldStart={hunk.oldStart} newStart={hunk.newStart} onReviewNote={onReviewNote} + onLineClick={onClick} /> ) : ( diff --git a/src/components/shared/DiffRenderer.tsx b/src/components/shared/DiffRenderer.tsx index ea68d8b75..42ce1bf80 100644 --- a/src/components/shared/DiffRenderer.tsx +++ b/src/components/shared/DiffRenderer.tsx @@ -207,6 +207,8 @@ interface SelectableDiffRendererProps extends DiffRendererProps { filePath: string; /** Callback when user submits a review note */ onReviewNote?: (note: string) => void; + /** Callback when user clicks on a line (to activate parent hunk) */ + onLineClick?: () => void; } interface LineSelection { @@ -276,6 +278,7 @@ export const SelectableDiffRenderer: React.FC = ({ newStart = 1, filePath, onReviewNote, + onLineClick, }) => { const [selection, setSelection] = React.useState(null); const [noteText, setNoteText] = React.useState(""); @@ -333,6 +336,9 @@ export const SelectableDiffRenderer: React.FC = ({ }); const handleClick = (lineIndex: number, shiftKey: boolean) => { + // Notify parent that this hunk should become active + onLineClick?.(); + // Shift-click: extend existing selection if (shiftKey && selection && isSelectingMode) { const start = selection.startIndex; From 0a0699a42a9f3a467a4168ee5516e9b741947a45 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:14:40 -0500 Subject: [PATCH 20/26] =?UTF-8?q?=F0=9F=A4=96=20Remove=20duplicated=20evic?= =?UTF-8?q?tOldestReviews=20logic=20from=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export evictOldestReviews from useReviewState.ts and import it in the test file instead of duplicating the implementation. Tests should verify behavior, not reimplement the logic they're testing. --- src/hooks/useReviewState.test.ts | 19 +------------------ src/hooks/useReviewState.ts | 3 ++- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/src/hooks/useReviewState.test.ts b/src/hooks/useReviewState.test.ts index d5e1ad29f..1088814fa 100644 --- a/src/hooks/useReviewState.test.ts +++ b/src/hooks/useReviewState.test.ts @@ -8,24 +8,7 @@ import { describe, it, expect } from "bun:test"; import type { HunkReadState } from "@/types/review"; - -/** - * Evict oldest read states if count exceeds max - * (Extracted from hook for testing) - */ -function evictOldestReviews( - readState: Record, - maxCount: number -): Record { - const entries = Object.entries(readState); - if (entries.length <= maxCount) return readState; - - // Sort by timestamp descending (newest first) - entries.sort((a, b) => b[1].timestamp - a[1].timestamp); - - // Keep only the newest maxCount - return Object.fromEntries(entries.slice(0, maxCount)); -} +import { evictOldestReviews } from "./useReviewState"; describe("evictOldestReviews", () => { it("should not evict when under limit", () => { diff --git a/src/hooks/useReviewState.ts b/src/hooks/useReviewState.ts index bc85721f9..743071ce7 100644 --- a/src/hooks/useReviewState.ts +++ b/src/hooks/useReviewState.ts @@ -22,8 +22,9 @@ function getReviewStateKey(workspaceId: string): string { /** * Evict oldest read states if count exceeds max * Keeps the newest MAX_READ_STATES entries + * Exported for testing */ -function evictOldestReviews( +export function evictOldestReviews( readState: Record, maxCount: number ): Record { From 8cc4b7eb4688088b4f9b3f3d28f510407b093b5b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:17:50 -0500 Subject: [PATCH 21/26] =?UTF-8?q?=F0=9F=A4=96=20Auto-navigate=20to=20next?= =?UTF-8?q?=20hunk=20when=20marking=20as=20read=20with=20filter=20off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When marking a hunk as read while 'Show read hunks' is off, the marked hunk gets filtered out. Now automatically moves selection to: - Next visible hunk (preferred) - Previous hunk if at end - None if this was the last hunk This prevents the selection from disappearing and allows efficient review workflow: mark as read (m) → automatically moves to next unread hunk. --- .../RightSidebar/CodeReview/ReviewPanel.tsx | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index 2823f6c47..d26bfba4f 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -486,6 +486,30 @@ export const ReviewPanel: React.FC = ({ return hunks.filter((hunk) => !reviewState.isRead(hunk.id)); }, [hunks, filters.showReadHunks, reviewState]); + // Handle toggling read state with auto-navigation + const handleToggleRead = useCallback( + (hunkId: string) => { + const wasRead = reviewState.isRead(hunkId); + reviewState.toggleRead(hunkId); + + // If marking as read and "Show read hunks" is off, move to next visible hunk + if (!wasRead && !filters.showReadHunks) { + const currentIndex = filteredHunks.findIndex((h) => h.id === hunkId); + if (currentIndex !== -1) { + // Select the hunk that will be at the same position after filtering + if (currentIndex < filteredHunks.length - 1) { + setSelectedHunkId(filteredHunks[currentIndex + 1].id); + } else if (currentIndex > 0) { + setSelectedHunkId(filteredHunks[currentIndex - 1].id); + } else { + setSelectedHunkId(null); + } + } + } + }, + [reviewState, filters.showReadHunks, filteredHunks] + ); + // Calculate stats const stats = useMemo(() => { const total = hunks.length; @@ -544,13 +568,13 @@ export const ReviewPanel: React.FC = ({ } else if (matchesKeybind(e, KEYBINDS.TOGGLE_HUNK_READ)) { // Toggle read state of selected hunk e.preventDefault(); - reviewState.toggleRead(selectedHunkId); + handleToggleRead(selectedHunkId); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isPanelFocused, selectedHunkId, filteredHunks, reviewState]); + }, [isPanelFocused, selectedHunkId, filteredHunks, handleToggleRead]); // Global keyboard shortcut for refresh (Ctrl+R / Cmd+R) useEffect(() => { @@ -647,7 +671,7 @@ export const ReviewPanel: React.FC = ({ isSelected={isSelected} isRead={isRead} onClick={() => setSelectedHunkId(hunk.id)} - onToggleRead={() => reviewState.toggleRead(hunk.id)} + onToggleRead={() => handleToggleRead(hunk.id)} onReviewNote={onReviewNote} /> ); From d06c9598bb91d0b077851fe682dbddbfb7f3c6cc Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:19:42 -0500 Subject: [PATCH 22/26] =?UTF-8?q?=F0=9F=A4=96=20Simplify=20toggle=20read?= =?UTF-8?q?=20navigation=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of checking specific conditions (!wasRead && !showReadHunks), just check if the toggled hunk will still be visible after the toggle. A hunk is visible if: showReadHunks is on OR it will be unread after toggle. This handles all cases cleanly without special-casing mark vs unmark. --- .../RightSidebar/CodeReview/ReviewPanel.tsx | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index d26bfba4f..6e65fba7d 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -492,22 +492,27 @@ export const ReviewPanel: React.FC = ({ const wasRead = reviewState.isRead(hunkId); reviewState.toggleRead(hunkId); - // If marking as read and "Show read hunks" is off, move to next visible hunk - if (!wasRead && !filters.showReadHunks) { - const currentIndex = filteredHunks.findIndex((h) => h.id === hunkId); - if (currentIndex !== -1) { - // Select the hunk that will be at the same position after filtering - if (currentIndex < filteredHunks.length - 1) { - setSelectedHunkId(filteredHunks[currentIndex + 1].id); - } else if (currentIndex > 0) { - setSelectedHunkId(filteredHunks[currentIndex - 1].id); - } else { - setSelectedHunkId(null); + // If toggling the selected hunk, check if it will still be visible after toggle + if (hunkId === selectedHunkId) { + // Hunk is visible if: showReadHunks is on OR it will be unread after toggle + const willBeVisible = filters.showReadHunks || wasRead; + + if (!willBeVisible) { + // Hunk will be filtered out - move to next visible hunk + const currentIndex = filteredHunks.findIndex((h) => h.id === hunkId); + if (currentIndex !== -1) { + if (currentIndex < filteredHunks.length - 1) { + setSelectedHunkId(filteredHunks[currentIndex + 1].id); + } else if (currentIndex > 0) { + setSelectedHunkId(filteredHunks[currentIndex - 1].id); + } else { + setSelectedHunkId(null); + } } } } }, - [reviewState, filters.showReadHunks, filteredHunks] + [reviewState, filters.showReadHunks, filteredHunks, selectedHunkId] ); // Calculate stats From 4f293b319a25b6859da9aa435d53a0d2108e9a96 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:26:02 -0500 Subject: [PATCH 23/26] =?UTF-8?q?=F0=9F=A4=96=20Add=20eslint-disable=20com?= =?UTF-8?q?ment=20for=20intentional=20dependency=20omission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectedHunkId is intentionally omitted from the dependency array in loadDiff effect - we only want to auto-select on initial load, not on every selection change. --- src/components/RightSidebar/CodeReview/ReviewPanel.tsx | 2 ++ src/styles/colors.tsx | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx index 6e65fba7d..6536750c8 100644 --- a/src/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -440,6 +440,8 @@ export const ReviewPanel: React.FC = ({ return () => { cancelled = true; }; + // selectedHunkId intentionally omitted - only auto-select on initial load, not on every selection change + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ workspaceId, workspacePath, diff --git a/src/styles/colors.tsx b/src/styles/colors.tsx index b5dc11a59..5e45bdfba 100644 --- a/src/styles/colors.tsx +++ b/src/styles/colors.tsx @@ -117,7 +117,9 @@ export const GlobalColors = () => ( --color-interrupted-alpha: hsl(from var(--color-interrupted) h s l / 0.3); /* Review/Selection Colors (Yellow/Orange) */ - --color-review-accent: hsl(48 70% 50% / 0.75); /* Muted yellow/orange - used for review notes and active hunks */ + --color-review-accent: hsl( + 48 70% 50% / 0.75 + ); /* Muted yellow/orange - used for review notes and active hunks */ /* ANTI-PATTERN: Don't create variants like --color-review-accent-alpha-10, -20, -30, etc. Instead, use inline alpha: hsl(from var(--color-review-accent) h s l / 0.2) directly in components. This avoids cluttering color definitions with every possible alpha variant. */ From 7879f77d13600b81ac70e8d788647a017d5ab01a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:27:11 -0500 Subject: [PATCH 24/26] =?UTF-8?q?=F0=9F=A4=96=20Include=20line=20ranges=20?= =?UTF-8?q?in=20hunk=20ID=20hashing=20to=20avoid=20collisions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously only hashed filePath + content, which caused collisions when multiple hunks in the same file had identical diff content (e.g., repeated code blocks). Now includes oldStart and newStart line numbers to ensure unique IDs for each hunk. This prevents marking one hunk as read from incorrectly marking all identical hunks as read. --- src/utils/git/diffParser.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/utils/git/diffParser.ts b/src/utils/git/diffParser.ts index c8a6ff236..510771ee7 100644 --- a/src/utils/git/diffParser.ts +++ b/src/utils/git/diffParser.ts @@ -6,11 +6,16 @@ import type { DiffHunk, FileDiff } from "@/types/review"; /** * Generate a stable content-based ID for a hunk - * Uses file path + diff content (not line numbers) to survive rebases + * Uses file path + line range + diff content to ensure uniqueness */ -function generateHunkId(filePath: string, content: string): string { - // Hash file path + actual diff content for rebase stability - const str = `${filePath}:${content}`; +function generateHunkId( + filePath: string, + oldStart: number, + newStart: number, + content: string +): string { + // Hash file path + line range + diff content for uniqueness and rebase stability + const str = `${filePath}:${oldStart}-${newStart}:${content}`; let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); @@ -56,7 +61,12 @@ export function parseDiff(diffOutput: string): FileDiff[] { const finishHunk = () => { if (currentHunk && currentFile && hunkLines.length > 0) { const content = hunkLines.join("\n"); - const hunkId = generateHunkId(currentFile.filePath, content); + const hunkId = generateHunkId( + currentFile.filePath, + currentHunk.oldStart!, + currentHunk.newStart!, + content + ); currentFile.hunks.push({ ...currentHunk, id: hunkId, From a7a786130fef9b93d0a3f808fcc76fa306c9ba76 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:29:04 -0500 Subject: [PATCH 25/26] =?UTF-8?q?=F0=9F=A4=96=20Propagate=20read=20status?= =?UTF-8?q?=20up=20through=20file=20tree=20directories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directories now show aggregate read status based on descendant files: - All files fully read → directory is dimmed + blue strikethrough - Any file has unknown state → directory is dimmed only - Otherwise → directory shows normal state (partially read/unread) This provides visual feedback at directory level, making it easier to track review progress through the tree structure. --- .../RightSidebar/CodeReview/FileTree.tsx | 85 +++++++++++++++++-- 1 file changed, 77 insertions(+), 8 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index 8925cceb4..fa6462a23 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -52,9 +52,23 @@ const FileName = styled.span<{ isFullyRead?: boolean; isUnknownState?: boolean } `} `; -const DirectoryName = styled.span` +const DirectoryName = styled.span<{ isFullyRead?: boolean; isUnknownState?: boolean }>` color: #888; flex: 1; + ${(props) => + props.isFullyRead && + ` + color: #666; + text-decoration: line-through; + text-decoration-color: var(--color-read); + text-decoration-thickness: 2px; + `} + ${(props) => + props.isUnknownState && + !props.isFullyRead && + ` + color: #666; + `} `; const DirectoryStats = styled.span<{ isOpen: boolean }>` @@ -131,6 +145,51 @@ const EmptyState = styled.div` text-align: center; `; +/** + * Compute read status for a directory by recursively checking all descendant files + * Returns "fully-read" if all files are fully read, "unknown" if any file has unknown status, null otherwise + */ +function computeDirectoryReadStatus( + node: FileTreeNode, + getFileReadStatus?: (filePath: string) => { total: number; read: number } | null +): "fully-read" | "unknown" | null { + if (!node.isDirectory || !getFileReadStatus) return null; + + let hasUnknown = false; + let hasPartiallyRead = false; + let fileCount = 0; + let fullyReadCount = 0; + + const checkNode = (n: FileTreeNode) => { + if (n.isDirectory) { + // Recurse into children + n.children.forEach(checkNode); + } else { + // Check file status + fileCount++; + const status = getFileReadStatus(n.path); + if (status === null) { + hasUnknown = true; + } else if (status.read === status.total && status.total > 0) { + fullyReadCount++; + } else { + hasPartiallyRead = true; + } + } + }; + + checkNode(node); + + // If any file has unknown state, directory is unknown + if (hasUnknown) return "unknown"; + + // If all files are fully read, directory is fully read + if (fileCount > 0 && fullyReadCount === fileCount) return "fully-read"; + + // Otherwise, directory has partial/no read state + return null; +} + const TreeNodeContent: React.FC<{ node: FileTreeNode; depth: number; @@ -168,12 +227,20 @@ const TreeNodeContent: React.FC<{ }; const isSelected = selectedPath === node.path; - const readStatus = !node.isDirectory && getFileReadStatus ? getFileReadStatus(node.path) : null; - const isFullyRead = readStatus - ? readStatus.read === readStatus.total && readStatus.total > 0 - : false; - // Dim files when we don't have hunk data (unknown state) - const isUnknownState = !node.isDirectory && getFileReadStatus && readStatus === null; + + // Compute read status for files and directories + let isFullyRead = false; + let isUnknownState = false; + + if (node.isDirectory) { + const dirStatus = computeDirectoryReadStatus(node, getFileReadStatus); + isFullyRead = dirStatus === "fully-read"; + isUnknownState = dirStatus === "unknown"; + } else if (getFileReadStatus) { + const readStatus = getFileReadStatus(node.path); + isFullyRead = readStatus ? readStatus.read === readStatus.total && readStatus.total > 0 : false; + isUnknownState = readStatus === null; + } return ( <> @@ -183,7 +250,9 @@ const TreeNodeContent: React.FC<{ - {node.name || "/"} + + {node.name || "/"} + {node.totalStats && (node.totalStats.additions > 0 || node.totalStats.deletions > 0) && ( From eaae538f4c8547280edff109e970f54224fdb143 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 18 Oct 2025 21:31:09 -0500 Subject: [PATCH 26/26] =?UTF-8?q?=F0=9F=A4=96=20Remove=20unused=20hasParti?= =?UTF-8?q?allyRead=20variable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RightSidebar/CodeReview/FileTree.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/components/RightSidebar/CodeReview/FileTree.tsx b/src/components/RightSidebar/CodeReview/FileTree.tsx index fa6462a23..89f07f03c 100644 --- a/src/components/RightSidebar/CodeReview/FileTree.tsx +++ b/src/components/RightSidebar/CodeReview/FileTree.tsx @@ -156,7 +156,6 @@ function computeDirectoryReadStatus( if (!node.isDirectory || !getFileReadStatus) return null; let hasUnknown = false; - let hasPartiallyRead = false; let fileCount = 0; let fullyReadCount = 0; @@ -172,8 +171,6 @@ function computeDirectoryReadStatus( hasUnknown = true; } else if (status.read === status.total && status.total > 0) { fullyReadCount++; - } else { - hasPartiallyRead = true; } } };