Skip to content

Commit 5e57730

Browse files
committed
🤖 feat: improve pending reviews UX with attachment model
- Track attached reviews separately from text input in ChatInput - Add attachReview/detachReview/getAttachedReviews to ChatInputAPI - Show attached reviews as styled blocks with X to remove above input - Auto-mark reviews as completed when sent to chat - Use text-primary for review comments (more readable) - Pass filePath to DiffRenderer for syntax highlighting - Rename ConnectedPendingReviewsBanner to PendingReviewsBanner _Generated with mux_
1 parent 702f462 commit 5e57730

File tree

6 files changed

+122
-34
lines changed

6 files changed

+122
-34
lines changed

src/browser/components/AIView.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { useSendMessageOptions } from "@/browser/hooks/useSendMessageOptions";
5151
import { useForceCompaction } from "@/browser/hooks/useForceCompaction";
5252
import { useAPI } from "@/browser/contexts/API";
5353
import { usePendingReviews } from "@/browser/hooks/usePendingReviews";
54-
import { ConnectedPendingReviewsBanner } from "./PendingReviewsBanner";
54+
import { PendingReviewsBanner } from "./PendingReviewsBanner";
5555
import type { ReviewNoteData } from "@/common/types/review";
5656

5757
interface AIViewProps {
@@ -616,7 +616,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
616616
onCompactClick={handleCompactClick}
617617
/>
618618
)}
619-
<ConnectedPendingReviewsBanner workspaceId={workspaceId} chatInputAPI={chatInputAPI} />
619+
<PendingReviewsBanner workspaceId={workspaceId} chatInputAPI={chatInputAPI} />
620620
<ChatInput
621621
variant="workspace"
622622
workspaceId={workspaceId}
@@ -632,6 +632,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
632632
canInterrupt={canInterrupt}
633633
onReady={handleChatInputReady}
634634
autoCompactionCheck={autoCompactionResult}
635+
getReview={pendingReviews.getReview}
636+
onReviewsSent={(ids) => ids.forEach((id) => pendingReviews.checkReview(id))}
635637
/>
636638
</div>
637639

src/browser/components/ChatInput/index.tsx

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ import {
5252
} from "@/browser/utils/ui/keybinds";
5353
import { ModelSelector, type ModelSelectorRef } from "../ModelSelector";
5454
import { useModelLRU } from "@/browser/hooks/useModelLRU";
55-
import { SendHorizontal } from "lucide-react";
55+
import { SendHorizontal, X } from "lucide-react";
5656
import { VimTextArea } from "../VimTextArea";
5757
import { ImageAttachments, type ImageAttachment } from "../ImageAttachments";
5858
import {
@@ -75,7 +75,8 @@ import { useTutorial } from "@/browser/contexts/TutorialContext";
7575
import { useVoiceInput } from "@/browser/hooks/useVoiceInput";
7676
import { VoiceInputButton } from "./VoiceInputButton";
7777
import { RecordingOverlay } from "./RecordingOverlay";
78-
import { ReviewBlock, hasReviewBlocks } from "../shared/ReviewBlock";
78+
import { ReviewBlock } from "../shared/ReviewBlock";
79+
import { formatReviewNoteForChat } from "@/common/types/review";
7980

8081
type TokenCountReader = () => number;
8182

@@ -146,6 +147,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
146147
const [providerNames, setProviderNames] = useState<string[]>([]);
147148
const [toast, setToast] = useState<Toast | null>(null);
148149
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);
150+
const [attachedReviewIds, setAttachedReviewIds] = useState<string[]>([]);
149151
const handleToastDismiss = useCallback(() => {
150152
setToast(null);
151153
}, []);
@@ -341,6 +343,19 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
341343
setImageAttachments(attachments);
342344
}, []);
343345

346+
// Attach a review by ID (for pending reviews feature)
347+
const attachReview = useCallback((reviewId: string) => {
348+
setAttachedReviewIds((prev) => (prev.includes(reviewId) ? prev : [...prev, reviewId]));
349+
}, []);
350+
351+
// Detach a review by ID
352+
const detachReview = useCallback((reviewId: string) => {
353+
setAttachedReviewIds((prev) => prev.filter((id) => id !== reviewId));
354+
}, []);
355+
356+
// Get currently attached reviews
357+
const getAttachedReviews = useCallback(() => attachedReviewIds, [attachedReviewIds]);
358+
344359
// Provide API to parent via callback
345360
useEffect(() => {
346361
if (props.onReady) {
@@ -350,6 +365,9 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
350365
appendText,
351366
prependText,
352367
restoreImages,
368+
attachReview,
369+
detachReview,
370+
getAttachedReviews,
353371
});
354372
}
355373
}, [
@@ -359,6 +377,9 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
359377
appendText,
360378
prependText,
361379
restoreImages,
380+
attachReview,
381+
detachReview,
382+
getAttachedReviews,
362383
props,
363384
]);
364385

@@ -1015,6 +1036,9 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10151036
return; // Skip normal send
10161037
}
10171038

1039+
// Store attached review IDs before try block so catch block can access them
1040+
const sentReviewIds = [...attachedReviewIds];
1041+
10181042
try {
10191043
// Prepare image parts if any
10201044
const imageParts = imageAttachments.map((img, index) => {
@@ -1073,18 +1097,30 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10731097
}
10741098
}
10751099

1076-
// Clear input and images immediately for responsive UI
1100+
// Prepend attached reviews to message
1101+
const attachedReviews = attachedReviewIds
1102+
.map((id) => props.getReview?.(id))
1103+
.filter((r): r is NonNullable<typeof r> => r !== undefined);
1104+
const reviewsText = attachedReviews
1105+
.map((r) => formatReviewNoteForChat(r.data))
1106+
.join("\n\n");
1107+
const finalMessageText = reviewsText
1108+
? reviewsText + (actualMessageText ? "\n\n" + actualMessageText : "")
1109+
: actualMessageText;
1110+
1111+
// Clear input, images, and attached reviews immediately for responsive UI
10771112
// These will be restored if the send operation fails
10781113
setInput("");
10791114
setImageAttachments([]);
1115+
setAttachedReviewIds([]);
10801116
// Clear inline height style - VimTextArea's useLayoutEffect will handle sizing
10811117
if (inputRef.current) {
10821118
inputRef.current.style.height = "";
10831119
}
10841120

10851121
const result = await api.workspace.sendMessage({
10861122
workspaceId: props.workspaceId,
1087-
message: actualMessageText,
1123+
message: finalMessageText,
10881124
options: {
10891125
...sendMessageOptions,
10901126
...compactionOptions,
@@ -1099,20 +1135,26 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
10991135
console.error("Failed to send message:", result.error);
11001136
// Show error using enhanced toast
11011137
setToast(createErrorToast(result.error));
1102-
// Restore input and images on error so user can try again
1138+
// Restore input, images, and attached reviews on error so user can try again
11031139
setInput(messageText);
11041140
setImageAttachments(previousImageAttachments);
1141+
setAttachedReviewIds(sentReviewIds);
11051142
} else {
11061143
// Track telemetry for successful message send
11071144
telemetry.messageSent(
11081145
props.workspaceId,
11091146
sendMessageOptions.model,
11101147
mode,
1111-
actualMessageText.length,
1148+
finalMessageText.length,
11121149
runtimeType,
11131150
sendMessageOptions.thinkingLevel ?? "off"
11141151
);
11151152

1153+
// Mark attached reviews as completed
1154+
if (sentReviewIds.length > 0) {
1155+
props.onReviewsSent?.(sentReviewIds);
1156+
}
1157+
11161158
// Exit editing mode if we were editing
11171159
if (editingMessage && props.onCancelEdit) {
11181160
props.onCancelEdit();
@@ -1130,6 +1172,7 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
11301172
);
11311173
setInput(messageText);
11321174
setImageAttachments(previousImageAttachments);
1175+
setAttachedReviewIds(sentReviewIds);
11331176
} finally {
11341177
setIsSending(false);
11351178
}
@@ -1309,12 +1352,26 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
13091352
<ChatInputToast toast={toast} onDismiss={handleToastDismiss} />
13101353
)}
13111354

1312-
{/* Review preview - show styled review blocks above input */}
1313-
{variant === "workspace" && hasReviewBlocks(input) && (
1314-
<div className="border-border max-h-40 overflow-y-auto border-b px-2 pb-2">
1315-
{input.match(/<review>([\s\S]*?)<\/review>/g)?.map((match, idx) => (
1316-
<ReviewBlock key={idx} content={match.slice(8, -9)} />
1317-
))}
1355+
{/* Attached reviews preview - show styled blocks with remove buttons */}
1356+
{variant === "workspace" && attachedReviewIds.length > 0 && props.getReview && (
1357+
<div className="border-border max-h-40 space-y-1 overflow-y-auto border-b px-2 py-1.5">
1358+
{attachedReviewIds.map((reviewId) => {
1359+
const review = props.getReview!(reviewId);
1360+
if (!review) return null;
1361+
return (
1362+
<div key={reviewId} className="group relative">
1363+
<ReviewBlock content={formatReviewNoteForChat(review.data).slice(8, -9)} />
1364+
<button
1365+
type="button"
1366+
onClick={() => detachReview(reviewId)}
1367+
className="bg-dark/80 text-muted hover:text-error absolute top-3 right-3 rounded-full p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
1368+
title="Remove from message"
1369+
>
1370+
<X className="size-3.5" />
1371+
</button>
1372+
</div>
1373+
);
1374+
})}
13181375
</div>
13191376
)}
13201377

src/browser/components/ChatInput/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,20 @@ 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 { PendingReview } from "@/common/types/review";
56

67
export interface ChatInputAPI {
78
focus: () => void;
89
restoreText: (text: string) => void;
910
appendText: (text: string) => void;
1011
prependText: (text: string) => void;
1112
restoreImages: (images: ImagePart[]) => void;
13+
/** Attach a review by ID (shows in preview, included when sending) */
14+
attachReview: (reviewId: string) => void;
15+
/** Detach a review by ID */
16+
detachReview: (reviewId: string) => void;
17+
/** Get currently attached review IDs */
18+
getAttachedReviews: () => string[];
1219
}
1320

1421
// Workspace variant: full functionality for existing workspaces
@@ -29,6 +36,10 @@ export interface ChatInputWorkspaceVariant {
2936
disabled?: boolean;
3037
onReady?: (api: ChatInputAPI) => void;
3138
autoCompactionCheck?: AutoCompactionCheckResult; // Computed in parent (AIView) to avoid duplicate calculation
39+
/** Called after reviews are sent in a message - allows parent to mark them as checked */
40+
onReviewsSent?: (reviewIds: string[]) => void;
41+
/** Get a pending review by ID (for resolving attached review IDs to data) */
42+
getReview?: (id: string) => PendingReview | undefined;
3243
}
3344

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

src/browser/components/Messages/UserMessage.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ export const UserMessage: React.FC<UserMessageProps> = ({
102102
className={className}
103103
variant="user"
104104
>
105-
{content && (
106-
containsReviews ? (
105+
{content &&
106+
(containsReviews ? (
107107
<ContentWithReviews
108108
content={content}
109109
textClassName="font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]"
@@ -112,8 +112,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({
112112
<pre className="font-primary m-0 leading-6 break-words whitespace-pre-wrap text-[var(--color-user-text)]">
113113
{content}
114114
</pre>
115-
)
116-
)}
115+
))}
117116
{message.imageParts && message.imageParts.length > 0 && (
118117
<div className="mt-3 flex flex-wrap gap-3">
119118
{message.imageParts.map((img, idx) => (

src/browser/components/PendingReviewsBanner.tsx

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ import {
2525
import { cn } from "@/common/lib/utils";
2626
import { Button } from "./ui/button";
2727
import { Tooltip, TooltipWrapper } from "./Tooltip";
28-
import type { PendingReview, ReviewNoteData } from "@/common/types/review";
29-
import { formatReviewNoteForChat } from "@/common/types/review";
28+
import type { PendingReview } from "@/common/types/review";
3029
import { usePendingReviews } from "@/browser/hooks/usePendingReviews";
3130
import type { ChatInputAPI } from "./ChatInput";
3231
import { formatRelativeTime } from "@/browser/utils/ui/dateTime";
@@ -169,7 +168,11 @@ const ReviewItem: React.FC<ReviewItemProps> = ({
169168
onClick={handleToggleExpand}
170169
className="text-muted hover:text-secondary shrink-0"
171170
>
172-
{isExpanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
171+
{isExpanded ? (
172+
<ChevronDown className="size-3.5" />
173+
) : (
174+
<ChevronRight className="size-3.5" />
175+
)}
173176
</button>
174177

175178
{/* Check/Uncheck button */}
@@ -200,7 +203,12 @@ const ReviewItem: React.FC<ReviewItemProps> = ({
200203
{/* Actions */}
201204
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
202205
<TooltipWrapper inline>
203-
<Button variant="ghost" size="icon" className="size-5 [&_svg]:size-3" onClick={onSendToChat}>
206+
<Button
207+
variant="ghost"
208+
size="icon"
209+
className="size-5 [&_svg]:size-3"
210+
onClick={onSendToChat}
211+
>
204212
<Send />
205213
</Button>
206214
<Tooltip align="center">Send to chat</Tooltip>
@@ -243,7 +251,12 @@ const ReviewItem: React.FC<ReviewItemProps> = ({
243251
/>
244252
<div className="flex items-center justify-end gap-1">
245253
<span className="text-muted mr-2 text-[10px]">⌘Enter to save, Esc to cancel</span>
246-
<Button variant="ghost" size="sm" className="h-5 px-2 text-xs" onClick={handleCancelEdit}>
254+
<Button
255+
variant="ghost"
256+
size="sm"
257+
className="h-5 px-2 text-xs"
258+
onClick={handleCancelEdit}
259+
>
247260
<X className="mr-1 size-3" />
248261
Cancel
249262
</Button>
@@ -260,7 +273,7 @@ const ReviewItem: React.FC<ReviewItemProps> = ({
260273
</div>
261274
) : (
262275
<div className="group/comment flex items-start gap-2">
263-
<blockquote className="text-secondary flex-1 border-l-2 border-[var(--color-review-accent)] pl-2 text-xs italic">
276+
<blockquote className="text-primary flex-1 border-l-2 border-[var(--color-review-accent)] pl-2 text-xs italic">
264277
{review.data.userNote || <span className="text-muted">No comment</span>}
265278
</blockquote>
266279
<Button
@@ -319,8 +332,8 @@ const PendingReviewsBannerInner: React.FC<PendingReviewsBannerInnerProps> = ({
319332
}, []);
320333

321334
const handleSendToChat = useCallback(
322-
(data: ReviewNoteData) => {
323-
chatInputAPI.current?.appendText(formatReviewNoteForChat(data));
335+
(reviewId: string) => {
336+
chatInputAPI.current?.attachReview(reviewId);
324337
},
325338
[chatInputAPI]
326339
);
@@ -386,7 +399,7 @@ const PendingReviewsBannerInner: React.FC<PendingReviewsBannerInnerProps> = ({
386399
review={review}
387400
onCheck={() => pendingReviews.checkReview(review.id)}
388401
onUncheck={() => pendingReviews.uncheckReview(review.id)}
389-
onSendToChat={() => handleSendToChat(review.data)}
402+
onSendToChat={() => handleSendToChat(review.id)}
390403
onRemove={() => pendingReviews.removeReview(review.id)}
391404
onUpdateNote={(note) => handleUpdateNote(review.id, note)}
392405
/>
@@ -419,7 +432,7 @@ const PendingReviewsBannerInner: React.FC<PendingReviewsBannerInnerProps> = ({
419432
review={review}
420433
onCheck={() => pendingReviews.checkReview(review.id)}
421434
onUncheck={() => pendingReviews.uncheckReview(review.id)}
422-
onSendToChat={() => handleSendToChat(review.data)}
435+
onSendToChat={() => handleSendToChat(review.id)}
423436
onRemove={() => pendingReviews.removeReview(review.id)}
424437
onUpdateNote={(note) => handleUpdateNote(review.id, note)}
425438
/>
@@ -430,7 +443,8 @@ const PendingReviewsBannerInner: React.FC<PendingReviewsBannerInnerProps> = ({
430443
onClick={() => setShowAllCompleted(true)}
431444
className="text-muted hover:text-secondary w-full py-1 text-center text-xs transition-colors"
432445
>
433-
Show {hiddenCompletedCount} more completed review{hiddenCompletedCount !== 1 && "s"}
446+
Show {hiddenCompletedCount} more completed review
447+
{hiddenCompletedCount !== 1 && "s"}
434448
</button>
435449
)}
436450
</div>
@@ -447,10 +461,10 @@ const PendingReviewsBannerInner: React.FC<PendingReviewsBannerInnerProps> = ({
447461
};
448462

449463
// ═══════════════════════════════════════════════════════════════════════════════
450-
// EXPORTED CONNECTED COMPONENT
464+
// EXPORTED COMPONENT
451465
// ═══════════════════════════════════════════════════════════════════════════════
452466

453-
interface ConnectedPendingReviewsBannerProps {
467+
interface PendingReviewsBannerProps {
454468
workspaceId: string;
455469
chatInputAPI: React.RefObject<ChatInputAPI | null>;
456470
}
@@ -459,7 +473,7 @@ interface ConnectedPendingReviewsBannerProps {
459473
* Self-contained pending reviews banner.
460474
* Uses usePendingReviews hook internally - only needs workspaceId and chatInputAPI.
461475
*/
462-
export const ConnectedPendingReviewsBanner: React.FC<ConnectedPendingReviewsBannerProps> = ({
476+
export const PendingReviewsBanner: React.FC<PendingReviewsBannerProps> = ({
463477
workspaceId,
464478
chatInputAPI,
465479
}) => {

0 commit comments

Comments
 (0)