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
83 changes: 20 additions & 63 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ import {
type ProviderInstanceEntry,
} from "../providerInstances";
import { useThreadRunningTerminalIds } from "../state/terminalSessions";
import { useStableCallback } from "../hooks/useStableCallback";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Button, InlineButton } from "./ui/button";
import {
Expand Down Expand Up @@ -2628,20 +2629,19 @@ export default function Sidebar() {
() => projectGroupsSpanEnvironments(projectGroups),
[projectGroups],
);
const scratchMachineLabelFor = useCallback(
(thread: Pick<SidebarThreadSummary, "environmentId" | "projectId">) => {
if (!showProjectEnvironments) return null;
const project = projectByKey.get(`${thread.environmentId}:${thread.projectId}`);
if (
!project ||
!isScratchProject(project, serverConfigs.get(thread.environmentId)?.scratchWorkspaceRoot)
) {
return null;
}
return environmentLabelById.get(thread.environmentId) ?? null;
},
[environmentLabelById, projectByKey, serverConfigs, showProjectEnvironments],
);
const scratchMachineLabelFor = (
thread: Pick<SidebarThreadSummary, "environmentId" | "projectId">,
) => {
if (!showProjectEnvironments) return null;
const project = projectByKey.get(`${thread.environmentId}:${thread.projectId}`);
if (
!project ||
!isScratchProject(project, serverConfigs.get(thread.environmentId)?.scratchWorkspaceRoot)
) {
return null;
}
return environmentLabelById.get(thread.environmentId) ?? null;
};
const projectGroupByScopeKey = useMemo(
() => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
[projectGroups],
Expand Down Expand Up @@ -2745,7 +2745,7 @@ export default function Sidebar() {
clearSelection();
}, [clearSelection, projectScopeKey]);

const openProjectSettings = useCallback(
const openProjectSettings = useStableCallback(
(projectGroup: SidebarProjectSnapshot, projectRef?: ScopedProjectRef) => {
if (isMobile) {
setOpenMobile(false);
Expand All @@ -2762,15 +2762,14 @@ export default function Sidebar() {
},
});
},
[isMobile, projectGroupingSettings, projects, router, setOpenMobile],
);
// Anchor for the scope popup: the header search field, not its icon trigger.
const headerSearchRef = useRef<HTMLDivElement | null>(null);
// Safari can send a click after Ctrl+click opens settings. Ignore that one
// selection, then clear the guard when the picker opens again.
const suppressNextScopeChangeRef = useRef(false);
const highlightedProjectScopeKeyRef = useRef<string | null>(null);
const handleProjectSettings = useCallback(
const handleProjectSettings = useStableCallback(
(
event: ReactMouseEvent<HTMLElement> | ReactKeyboardEvent<HTMLInputElement>,
projectGroup: SidebarProjectSnapshot,
Expand All @@ -2781,7 +2780,6 @@ export default function Sidebar() {
dispatchProjectScopeMenu({ type: "project-settings-opened" });
openProjectSettings(projectGroup);
},
[openProjectSettings],
);

// Keep a dropped row at its destination while its server applies the
Expand Down Expand Up @@ -3602,7 +3600,7 @@ export default function Sidebar() {
action: SidebarSweepAction;
keys: ReadonlySet<string>;
} | null>(null);
const startActionSweep = useCallback(
const startActionSweep = useStableCallback(
(threadRef: ScopedThreadRef, action: SidebarSweepAction, event: PointerEvent) => {
const originKey = scopedThreadKey(threadRef);
const originSection = sectionByThreadKeyRef.current.get(originKey);
Expand Down Expand Up @@ -3661,7 +3659,6 @@ export default function Sidebar() {
onAbort: () => {},
});
},
[attemptUnsettle, attemptUnsnooze, serverConfigs, settleThreads],
);
const pinnedKeys = useMemo(
() =>
Expand Down Expand Up @@ -4280,7 +4277,7 @@ export default function Sidebar() {
);

const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection);
const handleMultiSelectContextMenu = useCallback(
const handleMultiSelectContextMenu = useStableCallback(
async (position: { x: number; y: number }) => {
const api = readLocalApi();
if (!api) return;
Expand Down Expand Up @@ -4540,22 +4537,9 @@ export default function Sidebar() {
}),
);
},
[
attemptUnpin,
clearSelection,
confirmThreadDelete,
deleteThread,
markThreadUnread,
performSnooze,
removeFromSelection,
serverConfigs,
settleThreads,
updateThreadMetadata,
timestampFormat,
],
);

const handleDraftContextMenu = useCallback(
const handleDraftContextMenu = useStableCallback(
(draftId: DraftId, position: { x: number; y: number }) => {
void (async () => {
const api = readLocalApi();
Expand Down Expand Up @@ -4605,10 +4589,9 @@ export default function Sidebar() {
}
})();
},
[copyBranchToClipboard, copyPathToClipboard, openProjectSettings, projectByKey],
);

const handleThreadContextMenu = useCallback(
const handleThreadContextMenu = useStableCallback(
(threadRef: ScopedThreadRef, position: { x: number; y: number }) => {
void (async () => {
const api = readLocalApi();
Expand Down Expand Up @@ -4878,32 +4861,6 @@ export default function Sidebar() {
}
})();
},
[
archiveThread,
attemptPin,
attemptSettle,
attemptSnooze,
attemptUnpin,
attemptUnsettle,
attemptUnsnooze,
confirmThreadArchive,
confirmThreadDelete,
copyBranchToClipboard,
copyPathToClipboard,
copyThreadIdToClipboard,
deleteThread,
handleMultiSelectContextMenu,
markThreadUnread,
openProjectSettings,
projectScopeKey,
projectByKey,
serverConfigs,
setProjectScopeKey,
setThreadAutoSettle,
startThreadRename,
updateThreadMetadata,
timestampFormat,
],
);

// Thread jump (cmd+1..9) and prev/next traversal reuse the same commands as
Expand Down
41 changes: 41 additions & 0 deletions apps/web/src/hooks/useStableCallback.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { act } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import { useStableCallback } from "./useStableCallback";

let renderer: ReactTestRenderer | null = null;
let handlers: Array<() => string> = [];

function Probe({ label }: { label: string }) {
handlers.push(useStableCallback(() => label));
return null;
}

async function render(label: string) {
await act(() => {
if (renderer) renderer.update(<Probe label={label} />);
else renderer = create(<Probe label={label} />);
});
}

beforeEach(() => {
handlers = [];
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
});

afterEach(async () => {
await act(() => renderer?.unmount());
renderer = null;
vi.unstubAllGlobals();
});

describe("useStableCallback", () => {
it("keeps one identity and calls the latest committed function", async () => {
await render("first");
await render("second");

expect(new Set(handlers).size).toBe(1);
expect(handlers[0]!()).toBe("second");
});
});
22 changes: 22 additions & 0 deletions apps/web/src/hooks/useStableCallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useCallback, useLayoutEffect, useRef } from "react";

/**
* Returns a function with a fixed identity that always calls the latest `fn`.
* Only call it from event handlers and effects; during render it would see the
* previous commit's `fn`.
*
* Use it for event handlers in large, long-lived components. A `useCallback`
* result keeps the render scope it was created in, and V8 shares one scope
* between all closures of a render. When several memoized handlers change at
* different times, each keeps an older scope holding older copies of the
* others, and old renders pile up for as long as the component is mounted.
*/
export function useStableCallback<Args extends ReadonlyArray<unknown>, Result>(
fn: (...args: Args) => Result,
): (...args: Args) => Result {
const latest = useRef(fn);
useLayoutEffect(() => {
latest.current = fn;
});
return useCallback((...args: Args) => latest.current(...args), []);
}
Loading