Skip to content

Commit e889d20

Browse files
authored
🤖 feat: add bulk attach/detach for pending reviews (#1031)
## Summary Adds quick actions to bulk manage pending reviews near ChatInput: - **Clear all reviews**: Remove all attached reviews from current chat message (shown when 2+ reviews attached) - **Attach all to chat**: Attach all pending reviews at once from ReviewsBanner (shown when 2+ pending) ## Changes - `useReviews` hook: Added `attachAllPending()` and `detachAllAttached()` methods - `ChatInput`: Added header with count and 'Clear all' button above attached reviews - `ReviewsBanner`: Added 'Attach all to chat' button in pending section header - `AIView`: Wired up the new `onDetachAllReviews` prop _Generated with `mux`_
1 parent 6248a30 commit e889d20

File tree

7 files changed

+175
-4
lines changed

7 files changed

+175
-4
lines changed

src/browser/components/AIView.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
665665
autoCompactionCheck={autoCompactionResult}
666666
attachedReviews={reviews.attachedReviews}
667667
onDetachReview={reviews.detachReview}
668+
onDetachAllReviews={reviews.detachAllAttached}
668669
onCheckReviews={(ids) => ids.forEach((id) => reviews.checkReview(id))}
669670
onUpdateReviewNote={reviews.updateReviewNote}
670671
/>

src/browser/components/ChatInput/index.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import {
5454
} from "@/browser/utils/ui/keybinds";
5555
import { ModelSelector, type ModelSelectorRef } from "../ModelSelector";
5656
import { useModelLRU } from "@/browser/hooks/useModelLRU";
57-
import { SendHorizontal } from "lucide-react";
57+
import { SendHorizontal, X } from "lucide-react";
5858
import { VimTextArea } from "../VimTextArea";
5959
import { ImageAttachments, type ImageAttachment } from "../ImageAttachments";
6060
import {
@@ -1381,6 +1381,27 @@ export const ChatInput: React.FC<ChatInputProps> = (props) => {
13811381
{/* Hide during send to avoid duplicate display with the sent message */}
13821382
{variant === "workspace" && attachedReviews.length > 0 && !hideReviewsDuringSend && (
13831383
<div className="border-border max-h-[50vh] space-y-2 overflow-y-auto border-b px-1.5 py-1.5">
1384+
{/* Header with count and clear all button */}
1385+
<div className="flex items-center justify-between text-xs">
1386+
<span className="text-muted font-medium">
1387+
{attachedReviews.length} review{attachedReviews.length !== 1 && "s"} attached
1388+
</span>
1389+
{props.onDetachAllReviews && attachedReviews.length > 1 && (
1390+
<Tooltip>
1391+
<TooltipTrigger asChild>
1392+
<button
1393+
type="button"
1394+
onClick={props.onDetachAllReviews}
1395+
className="text-muted hover:text-error flex items-center gap-1 text-xs transition-colors"
1396+
>
1397+
<X className="size-3" />
1398+
Clear all
1399+
</button>
1400+
</TooltipTrigger>
1401+
<TooltipContent>Remove all reviews from message</TooltipContent>
1402+
</Tooltip>
1403+
)}
1404+
</div>
13841405
{attachedReviews.map((review) => (
13851406
<ReviewBlockFromData
13861407
key={review.id}

src/browser/components/ChatInput/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface ChatInputWorkspaceVariant {
3434
attachedReviews?: Review[];
3535
/** Detach a review from chat input (sets status to pending) */
3636
onDetachReview?: (reviewId: string) => void;
37+
/** Detach all attached reviews from chat input */
38+
onDetachAllReviews?: () => void;
3739
/** Mark reviews as checked after sending */
3840
onCheckReviews?: (reviewIds: string[]) => void;
3941
/** Update a review's comment/note */

src/browser/components/ReviewsBanner.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,8 +406,20 @@ const ReviewsBannerInner: React.FC<ReviewsBannerInnerProps> = ({ workspaceId })
406406
{/* Pending reviews section */}
407407
{pendingList.length > 0 && (
408408
<div className="space-y-1.5">
409-
<div className="text-muted text-[10px] font-medium tracking-wide uppercase">
410-
Pending ({pendingList.length})
409+
<div className="flex items-center justify-between">
410+
<div className="text-muted text-[10px] font-medium tracking-wide uppercase">
411+
Pending ({pendingList.length})
412+
</div>
413+
{pendingList.length > 1 && (
414+
<button
415+
type="button"
416+
onClick={reviewsHook.attachAllPending}
417+
className="text-muted flex items-center gap-1 text-[10px] transition-colors hover:text-[var(--color-review-accent)]"
418+
>
419+
<Send className="size-3" />
420+
Attach all
421+
</button>
422+
)}
411423
</div>
412424
{pendingList.map((review) => (
413425
<ReviewItem

src/browser/hooks/useReviews.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export interface UseReviewsReturn {
3232
attachReview: (reviewId: string) => void;
3333
/** Detach a review from chat input (back to pending) */
3434
detachReview: (reviewId: string) => void;
35+
/** Attach all pending reviews to chat input */
36+
attachAllPending: () => void;
37+
/** Detach all attached reviews from chat input (back to pending) */
38+
detachAllAttached: () => void;
3539
/** Mark a review as checked (sent) */
3640
checkReview: (reviewId: string) => void;
3741
/** Uncheck a review (mark as pending again) */
@@ -153,6 +157,42 @@ export function useReviews(workspaceId: string): UseReviewsReturn {
153157
[setState]
154158
);
155159

160+
const attachAllPending = useCallback(() => {
161+
setState((prev) => {
162+
const now = Date.now();
163+
const updated = { ...prev.reviews };
164+
let hasChanges = false;
165+
166+
for (const [id, review] of Object.entries(prev.reviews)) {
167+
if (review.status === "pending") {
168+
updated[id] = { ...review, status: "attached", statusChangedAt: now };
169+
hasChanges = true;
170+
}
171+
}
172+
173+
if (!hasChanges) return prev;
174+
return { ...prev, reviews: updated, lastUpdated: now };
175+
});
176+
}, [setState]);
177+
178+
const detachAllAttached = useCallback(() => {
179+
setState((prev) => {
180+
const now = Date.now();
181+
const updated = { ...prev.reviews };
182+
let hasChanges = false;
183+
184+
for (const [id, review] of Object.entries(prev.reviews)) {
185+
if (review.status === "attached") {
186+
updated[id] = { ...review, status: "pending", statusChangedAt: now };
187+
hasChanges = true;
188+
}
189+
}
190+
191+
if (!hasChanges) return prev;
192+
return { ...prev, reviews: updated, lastUpdated: now };
193+
});
194+
}, [setState]);
195+
156196
const checkReview = useCallback(
157197
(reviewId: string) => {
158198
setState((prev) => {
@@ -276,6 +316,8 @@ export function useReviews(workspaceId: string): UseReviewsReturn {
276316
addReview,
277317
attachReview,
278318
detachReview,
319+
attachAllPending,
320+
detachAllAttached,
279321
checkReview,
280322
uncheckReview,
281323
removeReview,
@@ -293,6 +335,8 @@ export function useReviews(workspaceId: string): UseReviewsReturn {
293335
addReview,
294336
attachReview,
295337
detachReview,
338+
attachAllPending,
339+
detachAllAttached,
296340
checkReview,
297341
uncheckReview,
298342
removeReview,

src/browser/stories/App.reviews.stories.tsx

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { appMeta, AppWithMocks, type AppStory } from "./meta.js";
66
import { setupSimpleChatStory, setReviews, createReview } from "./storyHelpers";
77
import { createUserMessage, createAssistantMessage } from "./mockFactory";
8+
import { within, userEvent, waitFor } from "@storybook/test";
89

910
export default {
1011
...appMeta,
@@ -156,3 +157,93 @@ export const ManyReviews: AppStory = {
156157
/>
157158
),
158159
};
160+
161+
/**
162+
* Shows multiple attached reviews in ChatInput with "Clear all" button.
163+
* Also shows pending reviews in banner with "Attach all to chat" button.
164+
*/
165+
export const BulkReviewActions: AppStory = {
166+
render: () => (
167+
<AppWithMocks
168+
setup={() => {
169+
const workspaceId = "ws-bulk-reviews";
170+
171+
setReviews(workspaceId, [
172+
// Attached reviews - shown in ChatInput with "Clear all" button
173+
createReview(
174+
"review-attached-1",
175+
"src/api/auth.ts",
176+
"42-48",
177+
"Consider using a constant for the token expiry",
178+
"attached"
179+
),
180+
createReview(
181+
"review-attached-2",
182+
"src/utils/helpers.ts",
183+
"15-20",
184+
"This function could be simplified using reduce",
185+
"attached"
186+
),
187+
createReview(
188+
"review-attached-3",
189+
"src/hooks/useAuth.ts",
190+
"30-35",
191+
"Missing error handling for network failures",
192+
"attached"
193+
),
194+
// Pending reviews - shown in banner with "Attach all to chat" button
195+
createReview(
196+
"review-pending-1",
197+
"src/components/LoginForm.tsx",
198+
"55-60",
199+
"Add loading state while authenticating",
200+
"pending"
201+
),
202+
createReview(
203+
"review-pending-2",
204+
"src/services/api.ts",
205+
"12-18",
206+
"Consider adding retry logic for failed requests",
207+
"pending"
208+
),
209+
createReview(
210+
"review-pending-3",
211+
"src/types/user.ts",
212+
"5-10",
213+
"Make email field optional for guest users",
214+
"pending"
215+
),
216+
]);
217+
218+
return setupSimpleChatStory({
219+
workspaceId,
220+
workspaceName: "feature/auth-improvements",
221+
projectName: "my-app",
222+
messages: [
223+
createUserMessage("msg-1", "Help me fix the authentication issues", {
224+
historySequence: 1,
225+
}),
226+
createAssistantMessage("msg-2", "I'll help you address the authentication issues.", {
227+
historySequence: 2,
228+
}),
229+
],
230+
});
231+
}}
232+
/>
233+
),
234+
play: async ({ canvasElement }: { canvasElement: HTMLElement }) => {
235+
const canvas = within(canvasElement);
236+
237+
// Expand the ReviewsBanner to show "Attach all to chat" button
238+
// The banner shows "3 pending reviews" but number is in separate span,
239+
// so we click on "pending review" text
240+
await waitFor(async () => {
241+
const bannerButton = canvas.getByText(/pending review/i);
242+
await userEvent.click(bannerButton);
243+
});
244+
245+
// Wait for any auto-focus timers, then blur
246+
await new Promise((resolve) => setTimeout(resolve, 150));
247+
(document.activeElement as HTMLElement)?.blur();
248+
},
249+
};

src/browser/stories/storyHelpers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export function createReview(
7676
filePath: string,
7777
lineRange: string,
7878
note: string,
79-
status: "pending" | "checked" = "pending"
79+
status: "pending" | "attached" | "checked" = "pending"
8080
): Review {
8181
return {
8282
id,

0 commit comments

Comments
 (0)