diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx
index 579c120f72..d852466c7a 100644
--- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx
+++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx
@@ -139,23 +139,25 @@ function renderProjectRow(
) {
const onToggleEnvironmentCollapsed = vi.fn();
const result = render(
-
-
- 0}
- collapsedThreadIds={new Set()}
- collapsedEnvironmentIds={collapsedEnvironmentIds}
- isLocalPathInvalid={false}
- onToggleProjectCollapsed={onToggleProjectCollapsed}
- onToggleThreadCollapsed={vi.fn()}
- onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed}
- />
-
- ,
+
+
+
+ 0}
+ collapsedThreadIds={new Set()}
+ collapsedEnvironmentIds={collapsedEnvironmentIds}
+ isLocalPathInvalid={false}
+ onToggleProjectCollapsed={onToggleProjectCollapsed}
+ onToggleThreadCollapsed={vi.fn()}
+ onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed}
+ />
+
+
+ ,
);
return { ...result, onToggleEnvironmentCollapsed, onToggleProjectCollapsed };
}
diff --git a/apps/app/src/components/sidebar/ThreadRow.test.tsx b/apps/app/src/components/sidebar/ThreadRow.test.tsx
index 129c24c159..26b8000660 100644
--- a/apps/app/src/components/sidebar/ThreadRow.test.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.test.tsx
@@ -9,7 +9,8 @@ import {
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
-import type { ReactNode } from "react";
+import { useState, type ReactNode } from "react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createStore, Provider } from "jotai";
import type { ThreadListEntry } from "@bb/domain";
import type { PluginComposerThreadRowStatus } from "@get-bb/plugin-sdk";
@@ -47,6 +48,18 @@ import { splitLayoutAtom } from "@/lib/split-layout/atoms";
import { SPLIT_LAYOUT_STORAGE_KEY } from "@/lib/split-layout/persistence";
import { NO_COLLAPSED_CHILD_ACTIVITY } from "@bb/client-core";
import { sdk } from "@/lib/sdk";
+import { createPerfPhaseLog } from "@/test/perf-phase";
+
+const prefetchThreadDetailBootstrap = vi.hoisted(() => vi.fn());
+
+vi.mock("@/hooks/queries/thread-queries", async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ prefetchThreadDetailBootstrap,
+ };
+});
vi.mock("@/components/thread/ThreadActionsMenu", () => ({
ThreadActionsContextMenu: ({ children }: { children: ReactNode }) => (
@@ -133,20 +146,37 @@ function ThreadRowTestHarness({
: EMPTY_SIDEBAR_THREAD_SHORTCUT_KEYS;
return (
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ThreadRowQueryProvider({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ }),
+ );
+ return (
+ {children}
);
}
@@ -245,6 +275,7 @@ function renderSplitThreadRow({
afterEach(() => {
cleanup();
mocks.renameThread.mockReset();
+ prefetchThreadDetailBootstrap.mockReset();
resetSidebarTitleDoubleClickForTest();
resetPluginThreadRowStatusesForTest();
expect(vi.isMockFunction(sdk.threads.resolveMentions)).toBe(false);
@@ -253,6 +284,35 @@ afterEach(() => {
});
describe("ThreadRow", () => {
+ it("starts thread bootstrap on pointerdown before click would navigate", () => {
+ const phase = createPerfPhaseLog();
+ prefetchThreadDetailBootstrap.mockImplementation(() => {
+ phase.mark("bootstrap-prefetch");
+ });
+ renderThreadRow({ thread: createThread({ id: "thr_open" }) });
+ const link = screen.getByRole("link", { name: "Open Thread" });
+
+ fireEvent.pointerDown(link);
+ phase.mark("pointerdown-complete");
+ fireEvent.click(link);
+ phase.mark("click-complete");
+
+ phase.expectBefore("bootstrap-prefetch", "pointerdown-complete");
+ phase.expectBefore("pointerdown-complete", "click-complete");
+ expect(prefetchThreadDetailBootstrap).toHaveBeenCalledWith(
+ expect.anything(),
+ "thr_open",
+ );
+ });
+
+ it("does not prefetch thread bootstrap on a non-primary pointer", () => {
+ renderThreadRow({});
+ fireEvent.pointerDown(screen.getByRole("link", { name: "Open Thread" }), {
+ button: 2,
+ });
+ expect(prefetchThreadDetailBootstrap).not.toHaveBeenCalled();
+ });
+
const splitWorkingCases: Array<{
label: string;
pluginStatus?: PluginComposerThreadRowStatus;
diff --git a/apps/app/src/components/sidebar/ThreadRow.tsx b/apps/app/src/components/sidebar/ThreadRow.tsx
index 9bf9175280..e14650ee24 100644
--- a/apps/app/src/components/sidebar/ThreadRow.tsx
+++ b/apps/app/src/components/sidebar/ThreadRow.tsx
@@ -8,6 +8,7 @@ import {
type ReactNode,
useRef,
} from "react";
+import { useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import type { ThreadListEntry } from "@bb/domain";
import type { PluginComposerThreadRowStatus } from "@get-bb/plugin-sdk";
@@ -53,6 +54,7 @@ import {
} from "@bb/client-core";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { getThreadRoutePath } from "@/lib/route-paths";
+import { prefetchThreadDetailBootstrap } from "@/hooks/queries/thread-queries";
import { cn } from "@bb/shared-ui/lib/utils";
import { LIST_HOVER_TRANSITION } from "@bb/shared-ui/motion";
import {
@@ -522,6 +524,7 @@ function ThreadRowComponent({
onProjectSelect,
options,
}: ThreadRowProps) {
+ const queryClient = useQueryClient();
const [isDropdownActionsOpen, setIsDropdownActionsOpen] = useState(false);
const [isContextActionsOpen, setIsContextActionsOpen] = useState(false);
const { renameThread } = useThreadActions();
@@ -580,6 +583,15 @@ function ThreadRowComponent({
threadId: thread.id,
title: labelTitle,
});
+ const handleRowPointerDown = useCallback>(
+ (event) => {
+ if (event.button === 0) {
+ prefetchThreadDetailBootstrap(queryClient, thread.id);
+ }
+ onSplitDragPointerDown?.(event);
+ },
+ [onSplitDragPointerDown, queryClient, thread.id],
+ );
const splitAvailable = onSplitDragPointerDown !== undefined;
const parentOptions = options.kind === "parent" ? options : null;
const isParentRow = parentOptions !== null;
@@ -853,7 +865,7 @@ function ThreadRowComponent({
onClickCapture: options.consumeClickSuppression
? handleRowClickCapture
: undefined,
- onSplitDragPointerDown,
+ onSplitDragPointerDown: handleRowPointerDown,
stickyLevel: parentOptions?.stickyLevel,
style: rowStyle,
});