Skip to content

Commit 831d6b8

Browse files
authored
🤖 feat: add stateful pending reviews (#970)
## Summary Adds a reviews system that allows users to create review notes from diff viewers, manage them via a collapsible banner, and send them to Claude with messages. Reviews are orthogonal to message type (works with both normal messages and auto-compaction). ## Architecture **Data Flow:** 1. User selects code in diff viewer → clicks "+" → enters comment → `ReviewNoteData` created 2. New reviews start with `status: "attached"` (auto-shows in ChatInput) 3. User can detach (→ pending), check as done, or delete reviews 4. On send: reviews formatted into message text + stored in `muxMetadata` for rich UI display **Key Types** (`src/common/types/`): - `ReviewNoteData`: Structured data with `filePath`, `lineRange`, `selectedCode`, `userNote` - `Review`: Contains `id`, `data`, `status` ("pending" | "attached" | "checked"), timestamps - `UserMessageContent`: Shared type for normal send and continue-after-compaction (ensures reviews work with auto-compaction) - `prepareUserMessageForSend()`: Shared utility to format reviews → message text + metadata **Components:** - `useReviews` hook: localStorage persistence per workspace with cross-component sync - `ReviewsBanner`: Collapsible banner above chat input showing pending/checked reviews - `ReviewBlockFromData`: Renders review with diff preview and editable comments - `ChatInput`: Shows attached reviews, handles send with reviews ## Review State Machine ``` Created → Attached (auto) Attached → Send message → Checked Attached → Click X → Pending Pending → "Send to chat" → Attached Pending/Checked → Delete → Removed Checked → Uncheck → Pending ``` ## Key Implementation Details - **Orthogonal to auto-compaction**: Reviews included in `continueMessage` so they survive compaction flow - **No duplicate display**: Reviews hidden from ChatInput during send operation - **Wide viewport alignment**: Banner content uses `max-w-4xl mx-auto` to align with chat - **Error boundary**: Corrupted localStorage data shows recovery UI instead of crashing ## Stories - `App/Reviews/ReviewsBanner` - Multiple reviews in different states - `App/Reviews/AllReviewsChecked` - All completed state - `App/Reviews/ManyReviews` - Scroll behavior with many items --- _Generated with `mux`_
1 parent c59135c commit 831d6b8

File tree

23 files changed

+1569
-84
lines changed

23 files changed

+1569
-84
lines changed

src/browser/components/AIView.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ import { useAutoCompactionSettings } from "../hooks/useAutoCompactionSettings";
5050
import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
5151
import { useForceCompaction } from "@/browser/hooks/useForceCompaction";
5252
import { useAPI } from "@/browser/contexts/API";
53+
import { useReviews } from "@/browser/hooks/useReviews";
54+
import { ReviewsBanner } from "./ReviewsBanner";
55+
import type { ReviewNoteData } from "@/common/types/review";
5356

5457
interface AIViewProps {
5558
workspaceId: string;
@@ -105,6 +108,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
105108
const workspaceState = useWorkspaceState(workspaceId);
106109
const aggregator = useWorkspaceAggregator(workspaceId);
107110
const workspaceUsage = useWorkspaceUsage(workspaceId);
111+
112+
// Reviews state
113+
const reviews = useReviews(workspaceId);
108114
const { options } = useProviderOptions();
109115
const use1M = options.anthropic?.use1MContext ?? false;
110116
// Get pending model for auto-compaction settings (threshold is per-model)
@@ -213,10 +219,14 @@ const AIViewInner: React.FC<AIViewProps> = ({
213219
chatInputAPI.current = api;
214220
}, []);
215221

216-
// Handler for review notes from Code Review tab
217-
const handleReviewNote = useCallback((note: string) => {
218-
chatInputAPI.current?.appendText(note);
219-
}, []);
222+
// Handler for review notes from Code Review tab - adds review (starts attached)
223+
const handleReviewNote = useCallback(
224+
(data: ReviewNoteData) => {
225+
reviews.addReview(data);
226+
// New reviews start with status "attached" so they appear in chat input immediately
227+
},
228+
[reviews]
229+
);
220230

221231
// Handler for manual compaction from CompactionWarning click
222232
const handleCompactClick = useCallback(() => {
@@ -532,6 +542,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
532542
onEditUserMessage={handleEditUserMessage}
533543
workspaceId={workspaceId}
534544
isCompacting={isCompacting}
545+
onReviewNote={handleReviewNote}
535546
/>
536547
</div>
537548
{isAtCutoff && (
@@ -606,6 +617,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
606617
onCompactClick={handleCompactClick}
607618
/>
608619
)}
620+
<ReviewsBanner workspaceId={workspaceId} />
609621
<ChatInput
610622
variant="workspace"
611623
workspaceId={workspaceId}
@@ -621,6 +633,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
621633
canInterrupt={canInterrupt}
622634
onReady={handleChatInputReady}
623635
autoCompactionCheck={autoCompactionResult}
636+
attachedReviews={reviews.attachedReviews}
637+
onDetachReview={reviews.detachReview}
638+
onCheckReviews={(ids) => ids.forEach((id) => reviews.checkReview(id))}
639+
onUpdateReviewNote={reviews.updateReviewNote}
624640
/>
625641
</div>
626642

src/browser/components/ChatInput/index.tsx

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ import {
6363

6464
import type { ThinkingLevel } from "@/common/types/thinking";
6565
import type { MuxFrontendMetadata } from "@/common/types/message";
66+
import { prepareUserMessageForSend } from "@/common/types/message";
6667
import { MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels";
6768
import { useTelemetry } from "@/browser/hooks/useTelemetry";
6869

@@ -75,6 +76,7 @@ import { useTutorial } from "@/browser/contexts/TutorialContext";
7576
import { useVoiceInput } from "@/browser/hooks/useVoiceInput";
7677
import { VoiceInputButton } from "./VoiceInputButton";
7778
import { RecordingOverlay } from "./RecordingOverlay";
79+
import { ReviewBlockFromData } from "../shared/ReviewBlock";
7880

7981
type TokenCountReader = () => number;
8082

@@ -140,19 +142,22 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
140142

141143
const [input, setInput] = usePersistedState(storageKeys.inputKey, "", { listener: true });
142144
const [isSending, setIsSending] = useState(false);
145+
const [hideReviewsDuringSend, setHideReviewsDuringSend] = useState(false);
143146
const [showCommandSuggestions, setShowCommandSuggestions] = useState(false);
144147
const [commandSuggestions, setCommandSuggestions] = useState<SlashSuggestion[]>([]);
145148
const [providerNames, setProviderNames] = useState<string[]>([]);
146149
const [toast, setToast] = useState<Toast | null>(null);
147150
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
151+
// Attached reviews come from parent via props (persisted in pendingReviews state)
152+
const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : [];
148153
const handleToastDismiss = useCallback(() => {
149154
setToast(null);
150155
}, []);
151156
const inputRef = useRef<HTMLTextAreaElement>(null);
152157
const modelSelectorRef = useRef<ModelSelectorRef>(null);
153158

154159
// Draft state combines text input and image attachments
155-
// Use these helpers to avoid accidentally losing images when modifying text
160+
// Reviews are managed separately via props (persisted in pendingReviews state)
156161
interface DraftState {
157162
text: string;
158163
images: ImageAttachment[];
@@ -236,7 +241,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
236241
);
237242
const hasTypedText = input.trim().length > 0;
238243
const hasImages = imageAttachments.length > 0;
239-
const canSend = (hasTypedText || hasImages) && !disabled && !isSending;
244+
const hasReviews = attachedReviews.length > 0;
245+
const canSend = (hasTypedText || hasImages || hasReviews) && !disabled && !isSending;
240246
// Setter for model - updates localStorage directly so useSendMessageOptions picks it up
241247
const setPreferredModel = useCallback(
242248
(model: string) => {
@@ -946,8 +952,8 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
946952
}
947953
setIsSending(true);
948954

949-
// Save current state for restoration on error
950-
const previousImageAttachments = [...imageAttachments];
955+
// Save current draft state for restoration on error
956+
const preSendDraft = getDraft();
951957

952958
// Auto-compaction check (workspace variant only)
953959
// Check if we should auto-compact before sending this message
@@ -962,9 +968,18 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
962968
mediaType: img.mediaType,
963969
}));
964970

971+
// Prepare reviews data for the continue message (orthogonal to compaction)
972+
// Review.data is already ReviewNoteData shape
973+
const reviewsData =
974+
attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined;
975+
976+
// Capture review IDs for marking as checked on success
977+
const sentReviewIds = attachedReviews.map((r) => r.id);
978+
965979
// Clear input immediately for responsive UX
966980
setInput("");
967981
setImageAttachments([]);
982+
setHideReviewsDuringSend(true);
968983

969984
try {
970985
const result = await executeCompaction({
@@ -974,21 +989,25 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
974989
text: messageText,
975990
imageParts,
976991
model: sendMessageOptions.model,
992+
reviews: reviewsData,
977993
},
978994
sendMessageOptions,
979995
});
980996

981997
if (!result.success) {
982998
// Restore on error
983-
setInput(messageText);
984-
setImageAttachments(previousImageAttachments);
999+
setDraft(preSendDraft);
9851000
setToast({
9861001
id: Date.now().toString(),
9871002
type: "error",
9881003
title: "Auto-Compaction Failed",
9891004
message: result.error ?? "Failed to start auto-compaction",
9901005
});
9911006
} else {
1007+
// Mark reviews as checked on success
1008+
if (sentReviewIds.length > 0) {
1009+
props.onCheckReviews?.(sentReviewIds);
1010+
}
9921011
setToast({
9931012
id: Date.now().toString(),
9941013
type: "success",
@@ -998,8 +1017,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
9981017
}
9991018
} catch (error) {
10001019
// Restore on unexpected error
1001-
setInput(messageText);
1002-
setImageAttachments(previousImageAttachments);
1020+
setDraft(preSendDraft);
10031021
setToast({
10041022
id: Date.now().toString(),
10051023
type: "error",
@@ -1009,6 +1027,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10091027
});
10101028
} finally {
10111029
setIsSending(false);
1030+
setHideReviewsDuringSend(false);
10121031
}
10131032

10141033
return; // Skip normal send
@@ -1072,18 +1091,33 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10721091
}
10731092
}
10741093

1075-
// Clear input and images immediately for responsive UI
1076-
// These will be restored if the send operation fails
1094+
// Process reviews into message text and metadata using shared utility
1095+
// Review.data is already ReviewNoteData shape
1096+
const reviewsData =
1097+
attachedReviews.length > 0 ? attachedReviews.map((r) => r.data) : undefined;
1098+
const { finalText: finalMessageText, metadata: reviewMetadata } = prepareUserMessageForSend(
1099+
{ text: actualMessageText, reviews: reviewsData },
1100+
muxMetadata
1101+
);
1102+
muxMetadata = reviewMetadata;
1103+
1104+
// Capture review IDs before clearing (for marking as checked on success)
1105+
const sentReviewIds = attachedReviews.map((r) => r.id);
1106+
1107+
// Clear input, images, and hide reviews immediately for responsive UI
1108+
// Text/images are restored if send fails; reviews remain "attached" in state
1109+
// so they'll reappear naturally on failure (we only call onCheckReviews on success)
10771110
setInput("");
10781111
setImageAttachments([]);
1112+
setHideReviewsDuringSend(true);
10791113
// Clear inline height style - VimTextArea's useLayoutEffect will handle sizing
10801114
if (inputRef.current) {
10811115
inputRef.current.style.height = "";
10821116
}
10831117

10841118
const result = await api.workspace.sendMessage({
10851119
workspaceId: props.workspaceId,
1086-
message: actualMessageText,
1120+
message: finalMessageText,
10871121
options: {
10881122
...sendMessageOptions,
10891123
...compactionOptions,
@@ -1098,20 +1132,24 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10981132
console.error("Failed to send message:", result.error);
10991133
// Show error using enhanced toast
11001134
setToast(createErrorToast(result.error));
1101-
// Restore input and images on error so user can try again
1102-
setInput(messageText);
1103-
setImageAttachments(previousImageAttachments);
1135+
// Restore draft on error so user can try again
1136+
setDraft(preSendDraft);
11041137
} else {
11051138
// Track telemetry for successful message send
11061139
telemetry.messageSent(
11071140
props.workspaceId,
11081141
sendMessageOptions.model,
11091142
mode,
1110-
actualMessageText.length,
1143+
finalMessageText.length,
11111144
runtimeType,
11121145
sendMessageOptions.thinkingLevel ?? "off"
11131146
);
11141147

1148+
// Mark attached reviews as completed (checked)
1149+
if (sentReviewIds.length > 0) {
1150+
props.onCheckReviews?.(sentReviewIds);
1151+
}
1152+
11151153
// Exit editing mode if we were editing
11161154
if (editingMessage && props.onCancelEdit) {
11171155
props.onCancelEdit();
@@ -1127,10 +1165,11 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
11271165
raw: error instanceof Error ? error.message : "Failed to send message",
11281166
})
11291167
);
1130-
setInput(messageText);
1131-
setImageAttachments(previousImageAttachments);
1168+
// Restore draft on error
1169+
setDraft(preSendDraft);
11321170
} finally {
11331171
setIsSending(false);
1172+
setHideReviewsDuringSend(false);
11341173
}
11351174
} finally {
11361175
// Always restore focus at the end
@@ -1308,6 +1347,27 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
13081347
<ChatInputToast toast={toast} onDismiss={handleToastDismiss} />
13091348
)}
13101349

1350+
{/* Attached reviews preview - show styled blocks with remove/edit buttons */}
1351+
{/* Hide during send to avoid duplicate display with the sent message */}
1352+
{variant === "workspace" && attachedReviews.length > 0 && !hideReviewsDuringSend && (
1353+
<div className="border-border max-h-[50vh] space-y-2 overflow-y-auto border-b px-1.5 py-1.5">
1354+
{attachedReviews.map((review) => (
1355+
<ReviewBlockFromData
1356+
key={review.id}
1357+
data={review.data}
1358+
onRemove={
1359+
props.onDetachReview ? () => props.onDetachReview!(review.id) : undefined
1360+
}
1361+
onEditComment={
1362+
props.onUpdateReviewNote
1363+
? (newNote) => props.onUpdateReviewNote!(review.id, newNote)
1364+
: undefined
1365+
}
1366+
/>
1367+
))}
1368+
</div>
1369+
)}
1370+
13111371
{/* Command suggestions - workspace only */}
13121372
{variant === "workspace" && (
13131373
<CommandSuggestions

src/browser/components/ChatInput/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ImagePart } from "@/common/orpc/types";
22
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
33
import type { TelemetryRuntimeType } from "@/common/telemetry/payload";
44
import type { AutoCompactionCheckResult } from "@/browser/utils/compaction/autoCompactionCheck";
5+
import type { Review } from "@/common/types/review";
56

67
export interface ChatInputAPI {
78
focus: () => void;
@@ -29,6 +30,14 @@ export interface ChatInputWorkspaceVariant {
2930
disabled?: boolean;
3031
onReady?: (api: ChatInputAPI) => void;
3132
autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation
33+
/** Reviews currently attached to chat (from useReviews hook) */
34+
attachedReviews?: Review[];
35+
/** Detach a review from chat input (sets status to pending) */
36+
onDetachReview?: (reviewId: string) => void;
37+
/** Mark reviews as checked after sending */
38+
onCheckReviews?: (reviewIds: string[]) => void;
39+
/** Update a review's comment/note */
40+
onUpdateReviewNote?: (reviewId: string, newNote: string) => void;
3241
}
3342

3443
// Creation variant: simplified for first message / workspace creation

src/browser/components/Messages/MessageRenderer.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from "react";
22
import type { DisplayedMessage } from "@/common/types/message";
3+
import type { ReviewNoteData } from "@/common/types/review";
34
import { UserMessage } from "./UserMessage";
45
import { AssistantMessage } from "./AssistantMessage";
56
import { ToolMessage } from "./ToolMessage";
@@ -15,11 +16,13 @@ interface MessageRendererProps {
1516
onEditQueuedMessage?: () => void;
1617
workspaceId?: string;
1718
isCompacting?: boolean;
19+
/** Handler for adding review notes from inline diffs */
20+
onReviewNote?: (data: ReviewNoteData) => void;
1821
}
1922

2023
// Memoized to prevent unnecessary re-renders when parent (AIView) updates
2124
export const MessageRenderer = React.memo<MessageRendererProps>(
22-
({ message, className, onEditUserMessage, workspaceId, isCompacting }) => {
25+
({ message, className, onEditUserMessage, workspaceId, isCompacting, onReviewNote }) => {
2326
// Route based on message type
2427
switch (message.type) {
2528
case "user":
@@ -41,7 +44,14 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
4144
/>
4245
);
4346
case "tool":
44-
return <ToolMessage message={message} className={className} workspaceId={workspaceId} />;
47+
return (
48+
<ToolMessage
49+
message={message}
50+
className={className}
51+
workspaceId={workspaceId}
52+
onReviewNote={onReviewNote}
53+
/>
54+
);
4555
case "reasoning":
4656
return <ReasoningMessage message={message} className={className} />;
4757
case "stream-error":

0 commit comments

Comments
 (0)