diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 03dc7ea25f..53b0df58d4 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -50,6 +50,9 @@ import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings"; import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions"; import { useForceCompaction } from "@/browser/hooks/useForceCompaction"; import { useAPI } from "@/browser/contexts/API"; +import { useReviews } from "@/browser/hooks/useReviews"; +import { ReviewsBanner } from "./ReviewsBanner"; +import type { ReviewNoteData } from "@/common/types/review"; interface AIViewProps { workspaceId: string; @@ -105,6 +108,9 @@ const AIViewInner: React.FC = ({ const workspaceState = useWorkspaceState(workspaceId); const aggregator = useWorkspaceAggregator(workspaceId); const workspaceUsage = useWorkspaceUsage(workspaceId); + + // Reviews state + const reviews = useReviews(workspaceId); const { options } = useProviderOptions(); const use1M = options.anthropic?.use1MContext ?? false; // Get pending model for auto-compaction settings (threshold is per-model) @@ -213,10 +219,14 @@ 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 review (starts attached) + const handleReviewNote = useCallback( + (data: ReviewNoteData) => { + reviews.addReview(data); + // New reviews start with status "attached" so they appear in chat input immediately + }, + [reviews] + ); // Handler for manual compaction from CompactionWarning click const handleCompactClick = useCallback(() => { @@ -532,6 +542,7 @@ const AIViewInner: React.FC = ({ onEditUserMessage={handleEditUserMessage} workspaceId={workspaceId} isCompacting={isCompacting} + onReviewNote={handleReviewNote} /> {isAtCutoff && ( @@ -606,6 +617,7 @@ const AIViewInner: React.FC = ({ onCompactClick={handleCompactClick} /> )} + = ({ canInterrupt={canInterrupt} onReady={handleChatInputReady} autoCompactionCheck={autoCompactionResult} + attachedReviews={reviews.attachedReviews} + onDetachReview={reviews.detachReview} + onCheckReviews={(ids) => ids.forEach((id) => reviews.checkReview(id))} + onUpdateReviewNote={reviews.updateReviewNote} /> diff --git a/src/browser/components/ChatInput/index.tsx b/src/browser/components/ChatInput/index.tsx index 247b60d299..6def801975 100644 --- a/src/browser/components/ChatInput/index.tsx +++ b/src/browser/components/ChatInput/index.tsx @@ -63,6 +63,7 @@ import { import type { ThinkingLevel } from "@/common/types/thinking"; import type { MuxFrontendMetadata } from "@/common/types/message"; +import { prepareUserMessageForSend } from "@/common/types/message"; import { MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels"; import { useTelemetry } from "@/browser/hooks/useTelemetry"; @@ -75,6 +76,7 @@ import { useTutorial } from "@/browser/contexts/TutorialContext"; import { useVoiceInput } from "@/browser/hooks/useVoiceInput"; import { VoiceInputButton } from "./VoiceInputButton"; import { RecordingOverlay } from "./RecordingOverlay"; +import { ReviewBlockFromData } from "../shared/ReviewBlock"; type TokenCountReader = () => number; @@ -140,11 +142,14 @@ export const ChatInput: React.FC = (props) => { const [input, setInput] = usePersistedState(storageKeys.inputKey, "", { listener: true }); const [isSending, setIsSending] = useState(false); + const [hideReviewsDuringSend, setHideReviewsDuringSend] = useState(false); const [showCommandSuggestions, setShowCommandSuggestions] = useState(false); const [commandSuggestions, setCommandSuggestions] = useState([]); const [providerNames, setProviderNames] = useState([]); const [toast, setToast] = useState(null); const [imageAttachments, setImageAttachments] = useState([]); + // Attached reviews come from parent via props (persisted in pendingReviews state) + const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : []; const handleToastDismiss = useCallback(() => { setToast(null); }, []); @@ -152,7 +157,7 @@ export const ChatInput: React.FC = (props) => { const modelSelectorRef = useRef(null); // Draft state combines text input and image attachments - // Use these helpers to avoid accidentally losing images when modifying text + // Reviews are managed separately via props (persisted in pendingReviews state) interface DraftState { text: string; images: ImageAttachment[]; @@ -236,7 +241,8 @@ export const ChatInput: React.FC = (props) => { ); const hasTypedText = input.trim().length > 0; const hasImages = imageAttachments.length > 0; - const canSend = (hasTypedText || hasImages) && !disabled && !isSending; + const hasReviews = attachedReviews.length > 0; + const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSending; // Setter for model - updates localStorage directly so useSendMessageOptions picks it up const setPreferredModel = useCallback( (model: string) => { @@ -946,8 +952,8 @@ export const ChatInput: React.FC = (props) => { } setIsSending(true); - // Save current state for restoration on error - const previousImageAttachments = [...imageAttachments]; + // Save current draft state for restoration on error + const preSendDraft = getDraft(); // Auto-compaction check (workspace variant only) // Check if we should auto-compact before sending this message @@ -962,9 +968,18 @@ export const ChatInput: React.FC = (props) => { mediaType: img.mediaType, })); + // Prepare reviews data for the continue message (orthogonal to compaction) + // Review.data is already ReviewNoteData shape + const reviewsData = + attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined; + + // Capture review IDs for marking as checked on success + const sentReviewIds = attachedReviews.map((r) => r.id); + // Clear input immediately for responsive UX setInput(""); setImageAttachments([]); + setHideReviewsDuringSend(true); try { const result = await executeCompaction({ @@ -974,14 +989,14 @@ export const ChatInput: React.FC = (props) => { text: messageText, imageParts, model: sendMessageOptions.model, + reviews: reviewsData, }, sendMessageOptions, }); if (!result.success) { // Restore on error - setInput(messageText); - setImageAttachments(previousImageAttachments); + setDraft(preSendDraft); setToast({ id: Date.now().toString(), type: "error", @@ -989,6 +1004,10 @@ export const ChatInput: React.FC = (props) => { message: result.error ?? "Failed to start auto-compaction", }); } else { + // Mark reviews as checked on success + if (sentReviewIds.length > 0) { + props.onCheckReviews?.(sentReviewIds); + } setToast({ id: Date.now().toString(), type: "success", @@ -998,8 +1017,7 @@ export const ChatInput: React.FC = (props) => { } } catch (error) { // Restore on unexpected error - setInput(messageText); - setImageAttachments(previousImageAttachments); + setDraft(preSendDraft); setToast({ id: Date.now().toString(), type: "error", @@ -1009,6 +1027,7 @@ export const ChatInput: React.FC = (props) => { }); } finally { setIsSending(false); + setHideReviewsDuringSend(false); } return; // Skip normal send @@ -1072,10 +1091,25 @@ export const ChatInput: React.FC = (props) => { } } - // Clear input and images immediately for responsive UI - // These will be restored if the send operation fails + // Process reviews into message text and metadata using shared utility + // Review.data is already ReviewNoteData shape + const reviewsData = + attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined; + const { finalText: finalMessageText, metadata: reviewMetadata } = prepareUserMessageForSend( + { text: actualMessageText, reviews: reviewsData }, + muxMetadata + ); + muxMetadata = reviewMetadata; + + // Capture review IDs before clearing (for marking as checked on success) + const sentReviewIds = attachedReviews.map((r) => r.id); + + // Clear input, images, and hide reviews immediately for responsive UI + // Text/images are restored if send fails; reviews remain "attached" in state + // so they'll reappear naturally on failure (we only call onCheckReviews on success) setInput(""); setImageAttachments([]); + setHideReviewsDuringSend(true); // Clear inline height style - VimTextArea's useLayoutEffect will handle sizing if (inputRef.current) { inputRef.current.style.height = ""; @@ -1083,7 +1117,7 @@ export const ChatInput: React.FC = (props) => { const result = await api.workspace.sendMessage({ workspaceId: props.workspaceId, - message: actualMessageText, + message: finalMessageText, options: { ...sendMessageOptions, ...compactionOptions, @@ -1098,20 +1132,24 @@ export const ChatInput: React.FC = (props) => { console.error("Failed to send message:", result.error); // Show error using enhanced toast setToast(createErrorToast(result.error)); - // Restore input and images on error so user can try again - setInput(messageText); - setImageAttachments(previousImageAttachments); + // Restore draft on error so user can try again + setDraft(preSendDraft); } else { // Track telemetry for successful message send telemetry.messageSent( props.workspaceId, sendMessageOptions.model, mode, - actualMessageText.length, + finalMessageText.length, runtimeType, sendMessageOptions.thinkingLevel ?? "off" ); + // Mark attached reviews as completed (checked) + if (sentReviewIds.length > 0) { + props.onCheckReviews?.(sentReviewIds); + } + // Exit editing mode if we were editing if (editingMessage && props.onCancelEdit) { props.onCancelEdit(); @@ -1127,10 +1165,11 @@ export const ChatInput: React.FC = (props) => { raw: error instanceof Error ? error.message : "Failed to send message", }) ); - setInput(messageText); - setImageAttachments(previousImageAttachments); + // Restore draft on error + setDraft(preSendDraft); } finally { setIsSending(false); + setHideReviewsDuringSend(false); } } finally { // Always restore focus at the end @@ -1308,6 +1347,27 @@ export const ChatInput: React.FC = (props) => { )} + {/* Attached reviews preview - show styled blocks with remove/edit buttons */} + {/* Hide during send to avoid duplicate display with the sent message */} + {variant === "workspace" && attachedReviews.length > 0 && !hideReviewsDuringSend && ( +
+ {attachedReviews.map((review) => ( + props.onDetachReview!(review.id) : undefined + } + onEditComment={ + props.onUpdateReviewNote + ? (newNote) => props.onUpdateReviewNote!(review.id, newNote) + : undefined + } + /> + ))} +
+ )} + {/* Command suggestions - workspace only */} {variant === "workspace" && ( void; @@ -29,6 +30,14 @@ export interface ChatInputWorkspaceVariant { disabled?: boolean; onReady?: (api: ChatInputAPI) => void; autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation + /** Reviews currently attached to chat (from useReviews hook) */ + attachedReviews?: Review[]; + /** Detach a review from chat input (sets status to pending) */ + onDetachReview?: (reviewId: string) => void; + /** Mark reviews as checked after sending */ + onCheckReviews?: (reviewIds: string[]) => void; + /** Update a review's comment/note */ + onUpdateReviewNote?: (reviewId: string, newNote: string) => void; } // Creation variant: simplified for first message / workspace creation diff --git a/src/browser/components/Messages/MessageRenderer.tsx b/src/browser/components/Messages/MessageRenderer.tsx index 9f74ebabde..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"; @@ -15,11 +16,13 @@ interface MessageRendererProps { onEditQueuedMessage?: () => void; workspaceId?: string; isCompacting?: boolean; + /** Handler for adding review notes from inline diffs */ + onReviewNote?: (data: ReviewNoteData) => 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 +44,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..1de41ffb36 100644 --- a/src/browser/components/Messages/ToolMessage.tsx +++ b/src/browser/components/Messages/ToolMessage.tsx @@ -35,11 +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?: (data: ReviewNoteData) => void; } // Type guards using Zod schemas for single source of truth @@ -108,7 +111,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 +151,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 +165,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 +179,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/Messages/UserMessage.tsx b/src/browser/components/Messages/UserMessage.tsx index 9f7ffdd6e5..657a9e74b2 100644 --- a/src/browser/components/Messages/UserMessage.tsx +++ b/src/browser/components/Messages/UserMessage.tsx @@ -1,5 +1,5 @@ import React from "react"; -import type { DisplayedMessage } from "@/common/types/message"; +import type { DisplayedMessage, ReviewNoteDataForDisplay } from "@/common/types/message"; import type { ButtonConfig } from "./MessageWindow"; import { MessageWindow } from "./MessageWindow"; import { TerminalOutput } from "./TerminalOutput"; @@ -9,6 +9,24 @@ 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 { ReviewBlockFromData } from "../shared/ReviewBlock"; + +/** Helper component to render reviews from structured data with optional text */ +const ReviewsWithText: React.FC<{ + reviews: ReviewNoteDataForDisplay[]; + textContent: string; +}> = ({ reviews, textContent }) => ( +
+ {reviews.map((review, idx) => ( + + ))} + {textContent && ( +
+        {textContent}
+      
+ )} +
+); interface UserMessageProps { message: DisplayedMessage & { type: "user" }; @@ -89,6 +107,14 @@ export const UserMessage: React.FC = ({ ); } + // Check if we have structured review data in metadata + const hasReviews = message.reviews && message.reviews.length > 0; + + // Extract plain text content (without review tags) for display alongside review blocks + const plainTextContent = hasReviews + ? content.replace(/[\s\S]*?<\/review>\s*/g, "").trim() + : content; + // Otherwise, render as normal user message return ( = ({ className={className} variant="user" > - {content && ( -
-          {content}
-        
+ {hasReviews ? ( + // Use structured review data from metadata + + ) : ( + // No reviews - just plain text + content && ( +
+            {content}
+          
+ ) )} {message.imageParts && message.imageParts.length > 0 && (
diff --git a/src/browser/components/ReviewsBanner.tsx b/src/browser/components/ReviewsBanner.tsx new file mode 100644 index 0000000000..b9ea84254c --- /dev/null +++ b/src/browser/components/ReviewsBanner.tsx @@ -0,0 +1,496 @@ +/** + * ReviewsBanner - Self-contained reviews UI + * + * 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, useRef } from "react"; +import { + ChevronDown, + ChevronRight, + Check, + Undo2, + Send, + Trash2, + MessageSquare, + AlertTriangle, + Pencil, + X, +} from "lucide-react"; +import { cn } from "@/common/lib/utils"; +import { Button } from "./ui/button"; +import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; +import type { Review } from "@/common/types/review"; +import { useReviews } from "@/browser/hooks/useReviews"; +import { formatRelativeTime } from "@/browser/utils/ui/dateTime"; +import { DiffRenderer } from "./shared/DiffRenderer"; +import { matchesKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; + +// ═══════════════════════════════════════════════════════════════════════════════ +// ERROR BOUNDARY +// ═══════════════════════════════════════════════════════════════════════════════ + +interface ErrorBoundaryState { + hasError: boolean; +} + +class BannerErrorBoundary extends Component< + { children: ReactNode; onClear: () => 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; + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// REVIEW ITEM COMPONENT +// ═══════════════════════════════════════════════════════════════════════════════ + +interface ReviewItemProps { + review: Review; + onCheck: () => void; + onUncheck: () => void; + onSendToChat: () => void; + onRemove: () => void; + 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 (matchesKeybind(e, KEYBINDS.SAVE_EDIT)) { + e.preventDefault(); + handleSaveEdit(); + } else if (matchesKeybind(e, KEYBINDS.CANCEL_EDIT)) { + 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 ( +
+ {/* Header row - always visible */} +
+ {/* Expand toggle */} + + + {/* Check/Uncheck button */} + + + + + {isChecked ? "Mark as pending" : "Mark as done"} + + + {/* Send to chat - always visible for pending items, away from delete */} + {!isChecked && ( + + + + + Send to chat + + )} + + {/* File path, comment preview, and age */} + + + {/* Delete action - separate from safe actions */} + + + + + Remove + +
+ + {/* Expanded content */} + {isExpanded && ( +
+ {/* Code diff */} +
+ +
+ + {/* Comment section */} +
+ {isEditing ? ( +
+