Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 58 additions & 42 deletions src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,19 +377,6 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
return grouped;
}, [reviews]);

// Memoized review action callbacks for inline review notes
const reviewActions: ReviewActionCallbacks = useMemo(
() => ({
onEditComment: updateReviewNote,
onComplete: checkReview,
onUncheck: uncheckReview,
onAttach: attachReview,
onDetach: detachReview,
onDelete: removeReview,
}),
[updateReviewNote, checkReview, uncheckReview, attachReview, detachReview, removeReview]
);

// Derive hunks from diffState for use in filters and rendering
const hunks = useMemo(
() =>
Expand Down Expand Up @@ -429,23 +416,17 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
// Keep showReadHunksRef in sync for stable callbacks
showReadHunksRef.current = filters.showReadHunks;

// Ref to track when user is interacting (pauses auto-refresh)
const isInteractingRef = useRef(false);

// Track if user is composing a review note (pauses auto-refresh to prevent losing work)
// Uses a Set to track which hunks have active selections (multiple hunks possible)
// Track if user is drafting a review note (selection or editing an existing note).
// We only pause scheduled refreshes while drafting so tool-driven refresh stays unified
// (and keeps the untracked banner in sync) when the panel is focused.
// Uses Sets to track which hunks have active selections / inline notes in edit mode.
const isComposingReviewNoteRef = useRef(false);
const composingHunksRef = useRef(new Set<string>());
const editingReviewIdsRef = useRef(new Set<string>());

// Handler for when a hunk's composing state changes
const handleHunkComposingChange = useCallback((hunkId: string, isComposing: boolean) => {
if (isComposing) {
composingHunksRef.current.add(hunkId);
} else {
composingHunksRef.current.delete(hunkId);
}
const updateRefreshBlockState = useCallback(() => {
const wasComposing = isComposingReviewNoteRef.current;
const nowComposing = composingHunksRef.current.size > 0;
const nowComposing = composingHunksRef.current.size > 0 || editingReviewIdsRef.current.size > 0;
isComposingReviewNoteRef.current = nowComposing;

// Update UI state for button disabled state
Expand All @@ -457,12 +438,59 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
}
}, []);

// Handler for when a hunk's composing state changes
const handleHunkComposingChange = useCallback(
(hunkId: string, isComposing: boolean) => {
if (isComposing) {
composingHunksRef.current.add(hunkId);
} else {
composingHunksRef.current.delete(hunkId);
}
updateRefreshBlockState();
},
[updateRefreshBlockState]
);

const handleInlineReviewEditingChange = useCallback(
(reviewId: string, isEditing: boolean) => {
if (isEditing) {
editingReviewIdsRef.current.add(reviewId);
} else {
editingReviewIdsRef.current.delete(reviewId);
}
updateRefreshBlockState();
},
[updateRefreshBlockState]
);

// Memoized review action callbacks for inline review notes
const reviewActions: ReviewActionCallbacks = useMemo(
() => ({
onEditComment: updateReviewNote,
onEditingChange: handleInlineReviewEditingChange,
onComplete: checkReview,
onUncheck: uncheckReview,
onAttach: attachReview,
onDetach: detachReview,
onDelete: removeReview,
}),
[
updateReviewNote,
handleInlineReviewEditingChange,
checkReview,
uncheckReview,
attachReview,
detachReview,
removeReview,
]
);

// Track last fetch time for detecting tool completions while unmounted
const lastFetchTimeRef = useRef(0);

// Last refresh info for UI display (tooltip showing trigger reason + time)
const [lastRefreshInfo, setLastRefreshInfo] = useState<LastRefreshInfo | null>(null);
// Track if refresh button should be disabled (user composing review note)
// Track if refresh button should be disabled (drafting or editing a review note)
const [isRefreshBlocked, setIsRefreshBlocked] = useState(false);

// RefreshController - handles debouncing, in-flight guards, etc.
Expand All @@ -473,10 +501,9 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
useEffect(() => {
const controller = new RefreshController({
debounceMs: 3000,
// Pause scheduled refresh while user is interacting (scrolling, hovering)
isPaused: () => isInteractingRef.current,
// Block ALL refresh (including manual) while composing review note
// This prevents losing work-in-progress when user clicks refresh or presses Ctrl+R
// Pause scheduled refreshes while drafting to avoid wiping draft notes.
isPaused: () => isComposingReviewNoteRef.current,
Comment thread
ammar-agent marked this conversation as resolved.
// Block manual refresh while drafting (e.g., user clicks refresh or presses Ctrl+R).
isManualBlocked: () => isComposingReviewNoteRef.current,
onRefresh: () => {
lastFetchTimeRef.current = Date.now();
Expand Down Expand Up @@ -512,17 +539,6 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
controllerRef.current?.requestImmediate();
};

// Sync panel focus with interaction tracking (pauses auto-refresh while user is focused)
useEffect(() => {
const wasFocused = isInteractingRef.current;
isInteractingRef.current = isPanelFocused;

// If focus was lost, flush any pending refresh that was paused during interaction
if (wasFocused && !isPanelFocused) {
controllerRef.current?.notifyUnpaused();
}
}, [isPanelFocused]);

// Focus panel when focusTrigger changes (preserves current hunk selection)
useEffect(() => {
if (focusTrigger && focusTrigger > 0) {
Expand Down
28 changes: 25 additions & 3 deletions src/browser/components/shared/InlineReviewNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Does NOT include code chunk rendering; parent components provide that context.
*/

import React, { useState, useCallback, useRef } from "react";
import React, { useState, useCallback, useEffect, useRef } from "react";
import { Pencil, Check, Trash2, Unlink, MessageSquare } from "lucide-react";
import { Button } from "../ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "../ui/tooltip";
Expand All @@ -21,6 +21,8 @@ import type { Review } from "@/common/types/review";
export interface ReviewActionCallbacks {
/** Edit the review comment */
onEditComment?: (reviewId: string, newComment: string) => void;
/** Notify parent when inline note enters/leaves edit mode */
onEditingChange?: (reviewId: string, isEditing: boolean) => void;
/** Mark review as complete (checked) */
onComplete?: (reviewId: string) => void;
/** Detach review from message (back to pending) */
Expand Down Expand Up @@ -60,24 +62,44 @@ export const InlineReviewNote: React.FC<InlineReviewNoteProps> = ({
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(review.data.userNote);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isEditingRef = useRef(false);
const actionsRef = useRef(actions);

useEffect(() => {
actionsRef.current = actions;
}, [actions]);

useEffect(() => {
return () => {
if (isEditingRef.current) {
actionsRef.current?.onEditingChange?.(review.id, false);
}
};
}, [review.id]);

const handleStartEdit = useCallback(() => {
setEditValue(review.data.userNote);
setIsEditing(true);
isEditingRef.current = true;
actions?.onEditingChange?.(review.id, true);
setTimeout(() => textareaRef.current?.focus(), 0);
}, [review.data.userNote]);
}, [review.data.userNote, review.id, actions]);

const handleSaveEdit = useCallback(() => {
if (actions?.onEditComment && editValue.trim() !== review.data.userNote) {
actions.onEditComment(review.id, editValue.trim());
}
setIsEditing(false);
isEditingRef.current = false;
actions?.onEditingChange?.(review.id, false);
}, [editValue, review.data.userNote, review.id, actions]);
Comment thread
ammar-agent marked this conversation as resolved.

const handleCancelEdit = useCallback(() => {
setEditValue(review.data.userNote);
setIsEditing(false);
}, [review.data.userNote]);
isEditingRef.current = false;
actions?.onEditingChange?.(review.id, false);
}, [review.data.userNote, review.id, actions]);

const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
Expand Down
18 changes: 4 additions & 14 deletions tests/ui/reviewRefresh.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ describeIntegration("ReviewPanel simulated tool refresh (UI + ORPC, no LLM)", ()
});
}, 120_000);

test("refresh paused while panel focused, flushed on blur", async () => {
test("refresh runs while panel focused", async () => {
await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => {
const cleanupDom = installDom();

Expand All @@ -402,29 +402,19 @@ describeIntegration("ReviewPanel simulated tool refresh (UI + ORPC, no LLM)", ()
fireEvent.focus(reviewPanel!);

// Make a file change while panel is focused
const FOCUS_MARKER = "FOCUS_BLUR_TEST_MARKER";
const FOCUS_MARKER = "FOCUS_MARKER_WHILE_FOCUSED";
await env.orpc.workspace.executeBash({
workspaceId,
script: `echo "${FOCUS_MARKER}" >> README.md`,
});

// Simulate tool completion - this should be PAUSED because panel is focused
// Simulate tool completion - refresh should still run while focused
simulateFileModifyingToolEnd(workspaceId);

// Wait a bit for debounce timer to fire (if it wasn't paused, it would refresh)
await new Promise((r) => setTimeout(r, 4000));

// Verify the change is NOT visible yet (refresh was paused)
expect(view.queryByText(new RegExp(FOCUS_MARKER))).toBeNull();

// Now blur the panel - this should flush the pending refresh
fireEvent.blur(reviewPanel!);

// Wait for the refresh to complete
await view.findByText(new RegExp(FOCUS_MARKER), {}, { timeout: 60_000 });

// Verify lastRefreshInfo - trigger is "scheduled" since it was originally scheduled,
// just deferred by the pause. notifyUnpaused() uses the pending trigger.
// Verify lastRefreshInfo
await waitForRefreshButtonIdle(refreshButton);
await assertRefreshButtonHasLastRefreshInfo(refreshButton, "scheduled");
} finally {
Expand Down
Loading