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
3 changes: 0 additions & 3 deletions scripts/check_react_compiler_coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,6 @@ const HOT_COMPONENTS: Record<string, readonly string[]> = {
*/
const KNOWN_SKIPPED: ReadonlySet<string> = new Set([
"src/browser/components/ProjectSidebar/ProjectSidebar.tsx#ProjectSidebarInner",
"src/browser/components/AppLoader/AppLoader.tsx#UserPreferencesStartupGate",
"src/browser/features/RightSidebar/RightSidebar.tsx#RightSidebarComponent",
"src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx#ReviewPanel",
"src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx#ImmersiveReviewView",
]);

Expand Down
4 changes: 3 additions & 1 deletion src/browser/components/AppLoader/AppLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,9 @@ function UserPreferencesStartupGate(props: { children: ReactNode }) {
};
}, [apiState.api]);

if (bootstrappedRef.current || ready) {
// bootstrappedRef is set together with `ready`, so `ready` alone decides here; reading
// the ref during render would make React Compiler skip this component.
if (ready) {
return <>{props.children}</>;
}

Expand Down
62 changes: 40 additions & 22 deletions src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import React, {
useEffect,
useMemo,
useCallback,
useLayoutEffect,
useRef,
useSyncExternalStore,
} from "react";
Expand Down Expand Up @@ -106,6 +107,7 @@ import { useWorkspaceMetadata } from "@/browser/contexts/WorkspaceContext";
import { workspaceStore, useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore";
import { invalidateGitStatus } from "@/browser/stores/GitStatusStore";
import { getErrorMessage } from "@/common/utils/errors";
import { runWithCatch, runWithCatchFinally } from "@/browser/utils/compilerSafeControlFlow";

/** Stats reported to parent for tab display */
interface ReviewPanelStats {
Expand Down Expand Up @@ -924,8 +926,8 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
}

let cancelled = false;
void (async () => {
try {
void runWithCatch(
async () => {
const branchResult = await api.projects.listBranches({ projectPath });
const detectedBase = toOriginDiffBase(branchResult.recommendedTrunk);
if (cancelled) {
Expand All @@ -949,10 +951,11 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
setDiffBase(detectedBase);
}
}
} catch {
},
() => {
// Best effort only; keep WORKSPACE_DEFAULTS.reviewBase when detection fails.
}
})();
);

return () => {
cancelled = true;
Expand All @@ -974,10 +977,14 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
// Refs for values that change frequently but are only read at callback invocation time.
// Using refs allows callbacks to stay stable (same reference) while still accessing current values.
// This prevents all HunkViewer components from re-rendering when these values change.
// Refs are synced in layout effects (same task as the commit) because React Compiler
// rejects ref writes during render; the same applies to the other latest-value refs below.
const isReadRef = useRef(isRead);
isReadRef.current = isRead;
const selectedHunkIdRef = useRef(selectedHunkId);
selectedHunkIdRef.current = selectedHunkId;
useLayoutEffect(() => {
isReadRef.current = isRead;
selectedHunkIdRef.current = selectedHunkId;
});

useEffect(() => {
updatePersistedState(selectedHunkStorageKey, selectedHunkId);
Expand Down Expand Up @@ -1206,9 +1213,12 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
// mirrored only `filters.showReadHunks`, which caused the panel to
// navigate away from a hunk that was actually still visible whenever
// Assisted's override forced show-read true.
showReadHunksRef.current = filters.assistedOnly
const effectiveShowReadHunks = filters.assistedOnly
? filters.assistedShowReadHunks
: filters.showReadHunks;
useLayoutEffect(() => {
showReadHunksRef.current = effectiveShowReadHunks;
});

// 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
Expand All @@ -1218,6 +1228,14 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
const composingHunksRef = useRef(new Set<string>());
const editingReviewIdsRef = useRef(new Set<string>());

// 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.
// Created in useEffect to survive React StrictMode double-mount.
// (StrictMode calls cleanup then re-mounts; refs persist but controller would be disposed)
const controllerRef = useRef<RefreshController | null>(null);

const updateRefreshBlockState = useCallback(() => {
const wasComposing = isComposingReviewNoteRef.current;
const nowComposing = composingHunksRef.current.size > 0 || editingReviewIdsRef.current.size > 0;
Expand Down Expand Up @@ -1285,13 +1303,6 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
const [lastRefreshInfo, setLastRefreshInfo] = useState<LastRefreshInfo | null>(null);
// Last refresh failure for UI display (tooltip showing latest refresh error)
const [lastRefreshFailure, setLastRefreshFailure] = useState<RefreshFailureInfo | null>(null);
// 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.
// Created in useEffect to survive React StrictMode double-mount.
// (StrictMode calls cleanup then re-mounts; refs persist but controller would be disposed)
const controllerRef = useRef<RefreshController | null>(null);

useEffect(() => {
const controller = new RefreshController({
Expand Down Expand Up @@ -1408,7 +1419,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({

const loadFileTree = async () => {
setIsLoadingTree(true);
try {
const fetchFileTree = async () => {
await ensureOriginFetched({
api,
workspaceId,
Expand Down Expand Up @@ -1486,13 +1497,18 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
if (cancelled) return;
lastFileTreeRefreshTriggerRef.current = refreshTrigger;
setFileTree(tree);
} catch (err) {
console.error("Failed to load file tree:", err);
} finally {
if (!cancelled) {
setIsLoadingTree(false);
};
await runWithCatchFinally(
fetchFileTree,
(err) => {
console.error("Failed to load file tree:", err);
},
() => {
if (!cancelled) {
setIsLoadingTree(false);
}
}
}
);
};

void loadFileTree();
Expand Down Expand Up @@ -1966,7 +1982,9 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
: false);

// Keep ref in sync so callbacks can access current filtered list without dependency
filteredHunksRef.current = filteredHunks;
useLayoutEffect(() => {
filteredHunksRef.current = filteredHunks;
});

// Ensure selectedHunkId is valid after filtering/sorting:
// - If no selection or selection not in the validity list, select first visible hunk
Expand Down
23 changes: 18 additions & 5 deletions src/browser/features/RightSidebar/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -877,9 +877,12 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
const [layoutDraft, setLayoutDraft] = React.useState<RightSidebarLayoutState | null>(null);
const layoutDraftRef = React.useRef<RightSidebarLayoutState | null>(null);

// Ref to access latest layoutRaw without causing callback recreation
// Ref to access latest layoutRaw without causing callback recreation. Synced in a
// layout effect (same task as the commit) because React Compiler rejects render-time ref writes.
const layoutRawRef = React.useRef(layoutRaw);
layoutRawRef.current = layoutRaw;
React.useLayoutEffect(() => {
layoutRawRef.current = layoutRaw;
});

const isSidebarTabDragInProgressRef = React.useRef(false);

Expand Down Expand Up @@ -1295,8 +1298,10 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
_setFocusTrigger((prev) => prev + 1);
}

// A per-iteration copy: React Compiler can't lower `i++` on a variable a closure captures.
const tabIndex = i;
setLayout((prev) =>
selectTabByIndex(canReviewDiffs ? prev : removeTabEverywhere(prev, "review"), i)
selectTabByIndex(canReviewDiffs ? prev : removeTabEverywhere(prev, "review"), tabIndex)
);
setCollapsed(false);
return;
Expand Down Expand Up @@ -1428,18 +1433,27 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
// Sync terminal tabs with backend sessions on workspace mount.
// - Adds tabs for backend sessions that don't have tabs (restore after reload)
// - Removes "ghost" tabs for sessions that no longer exist (cleanup after app restart)
// Runs only on workspace change, not layout change (layout.root as a dependency would
// loop), so it snapshots the layout through a ref when the request starts. Comparing the
// backend list with that snapshot (not the latest layout) keeps terminals created or
// closed while the request is in flight from being removed or re-added.
const layoutForSessionSyncRef = React.useRef(layout);
React.useLayoutEffect(() => {
layoutForSessionSyncRef.current = layout;
});
React.useEffect(() => {
if (!api) return;

let cancelled = false;
const layoutAtRequestStart = layoutForSessionSyncRef.current;

void api.terminal.listSessions({ workspaceId }).then((backendSessionIds) => {
if (cancelled) return;

const backendSessionSet = new Set(backendSessionIds);

// Get current terminal tabs in layout
const currentTabs = collectAllTabs(layout.root);
const currentTabs = collectAllTabs(layoutAtRequestStart.root);
const currentTerminalTabs = currentTabs.filter(isTerminalTab);
const currentTerminalSessionIds = new Set(
currentTerminalTabs.map(getTerminalSessionId).filter(Boolean)
Expand Down Expand Up @@ -1478,7 +1492,6 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only run on workspace change, not layout change. layout.root would cause infinite loop.
}, [api, workspaceId, setLayout]);

// Handler to update a terminal's title (from OSC sequences)
Expand Down
Loading