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, });