From dc6ca67c3cade1e6124477c8001f1a4df8a7ef44 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 7 Dec 2025 20:24:59 -0600 Subject: [PATCH 01/25] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20stateful=20p?= =?UTF-8?q?ending=20reviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PendingReview types (pending/checked status, timestamps) - Add usePendingReviews hook for localStorage persistence per workspace - Add PendingReviewsBanner component above chat input - Thin collapsible stripe showing pending count - Expand to see review list with check/send/remove actions - Toggle between pending and checked views - Clear all checked reviews option - Uses shadcn Button component with semantic Tailwind classes - Review notes from diff viewer now queue to pending instead of direct chat - Enable review notes on inline file_edit diffs in chat messages - DRY: reuses existing onReviewNote prop through message rendering chain - AIView -> MessageRenderer -> ToolMessage -> FileEditToolCall - Reviews persist across sessions via localStorage - Add Storybook stories for pending reviews feature --- src/browser/components/AIView.tsx | 30 +- .../components/Messages/MessageRenderer.tsx | 13 +- .../components/Messages/ToolMessage.tsx | 12 +- .../components/PendingReviewsBanner.tsx | 256 ++++++++++++++++++ src/browser/hooks/usePendingReviews.ts | 196 ++++++++++++++ src/browser/stories/App.reviews.stories.tsx | 170 ++++++++++++ src/browser/stories/storyHelpers.ts | 31 +++ src/common/constants/storage.ts | 10 + src/common/types/review.ts | 38 +++ 9 files changed, 750 insertions(+), 6 deletions(-) create mode 100644 src/browser/components/PendingReviewsBanner.tsx create mode 100644 src/browser/hooks/usePendingReviews.ts create mode 100644 src/browser/stories/App.reviews.stories.tsx diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 03dc7ea25f..b342ce40d3 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -50,6 +50,8 @@ import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { useForceCompaction } from "@/browser/hooks/useForceCompaction"; import { useAPI } from "@/browser/contexts/API"; +import { usePendingReviews } from "@/browser/hooks/usePendingReviews"; +import { PendingReviewsBanner } from "./PendingReviewsBanner"; interface AIViewProps { workspaceId: string; @@ -105,6 +107,9 @@ const AIViewInner: React.FC = ({ const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); const workspaceUsage = useWorkspaceUsage(workspaceId); + + // Pending reviews state + const pendingReviews = usePendingReviews(workspaceId); const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; // Get pending model for auto-compaction settings (threshold is per-model) @@ -213,9 +218,17 @@ const AIViewInner: React.FC = ({ chatInputAPI.current = api; }, []); - // Handler for review notes from Code Review tab - const handleReviewNote = useCallback((note: string) => { - chatInputAPI.current?.appendText(note); + // Handler for review notes from Code Review tab - adds to pending reviews + const handleReviewNote = useCallback( + (note: string) => { + pendingReviews.addReview(note); + }, + [pendingReviews] + ); + + // Handler to send a review to chat input + const handleSendReviewToChat = useCallback((content: string) => { + chatInputAPI.current?.appendText(content); }, []); // Handler for manual compaction from CompactionWarning click @@ -532,6 +545,7 @@ const AIViewInner: React.FC = ({ onEditUserMessage={handleEditUserMessage} workspaceId={workspaceId} isCompacting={isCompacting} + onReviewNote={handleReviewNote} /> {isAtCutoff && ( @@ -606,6 +620,16 @@ const AIViewInner: React.FC = ({ onCompactClick={handleCompactClick} /> )} + void; workspaceId?: string; isCompacting?: boolean; + /** Handler for adding review notes from inline diffs */ + onReviewNote?: (note: string) => void; } // Memoized to prevent unnecessary re-renders when parent (AIView) updates export const MessageRenderer = React.memo( - ({ message, className, onEditUserMessage, workspaceId, isCompacting }) => { + ({ message, className, onEditUserMessage, workspaceId, isCompacting, onReviewNote }) => { // Route based on message type switch (message.type) { case "user": @@ -41,7 +43,14 @@ export const MessageRenderer = React.memo( /> ); case "tool": - return ; + return ( + + ); case "reasoning": return ; case "stream-error": diff --git a/src/browser/components/Messages/ToolMessage.tsx b/src/browser/components/Messages/ToolMessage.tsx index 77b0f61554..9329b348c5 100644 --- a/src/browser/components/Messages/ToolMessage.tsx +++ b/src/browser/components/Messages/ToolMessage.tsx @@ -40,6 +40,8 @@ interface ToolMessageProps { message: DisplayedMessage & { type: "tool" }; className?: string; workspaceId?: string; + /** Handler for adding review notes from inline diffs */ + onReviewNote?: (note: string) => void; } // Type guards using Zod schemas for single source of truth @@ -108,7 +110,12 @@ function isBashBackgroundTerminateTool( return TOOL_DEFINITIONS.bash_background_terminate.schema.safeParse(args).success; } -export const ToolMessage: React.FC = ({ message, className, workspaceId }) => { +export const ToolMessage: React.FC = ({ + message, + className, + workspaceId, + onReviewNote, +}) => { // Route to specialized components based on tool name if (isBashTool(message.toolName, message.args)) { return ( @@ -143,6 +150,7 @@ export const ToolMessage: React.FC = ({ message, className, wo args={message.args} result={message.result as FileEditReplaceStringToolResult | undefined} status={message.status} + onReviewNote={onReviewNote} /> ); @@ -156,6 +164,7 @@ export const ToolMessage: React.FC = ({ message, className, wo args={message.args} result={message.result as FileEditInsertToolResult | undefined} status={message.status} + onReviewNote={onReviewNote} /> ); @@ -169,6 +178,7 @@ export const ToolMessage: React.FC = ({ message, className, wo args={message.args} result={message.result as FileEditReplaceLinesToolResult | undefined} status={message.status} + onReviewNote={onReviewNote} /> ); diff --git a/src/browser/components/PendingReviewsBanner.tsx b/src/browser/components/PendingReviewsBanner.tsx new file mode 100644 index 0000000000..7ce9470e9f --- /dev/null +++ b/src/browser/components/PendingReviewsBanner.tsx @@ -0,0 +1,256 @@ +/** + * PendingReviewsBanner - Shows pending code reviews in the chat area + * Displays as a thin collapsible stripe above the chat input + * + * Uses shadcn/ui Button component and semantic Tailwind color classes + * that map to CSS variables defined in globals.css. + */ + +import React, { useState, useCallback, useMemo } from "react"; +import { + ChevronDown, + ChevronUp, + Check, + Undo2, + Send, + Trash2, + MessageSquare, + Eye, + EyeOff, +} from "lucide-react"; +import { cn } from "@/common/lib/utils"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipWrapper } from "./Tooltip"; +import type { PendingReview } from "@/common/types/review"; + +interface PendingReviewsBannerProps { + /** All reviews (pending and checked) */ + reviews: PendingReview[]; + /** Count of pending reviews */ + pendingCount: number; + /** Count of checked reviews */ + checkedCount: number; + /** Mark a review as checked */ + onCheck: (reviewId: string) => void; + /** Uncheck a review */ + onUncheck: (reviewId: string) => void; + /** Send review content to chat input */ + onSendToChat: (content: string) => void; + /** Remove a review */ + onRemove: (reviewId: string) => void; + /** Clear all checked reviews */ + onClearChecked: () => void; +} + +/** + * Extract a short summary from review content for display + */ +function getReviewSummary(review: PendingReview): string { + // Extract the user's note from the review content (after the code block) + const noteMatch = /```\n> (.+?)\n<\/review>/s.exec(review.content); + if (noteMatch) { + const note = noteMatch[1].trim(); + return note.length > 50 ? note.slice(0, 50) + "…" : note; + } + return `${review.filePath}:${review.lineRange}`; +} + +/** + * Single review item in the list + */ +const ReviewItem: React.FC<{ + review: PendingReview; + onCheck: () => void; + onUncheck: () => void; + onSendToChat: () => void; + onRemove: () => void; +}> = ({ review, onCheck, onUncheck, onSendToChat, onRemove }) => { + const isChecked = review.status === "checked"; + + return ( +
+ {/* Check/Uncheck button */} + + + {isChecked ? "Mark as pending" : "Mark as done"} + + + {/* Review info */} +
+
+ + {review.filePath}:{review.lineRange} + +
+
{getReviewSummary(review)}
+
+ + {/* Actions */} +
+ + + Send to chat + + + + + Remove + +
+
+ ); +}; + +export const PendingReviewsBanner: React.FC = ({ + reviews, + pendingCount, + checkedCount, + onCheck, + onUncheck, + onSendToChat, + onRemove, + onClearChecked, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [showChecked, setShowChecked] = useState(false); + + // Filter reviews based on view mode + const displayedReviews = useMemo(() => { + if (showChecked) { + return reviews.filter((r) => r.status === "checked"); + } + return reviews.filter((r) => r.status === "pending"); + }, [reviews, showChecked]); + + const handleToggle = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const handleToggleShowChecked = useCallback(() => { + setShowChecked((prev) => !prev); + }, []); + + // Don't show anything if no reviews + if (reviews.length === 0) { + return null; + } + + return ( +
+ {/* Collapsed banner - thin stripe */} + + + {/* Expanded view */} + {isExpanded && ( +
+ {/* View toggle and actions */} +
+
+ + + + {showChecked ? "Showing checked reviews" : "Showing pending reviews"} + + +
+ + {showChecked && checkedCount > 0 && ( + + )} +
+ + {/* Review list */} +
+ {displayedReviews.length === 0 ? ( +
+ {showChecked ? "No checked reviews" : "No pending reviews"} +
+ ) : ( + displayedReviews.map((review) => ( + onCheck(review.id)} + onUncheck={() => onUncheck(review.id)} + onSendToChat={() => onSendToChat(review.content)} + onRemove={() => onRemove(review.id)} + /> + )) + )} +
+
+ )} +
+ ); +}; diff --git a/src/browser/hooks/usePendingReviews.ts b/src/browser/hooks/usePendingReviews.ts new file mode 100644 index 0000000000..90b26fcb34 --- /dev/null +++ b/src/browser/hooks/usePendingReviews.ts @@ -0,0 +1,196 @@ +/** + * Hook for managing pending reviews per workspace + * Provides interface for adding, checking, and removing reviews + */ + +import { useCallback, useMemo } from "react"; +import { usePersistedState } from "./usePersistedState"; +import { getPendingReviewsKey } from "@/common/constants/storage"; +import type { PendingReview, PendingReviewsState } from "@/common/types/review"; + +/** + * Parse a review note to extract file path and line range + * Expected format: \nRe filePath:lineRange\n... + */ +function parseReviewNote(content: string): { filePath: string; lineRange: string } { + const match = /Re ([^:]+):(\d+(?:-\d+)?)/.exec(content); + if (match) { + return { filePath: match[1], lineRange: match[2] }; + } + return { filePath: "unknown", lineRange: "?" }; +} + +/** + * Generate a unique ID for a review + */ +function generateReviewId(): string { + return `review-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +export interface UsePendingReviewsReturn { + /** All reviews (pending and checked) */ + reviews: PendingReview[]; + /** Count of pending (unchecked) reviews */ + pendingCount: number; + /** Count of checked reviews */ + checkedCount: number; + /** Add a new review from a review note */ + addReview: (content: string) => PendingReview; + /** Mark a review as checked */ + checkReview: (reviewId: string) => void; + /** Uncheck a review (mark as pending again) */ + uncheckReview: (reviewId: string) => void; + /** Remove a review entirely */ + removeReview: (reviewId: string) => void; + /** Clear all checked reviews */ + clearChecked: () => void; + /** Get a review by ID */ + getReview: (reviewId: string) => PendingReview | undefined; +} + +/** + * Hook for managing pending reviews for a workspace + * Persists reviews to localStorage + */ +export function usePendingReviews(workspaceId: string): UsePendingReviewsReturn { + const [state, setState] = usePersistedState( + getPendingReviewsKey(workspaceId), + { + workspaceId, + reviews: {}, + lastUpdated: Date.now(), + } + ); + + // Convert reviews object to sorted array (newest first) + const reviews = useMemo(() => { + return Object.values(state.reviews).sort((a, b) => b.createdAt - a.createdAt); + }, [state.reviews]); + + // Count pending and checked reviews + const pendingCount = useMemo(() => { + return reviews.filter((r) => r.status === "pending").length; + }, [reviews]); + + const checkedCount = useMemo(() => { + return reviews.filter((r) => r.status === "checked").length; + }, [reviews]); + + const addReview = useCallback( + (content: string): PendingReview => { + const { filePath, lineRange } = parseReviewNote(content); + const review: PendingReview = { + id: generateReviewId(), + content, + filePath, + lineRange, + status: "pending", + createdAt: Date.now(), + }; + + setState((prev) => ({ + ...prev, + reviews: { + ...prev.reviews, + [review.id]: review, + }, + lastUpdated: Date.now(), + })); + + return review; + }, + [setState] + ); + + const checkReview = useCallback( + (reviewId: string) => { + setState((prev) => { + const review = prev.reviews[reviewId]; + if (!review || review.status === "checked") return prev; + + return { + ...prev, + reviews: { + ...prev.reviews, + [reviewId]: { + ...review, + status: "checked", + statusChangedAt: Date.now(), + }, + }, + lastUpdated: Date.now(), + }; + }); + }, + [setState] + ); + + const uncheckReview = useCallback( + (reviewId: string) => { + setState((prev) => { + const review = prev.reviews[reviewId]; + if (!review || review.status === "pending") return prev; + + return { + ...prev, + reviews: { + ...prev.reviews, + [reviewId]: { + ...review, + status: "pending", + statusChangedAt: Date.now(), + }, + }, + lastUpdated: Date.now(), + }; + }); + }, + [setState] + ); + + const removeReview = useCallback( + (reviewId: string) => { + setState((prev) => { + const { [reviewId]: _, ...rest } = prev.reviews; + return { + ...prev, + reviews: rest, + lastUpdated: Date.now(), + }; + }); + }, + [setState] + ); + + const clearChecked = useCallback(() => { + setState((prev) => { + const filtered = Object.fromEntries( + Object.entries(prev.reviews).filter(([_, r]) => r.status !== "checked") + ); + return { + ...prev, + reviews: filtered, + lastUpdated: Date.now(), + }; + }); + }, [setState]); + + const getReview = useCallback( + (reviewId: string): PendingReview | undefined => { + return state.reviews[reviewId]; + }, + [state.reviews] + ); + + return { + reviews, + pendingCount, + checkedCount, + addReview, + checkReview, + uncheckReview, + removeReview, + clearChecked, + getReview, + }; +} diff --git a/src/browser/stories/App.reviews.stories.tsx b/src/browser/stories/App.reviews.stories.tsx new file mode 100644 index 0000000000..a12f6ed793 --- /dev/null +++ b/src/browser/stories/App.reviews.stories.tsx @@ -0,0 +1,170 @@ +/** + * Stories for pending reviews feature + */ + +import { appMeta, AppWithMocks, type AppStory } from "./meta.js"; +import { setupSimpleChatStory, setPendingReviews, createPendingReview } from "./storyHelpers"; +import { createUserMessage, createAssistantMessage } from "./mockFactory"; + +export default { + ...appMeta, + title: "App/Reviews", +}; + +/** + * Shows pending reviews banner with multiple reviews in different states. + * Banner appears above chat input as a thin collapsible stripe. + */ +export const PendingReviewsBanner: AppStory = { + render: () => ( + { + const workspaceId = "ws-reviews"; + + // Set up pending reviews + setPendingReviews(workspaceId, [ + createPendingReview( + "review-1", + "src/api/auth.ts", + "42-48", + "Consider using a constant for the token expiry", + "pending" + ), + createPendingReview( + "review-2", + "src/utils/helpers.ts", + "15", + "This function could be simplified", + "pending" + ), + createPendingReview( + "review-3", + "src/components/Button.tsx", + "23-25", + "Already addressed in another PR", + "checked" + ), + ]); + + return setupSimpleChatStory({ + workspaceId, + workspaceName: "feature/auth", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Add authentication to the API", { historySequence: 1 }), + createAssistantMessage("msg-2", "I'll help you add authentication.", { + historySequence: 2, + }), + ], + }); + }} + /> + ), +}; + +/** + * Shows empty state - no pending reviews banner when there are no reviews. + */ +export const NoPendingReviews: AppStory = { + render: () => ( + { + return setupSimpleChatStory({ + workspaceId: "ws-no-reviews", + workspaceName: "feature/clean", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Help me refactor this code", { historySequence: 1 }), + createAssistantMessage("msg-2", "I'd be happy to help with refactoring.", { + historySequence: 2, + }), + ], + }); + }} + /> + ), +}; + +/** + * Shows banner with only checked reviews (all pending resolved). + */ +export const AllReviewsChecked: AppStory = { + render: () => ( + { + const workspaceId = "ws-all-checked"; + + setPendingReviews(workspaceId, [ + createPendingReview( + "review-1", + "src/api/users.ts", + "10-15", + "Fixed the null check", + "checked" + ), + createPendingReview( + "review-2", + "src/utils/format.ts", + "42", + "Added error handling", + "checked" + ), + ]); + + return setupSimpleChatStory({ + workspaceId, + workspaceName: "feature/fixes", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Fix the reported issues", { historySequence: 1 }), + createAssistantMessage("msg-2", "All issues have been addressed.", { + historySequence: 2, + }), + ], + }); + }} + /> + ), +}; + +/** + * Shows banner with many pending reviews to test scrolling. + */ +export const ManyPendingReviews: AppStory = { + render: () => ( + { + const workspaceId = "ws-many-reviews"; + + // Create many reviews to test scroll behavior + const reviews = Array.from({ length: 10 }, (_, i) => + createPendingReview( + `review-${i + 1}`, + `src/components/Feature${i + 1}.tsx`, + `${10 + i * 5}-${15 + i * 5}`, + `Review comment ${i + 1}: This needs attention`, + i < 7 ? "pending" : "checked" + ) + ); + + setPendingReviews(workspaceId, reviews); + + return setupSimpleChatStory({ + workspaceId, + workspaceName: "feature/big-refactor", + projectName: "my-app", + messages: [ + createUserMessage("msg-1", "Review all the changes", { historySequence: 1 }), + createAssistantMessage( + "msg-2", + "I've reviewed the changes. There are several items to address.", + { + historySequence: 2, + } + ), + ], + }); + }} + /> + ), +}; diff --git a/src/browser/stories/storyHelpers.ts b/src/browser/stories/storyHelpers.ts index 19161f6e9a..0f42a37bd6 100644 --- a/src/browser/stories/storyHelpers.ts +++ b/src/browser/stories/storyHelpers.ts @@ -13,7 +13,9 @@ import { EXPANDED_PROJECTS_KEY, getInputKey, getModelKey, + getPendingReviewsKey, } from "@/common/constants/storage"; +import type { PendingReview, PendingReviewsState } from "@/common/types/review"; import { DEFAULT_MODEL } from "@/common/constants/knownModels"; import { createWorkspace, @@ -57,6 +59,35 @@ export function expandProjects(projectPaths: string[]): void { localStorage.setItem(EXPANDED_PROJECTS_KEY, JSON.stringify(projectPaths)); } +/** Set pending reviews for a workspace */ +export function setPendingReviews(workspaceId: string, reviews: PendingReview[]): void { + const state: PendingReviewsState = { + workspaceId, + reviews: Object.fromEntries(reviews.map((r) => [r.id, r])), + lastUpdated: Date.now(), + }; + localStorage.setItem(getPendingReviewsKey(workspaceId), JSON.stringify(state)); +} + +/** Create a sample pending review for stories */ +export function createPendingReview( + id: string, + filePath: string, + lineRange: string, + note: string, + status: "pending" | "checked" = "pending" +): PendingReview { + return { + id, + content: `\nRe ${filePath}:${lineRange}\n\`\`\`\n// sample code\n\`\`\`\n> ${note}\n`, + filePath, + lineRange, + status, + createdAt: Date.now() - Math.random() * 3600000, // Random time in last hour + statusChangedAt: status === "checked" ? Date.now() : undefined, + }; +} + // ═══════════════════════════════════════════════════════════════════════════════ // GIT STATUS EXECUTOR // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 80127b7b1a..8af88d10c6 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -223,6 +223,15 @@ export function getReviewSearchStateKey(workspaceId: string): string { return `reviewSearchState:${workspaceId}`; } +/** + * Get the localStorage key for pending reviews per workspace + * Stores: PendingReviewsState (reviews created from diff viewer) + * Format: "pendingReviews:{workspaceId}" + */ +export function getPendingReviewsKey(workspaceId: string): string { + return `pendingReviews:${workspaceId}`; +} + /** * Get the localStorage key for auto-compaction enabled preference per workspace * Format: "autoCompaction:enabled:{workspaceId}" @@ -253,6 +262,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getReviewExpandStateKey, getFileTreeExpandStateKey, getReviewSearchStateKey, + getPendingReviewsKey, getAutoCompactionEnabledKey, getStatusUrlKey, // Note: getAutoCompactionThresholdKey is per-model, not per-workspace diff --git a/src/common/types/review.ts b/src/common/types/review.ts index 2a4e2fe9ba..1a1bc67dea 100644 --- a/src/common/types/review.ts +++ b/src/common/types/review.ts @@ -93,3 +93,41 @@ export interface ReviewStats { /** Number of unread hunks */ unread: number; } + +/** + * Status of a pending review + */ +export type PendingReviewStatus = "pending" | "checked"; + +/** + * A single pending review note + * Created when user adds a review note from the diff viewer + */ +export interface PendingReview { + /** Unique identifier */ + id: string; + /** The review note content (includes tags and context) */ + content: string; + /** File path referenced in the review */ + filePath: string; + /** Line range referenced (e.g., "42-45") */ + lineRange: string; + /** Current status */ + status: PendingReviewStatus; + /** Timestamp when created */ + createdAt: number; + /** Timestamp when status changed (checked/unchecked) */ + statusChangedAt?: number; +} + +/** + * Persisted state for pending reviews (per workspace) + */ +export interface PendingReviewsState { + /** Workspace ID */ + workspaceId: string; + /** All reviews keyed by ID */ + reviews: Record; + /** Last update timestamp */ + lastUpdated: number; +} From 600952574408eb0f9e7cdaed409c0450054ec3b7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 8 Dec 2025 11:58:24 -0600 Subject: [PATCH 02/25] refactor: store ReviewNoteData instead of parsing formatted strings - Add ReviewNoteData interface for structured review data - Add formatReviewNoteForChat() to format data when sending to chat - Update PendingReview.data to hold ReviewNoteData instead of content string - Update all onReviewNote callbacks throughout the callback chain - Simplify getReviewSummary() to use review.data.userNote directly - Remove string parsing regex from banner component --- src/browser/components/AIView.tsx | 12 ++++--- .../components/Messages/MessageRenderer.tsx | 3 +- .../components/Messages/ToolMessage.tsx | 3 +- .../components/PendingReviewsBanner.tsx | 17 ++++------ src/browser/components/RightSidebar.tsx | 3 +- .../RightSidebar/CodeReview/HunkViewer.tsx | 4 +-- .../RightSidebar/CodeReview/ReviewPanel.tsx | 4 +-- .../components/shared/DiffRenderer.tsx | 26 +++++++++------ .../components/tools/FileEditToolCall.tsx | 5 +-- src/browser/hooks/usePendingReviews.ts | 25 +++------------ src/browser/stories/storyHelpers.ts | 9 ++++-- src/common/types/review.ts | 32 +++++++++++++++---- 12 files changed, 79 insertions(+), 64 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index b342ce40d3..57fc9de72e 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -52,6 +52,8 @@ import { useForceCompaction } from "@/browser/hooks/useForceCompaction"; import { useAPI } from "@/browser/contexts/API"; import { usePendingReviews } from "@/browser/hooks/usePendingReviews"; import { PendingReviewsBanner } from "./PendingReviewsBanner"; +import type { ReviewNoteData } from "@/common/types/review"; +import { formatReviewNoteForChat } from "@/common/types/review"; interface AIViewProps { workspaceId: string; @@ -220,15 +222,15 @@ const AIViewInner: React.FC = ({ // Handler for review notes from Code Review tab - adds to pending reviews const handleReviewNote = useCallback( - (note: string) => { - pendingReviews.addReview(note); + (data: ReviewNoteData) => { + pendingReviews.addReview(data); }, [pendingReviews] ); - // Handler to send a review to chat input - const handleSendReviewToChat = useCallback((content: string) => { - chatInputAPI.current?.appendText(content); + // Handler to send a review to chat input (formats to message string) + const handleSendReviewToChat = useCallback((data: ReviewNoteData) => { + chatInputAPI.current?.appendText(formatReviewNoteForChat(data)); }, []); // Handler for manual compaction from CompactionWarning click diff --git a/src/browser/components/Messages/MessageRenderer.tsx b/src/browser/components/Messages/MessageRenderer.tsx index a175a8c116..fca1ad1726 100644 --- a/src/browser/components/Messages/MessageRenderer.tsx +++ b/src/browser/components/Messages/MessageRenderer.tsx @@ -1,5 +1,6 @@ import React from "react"; import type { DisplayedMessage } from "@/common/types/message"; +import type { ReviewNoteData } from "@/common/types/review"; import { UserMessage } from "./UserMessage"; import { AssistantMessage } from "./AssistantMessage"; import { ToolMessage } from "./ToolMessage"; @@ -16,7 +17,7 @@ interface MessageRendererProps { workspaceId?: string; isCompacting?: boolean; /** Handler for adding review notes from inline diffs */ - onReviewNote?: (note: string) => void; + onReviewNote?: (data: ReviewNoteData) => void; } // Memoized to prevent unnecessary re-renders when parent (AIView) updates diff --git a/src/browser/components/Messages/ToolMessage.tsx b/src/browser/components/Messages/ToolMessage.tsx index 9329b348c5..1de41ffb36 100644 --- a/src/browser/components/Messages/ToolMessage.tsx +++ b/src/browser/components/Messages/ToolMessage.tsx @@ -35,13 +35,14 @@ import type { WebFetchToolArgs, WebFetchToolResult, } from "@/common/types/tools"; +import type { ReviewNoteData } from "@/common/types/review"; interface ToolMessageProps { message: DisplayedMessage & { type: "tool" }; className?: string; workspaceId?: string; /** Handler for adding review notes from inline diffs */ - onReviewNote?: (note: string) => void; + onReviewNote?: (data: ReviewNoteData) => void; } // Type guards using Zod schemas for single source of truth diff --git a/src/browser/components/PendingReviewsBanner.tsx b/src/browser/components/PendingReviewsBanner.tsx index 7ce9470e9f..166176911d 100644 --- a/src/browser/components/PendingReviewsBanner.tsx +++ b/src/browser/components/PendingReviewsBanner.tsx @@ -21,7 +21,7 @@ import { import { cn } from "@/common/lib/utils"; import { Button } from "./ui/button"; import { Tooltip, TooltipWrapper } from "./Tooltip"; -import type { PendingReview } from "@/common/types/review"; +import type { PendingReview, ReviewNoteData } from "@/common/types/review"; interface PendingReviewsBannerProps { /** All reviews (pending and checked) */ @@ -35,7 +35,7 @@ interface PendingReviewsBannerProps { /** Uncheck a review */ onUncheck: (reviewId: string) => void; /** Send review content to chat input */ - onSendToChat: (content: string) => void; + onSendToChat: (data: ReviewNoteData) => void; /** Remove a review */ onRemove: (reviewId: string) => void; /** Clear all checked reviews */ @@ -46,13 +46,8 @@ interface PendingReviewsBannerProps { * Extract a short summary from review content for display */ function getReviewSummary(review: PendingReview): string { - // Extract the user's note from the review content (after the code block) - const noteMatch = /```\n> (.+?)\n<\/review>/s.exec(review.content); - if (noteMatch) { - const note = noteMatch[1].trim(); - return note.length > 50 ? note.slice(0, 50) + "…" : note; - } - return `${review.filePath}:${review.lineRange}`; + const note = review.data.userNote.trim(); + return note.length > 50 ? note.slice(0, 50) + "…" : note; } /** @@ -91,7 +86,7 @@ const ReviewItem: React.FC<{
- {review.filePath}:{review.lineRange} + {review.data.filePath}:{review.data.lineRange}
{getReviewSummary(review)}
@@ -243,7 +238,7 @@ export const PendingReviewsBanner: React.FC = ({ review={review} onCheck={() => onCheck(review.id)} onUncheck={() => onUncheck(review.id)} - onSendToChat={() => onSendToChat(review.content)} + onSendToChat={() => onSendToChat(review.data)} onRemove={() => onRemove(review.id)} /> )) diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index c5eef45ab9..a98855ce50 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -12,6 +12,7 @@ import { calculateTokenMeterData } from "@/common/utils/tokens/tokenMeterUtils"; import { matchesKeybind, KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { cn } from "@/common/lib/utils"; +import type { ReviewNoteData } from "@/common/types/review"; interface SidebarContainerProps { collapsed: boolean; @@ -84,7 +85,7 @@ interface RightSidebarProps { /** Whether currently resizing */ isResizing?: boolean; /** Callback when user adds a review note from Code Review tab */ - onReviewNote?: (note: string) => void; + onReviewNote?: (data: ReviewNoteData) => void; /** Workspace is still being created (git operations in progress) */ isCreating?: boolean; } diff --git a/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx b/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx index 64341a682b..8b88107415 100644 --- a/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx +++ b/src/browser/components/RightSidebar/CodeReview/HunkViewer.tsx @@ -3,7 +3,7 @@ */ import React, { useState, useMemo } from "react"; -import type { DiffHunk } from "@/common/types/review"; +import type { DiffHunk, ReviewNoteData } from "@/common/types/review"; import { SelectableDiffRenderer } from "../../shared/DiffRenderer"; import { type SearchHighlightConfig, @@ -24,7 +24,7 @@ interface HunkViewerProps { onClick?: (e: React.MouseEvent) => void; onToggleRead?: (e: React.MouseEvent) => void; onRegisterToggleExpand?: (hunkId: string, toggleFn: () => void) => void; - onReviewNote?: (note: string) => void; + onReviewNote?: (data: ReviewNoteData) => void; searchConfig?: SearchHighlightConfig; } diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 6ec5eb679b..ed3448dca0 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -32,7 +32,7 @@ import { parseDiff, extractAllHunks, buildGitDiffCommand } from "@/common/utils/ import { getReviewSearchStateKey } from "@/common/constants/storage"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip"; import { parseNumstat, buildFileTree, extractNewPath } from "@/common/utils/git/numstatParser"; -import type { DiffHunk, ReviewFilters as ReviewFiltersType } from "@/common/types/review"; +import type { DiffHunk, ReviewFilters as ReviewFiltersType, ReviewNoteData } from "@/common/types/review"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { matchesKeybind, KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { applyFrontendFilters } from "@/browser/utils/review/filterHunks"; @@ -42,7 +42,7 @@ import { useAPI } from "@/browser/contexts/API"; interface ReviewPanelProps { workspaceId: string; workspacePath: string; - onReviewNote?: (note: string) => void; + onReviewNote?: (data: ReviewNoteData) => void; /** Trigger to focus panel (increment to trigger) */ focusTrigger?: number; /** Workspace is still being created (git operations in progress) */ diff --git a/src/browser/components/shared/DiffRenderer.tsx b/src/browser/components/shared/DiffRenderer.tsx index 6d2c4621c5..f60199a4b6 100644 --- a/src/browser/components/shared/DiffRenderer.tsx +++ b/src/browser/components/shared/DiffRenderer.tsx @@ -18,6 +18,7 @@ import { highlightSearchMatches, type SearchHighlightConfig, } from "@/browser/utils/highlighting/highlightSearchTerms"; +import type { ReviewNoteData } from "@/common/types/review"; // Shared type for diff line types export type DiffLineType = "add" | "remove" | "context" | "header"; @@ -313,8 +314,8 @@ export const DiffRenderer: React.FC = ({ interface SelectableDiffRendererProps extends Omit { /** File path for generating review notes */ filePath: string; - /** Callback when user submits a review note */ - onReviewNote?: (note: string) => void; + /** Callback when user submits a review note with structured data */ + onReviewNote?: (data: ReviewNoteData) => void; /** Callback when user clicks on a line (to activate parent hunk) */ onLineClick?: () => void; /** Search highlight configuration (optional) */ @@ -339,7 +340,7 @@ interface ReviewNoteInputProps { lineData: Array<{ index: number; type: DiffLineType; lineNum: number }>; lines: string[]; // Original diff lines with +/- prefix filePath: string; - onSubmit: (note: string) => void; + onSubmit: (data: ReviewNoteData) => void; onCancel: () => void; } @@ -379,20 +380,25 @@ const ReviewNoteInput: React.FC = React.memo( }); // Elide middle lines if more than 3 lines selected - let selectedLines: string; + let selectedCode: string; if (allLines.length <= 3) { - selectedLines = allLines.join("\n"); + selectedCode = allLines.join("\n"); } else { const omittedCount = allLines.length - 2; - selectedLines = [ + selectedCode = [ allLines[0], ` (${omittedCount} lines omitted)`, allLines[allLines.length - 1], ].join("\n"); } - const reviewNote = `\nRe ${filePath}:${lineRange}\n\`\`\`\n${selectedLines}\n\`\`\`\n> ${noteText.trim()}\n`; - onSubmit(reviewNote); + // Pass structured data instead of formatted message + onSubmit({ + filePath, + lineRange, + selectedCode, + userNote: noteText.trim(), + }); }; return ( @@ -530,9 +536,9 @@ export const SelectableDiffRenderer = React.memo( }); }; - const handleSubmitNote = (reviewNote: string) => { + const handleSubmitNote = (data: ReviewNoteData) => { if (!onReviewNote) return; - onReviewNote(reviewNote); + onReviewNote(data); setSelection(null); }; diff --git a/src/browser/components/tools/FileEditToolCall.tsx b/src/browser/components/tools/FileEditToolCall.tsx index ff2c52d518..53eaca55a1 100644 --- a/src/browser/components/tools/FileEditToolCall.tsx +++ b/src/browser/components/tools/FileEditToolCall.tsx @@ -25,6 +25,7 @@ import { useToolExpansion, getStatusDisplay, type ToolStatus } from "./shared/to import { useCopyToClipboard } from "@/browser/hooks/useCopyToClipboard"; import { DiffContainer, DiffRenderer, SelectableDiffRenderer } from "../shared/DiffRenderer"; import { KebabMenu, type KebabMenuItem } from "../KebabMenu"; +import type { ReviewNoteData } from "@/common/types/review"; type FileEditOperationArgs = | FileEditReplaceStringToolArgs @@ -41,13 +42,13 @@ interface FileEditToolCallProps { args: FileEditOperationArgs; result?: FileEditToolResult; status?: ToolStatus; - onReviewNote?: (note: string) => void; + onReviewNote?: (data: ReviewNoteData) => void; } function renderDiff( diff: string, filePath?: string, - onReviewNote?: (note: string) => void + onReviewNote?: (data: ReviewNoteData) => void ): React.ReactNode { try { const patches = parsePatch(diff); diff --git a/src/browser/hooks/usePendingReviews.ts b/src/browser/hooks/usePendingReviews.ts index 90b26fcb34..901f228d87 100644 --- a/src/browser/hooks/usePendingReviews.ts +++ b/src/browser/hooks/usePendingReviews.ts @@ -6,19 +6,7 @@ import { useCallback, useMemo } from "react"; import { usePersistedState } from "./usePersistedState"; import { getPendingReviewsKey } from "@/common/constants/storage"; -import type { PendingReview, PendingReviewsState } from "@/common/types/review"; - -/** - * Parse a review note to extract file path and line range - * Expected format: \nRe filePath:lineRange\n... - */ -function parseReviewNote(content: string): { filePath: string; lineRange: string } { - const match = /Re ([^:]+):(\d+(?:-\d+)?)/.exec(content); - if (match) { - return { filePath: match[1], lineRange: match[2] }; - } - return { filePath: "unknown", lineRange: "?" }; -} +import type { PendingReview, PendingReviewsState, ReviewNoteData } from "@/common/types/review"; /** * Generate a unique ID for a review @@ -34,8 +22,8 @@ export interface UsePendingReviewsReturn { pendingCount: number; /** Count of checked reviews */ checkedCount: number; - /** Add a new review from a review note */ - addReview: (content: string) => PendingReview; + /** Add a new review from structured data */ + addReview: (data: ReviewNoteData) => PendingReview; /** Mark a review as checked */ checkReview: (reviewId: string) => void; /** Uncheck a review (mark as pending again) */ @@ -77,13 +65,10 @@ export function usePendingReviews(workspaceId: string): UsePendingReviewsReturn }, [reviews]); const addReview = useCallback( - (content: string): PendingReview => { - const { filePath, lineRange } = parseReviewNote(content); + (data: ReviewNoteData): PendingReview => { const review: PendingReview = { id: generateReviewId(), - content, - filePath, - lineRange, + data, status: "pending", createdAt: Date.now(), }; diff --git a/src/browser/stories/storyHelpers.ts b/src/browser/stories/storyHelpers.ts index 0f42a37bd6..d551ec8fbe 100644 --- a/src/browser/stories/storyHelpers.ts +++ b/src/browser/stories/storyHelpers.ts @@ -79,9 +79,12 @@ export function createPendingReview( ): PendingReview { return { id, - content: `\nRe ${filePath}:${lineRange}\n\`\`\`\n// sample code\n\`\`\`\n> ${note}\n`, - filePath, - lineRange, + data: { + filePath, + lineRange, + selectedCode: "// sample code", + userNote: note, + }, status, createdAt: Date.now() - Math.random() * 3600000, // Random time in last hour statusChangedAt: status === "checked" ? Date.now() : undefined, diff --git a/src/common/types/review.ts b/src/common/types/review.ts index 1a1bc67dea..a9da75fd8f 100644 --- a/src/common/types/review.ts +++ b/src/common/types/review.ts @@ -99,6 +99,22 @@ export interface ReviewStats { */ export type PendingReviewStatus = "pending" | "checked"; +/** + * Structured data for a review note. + * Passed from DiffRenderer when user creates a review. + * Stored as-is for rich UI display, formatted to message only when sending to chat. + */ +export interface ReviewNoteData { + /** File path being reviewed */ + filePath: string; + /** Line range (e.g., "42" or "42-45") */ + lineRange: string; + /** Selected code lines with line numbers and +/-/space indicators */ + selectedCode: string; + /** User's review comment */ + userNote: string; +} + /** * A single pending review note * Created when user adds a review note from the diff viewer @@ -106,12 +122,8 @@ export type PendingReviewStatus = "pending" | "checked"; export interface PendingReview { /** Unique identifier */ id: string; - /** The review note content (includes tags and context) */ - content: string; - /** File path referenced in the review */ - filePath: string; - /** Line range referenced (e.g., "42-45") */ - lineRange: string; + /** Structured review data for rich UI display */ + data: ReviewNoteData; /** Current status */ status: PendingReviewStatus; /** Timestamp when created */ @@ -131,3 +143,11 @@ export interface PendingReviewsState { /** Last update timestamp */ lastUpdated: number; } + +/** + * Format a ReviewNoteData into the message format for the model. + * Only called when sending to chat. + */ +export function formatReviewNoteForChat(data: ReviewNoteData): string { + return `\nRe ${data.filePath}:${data.lineRange}\n\`\`\`\n${data.selectedCode}\n\`\`\`\n> ${data.userNote.trim()}\n`; +} From 876f19d01cc4d6e42a920cd68d781fb546bd7fee Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 8 Dec 2025 12:04:31 -0600 Subject: [PATCH 03/25] fix: add error boundary to PendingReviewsBanner for corrupted data - Add BannerErrorBoundary class component to catch rendering errors - Show 'Reviews data corrupted' message with clear button on error - Add clearAll() method to usePendingReviews hook - Wire up onClearAll prop through AIView Handles old localStorage format gracefully by catching render errors rather than requiring migration logic. --- src/browser/components/AIView.tsx | 1 + .../components/PendingReviewsBanner.tsx | 60 ++++++++++++++++++- .../RightSidebar/CodeReview/ReviewPanel.tsx | 6 +- src/browser/hooks/usePendingReviews.ts | 11 ++++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 57fc9de72e..cec0dcc043 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -631,6 +631,7 @@ const AIViewInner: React.FC = ({ onSendToChat={handleSendReviewToChat} onRemove={pendingReviews.removeReview} onClearChecked={pendingReviews.clearChecked} + onClearAll={pendingReviews.clearAll} /> void }, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): ErrorBoundaryState { + return { hasError: true }; + } + + render() { + if (this.state.hasError) { + return ( +
+ + Reviews data corrupted + +
+ ); + } + return this.props.children; + } +} + interface PendingReviewsBannerProps { /** All reviews (pending and checked) */ reviews: PendingReview[]; @@ -40,6 +83,8 @@ interface PendingReviewsBannerProps { onRemove: (reviewId: string) => void; /** Clear all checked reviews */ onClearChecked: () => void; + /** Clear all reviews (used for error recovery) */ + onClearAll: () => void; } /** @@ -122,7 +167,7 @@ const ReviewItem: React.FC<{ ); }; -export const PendingReviewsBanner: React.FC = ({ +const PendingReviewsBannerInner: React.FC = ({ reviews, pendingCount, checkedCount, @@ -249,3 +294,14 @@ export const PendingReviewsBanner: React.FC = ({
); }; + +/** + * Exported component wrapped in error boundary + */ +export const PendingReviewsBanner: React.FC = (props) => { + return ( + + + + ); +}; diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index ed3448dca0..3331e99596 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -32,7 +32,11 @@ import { parseDiff, extractAllHunks, buildGitDiffCommand } from "@/common/utils/ import { getReviewSearchStateKey } from "@/common/constants/storage"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/ui/tooltip"; import { parseNumstat, buildFileTree, extractNewPath } from "@/common/utils/git/numstatParser"; -import type { DiffHunk, ReviewFilters as ReviewFiltersType, ReviewNoteData } from "@/common/types/review"; +import type { + DiffHunk, + ReviewFilters as ReviewFiltersType, + ReviewNoteData, +} from "@/common/types/review"; import type { FileTreeNode } from "@/common/utils/git/numstatParser"; import { matchesKeybind, KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; import { applyFrontendFilters } from "@/browser/utils/review/filterHunks"; diff --git a/src/browser/hooks/usePendingReviews.ts b/src/browser/hooks/usePendingReviews.ts index 901f228d87..487fb9ffa5 100644 --- a/src/browser/hooks/usePendingReviews.ts +++ b/src/browser/hooks/usePendingReviews.ts @@ -32,6 +32,8 @@ export interface UsePendingReviewsReturn { removeReview: (reviewId: string) => void; /** Clear all checked reviews */ clearChecked: () => void; + /** Clear all reviews (for error recovery) */ + clearAll: () => void; /** Get a review by ID */ getReview: (reviewId: string) => PendingReview | undefined; } @@ -160,6 +162,14 @@ export function usePendingReviews(workspaceId: string): UsePendingReviewsReturn }); }, [setState]); + const clearAll = useCallback(() => { + setState((prev) => ({ + ...prev, + reviews: {}, + lastUpdated: Date.now(), + })); + }, [setState]); + const getReview = useCallback( (reviewId: string): PendingReview | undefined => { return state.reviews[reviewId]; @@ -176,6 +186,7 @@ export function usePendingReviews(workspaceId: string): UsePendingReviewsReturn uncheckReview, removeReview, clearChecked, + clearAll, getReview, }; } From f1e1b4b68e2ae6ee31e365c8e689e23d437bdfd8 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 8 Dec 2025 12:19:56 -0600 Subject: [PATCH 04/25] feat: enhance pending reviews UI with full features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major improvements to PendingReviewsBanner: - Self-contained ConnectedPendingReviewsBanner - only needs workspaceId + chatInputAPI - Full review display with expandable diff viewer and editable comments - Pending reviews first, then completed reviews with 'show more' load - Relative timestamps (ages) for each review - updateReviewNote() method in usePendingReviews hook Styled review rendering in chat: - New shared ReviewBlock component in shared/ReviewBlock.tsx - Reviews render as styled cards in UserMessage (not raw text) - Live preview of reviews in ChatInput before sending - Custom 'review' element handler in MarkdownComponents Also removes ~10 props from AIView → PendingReviewsBanner wiring --- src/browser/components/AIView.tsx | 20 +- src/browser/components/ChatInput/index.tsx | 10 + .../Messages/MarkdownComponents.tsx | 28 + .../components/Messages/UserMessage.tsx | 17 +- .../components/PendingReviewsBanner.tsx | 498 ++++++++++++------ src/browser/components/shared/ReviewBlock.tsx | 165 ++++++ src/browser/hooks/usePendingReviews.ts | 28 + 7 files changed, 579 insertions(+), 187 deletions(-) create mode 100644 src/browser/components/shared/ReviewBlock.tsx diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index cec0dcc043..22bfa35714 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -51,9 +51,8 @@ import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { useForceCompaction } from "@/browser/hooks/useForceCompaction"; import { useAPI } from "@/browser/contexts/API"; import { usePendingReviews } from "@/browser/hooks/usePendingReviews"; -import { PendingReviewsBanner } from "./PendingReviewsBanner"; +import { ConnectedPendingReviewsBanner } from "./PendingReviewsBanner"; import type { ReviewNoteData } from "@/common/types/review"; -import { formatReviewNoteForChat } from "@/common/types/review"; interface AIViewProps { workspaceId: string; @@ -228,11 +227,6 @@ const AIViewInner: React.FC = ({ [pendingReviews] ); - // Handler to send a review to chat input (formats to message string) - const handleSendReviewToChat = useCallback((data: ReviewNoteData) => { - chatInputAPI.current?.appendText(formatReviewNoteForChat(data)); - }, []); - // Handler for manual compaction from CompactionWarning click const handleCompactClick = useCallback(() => { chatInputAPI.current?.prependText("/compact\n"); @@ -622,17 +616,7 @@ const AIViewInner: React.FC = ({ onCompactClick={handleCompactClick} /> )} - + number; @@ -1308,6 +1309,15 @@ export const ChatInput: React.FC = (props) => { )} + {/* Review preview - show styled review blocks above input */} + {variant === "workspace" && hasReviewBlocks(input) && ( +
+ {input.match(/([\s\S]*?)<\/review>/g)?.map((match, idx) => ( + + ))} +
+ )} + {/* Command suggestions - workspace only */} {variant === "workspace" && ( = ({ code, language }) => { ); }; +// Helper to extract text content from children +function extractTextContent(children: ReactNode): string { + if (typeof children === "string") return children; + if (Array.isArray(children)) { + return children + .map((child): string => { + if (typeof child === "string") return child; + // Handle React elements - check if it's a valid element with props + if (child !== null && typeof child === "object" && "props" in child) { + const element = child as React.ReactElement<{ children?: ReactNode }>; + if (element.props.children) { + return extractTextContent(element.props.children); + } + } + return ""; + }) + .join(""); + } + return ""; +} + // Custom components for markdown rendering export const markdownComponents = { // Pass through pre element - let code component handle the wrapping pre: ({ children }: PreProps) => <>{children}, + // Custom review component - renders tags as styled blocks + review: ({ children }: { children?: ReactNode }) => { + const content = extractTextContent(children); + return ; + }, + // Custom anchor to open links externally a: ({ href, children }: AnchorProps) => ( diff --git a/src/browser/components/Messages/UserMessage.tsx b/src/browser/components/Messages/UserMessage.tsx index 9f7ffdd6e5..cf323b3b7e 100644 --- a/src/browser/components/Messages/UserMessage.tsx +++ b/src/browser/components/Messages/UserMessage.tsx @@ -9,6 +9,7 @@ import { copyToClipboard } from "@/browser/utils/clipboard"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { VIM_ENABLED_KEY } from "@/common/constants/storage"; import { Clipboard, ClipboardCheck, Pencil } from "lucide-react"; +import { ContentWithReviews, hasReviewBlocks } from "../shared/ReviewBlock"; interface UserMessageProps { message: DisplayedMessage & { type: "user" }; @@ -89,6 +90,9 @@ export const UserMessage: React.FC = ({ ); } + // Check if content has review blocks + const containsReviews = content && hasReviewBlocks(content); + // Otherwise, render as normal user message return ( = ({ variant="user" > {content && ( -
-          {content}
-        
+ containsReviews ? ( + + ) : ( +
+            {content}
+          
+ ) )} {message.imageParts && message.imageParts.length > 0 && (
diff --git a/src/browser/components/PendingReviewsBanner.tsx b/src/browser/components/PendingReviewsBanner.tsx index f3e8b21d80..193fffc9e8 100644 --- a/src/browser/components/PendingReviewsBanner.tsx +++ b/src/browser/components/PendingReviewsBanner.tsx @@ -1,32 +1,41 @@ /** - * PendingReviewsBanner - Shows pending code reviews in the chat area - * Displays as a thin collapsible stripe above the chat input + * PendingReviewsBanner - Self-contained pending reviews UI * - * Uses shadcn/ui Button component and semantic Tailwind color classes - * that map to CSS variables defined in globals.css. + * Features: + * - Collapsible banner above chat input + * - Full review display with diff and editable comments + * - Pending reviews first, then completed with "show more" + * - Relative timestamps + * - Error boundary for corrupted data */ -import React, { useState, useCallback, useMemo, Component, type ReactNode } from "react"; +import React, { useState, useCallback, useMemo, Component, type ReactNode, useRef } from "react"; import { ChevronDown, - ChevronUp, + ChevronRight, Check, Undo2, Send, Trash2, MessageSquare, - Eye, - EyeOff, AlertTriangle, + Pencil, + X, } from "lucide-react"; import { cn } from "@/common/lib/utils"; import { Button } from "./ui/button"; import { Tooltip, TooltipWrapper } from "./Tooltip"; import type { PendingReview, ReviewNoteData } from "@/common/types/review"; +import { formatReviewNoteForChat } from "@/common/types/review"; +import { usePendingReviews } from "@/browser/hooks/usePendingReviews"; +import type { ChatInputAPI } from "./ChatInput"; +import { formatRelativeTime } from "@/browser/utils/ui/dateTime"; +import { DiffRenderer } from "./shared/DiffRenderer"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// ERROR BOUNDARY +// ═══════════════════════════════════════════════════════════════════════════════ -/** - * Error boundary for the banner - catches rendering errors from malformed data - */ interface ErrorBoundaryState { hasError: boolean; } @@ -56,7 +65,7 @@ class BannerErrorBoundary extends Component< this.setState({ hasError: false }); }} > - + Clear all
@@ -66,138 +75,265 @@ class BannerErrorBoundary extends Component< } } -interface PendingReviewsBannerProps { - /** All reviews (pending and checked) */ - reviews: PendingReview[]; - /** Count of pending reviews */ - pendingCount: number; - /** Count of checked reviews */ - checkedCount: number; - /** Mark a review as checked */ - onCheck: (reviewId: string) => void; - /** Uncheck a review */ - onUncheck: (reviewId: string) => void; - /** Send review content to chat input */ - onSendToChat: (data: ReviewNoteData) => void; - /** Remove a review */ - onRemove: (reviewId: string) => void; - /** Clear all checked reviews */ - onClearChecked: () => void; - /** Clear all reviews (used for error recovery) */ - onClearAll: () => void; -} +// ═══════════════════════════════════════════════════════════════════════════════ +// REVIEW ITEM COMPONENT +// ═══════════════════════════════════════════════════════════════════════════════ -/** - * Extract a short summary from review content for display - */ -function getReviewSummary(review: PendingReview): string { - const note = review.data.userNote.trim(); - return note.length > 50 ? note.slice(0, 50) + "…" : note; -} - -/** - * Single review item in the list - */ -const ReviewItem: React.FC<{ +interface ReviewItemProps { review: PendingReview; onCheck: () => void; onUncheck: () => void; onSendToChat: () => void; onRemove: () => void; -}> = ({ review, onCheck, onUncheck, onSendToChat, onRemove }) => { + onUpdateNote: (newNote: string) => void; +} + +const ReviewItem: React.FC = ({ + review, + onCheck, + onUncheck, + onSendToChat, + onRemove, + onUpdateNote, +}) => { const isChecked = review.status === "checked"; + const [isExpanded, setIsExpanded] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editValue, setEditValue] = useState(review.data.userNote); + const textareaRef = useRef(null); + + const handleToggleExpand = useCallback(() => { + setIsExpanded((prev) => !prev); + }, []); + + const handleStartEdit = useCallback(() => { + setEditValue(review.data.userNote); + setIsEditing(true); + // Focus textarea after render + setTimeout(() => textareaRef.current?.focus(), 0); + }, [review.data.userNote]); + + const handleSaveEdit = useCallback(() => { + if (editValue.trim() !== review.data.userNote) { + onUpdateNote(editValue.trim()); + } + setIsEditing(false); + }, [editValue, review.data.userNote, onUpdateNote]); + + const handleCancelEdit = useCallback(() => { + setEditValue(review.data.userNote); + setIsEditing(false); + }, [review.data.userNote]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSaveEdit(); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancelEdit(); + } + }, + [handleSaveEdit, handleCancelEdit] + ); + + // Format code for diff display - add diff markers if not present + const diffContent = useMemo(() => { + const lines = review.data.selectedCode.split("\n"); + // Check if lines already have diff markers + const hasDiffMarkers = lines.some((l) => /^[+-\s]/.test(l)); + if (hasDiffMarkers) { + return review.data.selectedCode; + } + // Add context markers + return lines.map((l) => ` ${l}`).join("\n"); + }, [review.data.selectedCode]); + + const age = formatRelativeTime(review.createdAt); return (
- {/* Check/Uncheck button */} - - - {isChecked ? "Mark as pending" : "Mark as done"} - - - {/* Review info */} -
-
- - {review.data.filePath}:{review.data.lineRange} - -
-
{getReviewSummary(review)}
-
+ {isExpanded ? : } + - {/* Actions */} -
+ {/* Check/Uncheck button */} - Send to chat + {isChecked ? "Mark as pending" : "Mark as done"} - - - Remove - + {/* File path and age */} + + + {/* Actions */} +
+ + + Send to chat + + + + + Remove + +
+ + {/* Expanded content */} + {isExpanded && ( +
+ {/* Code diff */} +
+ +
+ + {/* Comment section */} +
+ {isEditing ? ( +
+