Skip to content

Commit 3f281ea

Browse files
committed
🤖 feat: add stateful pending reviews
- 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
1 parent 4731ce1 commit 3f281ea

File tree

9 files changed

+750
-6
lines changed

9 files changed

+750
-6
lines changed

src/browser/components/AIView.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ 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 { usePendingReviews } from "@/browser/hooks/usePendingReviews";
54+
import { PendingReviewsBanner } from "./PendingReviewsBanner";
5355

5456
interface AIViewProps {
5557
workspaceId: string;
@@ -105,6 +107,9 @@ const AIViewInner: React.FC<AIViewProps> = ({
105107
const workspaceState = useWorkspaceState(workspaceId);
106108
const aggregator = useWorkspaceAggregator(workspaceId);
107109
const workspaceUsage = useWorkspaceUsage(workspaceId);
110+
111+
// Pending reviews state
112+
const pendingReviews = usePendingReviews(workspaceId);
108113
const { options } = useProviderOptions();
109114
const use1M = options.anthropic?.use1MContext ?? false;
110115
// Get pending model for auto-compaction settings (threshold is per-model)
@@ -213,9 +218,17 @@ const AIViewInner: React.FC<AIViewProps> = ({
213218
chatInputAPI.current = api;
214219
}, []);
215220

216-
// Handler for review notes from Code Review tab
217-
const handleReviewNote = useCallback((note: string) => {
218-
chatInputAPI.current?.appendText(note);
221+
// Handler for review notes from Code Review tab - adds to pending reviews
222+
const handleReviewNote = useCallback(
223+
(note: string) => {
224+
pendingReviews.addReview(note);
225+
},
226+
[pendingReviews]
227+
);
228+
229+
// Handler to send a review to chat input
230+
const handleSendReviewToChat = useCallback((content: string) => {
231+
chatInputAPI.current?.appendText(content);
219232
}, []);
220233

221234
// Handler for manual compaction from CompactionWarning click
@@ -532,6 +545,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
532545
onEditUserMessage={handleEditUserMessage}
533546
workspaceId={workspaceId}
534547
isCompacting={isCompacting}
548+
onReviewNote={handleReviewNote}
535549
/>
536550
</div>
537551
{isAtCutoff && (
@@ -606,6 +620,16 @@ const AIViewInner: React.FC<AIViewProps> = ({
606620
onCompactClick={handleCompactClick}
607621
/>
608622
)}
623+
<PendingReviewsBanner
624+
reviews={pendingReviews.reviews}
625+
pendingCount={pendingReviews.pendingCount}
626+
checkedCount={pendingReviews.checkedCount}
627+
onCheck={pendingReviews.checkReview}
628+
onUncheck={pendingReviews.uncheckReview}
629+
onSendToChat={handleSendReviewToChat}
630+
onRemove={pendingReviews.removeReview}
631+
onClearChecked={pendingReviews.clearChecked}
632+
/>
609633
<ChatInput
610634
variant="workspace"
611635
workspaceId={workspaceId}

src/browser/components/Messages/MessageRenderer.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ interface MessageRendererProps {
1515
onEditQueuedMessage?: () => void;
1616
workspaceId?: string;
1717
isCompacting?: boolean;
18+
/** Handler for adding review notes from inline diffs */
19+
onReviewNote?: (note: string) => void;
1820
}
1921

2022
// Memoized to prevent unnecessary re-renders when parent (AIView) updates
2123
export const MessageRenderer = React.memo<MessageRendererProps>(
22-
({ message, className, onEditUserMessage, workspaceId, isCompacting }) => {
24+
({ message, className, onEditUserMessage, workspaceId, isCompacting, onReviewNote }) => {
2325
// Route based on message type
2426
switch (message.type) {
2527
case "user":
@@ -41,7 +43,14 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
4143
/>
4244
);
4345
case "tool":
44-
return <ToolMessage message={message} className={className} workspaceId={workspaceId} />;
46+
return (
47+
<ToolMessage
48+
message={message}
49+
className={className}
50+
workspaceId={workspaceId}
51+
onReviewNote={onReviewNote}
52+
/>
53+
);
4554
case "reasoning":
4655
return <ReasoningMessage message={message} className={className} />;
4756
case "stream-error":

src/browser/components/Messages/ToolMessage.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ interface ToolMessageProps {
4040
message: DisplayedMessage & { type: "tool" };
4141
className?: string;
4242
workspaceId?: string;
43+
/** Handler for adding review notes from inline diffs */
44+
onReviewNote?: (note: string) => void;
4345
}
4446

4547
// Type guards using Zod schemas for single source of truth
@@ -108,7 +110,12 @@ function isBashBackgroundTerminateTool(
108110
return TOOL_DEFINITIONS.bash_background_terminate.schema.safeParse(args).success;
109111
}
110112

111-
export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, workspaceId }) => {
113+
export const ToolMessage: React.FC<ToolMessageProps> = ({
114+
message,
115+
className,
116+
workspaceId,
117+
onReviewNote,
118+
}) => {
112119
// Route to specialized components based on tool name
113120
if (isBashTool(message.toolName, message.args)) {
114121
return (
@@ -143,6 +150,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, wo
143150
args={message.args}
144151
result={message.result as FileEditReplaceStringToolResult | undefined}
145152
status={message.status}
153+
onReviewNote={onReviewNote}
146154
/>
147155
</div>
148156
);
@@ -156,6 +164,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, wo
156164
args={message.args}
157165
result={message.result as FileEditInsertToolResult | undefined}
158166
status={message.status}
167+
onReviewNote={onReviewNote}
159168
/>
160169
</div>
161170
);
@@ -169,6 +178,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ message, className, wo
169178
args={message.args}
170179
result={message.result as FileEditReplaceLinesToolResult | undefined}
171180
status={message.status}
181+
onReviewNote={onReviewNote}
172182
/>
173183
</div>
174184
);
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
/**
2+
* PendingReviewsBanner - Shows pending code reviews in the chat area
3+
* Displays as a thin collapsible stripe above the chat input
4+
*
5+
* Uses shadcn/ui Button component and semantic Tailwind color classes
6+
* that map to CSS variables defined in globals.css.
7+
*/
8+
9+
import React, { useState, useCallback, useMemo } from "react";
10+
import {
11+
ChevronDown,
12+
ChevronUp,
13+
Check,
14+
Undo2,
15+
Send,
16+
Trash2,
17+
MessageSquare,
18+
Eye,
19+
EyeOff,
20+
} from "lucide-react";
21+
import { cn } from "@/common/lib/utils";
22+
import { Button } from "./ui/button";
23+
import { Tooltip, TooltipWrapper } from "./Tooltip";
24+
import type { PendingReview } from "@/common/types/review";
25+
26+
interface PendingReviewsBannerProps {
27+
/** All reviews (pending and checked) */
28+
reviews: PendingReview[];
29+
/** Count of pending reviews */
30+
pendingCount: number;
31+
/** Count of checked reviews */
32+
checkedCount: number;
33+
/** Mark a review as checked */
34+
onCheck: (reviewId: string) => void;
35+
/** Uncheck a review */
36+
onUncheck: (reviewId: string) => void;
37+
/** Send review content to chat input */
38+
onSendToChat: (content: string) => void;
39+
/** Remove a review */
40+
onRemove: (reviewId: string) => void;
41+
/** Clear all checked reviews */
42+
onClearChecked: () => void;
43+
}
44+
45+
/**
46+
* Extract a short summary from review content for display
47+
*/
48+
function getReviewSummary(review: PendingReview): string {
49+
// Extract the user's note from the review content (after the code block)
50+
const noteMatch = /```\n> (.+?)\n<\/review>/s.exec(review.content);
51+
if (noteMatch) {
52+
const note = noteMatch[1].trim();
53+
return note.length > 50 ? note.slice(0, 50) + "…" : note;
54+
}
55+
return `${review.filePath}:${review.lineRange}`;
56+
}
57+
58+
/**
59+
* Single review item in the list
60+
*/
61+
const ReviewItem: React.FC<{
62+
review: PendingReview;
63+
onCheck: () => void;
64+
onUncheck: () => void;
65+
onSendToChat: () => void;
66+
onRemove: () => void;
67+
}> = ({ review, onCheck, onUncheck, onSendToChat, onRemove }) => {
68+
const isChecked = review.status === "checked";
69+
70+
return (
71+
<div
72+
className={cn(
73+
"group flex items-center gap-2 rounded px-2 py-1.5 text-xs transition-colors",
74+
isChecked ? "bg-hover opacity-60" : "bg-border-medium/30 hover:bg-hover"
75+
)}
76+
>
77+
{/* Check/Uncheck button */}
78+
<TooltipWrapper inline>
79+
<Button
80+
variant="ghost"
81+
size="icon"
82+
className={cn("h-5 w-5 shrink-0 [&_svg]:size-3", isChecked && "text-success")}
83+
onClick={isChecked ? onUncheck : onCheck}
84+
>
85+
{isChecked ? <Undo2 /> : <Check />}
86+
</Button>
87+
<Tooltip align="center">{isChecked ? "Mark as pending" : "Mark as done"}</Tooltip>
88+
</TooltipWrapper>
89+
90+
{/* Review info */}
91+
<div className="min-w-0 flex-1">
92+
<div className="flex items-baseline gap-1.5">
93+
<span className="font-mono text-[var(--color-review-accent)]">
94+
{review.filePath}:{review.lineRange}
95+
</span>
96+
</div>
97+
<div className="text-muted truncate">{getReviewSummary(review)}</div>
98+
</div>
99+
100+
{/* Actions */}
101+
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
102+
<TooltipWrapper inline>
103+
<Button
104+
variant="ghost"
105+
size="icon"
106+
className="h-5 w-5 [&_svg]:size-3"
107+
onClick={onSendToChat}
108+
>
109+
<Send />
110+
</Button>
111+
<Tooltip align="center">Send to chat</Tooltip>
112+
</TooltipWrapper>
113+
114+
<TooltipWrapper inline>
115+
<Button
116+
variant="ghost"
117+
size="icon"
118+
className="text-error h-5 w-5 [&_svg]:size-3"
119+
onClick={onRemove}
120+
>
121+
<Trash2 />
122+
</Button>
123+
<Tooltip align="center">Remove</Tooltip>
124+
</TooltipWrapper>
125+
</div>
126+
</div>
127+
);
128+
};
129+
130+
export const PendingReviewsBanner: React.FC<PendingReviewsBannerProps> = ({
131+
reviews,
132+
pendingCount,
133+
checkedCount,
134+
onCheck,
135+
onUncheck,
136+
onSendToChat,
137+
onRemove,
138+
onClearChecked,
139+
}) => {
140+
const [isExpanded, setIsExpanded] = useState(false);
141+
const [showChecked, setShowChecked] = useState(false);
142+
143+
// Filter reviews based on view mode
144+
const displayedReviews = useMemo(() => {
145+
if (showChecked) {
146+
return reviews.filter((r) => r.status === "checked");
147+
}
148+
return reviews.filter((r) => r.status === "pending");
149+
}, [reviews, showChecked]);
150+
151+
const handleToggle = useCallback(() => {
152+
setIsExpanded((prev) => !prev);
153+
}, []);
154+
155+
const handleToggleShowChecked = useCallback(() => {
156+
setShowChecked((prev) => !prev);
157+
}, []);
158+
159+
// Don't show anything if no reviews
160+
if (reviews.length === 0) {
161+
return null;
162+
}
163+
164+
return (
165+
<div className="border-border bg-dark border-t">
166+
{/* Collapsed banner - thin stripe */}
167+
<button
168+
type="button"
169+
onClick={handleToggle}
170+
className="hover:bg-hover flex w-full items-center gap-2 px-3 py-1.5 text-xs transition-colors"
171+
>
172+
<MessageSquare className="h-3.5 w-3.5 text-[var(--color-review-accent)]" />
173+
<span className="text-secondary">
174+
{pendingCount > 0 ? (
175+
<>
176+
<span className="font-medium text-[var(--color-review-accent)]">{pendingCount}</span>
177+
{" pending review"}
178+
{pendingCount !== 1 && "s"}
179+
</>
180+
) : (
181+
<span className="text-muted">No pending reviews</span>
182+
)}
183+
{checkedCount > 0 && <span className="text-muted"> · {checkedCount} checked</span>}
184+
</span>
185+
<div className="ml-auto">
186+
{isExpanded ? (
187+
<ChevronDown className="text-muted h-3.5 w-3.5" />
188+
) : (
189+
<ChevronUp className="text-muted h-3.5 w-3.5" />
190+
)}
191+
</div>
192+
</button>
193+
194+
{/* Expanded view */}
195+
{isExpanded && (
196+
<div className="border-border border-t px-3 py-2">
197+
{/* View toggle and actions */}
198+
<div className="mb-2 flex items-center justify-between">
199+
<div className="flex items-center gap-2">
200+
<TooltipWrapper inline>
201+
<Button
202+
variant={showChecked ? "secondary" : "ghost"}
203+
size="sm"
204+
className="h-6 px-2 text-xs"
205+
onClick={handleToggleShowChecked}
206+
>
207+
{showChecked ? (
208+
<Eye className="mr-1 h-3 w-3" />
209+
) : (
210+
<EyeOff className="mr-1 h-3 w-3" />
211+
)}
212+
{showChecked ? "Checked" : "Pending"}
213+
</Button>
214+
<Tooltip align="center">
215+
{showChecked ? "Showing checked reviews" : "Showing pending reviews"}
216+
</Tooltip>
217+
</TooltipWrapper>
218+
</div>
219+
220+
{showChecked && checkedCount > 0 && (
221+
<Button
222+
variant="ghost"
223+
size="sm"
224+
className="text-error h-6 px-2 text-xs"
225+
onClick={onClearChecked}
226+
>
227+
<Trash2 className="mr-1 h-3 w-3" />
228+
Clear all
229+
</Button>
230+
)}
231+
</div>
232+
233+
{/* Review list */}
234+
<div className="max-h-48 space-y-1 overflow-y-auto">
235+
{displayedReviews.length === 0 ? (
236+
<div className="text-muted py-3 text-center text-xs">
237+
{showChecked ? "No checked reviews" : "No pending reviews"}
238+
</div>
239+
) : (
240+
displayedReviews.map((review) => (
241+
<ReviewItem
242+
key={review.id}
243+
review={review}
244+
onCheck={() => onCheck(review.id)}
245+
onUncheck={() => onUncheck(review.id)}
246+
onSendToChat={() => onSendToChat(review.content)}
247+
onRemove={() => onRemove(review.id)}
248+
/>
249+
))
250+
)}
251+
</div>
252+
</div>
253+
)}
254+
</div>
255+
);
256+
};

0 commit comments

Comments
 (0)