From 773707509d7462094bc15577ce4d2cc194d8fb04 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 22:42:28 -0400 Subject: [PATCH 01/23] Keep cached page data available during background refreshes --- .../queries/sidebar-navigation-query.test.tsx | 95 ++++++++++++++++++- .../hooks/queries/sidebar-navigation-query.ts | 46 ++++----- .../system-config-atoms.local-access.test.ts | 73 +++++++++++++- apps/app/src/lib/system-config-atoms.ts | 5 +- .../views/ProjectDetailSettingsView.test.tsx | 35 ++++++- .../src/views/ProjectDetailSettingsView.tsx | 6 +- .../views/ToolsView.plugin-detail.test.tsx | 86 ++++++++++++++++- apps/app/src/views/ToolsView.tsx | 4 +- 8 files changed, 314 insertions(+), 36 deletions(-) diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx index 94e6bb688b3..e6c62b9ce6b 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx @@ -14,7 +14,11 @@ import { } from "@/lib/sidebar-bootstrap-cache"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { useSidebarNavigation } from "./sidebar-navigation-query"; +import { + useProjectDisplayName, + useSidebarNavigation, +} from "./sidebar-navigation-query"; +import { sidebarNavigationQueryKey } from "./query-keys"; import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, @@ -68,6 +72,91 @@ afterEach(() => { }); describe("useSidebarNavigation", () => { + it("shares persisted page data and replaces it after a background refresh", async () => { + window.localStorage.setItem( + SIDEBAR_BOOTSTRAP_CACHE_KEY, + JSON.stringify(BOOTSTRAP), + ); + let complete!: (value: SidebarBootstrapResponse) => void; + vi.mocked(request).mockImplementation( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const { queryClient, wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => ({ + navigation: useSidebarNavigation(), + name: useProjectDisplayName("proj_felt"), + }), + { wrapper }, + ); + expect(result.current.navigation.data).toEqual(BOOTSTRAP); + expect(result.current.name).toBe("Felt walk"); + expect(result.current.navigation.isFetching).toBe(true); + expect(request).toHaveBeenCalledTimes(1); + const updated = { + ...BOOTSTRAP, + projects: [{ ...BOOTSTRAP.projects[0]!, name: "Refreshed project" }], + }; + await act(async () => { + complete(updated); + }); + await waitFor(() => expect(result.current.name).toBe("Refreshed project")); + expect(queryClient.getQueryData(sidebarNavigationQueryKey())).toEqual( + updated, + ); + }); + + it("keeps persisted content after refresh failure and recovers on retry", async () => { + window.localStorage.setItem( + SIDEBAR_BOOTSTRAP_CACHE_KEY, + JSON.stringify(BOOTSTRAP), + ); + vi.mocked(request).mockRejectedValue(new Error("refresh failed")); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook(() => useSidebarNavigation(), { wrapper }); + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.data).toEqual(BOOTSTRAP); + expect(result.current.isLoadingError).toBe(false); + vi.mocked(request).mockResolvedValue(BOOTSTRAP); + await act(async () => { + await result.current.refetch(); + }); + expect(result.current.isError).toBe(false); + expect(result.current.data).toEqual(BOOTSTRAP); + }); + + it("prefers newer query data to persistence and honors invalidation", async () => { + window.localStorage.setItem( + SIDEBAR_BOOTSTRAP_CACHE_KEY, + JSON.stringify(BOOTSTRAP), + ); + const current = { ...BOOTSTRAP, projects: [] }; + const { queryClient, wrapper } = createQueryClientTestHarness(); + queryClient.setQueryData(sidebarNavigationQueryKey(), current); + vi.mocked(request).mockResolvedValue(BOOTSTRAP); + const { result } = renderHook(() => useSidebarNavigation(), { wrapper }); + expect(result.current.data).toEqual(current); + expect(request).not.toHaveBeenCalled(); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: sidebarNavigationQueryKey(), + }); + }); + await waitFor(() => expect(result.current.data).toEqual(BOOTSTRAP)); + expect(request).toHaveBeenCalledTimes(1); + }); + + it("preserves errors on a cache miss", async () => { + vi.mocked(request).mockRejectedValue(new Error("load failed")); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook(() => useSidebarNavigation(), { wrapper }); + await waitFor(() => expect(result.current.isLoadingError).toBe(true)); + expect(result.current.data).toBeUndefined(); + }); + it("replays the last bootstrap while the live one loads", async () => { sidebarBootstrapResponseSchema.parse(BOOTSTRAP); @@ -84,7 +173,7 @@ describe("useSidebarNavigation", () => { const { result } = renderHook(() => useSidebarNavigation(), { wrapper: reloadHarness.wrapper, }); - expect(result.current.isPlaceholderData).toBe(true); + expect(result.current.isPlaceholderData).toBe(false); expect(result.current.data?.projects[0]?.name).toBe("Felt walk"); await waitFor(() => expect(request).toHaveBeenCalled()); }); @@ -145,7 +234,7 @@ describe("useSidebarNavigation", () => { const { result } = renderHook(() => useSidebarNavigation(), { wrapper: reloadHarness.wrapper, }); - expect(result.current.isPlaceholderData).toBe(true); + expect(result.current.isPlaceholderData).toBe(false); expect(result.current.data?.projects[0]?.threads).toHaveLength( MAX_CACHED_SIDEBAR_THREADS_PER_PROJECT, ); diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.ts b/apps/app/src/hooks/queries/sidebar-navigation-query.ts index 0a62120f2eb..18560b678b1 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.ts +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { queryOptions, useQuery } from "@tanstack/react-query"; import { useCallback } from "react"; import { PERSONAL_PROJECT_ID, type ThreadListEntry } from "@bb/domain"; import type { SidebarBootstrapResponse } from "@bb/server-contract"; @@ -13,18 +13,25 @@ import { } from "@/hooks/useRealtimeSubscription"; import type { QueryOptions } from "./query-helpers"; import { sidebarNavigationQueryKey } from "./query-keys"; -import { REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY } from "./query-policies"; import { readCachedSidebarBootstrap, writeCachedSidebarBootstrap, } from "@/lib/sidebar-bootstrap-cache"; -function fetchSidebarNavigation( - signal?: AbortSignal, -): Promise { - return request( - apiClient["sidebar-bootstrap"].$get(undefined, requestOptions(signal)), - ); +function sidebarNavigationQueryOptions() { + return queryOptions({ + queryKey: sidebarNavigationQueryKey(), + queryFn: async ({ signal }) => { + const response = await request( + apiClient["sidebar-bootstrap"].$get(undefined, requestOptions(signal)), + ); + writeCachedSidebarBootstrap(response); + return response; + }, + initialData: () => readCachedSidebarBootstrap() ?? undefined, + initialDataUpdatedAt: 0, + staleTime: (query) => (query.state.dataUpdatedAt === 0 ? 0 : Infinity), + }); } export function useSidebarNavigation(options?: QueryOptions) { @@ -34,26 +41,17 @@ export function useSidebarNavigation(options?: QueryOptions) { useProjectListRealtimeSubscription({ enabled }); useThreadListRealtimeSubscription({ enabled }); - return useQuery({ - queryKey: sidebarNavigationQueryKey(), - queryFn: async ({ signal }) => { - const response = await fetchSidebarNavigation(signal); - writeCachedSidebarBootstrap(response); - return response; - }, + return useQuery({ + ...sidebarNavigationQueryOptions(), enabled, - ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, - placeholderData: () => readCachedSidebarBootstrap() ?? undefined, }); } export function useProjectDisplayName( projectId: string | undefined, ): string | undefined { - const { data } = useQuery({ - queryKey: sidebarNavigationQueryKey(), - queryFn: ({ signal }) => fetchSidebarNavigation(signal), - ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, + const { data } = useQuery({ + ...sidebarNavigationQueryOptions(), enabled: Boolean(projectId), }); if (!data || !projectId) { @@ -78,10 +76,8 @@ export function useSidebarNavigationThreadSelection( select(listSidebarNavigationThreads(navigation)), [select], ); - const result = useQuery({ - queryKey: sidebarNavigationQueryKey(), - queryFn: ({ signal }) => fetchSidebarNavigation(signal), - ...REALTIME_OWNED_STATIC_CACHE_QUERY_POLICY, + const result = useQuery({ + ...sidebarNavigationQueryOptions(), enabled: false, select: selectFromNavigation, }); diff --git a/apps/app/src/lib/system-config-atoms.local-access.test.ts b/apps/app/src/lib/system-config-atoms.local-access.test.ts index 1f0f077f45d..e5146dd2733 100644 --- a/apps/app/src/lib/system-config-atoms.local-access.test.ts +++ b/apps/app/src/lib/system-config-atoms.local-access.test.ts @@ -43,7 +43,8 @@ vi.mock("./api-host-daemon", () => ({ fetchWorkspaceOpenTargets: vi.fn(async () => []), })); -vi.mock("./sdk", () => ({ +vi.mock("./sdk", async (importOriginal) => ({ + ...(await importOriginal()), sdk: { system: { config: mocks.fetchSdkSystemConfig, @@ -107,6 +108,76 @@ afterEach(() => { }); describe("local host daemon access atoms", () => { + it("serves stale config immediately and applies a background refresh", async () => { + const cached = { hostDaemonPort: 38_887, localHelperPorts: [38_887] }; + const updated = { hostDaemonPort: 38_887, localHelperPorts: [] }; + appQueryClient.setQueryData(systemConfigQueryKey(), cached, { + updatedAt: Date.now() - 120_000, + }); + let complete!: (config: typeof cached) => void; + mocks.fetchSdkSystemConfig.mockImplementation( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const store = createStore(); + const unsubscribe = store.sub(localHostDaemonAccessStateAtom, () => {}); + try { + await expect(store.get(localHostDaemonAccessStateAtom)).resolves.toBe( + "permission-required", + ); + expect(mocks.fetchSdkSystemConfig).toHaveBeenCalledTimes(1); + complete(updated); + await vi.waitFor(async () => { + await expect(store.get(localHostDaemonAccessStateAtom)).resolves.toBe( + "unavailable", + ); + }); + } finally { + unsubscribe(); + } + }); + + it("retains stale config after a background refresh fails", async () => { + const cached = { hostDaemonPort: 38_887, localHelperPorts: [38_887] }; + appQueryClient.setQueryData(systemConfigQueryKey(), cached, { + updatedAt: Date.now() - 120_000, + }); + mocks.fetchSdkSystemConfig.mockRejectedValue(new Error("refresh failed")); + const store = createStore(); + await expect(store.get(localHostDaemonAccessStateAtom)).resolves.toBe( + "permission-required", + ); + await vi.waitFor(() => { + expect(appQueryClient.getQueryState(systemConfigQueryKey())?.status).toBe( + "error", + ); + }); + expect(appQueryClient.getQueryData(systemConfigQueryKey())).toEqual(cached); + await expect(store.get(localHostDaemonAccessStateAtom)).resolves.toBe( + "permission-required", + ); + }); + + it("waits on a cache miss and preserves the failed-load fallback", async () => { + let fail!: (error: Error) => void; + mocks.fetchSdkSystemConfig.mockImplementation( + () => + new Promise((_resolve, reject) => { + fail = reject; + }), + ); + const store = createStore(); + const loaded = vi.fn(); + const result = store.get(localHostDaemonAccessStateAtom).then(loaded); + await Promise.resolve(); + expect(loaded).not.toHaveBeenCalled(); + fail(new Error("config unavailable")); + await result; + expect(loaded).toHaveBeenCalledWith("unavailable"); + }); + it("shares the system config request with the app query owner", async () => { const store = createStore(); const query = appQueryClient.fetchQuery({ diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts index 558bd5fc098..410a9e1bc12 100644 --- a/apps/app/src/lib/system-config-atoms.ts +++ b/apps/app/src/lib/system-config-atoms.ts @@ -89,7 +89,10 @@ function didLastSystemConfigLoadFail(): boolean { async function loadSystemConfig(): Promise { try { - const config = await appQueryClient.fetchQuery(systemConfigQueryOptions()); + const config = await appQueryClient.ensureQueryData({ + ...systemConfigQueryOptions(), + revalidateIfStale: true, + }); markSystemConfigLoadSucceeded(); return config; } catch { diff --git a/apps/app/src/views/ProjectDetailSettingsView.test.tsx b/apps/app/src/views/ProjectDetailSettingsView.test.tsx index a41a445fe43..213f102788a 100644 --- a/apps/app/src/views/ProjectDetailSettingsView.test.tsx +++ b/apps/app/src/views/ProjectDetailSettingsView.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -15,6 +16,14 @@ import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { makeSystemConfig } from "@/test/fixtures/system-config"; import { SETTINGS_PROJECT_ROUTE_PATH } from "@/lib/route-paths"; +import { + hostsQueryKey, + sidebarNavigationQueryKey, +} from "@/hooks/queries/query-keys"; +import { + resetSidebarBootstrapCacheForTest, + SIDEBAR_BOOTSTRAP_CACHE_KEY, +} from "@/lib/sidebar-bootstrap-cache"; import { ProjectDetailSettingsView } from "./ProjectDetailSettingsView"; vi.mock("@/lib/sdk", () => ({ @@ -124,8 +133,8 @@ function stubSidebarBootstrapFetch( } function renderView(projectId = "proj_bb") { - const { wrapper } = createQueryClientTestHarness(); - return render( + const { wrapper, queryClient } = createQueryClientTestHarness(); + const view = render( , { wrapper }, ); + return { ...view, queryClient }; } beforeEach(() => { @@ -160,11 +170,32 @@ beforeEach(() => { afterEach(() => { cleanup(); + resetSidebarBootstrapCacheForTest(); + window.localStorage.removeItem(SIDEBAR_BOOTSTRAP_CACHE_KEY); vi.unstubAllGlobals(); vi.clearAllMocks(); }); describe("ProjectDetailSettingsView", () => { + it("keeps the cached project visible when a page refresh fails", async () => { + stubSidebarBootstrapFetch([{ hostId: primaryHost.id, path: "/repos/bb" }]); + const { queryClient } = renderView(); + await screen.findByRole("heading", { name: "bb", level: 1 }); + vi.mocked(sdk.hosts.list).mockRejectedValue(new Error("refresh failed")); + stubSidebarBootstrapFetch([], { status: 503 }); + await act(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: hostsQueryKey() }), + queryClient.invalidateQueries({ + queryKey: sidebarNavigationQueryKey(), + }), + ]); + }); + expect(queryClient.getQueryState(hostsQueryKey())?.status).toBe("error"); + expect(screen.getByRole("heading", { name: "bb", level: 1 })).toBeTruthy(); + expect(screen.queryByText("Couldn't load this project.")).toBeNull(); + }); + it("keeps checkout counts in sync with the show-all machine toggle", async () => { const sandbox = host({ id: "host_sandbox", diff --git a/apps/app/src/views/ProjectDetailSettingsView.tsx b/apps/app/src/views/ProjectDetailSettingsView.tsx index c33d0fbf08e..5bc88a3da8a 100644 --- a/apps/app/src/views/ProjectDetailSettingsView.tsx +++ b/apps/app/src/views/ProjectDetailSettingsView.tsx @@ -292,7 +292,11 @@ export function ProjectDetailSettingsView() { [localSourcePicker, pickerHostId, project], ); - if (sidebarNavigationQuery.isError || hostsQuery.isError) { + if ( + (sidebarNavigationQuery.isError && + sidebarNavigationQuery.data === undefined) || + (hostsQuery.isError && hostsQuery.data === undefined) + ) { return (

diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index eb82c4e8194..09c05179439 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -43,7 +43,10 @@ import { pluginFrontendDiagnosticRequiresFailureBanner, } from "@/components/tools/PluginDetail"; import type { PluginCatalogSearchEntry } from "@/hooks/queries/plugin-catalog-queries"; -import { pluginSourceQueryKey } from "@/hooks/queries/query-keys"; +import { + pluginListQueryKey, + pluginSourceQueryKey, +} from "@/hooks/queries/query-keys"; import type { PluginFrontendDiagnostic } from "@/lib/plugin-frontend"; import { makeInstalledPlugin, @@ -154,6 +157,87 @@ afterEach(() => { vi.unstubAllGlobals(); }); +describe("plugin detail page cached loads", () => { + it.each([ + { cached: true, fails: false }, + { cached: true, fails: true }, + { cached: false, fails: false }, + { cached: false, fails: true }, + ])( + "loads with cached=$cached and refresh failure=$fails", + async ({ cached, fails }) => { + let complete!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + if (String(input) === "/api/v1/plugins") { + return new Promise((resolve) => { + complete = resolve; + }); + } + if (String(input).includes("plugin-catalog/search")) { + return Response.json({ results: [], collections: [] }); + } + return Response.json({ error: "not found" }, { status: 404 }); + }), + ); + const { queryClient, wrapper } = createQueryClientTestHarness(); + if (cached) { + queryClient.setQueryData( + pluginListQueryKey(true), + [makeInstalledPlugin({ id: "github", name: "Cached GitHub" })], + { updatedAt: Date.now() - 60_000 }, + ); + } + render( + + + , + { wrapper }, + ); + if (cached) { + expect( + screen.getByRole("heading", { name: "Cached GitHub" }), + ).toBeTruthy(); + expect(screen.queryByText("Loading plugin")).toBeNull(); + } else { + expect(screen.getByText("Loading plugin")).toBeTruthy(); + } + await act(async () => { + complete( + fails + ? Response.json({ error: "refresh failed" }, { status: 503 }) + : Response.json({ + plugins: [ + makeInstalledPlugin({ id: "github", name: "Updated GitHub" }), + ], + }), + ); + }); + if (!fails) { + await screen.findByRole("heading", { name: "Updated GitHub" }); + expect( + screen.queryByRole("heading", { name: "Cached GitHub" }), + ).toBeNull(); + } else { + await waitFor(() => + expect( + queryClient.getQueryState(pluginListQueryKey(true))?.status, + ).toBe("error"), + ); + if (cached) { + expect( + screen.getByRole("heading", { name: "Cached GitHub" }), + ).toBeTruthy(); + expect(screen.queryByText("Couldn't load plugin.")).toBeNull(); + } else { + expect(screen.getByText("Couldn't load plugin.")).toBeTruthy(); + } + } + }, + ); +}); + describe("PluginDetail official catalog lifecycle", () => { it("offers Install from an unowned BB Official plugin detail page", () => { const onInstall = vi.fn(); diff --git a/apps/app/src/views/ToolsView.tsx b/apps/app/src/views/ToolsView.tsx index b740c711794..29cfbdaa5fa 100644 --- a/apps/app/src/views/ToolsView.tsx +++ b/apps/app/src/views/ToolsView.tsx @@ -268,7 +268,7 @@ function PluginDetailToolView({ pluginId }: { pluginId: string }) { ); let detailContent: ReactNode; - if (listQuery.isError) { + if (listQuery.isError && listQuery.data === undefined) { detailContent = ( ); - } else if (catalogQuery.isError) { + } else if (catalogQuery.isError && catalogQuery.data === undefined) { detailContent = ( Date: Thu, 17 Sep 2026 22:45:07 -0400 Subject: [PATCH 02/23] Wait for query observer notification after retry --- apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx index e6c62b9ce6b..6d9565605b7 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx @@ -124,7 +124,7 @@ describe("useSidebarNavigation", () => { await act(async () => { await result.current.refetch(); }); - expect(result.current.isError).toBe(false); + await waitFor(() => expect(result.current.isError).toBe(false)); expect(result.current.data).toEqual(BOOTSTRAP); }); From 1238c340cdf84c7c85b83c1a5053487753ae152d Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 17 Sep 2026 22:47:04 -0400 Subject: [PATCH 03/23] Retain cached project and plugin lists on refresh failure --- .../plugin/PluginsOverview.test.tsx | 28 ++++++++++++++++ .../src/components/plugin/PluginsOverview.tsx | 2 +- .../settings/ProjectsSettingsSection.test.tsx | 32 +++++++++++++++++-- .../settings/ProjectsSettingsSection.tsx | 2 +- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx index 231f5c0c5ad..606bc0dff17 100644 --- a/apps/app/src/components/plugin/PluginsOverview.test.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx @@ -21,6 +21,7 @@ import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { makeSystemConfig } from "@/test/fixtures/system-config"; import { SidebarHistoryNavigationControls } from "@/components/sidebar/SidebarHistoryNavigationControls"; import { resetAppRouteHistoryForTest } from "@/lib/app-route-history"; +import { pluginListQueryKey } from "@/hooks/queries/query-keys"; import { PluginsOverview } from "./PluginsOverview"; vi.mock("@/components/plugin/PluginNewThreadComposer", () => ({ @@ -198,6 +199,33 @@ afterEach(() => { }); describe("PluginsOverview", () => { + it("keeps installed plugins visible when a background refresh fails", async () => { + installFetch(); + const { wrapper, queryClient } = createQueryClientTestHarness(); + render( + + + , + { wrapper }, + ); + await screen.findByTestId("plugin-row-automations"); + vi.mocked(fetch).mockResolvedValue( + responseJson({ error: "refresh failed" }, 503), + ); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: pluginListQueryKey(true), + }); + }); + await waitFor(() => + expect(queryClient.getQueryState(pluginListQueryKey(true))?.status).toBe( + "error", + ), + ); + expect(screen.getByTestId("plugin-row-automations")).toBeTruthy(); + expect(screen.queryByText("Couldn't load plugins.")).toBeNull(); + }); + it("opens on Browse and renders it before Installed", async () => { installFetch(); const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/components/plugin/PluginsOverview.tsx b/apps/app/src/components/plugin/PluginsOverview.tsx index 60caedae761..5dae99b089f 100644 --- a/apps/app/src/components/plugin/PluginsOverview.tsx +++ b/apps/app/src/components/plugin/PluginsOverview.tsx @@ -222,7 +222,7 @@ export function PluginsOverview({ } >

- {listQuery.isError ? ( + {listQuery.isError && listQuery.data === undefined ? ( , { wrapper }, ); + return { ...view, queryClient }; } async function openProjectMenu(projectName: string): Promise { @@ -173,6 +180,8 @@ beforeEach(() => { afterEach(() => { cleanup(); + resetSidebarBootstrapCacheForTest(); + window.localStorage.removeItem(SIDEBAR_BOOTSTRAP_CACHE_KEY); vi.unstubAllGlobals(); vi.clearAllMocks(); }); @@ -266,6 +275,25 @@ describe("formatGitRemote", () => { }); describe("ProjectsSettingsSection", () => { + it("keeps cached projects visible when a background refresh fails", async () => { + stubSidebarBootstrapFetch(projects); + const { queryClient } = renderSection(); + await screen.findByRole("link", { name: "Open bb settings" }); + stubSidebarBootstrapFetch([], 503); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: sidebarNavigationQueryKey(), + }); + }); + await waitFor(() => + expect( + queryClient.getQueryState(sidebarNavigationQueryKey())?.status, + ).toBe("error"), + ); + expect(screen.getByRole("link", { name: "Open bb settings" })).toBeTruthy(); + expect(screen.queryByText("Couldn't load projects.")).toBeNull(); + }); + it("summarises each project's remote, machine coverage, and threads", async () => { stubSidebarBootstrapFetch(projects); diff --git a/apps/app/src/components/settings/ProjectsSettingsSection.tsx b/apps/app/src/components/settings/ProjectsSettingsSection.tsx index 222b682a5a1..f56e7a84635 100644 --- a/apps/app/src/components/settings/ProjectsSettingsSection.tsx +++ b/apps/app/src/components/settings/ProjectsSettingsSection.tsx @@ -288,7 +288,7 @@ export function ProjectsSettingsSection() { } > - {sidebarNavigationQuery.isError ? ( + {sidebarNavigationQuery.isError && projects === undefined ? (

Couldn't load projects.

From 9fe8fa813e9fe1640472533403e9693966a64ae0 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:05:35 -0400 Subject: [PATCH 04/23] Retain thread history during background refreshes --- .../embedded-chat/EmbeddedThreadChat.test.tsx | 13 +- .../embedded-chat/EmbeddedThreadChat.tsx | 114 +++-- .../timeline/ThreadTimelineLatestContext.ts | 6 + .../ThreadTimelinePanelContent.test.tsx | 40 +- .../timeline/ThreadTimelinePanelContent.tsx | 19 +- .../thread/timeline/ThreadTimelineRows.tsx | 6 + .../ThreadTimelineRows.windowing.test.tsx | 63 ++- .../timeline/ThreadTimelineSurface.test.tsx | 60 ++- .../thread/timeline/ThreadTimelineSurface.tsx | 43 +- .../useThreadTimelineController.test.tsx | 199 +++++++- .../timeline/useThreadTimelineController.ts | 348 ++++++++----- ...d-scroll-body.scroll-preservation.test.tsx | 257 +++++++++- .../ui/bottom-anchored-scroll-body.tsx | 219 ++++++++- .../cache-owners/mutation-cache-effects.ts | 6 + .../hooks/cache-owners/project-cache-owner.ts | 55 +++ .../cache-owners/realtime-cache-registry.ts | 73 ++- .../cache-owners/system-cache-effects.ts | 48 +- .../thread-history-cache-owner.test.ts | 225 +++++++++ .../thread-history-cache-owner.ts | 193 ++++++++ apps/app/src/hooks/queries/query-keys.ts | 15 + .../queries/thread-history-query.test.tsx | 465 ++++++++++++++++++ .../src/hooks/queries/thread-history-query.ts | 463 +++++++++++++++++ apps/app/src/hooks/queries/thread-queries.ts | 4 +- .../thread-history-cache-effects.test.ts | 297 +++++++++++ .../views/thread-detail/ThreadDetailView.tsx | 12 + ...hreadTimelineScrollToBottomButton.test.tsx | 54 ++ .../ThreadTimelineScrollToBottomButton.tsx | 24 +- .../src/timeline/timeline-merge.ts | 249 +++++++++- .../client-core/test/timeline-merge.test.ts | 401 +++++++++++++++ 29 files changed, 3739 insertions(+), 232 deletions(-) create mode 100644 apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts create mode 100644 apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts create mode 100644 apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts create mode 100644 apps/app/src/hooks/queries/thread-history-query.test.tsx create mode 100644 apps/app/src/hooks/queries/thread-history-query.ts create mode 100644 apps/app/src/hooks/thread-history-cache-effects.test.ts create mode 100644 apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index e3da67a42a0..9b76e22020b 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -34,6 +34,7 @@ const mocks = vi.hoisted(() => ({ timelinePanelProps: [] as Array>, timelineProjectIds: [] as Array, resolveMentionLink: vi.fn(), + showLatestTimeline: vi.fn(), })); const hostDraftMocks = vi.hoisted(() => ({ @@ -161,6 +162,11 @@ vi.mock("@/components/ui/overflow-fade", () => ({ vi.mock("@/components/thread/timeline", () => ({ isRunningThreadRuntimeDisplayStatus: (status: string) => status === "active", + useThreadTimelineController: () => ({ + historyUnrefreshed: false, + showLatestTimeline: mocks.showLatestTimeline, + timelineRows: mocks.timelineRows, + }), ThreadTimelinePanelContent: (props: Record) => { mocks.timelinePanelProps.push(props); mocks.injectedTimelineProps.push(props.timeline); @@ -490,7 +496,12 @@ describe("EmbeddedThreadChat", () => { const rows = screen.getAllByTestId("embedded-chat-timeline-row"); expect(rows).toHaveLength(2); expect(rows[1]?.textContent).toBe("Streamed later"); - expect(mocks.injectedTimelineProps.at(-1)).toBeUndefined(); + expect(mocks.injectedTimelineProps.at(-1)).toEqual( + expect.objectContaining({ + timelineRows: mocks.timelineRows, + showLatestTimeline: mocks.showLatestTimeline, + }), + ); }); it("queues the submitted draft itself while the thread runtime is active", async () => { diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 63a54c76816..064342f298c 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -39,12 +39,14 @@ import { OverflowFade } from "@/components/ui/overflow-fade"; import { ThreadTimelinePanelContent, ThreadTimelineSurface, + useThreadTimelineController, type ThreadTimelineAddToChatHandler, type ThreadTimelineConsumerMessageAction, type ThreadTimelineLinkHandler, type ThreadTimelineLocalFileLinkHandler, type ThreadTimelineSurfaceProps, } from "@/components/thread/timeline"; +import { ThreadTimelineLatestContext } from "@/components/thread/timeline/ThreadTimelineLatestContext"; import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; import { getLatestPendingInteraction, @@ -64,6 +66,7 @@ import { useMarkThreadRead } from "@/hooks/mutations/thread-state-mutations"; import { useThreadReadTracking } from "@/hooks/useThreadReadTracking"; import { useComposerTextEffects } from "@/lib/composer-text-effects"; import { showMutationErrorToast } from "@/lib/mutation-errors"; +import { BbHttpError } from "@/lib/sdk"; import type { PromptDraftScope } from "@/hooks/usePromptDraftStorage"; import { appToast } from "@/components/ui/app-toast"; import { @@ -192,23 +195,35 @@ function EmbeddedThreadChatHostedFooter({ scrollOverlay, surface, }: EmbeddedThreadChatHostedFooterProps) { + const latestTimeline = useMemo( + () => + surface.onShowLatestTimeline === undefined + ? null + : { + historyUnrefreshed: surface.historyUnrefreshed ?? false, + showLatestTimeline: surface.onShowLatestTimeline, + }, + [surface.historyUnrefreshed, surface.onShowLatestTimeline], + ); return (
- - - + + + + +
); } @@ -240,6 +255,14 @@ function EmbeddedThreadChatWithComposer({ const sendThreadMessage = useSendThreadMessage(); const createQueuedMessage = useCreateThreadQueuedMessage(); const threadQuery = useThread(threadId); + const timeline = useThreadTimelineController({ + enabled: !( + threadQuery.error instanceof BbHttpError && + [401, 403, 404].includes(threadQuery.error.status) + ), + surfaceKey, + threadId, + }); const pendingInteractionsQuery = useThreadPendingInteractions(threadId); const activePendingInteraction = getLatestPendingInteraction( pendingInteractionsQuery.data, @@ -1161,6 +1184,7 @@ function EmbeddedThreadChatWithComposer({ const maxWidthClassName = measure === "page" ? "max-w-[760px]" : "max-w-none"; const timelineBody = ( +
- {timelineBody} +
+ {timelineBody} +
+
{footer}
-
{footer}
-
+ ); } return ( -
- +
- {timelineBody} - -
+ + {timelineBody} + +
+ ); } diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts b/apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts new file mode 100644 index 00000000000..a12f581d72c --- /dev/null +++ b/apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; + +export const ThreadTimelineLatestContext = createContext<{ + historyUnrefreshed: boolean; + showLatestTimeline: () => void; +} | null>(null); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx index 1b54f485c71..cd23a35ef5c 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx @@ -7,11 +7,13 @@ import type { ThreadRuntimeDisplayStatus } from "@bb/domain"; import type { TimelineWorkflowWorkRow } from "@bb/server-contract"; import { ThreadTimelinePanelContent } from "./ThreadTimelinePanelContent.js"; import type { UseThreadTimelineControllerResult } from "./useThreadTimelineController.js"; +import { BbHttpError } from "@/lib/sdk"; const mocks = vi.hoisted(() => ({ activeBackgroundAgentCount: 0, displayStatus: "idle" as ThreadRuntimeDisplayStatus, threadStatus: "idle", + threadError: null as Error | null, })); vi.mock("@/hooks/queries/thread-queries", () => ({ @@ -21,7 +23,7 @@ vi.mock("@/hooks/queries/thread-queries", () => ({ runtime: { displayStatus: mocks.displayStatus }, status: mocks.threadStatus, }, - error: null, + error: mocks.threadError, }), })); @@ -52,8 +54,14 @@ vi.mock("./useThreadTimelineController.js", () => ({ goal: null, modelFallback: null, hasOlderTimelineRows: false, + historyRefreshError: null, + historyUnrefreshed: false, + historyReplacementKey: null, isLoadingOlderTimelineRows: false, + isRefreshingHistory: false, loadOlderTimelineRows: vi.fn(), + refreshHistory: vi.fn().mockResolvedValue(undefined), + showLatestTimeline: vi.fn(), pendingTodos: null, timelineError: null, timelineLoading: false, @@ -104,8 +112,14 @@ function baseTimeline( contextWindowUsage: undefined, goal: null, hasOlderTimelineRows: false, + historyRefreshError: null, + historyUnrefreshed: false, + historyReplacementKey: null, isLoadingOlderTimelineRows: false, + isRefreshingHistory: false, loadOlderTimelineRows: vi.fn(), + refreshHistory: vi.fn().mockResolvedValue(undefined), + showLatestTimeline: vi.fn(), pendingTodos: null, timelineError: null, timelineLoading: false, @@ -121,9 +135,33 @@ afterEach(() => { mocks.activeBackgroundAgentCount = 0; mocks.displayStatus = "idle"; mocks.threadStatus = "idle"; + mocks.threadError = null; }); describe("ThreadTimelinePanelContent", () => { + it.each([401, 403, 404])( + "hides retained history after a %s access failure", + (status) => { + mocks.threadError = new BbHttpError({ + body: null, + code: null, + message: "Unavailable", + status, + }); + render( + , + ); + + expect( + screen.getByText("This thread is no longer available."), + ).not.toBeNull(); + expect(screen.queryByText("Background work running")).toBeNull(); + }, + ); + it("shows a background-only working indicator while runtime is idle", () => { render( {leadingContent} @@ -100,7 +101,11 @@ export function ThreadTimelinePanelContent({ activeThinking={resolvedTimeline.activeThinking} contextBoundarySeq={resolvedTimeline.contextBoundarySeq} hasOlderTimelineRows={resolvedTimeline.hasOlderTimelineRows} + historyRefreshError={resolvedTimeline.historyRefreshError} + historyUnrefreshed={resolvedTimeline.historyUnrefreshed} + historyReplacementKey={resolvedTimeline.historyReplacementKey} isLoadingOlderTimelineRows={resolvedTimeline.isLoadingOlderTimelineRows} + isRefreshingHistory={resolvedTimeline.isRefreshingHistory} isThreadTimelinePending={ resolvedTimeline.timelineLoading && timelineRows.length === 0 && @@ -116,6 +121,8 @@ export function ThreadTimelinePanelContent({ consumerMessageActions={consumerMessageActions} includePluginMessageActions={includePluginMessageActions} onLoadOlderRows={resolvedTimeline.loadOlderTimelineRows} + onRefreshHistory={resolvedTimeline.refreshHistory} + onShowLatestTimeline={resolvedTimeline.showLatestTimeline} onOpenLink={onOpenLink} onOpenLocalFileLink={onOpenLocalFileLink} projectId={projectId} diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index ca21e8c4ba3..b5465a869ad 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -100,6 +100,7 @@ import { } from "./PluginTimelineRendererBody.js"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { + TimelineReplacementScrollAnchor, TimelineScrollRestoreRowIdContext, useBottomAnchoredScroll, } from "@/components/ui/bottom-anchored-scroll-body.js"; @@ -167,6 +168,7 @@ export interface ThreadTimelineRowsProps { resolveImageViewSrc?: ThreadTimelineImageViewSrcResolver; resolveUserAttachmentImageSrc?: UserAttachmentImageSrcResolver; hasOlderTimelineRows?: boolean; + historyReplacementKey?: object | null; isLoadingOlderTimelineRows?: boolean; onLoadOlderRows?: () => Promise | void; timelineRows: TimelineRow[]; @@ -2180,6 +2182,10 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { snapRevision={heightSnapRevision} animateGrowth={!scopeActive} > + { }); describe("ThreadTimelineRows windowing experiment", () => { + it("uses rendered group IDs to preserve the nearest survivor when a refreshed bundle dissolves", () => { + const initialRows = [ + conversationRow({ id: "before", role: "user", text: "Inspect files" }), + fileReadRow({ id: "read-a", path: "a.ts", seq: 2 }), + fileReadRow({ id: "read-b", path: "b.ts", seq: 3 }), + conversationRow({ id: "after", text: "Result", seq: 4 }), + conversationRow({ id: "tail", role: "user", text: "Continue", seq: 5 }), + ]; + const queryClient = new QueryClient(); + const timeline = (rows: typeof initialRows, replacementKey: object) => ( + + + + + + + + ); + const view = render(timeline(initialRows, {})); + const scrollArea = view.container.querySelector( + ".replacement-scroll", + ); + if (!scrollArea) throw new Error("Expected a scroll area"); + Object.defineProperty(scrollArea, "clientHeight", { value: 100 }); + Object.defineProperty(scrollArea, "scrollHeight", { value: 400 }); + vi.mocked(HTMLElement.prototype.getBoundingClientRect).mockImplementation( + function (this: HTMLElement) { + const topLevelRows = view.container.querySelectorAll( + '[data-timeline-row-list="top-level"] > [data-timeline-row-id]', + ); + const index = Array.from(topLevelRows).indexOf(this); + return rect(index < 0 ? 0 : index * 100 - scrollArea.scrollTop, 100); + }, + ); + const groupId = buildTimelineViewRows(initialRows)[1]?.id; + expect(groupId).toContain("work-summary"); + scrollArea.scrollTop = 150; + fireEvent.wheel(scrollArea, { deltaY: -150 }); + fireEvent.scroll(scrollArea); + + view.rerender( + timeline(initialRows.filter((row) => row.id !== "read-a"), {}), + ); + + expect(scrollArea.scrollTop).toBe(200); + expect( + view.container.querySelector(`[data-timeline-row-id="${groupId}"]`), + ).toBeNull(); + }); + it("keeps the control timeline fully mounted", () => { const view = renderDelegation(false); const nestedList = view.container.querySelector( diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx index dc1521fd48f..80cc31bcdf8 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx @@ -1,20 +1,78 @@ // @vitest-environment jsdom -import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type { TimelineRow } from "@bb/server-contract"; import { BottomAnchorContext } from "@/components/ui/bottom-anchored-scroll-body.js"; +import { conversationRow } from "@/test/fixtures/thread-timeline-rows"; import { ThreadTimelineSurface } from "./ThreadTimelineSurface"; vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ data: undefined }), })); +vi.mock("./ThreadTimelineRows.js", () => ({ + ThreadTimelineRows: ({ timelineRows }: { timelineRows: TimelineRow[] }) => ( +
+ {timelineRows.map((row) => ( +
{row.kind === "conversation" ? row.text : row.id}
+ ))} +
+ ), +})); + afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); describe("ThreadTimelineSurface load-older control", () => { + it("keeps cached messages readable when refresh fails and offers a bounded retry", () => { + const refresh = vi.fn().mockResolvedValue(undefined); + const surface = (isRefreshingHistory: boolean) => ( + + ); + const view = render(surface(false)); + + expect(screen.getByText("Previously loaded reply")).not.toBeNull(); + expect(screen.queryByText("Failed to load timeline")).toBeNull(); + expect(screen.getByRole("status").textContent).toContain( + "Couldn't refresh history", + ); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(refresh).toHaveBeenCalledTimes(1); + + view.rerender(surface(true)); + expect( + screen.getByRole("button", { name: "Refreshing…" }) + .disabled, + ).toBe(true); + expect(screen.getByText("Previously loaded reply")).not.toBeNull(); + }); + it("resumes auto-loading after a context boundary replaces a timeline whose older page failed", async () => { const intersectionCallbacks: IntersectionObserverCallback[] = []; vi.stubGlobal( diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx index e8f81465f1f..61151b0e3e9 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx @@ -43,8 +43,12 @@ export interface ThreadTimelineSurfaceProps { contextBoundarySeq: number | null; threadOriginKind?: ThreadOriginKind | null; hasOlderTimelineRows?: boolean; + historyRefreshError?: Error | null; + historyUnrefreshed?: boolean; + historyReplacementKey?: object | null; hostConnectionNotice?: HostConnectionNotice | null; isLoadingOlderTimelineRows?: boolean; + isRefreshingHistory?: boolean; isThreadTimelinePending: boolean; timelineError: boolean; loadingContent?: ReactNode; @@ -58,6 +62,8 @@ export interface ThreadTimelineSurfaceProps { consumerMessageActions?: readonly ThreadTimelineConsumerMessageAction[]; includePluginMessageActions?: boolean; onLoadOlderRows?: () => Promise | void; + onRefreshHistory?: () => Promise; + onShowLatestTimeline?: () => void; onOpenLink?: ThreadTimelineLinkHandler; onOpenLocalFileLink?: ThreadTimelineLocalFileLinkHandler; onOpenPluginPanel?: ThreadTimelineOpenPluginPanelHandler; @@ -145,8 +151,12 @@ export function ThreadTimelineSurface({ contextBoundarySeq, threadOriginKind = null, hasOlderTimelineRows = false, + historyRefreshError = null, + historyUnrefreshed = false, + historyReplacementKey = null, hostConnectionNotice, isLoadingOlderTimelineRows = false, + isRefreshingHistory = false, isThreadTimelinePending, timelineError, loadingContent, @@ -160,6 +170,7 @@ export function ThreadTimelineSurface({ consumerMessageActions, includePluginMessageActions, onLoadOlderRows, + onRefreshHistory, onOpenLink, onOpenLocalFileLink, onOpenPluginPanel, @@ -203,12 +214,37 @@ export function ThreadTimelineSurface({ hasOlderTimelineRows && onLoadOlderRows !== undefined && !isThreadTimelinePending && - !timelineError; + (!timelineError || timelineRowsWithPendingStop.length > 0); return ( {leadingContent} + {timelineRowsWithPendingStop.length > 0 && + (historyRefreshError !== null || historyUnrefreshed) ? ( +
+ + {historyRefreshError !== null + ? "Couldn't refresh history. Showing saved messages." + : "This history hasn't been refreshed yet."} + + {onRefreshHistory ? ( + + ) : null} +
+ ) : null} {showLoadOlderRows ? ( ) : null} - {isThreadTimelinePending ? ( + {isThreadTimelinePending && timelineRowsWithPendingStop.length === 0 ? ( (loadingContent ?? ) - ) : timelineError ? ( + ) : timelineError && timelineRowsWithPendingStop.length === 0 ? ( ) : timelineRowsWithPendingStop.length > 0 ? ( { expect(result.current.hasOlderTimelineRows).toBe(true); }); - it("adopts the refetched latest cursor after a stale older-page cursor", async () => { + it("rebuilds retained pages once from fresh cursors after a stale older-page cursor", async () => { vi.mocked(sdk.threads.timeline) .mockResolvedValueOnce( makeTimelineResponse({ @@ -757,7 +769,13 @@ describe("useThreadTimelineController", () => { }), ) .mockResolvedValueOnce(makeSameSnapshotRealtimeResponse()) - .mockReturnValueOnce(new Promise(() => {})); + .mockResolvedValueOnce( + makeTimelineResponse({ + rows: [olderPageRow], + maxSeq: 2, + timelinePage: { kind: "older", historySnapshot: "snapshot-1" }, + }), + ); const { wrapper } = createQueryClientTestHarness(); const { result } = renderHook( @@ -793,14 +811,8 @@ describe("useThreadTimelineController", () => { }); expect(timelineRequests[3]?.[0]).toMatchObject({ afterSequence: "1" }); expect(result.current.isLoadingOlderTimelineRows).toBe(false); - expect(result.current.hasOlderTimelineRows).toBe(true); - - act(() => { - void result.current.loadOlderTimelineRows(); - }); - await waitFor(() => { - expect(sdk.threads.timeline).toHaveBeenCalledTimes(5); - }); + expect(result.current.hasOlderTimelineRows).toBe(false); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(5); expect(vi.mocked(sdk.threads.timeline).mock.calls[4]?.[0]).toMatchObject({ beforeAnchorId: newestLoadedRow.id, beforeAnchorSeq: "1", @@ -1012,6 +1024,169 @@ describe("useThreadTimelineController", () => { }); }); +describe("retained thread history", () => { + function seedHistory(queryClient: QueryClient, validatedAt = Date.now()) { + const latest = makeTimelineResponse({ + rows: [newestLoadedRow], + maxSeq: 1, + timelinePage: { + historySnapshot: "snapshot-1", + hasOlderRows: true, + olderCursor: { anchorId: newestLoadedRow.id, anchorSeq: 1 }, + }, + }); + const older = makeTimelineResponse({ + rows: [olderPageRow], + maxSeq: 1, + timelinePage: { kind: "older", historySnapshot: "snapshot-1" }, + }); + queryClient.setQueryData(TIMELINE_QUERY_KEY, latest); + const surfaceKey = resolveLoadedTimelineSurfaceKey("thread-1", latest); + queryClient.setQueryData( + threadHistoryQueryKey( + "thread-1", + surfaceKey, + latest.timelinePage.segmentLimit, + ), + { + surfaceKey, + pages: [ + createThreadHistoryPage(latest, null, validatedAt), + createThreadHistoryPage( + older, + latest.timelinePage.olderCursor, + validatedAt, + ), + ], + }, + { updatedAt: validatedAt }, + ); + return { latest, older }; + } + + it("shows retained rows immediately on a warm return without fetching fresh pages", () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + seedHistory(queryClient); + const first = renderHook( + () => useThreadTimelineController({ threadId: "thread-1" }), + { wrapper }, + ); + expect(rowIds(first.result.current)).toEqual([ + olderPageRow.id, + newestLoadedRow.id, + ]); + first.unmount(); + const returned = renderHook( + () => useThreadTimelineController({ threadId: "thread-1" }), + { wrapper }, + ); + expect(rowIds(returned.result.current)).toEqual([ + olderPageRow.id, + newestLoadedRow.id, + ]); + expect(sdk.threads.timeline).not.toHaveBeenCalled(); + }); + + it("keeps cached rows during a background failure and applies a successful retry", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const { latest, older } = seedHistory(queryClient, Date.now() - 10_000); + const refresh = createDeferredPromise(); + vi.mocked(sdk.threads.timeline).mockReturnValueOnce(refresh.promise); + const { result } = renderHook( + () => useThreadTimelineController({ threadId: "thread-1" }), + { wrapper }, + ); + expect(rowIds(result.current)).toEqual([ + olderPageRow.id, + newestLoadedRow.id, + ]); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); + refresh.reject(makeServerError()); + await waitFor(() => + expect(result.current.historyRefreshError).not.toBeNull(), + ); + expect(rowIds(result.current)).toEqual([ + olderPageRow.id, + newestLoadedRow.id, + ]); + const editedOlder = { ...olderPageRow, text: "Updated older message" }; + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce(latest) + .mockResolvedValueOnce({ ...older, rows: [editedOlder] }); + await act(async () => { + await result.current.refreshHistory(); + }); + await waitFor(() => + expect(result.current.timelineRows[0]).toEqual(editedOlder), + ); + expect(result.current.historyRefreshError).toBeNull(); + expect(result.current.timelineLoading).toBe(false); + }); + + it("holds detached history when the latest window has a gap until the reader selects latest", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + seedHistory(queryClient); + const { result } = renderHook( + () => ({ + timeline: useThreadTimelineController({ threadId: "thread-1" }), + store: useStore(), + }), + { wrapper }, + ); + act(() => { + result.current.store.set( + threadTimelineScrollAnchorAtomFamily("thread-1"), + { + rowId: olderPageRow.id, + offsetWithinRow: 12, + atBottom: false, + }, + ); + queryClient.setQueryData( + TIMELINE_QUERY_KEY, + makeTimelineResponse({ + rows: [contextClearRow], + maxSeq: 10, + timelinePage: { + historySnapshot: "snapshot-2", + hasOlderRows: true, + olderCursor: { anchorId: contextClearRow.id, anchorSeq: 10 }, + }, + }), + ); + }); + await waitFor(() => + expect(result.current.timeline.historyUnrefreshed).toBe(true), + ); + expect(rowIds(result.current.timeline)).toEqual([ + olderPageRow.id, + newestLoadedRow.id, + ]); + act(() => result.current.timeline.showLatestTimeline()); + expect(rowIds(result.current.timeline)).toEqual([contextClearRow.id]); + expect(result.current.timeline.historyUnrefreshed).toBe(false); + }); + + it("clears controller-held rows when history access is revoked", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + seedHistory(queryClient); + const { result } = renderHook( + () => useThreadTimelineController({ threadId: "thread-1" }), + { wrapper }, + ); + expect(rowIds(result.current)).toHaveLength(2); + act(() => removeThreadHistory({ queryClient, threadId: "thread-1" })); + await waitFor(() => expect(rowIds(result.current)).toEqual([])); + act(() => + queryClient.setQueryData( + TIMELINE_QUERY_KEY, + makeTimelineResponse({ rows: [realtimeRow], maxSeq: 2 }), + ), + ); + expect(rowIds(result.current)).toEqual([]); + }); +}); + describe("useThreadTimelineController commits", () => { it.each([ { diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index e91ac8f1a94..d7e22877bed 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -1,4 +1,5 @@ import { useCallback, useState } from "react"; +import { useStore } from "jotai"; import { useQueryClient, type QueryObserverResult, @@ -7,9 +8,11 @@ import type { ThreadTimelineResponse, TimelineRow } from "@bb/server-contract"; import { areTimelinePaginationCursorsEqual, buildLoadedTimelineState, + buildLoadedTimelineFromPages, + reconcileLoadedTimelineWithHistoryPages, + tryMergeLoadedTimelineWithLatest, mergeLoadedTimelineWithLatest, prependOlderTimelineRows, - recoverLoadedTimelineAfterStaleCursor, resolveLoadedTimelineSurfaceKey, type LoadedTimelineState, } from "@bb/client-core"; @@ -17,7 +20,10 @@ import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-q import { threadTimelineQueryKey } from "@/hooks/queries/query-keys"; import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { useThreadTimeline } from "@/hooks/queries/thread-queries"; -import { BbHttpError, sdk } from "@/lib/sdk"; +import { BbHttpError } from "@/lib/sdk"; +import { useThreadHistory } from "@/hooks/queries/thread-history-query"; +import type { ThreadHistoryChain } from "@/hooks/cache-owners/thread-history-cache-owner"; +import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; type TimelineQueryResultProp = keyof QueryObserverResult; @@ -50,6 +56,12 @@ export interface UseThreadTimelineControllerResult { hasOlderTimelineRows: boolean; isLoadingOlderTimelineRows: boolean; loadOlderTimelineRows: () => Promise; + historyRefreshError: Error | null; + historyUnrefreshed: boolean; + historyReplacementKey: object | null; + isRefreshingHistory: boolean; + refreshHistory: () => Promise; + showLatestTimeline: () => void; pendingTodos: ThreadTimelineResponse["pendingTodos"]; timelineError: Error | null; timelineLoading: boolean; @@ -58,20 +70,17 @@ export interface UseThreadTimelineControllerResult { interface LoadedTimelineTracker { latestTimeline: ThreadTimelineResponse | undefined; + history: ThreadHistoryChain | undefined; + generation: number; loaded: LoadedTimelineState; + unrefreshed: boolean; + replacementKey: object | null; } -interface ReconcileLoadedTimelineArgs { - current: LoadedTimelineState; - latestTimeline: ThreadTimelineResponse | undefined; - surfaceKey: string; -} - -function isStaleTimelinePaginationCursorError(error: Error): boolean { +function isAccessError(error: Error | null): boolean { return ( error instanceof BbHttpError && - error.status === 400 && - error.code === "invalid_request" + (error.status === 401 || error.status === 403 || error.status === 404) ); } @@ -86,24 +95,6 @@ function buildEmptyLoadedTimelineState( }); } -function reconcileLoadedTimeline({ - current, - latestTimeline, - surfaceKey, -}: ReconcileLoadedTimelineArgs): LoadedTimelineState { - if (!latestTimeline) { - return current.surfaceKey === surfaceKey - ? current - : buildEmptyLoadedTimelineState(surfaceKey); - } - - return mergeLoadedTimelineWithLatest({ - current, - latestTimeline, - surfaceKey, - }); -} - export function useThreadTimelineController({ enabled = true, surfaceKey: explicitSurfaceKey, @@ -128,126 +119,213 @@ export function useThreadTimelineController({ explicitSurfaceKey ?? threadId, latestTimeline, ); - const [loadedTimelineTracker, setLoadedTimelineTracker] = - useState(() => ({ - latestTimeline, - loaded: reconcileLoadedTimeline({ - current: buildEmptyLoadedTimelineState(surfaceKey), - latestTimeline, + const history = useThreadHistory({ threadId, latestTimeline, enabled }); + const store = useStore(); + const accessDenied = isAccessError(latestTimelineQuery.error); + const blocked = accessDenied || history.isBlocked; + const makeInitialLoaded = () => { + if (blocked) + return { + loaded: buildEmptyLoadedTimelineState(surfaceKey), + unrefreshed: false, + }; + const retained = + history.data && + buildLoadedTimelineFromPages({ + pages: history.data.pages.map((page) => page.response), surfaceKey, - }), - })); - let loadedTimeline = loadedTimelineTracker.loaded; - if ( - loadedTimelineTracker.latestTimeline !== latestTimeline || - loadedTimeline.surfaceKey !== surfaceKey - ) { - loadedTimeline = reconcileLoadedTimeline({ - current: loadedTimelineTracker.loaded, + }); + const loaded = retained ?? buildEmptyLoadedTimelineState(surfaceKey); + if (!latestTimeline) return { loaded, unrefreshed: false }; + const merged = tryMergeLoadedTimelineWithLatest({ + current: loaded, latestTimeline, surfaceKey, }); - setLoadedTimelineTracker({ latestTimeline, loaded: loadedTimeline }); - } - const updateLoadedTimeline = useCallback( - (update: (current: LoadedTimelineState) => LoadedTimelineState) => { - setLoadedTimelineTracker((current) => { - const loaded = update(current.loaded); - return loaded === current.loaded ? current : { ...current, loaded }; - }); - }, - [], - ); - const [isLoadingOlderTimelineRows, setIsLoadingOlderTimelineRows] = - useState(false); - const refetchLatestTimeline = latestTimelineQuery.refetch; - - const nextOlderCursor = - loadedTimeline.surfaceKey === surfaceKey - ? loadedTimeline.olderCursor - : null; - const hasOlderTimelineRows = nextOlderCursor !== null; - const loadOlderTimelineRows = useCallback(async (): Promise => { + if (merged) return { loaded: merged, unrefreshed: false }; if ( - !enabled || - !nextOlderCursor || - !threadId || - isLoadingOlderTimelineRows + store.get(threadTimelineScrollAnchorAtomFamily(threadId))?.atBottom === + false ) { - return; + return { loaded, unrefreshed: true }; } - - setIsLoadingOlderTimelineRows(true); - try { - const response = await sdk.threads.timeline({ - beforeAnchorId: nextOlderCursor.anchorId, - beforeAnchorSeq: String(nextOlderCursor.anchorSeq), - threadId, - }); - const olderRows = [...response.rows]; - updateLoadedTimeline((current) => { - if ( - current.surfaceKey !== surfaceKey || - !areTimelinePaginationCursorsEqual({ - left: current.olderCursor, - right: nextOlderCursor, - }) - ) { - return current; + return { + loaded: mergeLoadedTimelineWithLatest({ + current: loaded, + latestTimeline, + surfaceKey, + }), + unrefreshed: false, + }; + }; + const [tracker, setTracker] = useState(() => ({ + latestTimeline, + history: history.data, + generation: history.generation, + ...makeInitialLoaded(), + replacementKey: null, + })); + let current = tracker; + if ( + tracker.latestTimeline !== latestTimeline || + tracker.history !== history.data || + tracker.generation !== history.generation || + tracker.loaded.surfaceKey !== surfaceKey || + (blocked && tracker.loaded.rows.length > 0) + ) { + let loaded = tracker.loaded; + let unrefreshed = tracker.unrefreshed; + let replacementKey = tracker.replacementKey; + const detached = + store.get(threadTimelineScrollAnchorAtomFamily(threadId))?.atBottom === + false; + if (blocked) { + loaded = buildEmptyLoadedTimelineState(surfaceKey); + unrefreshed = false; + } else if ( + loaded.surfaceKey !== surfaceKey || + tracker.generation !== history.generation + ) { + const initial = makeInitialLoaded(); + loaded = initial.loaded; + unrefreshed = initial.unrefreshed; + replacementKey = history.data?.pages[0] ?? latestTimeline ?? null; + } else { + if (history.data && tracker.history !== history.data) { + const head = history.data.pages[0]; + const replaced = head !== tracker.history?.pages[0]; + const refreshed = replaced + ? reconcileLoadedTimelineWithHistoryPages({ + current: loaded, + pages: history.data.pages.map((page) => page.response), + surfaceKey, + }) + : null; + if (!replaced) { + for (const page of history.data.pages.slice(1)) { + if ( + areTimelinePaginationCursorsEqual({ + left: loaded.olderCursor, + right: page.requestCursor, + }) + ) { + loaded = { + ...loaded, + olderCursor: page.response.timelinePage.olderCursor, + rows: prependOlderTimelineRows({ + loadedRows: loaded.rows, + olderRows: page.response.rows, + }), + }; + } + } + } else if (refreshed) { + loaded = refreshed; + unrefreshed = false; + replacementKey = head ?? null; + } else { + const rebuilt = buildLoadedTimelineFromPages({ + pages: history.data.pages.map((page) => page.response), + surfaceKey, + }); + if (!detached && rebuilt) { + loaded = rebuilt; + unrefreshed = false; + replacementKey = head ?? null; + } else { + unrefreshed = true; + } } - return { - ...current, - olderCursor: response.timelinePage.olderCursor, - rows: prependOlderTimelineRows({ - loadedRows: current.rows, - olderRows, - }), - }; - }); - } catch (error) { + } if ( - !(error instanceof Error) || - !isStaleTimelinePaginationCursorError(error) + latestTimeline && + (tracker.latestTimeline !== latestTimeline || + tracker.history !== history.data) ) { - throw error; - } - - const latestTimelineResult = await refetchLatestTimeline(); - const recoveredLatestTimeline = - latestTimelineResult.data ?? latestTimeline; - updateLoadedTimeline((current) => { - if (current.surfaceKey !== surfaceKey) { - return current; - } - if (!recoveredLatestTimeline) { - return { - ...current, - olderCursor: null, - }; - } - return recoverLoadedTimelineAfterStaleCursor({ - current, - latestTimeline: recoveredLatestTimeline, + const merged = tryMergeLoadedTimelineWithLatest({ + current: loaded, + latestTimeline, surfaceKey, }); - }); - } finally { - setIsLoadingOlderTimelineRows(false); + if (merged) { + loaded = merged; + } else if (!detached) { + loaded = mergeLoadedTimelineWithLatest({ + current: loaded, + latestTimeline, + surfaceKey, + }); + unrefreshed = false; + replacementKey = latestTimeline; + } else { + unrefreshed = true; + } + } } + current = { + latestTimeline, + history: history.data, + generation: history.generation, + loaded, + unrefreshed, + replacementKey, + }; + setTracker(current); + } + const loadedTimeline = current.loaded; + const nextOlderCursor = blocked ? null : loadedTimeline.olderCursor; + const hasOlderTimelineRows = nextOlderCursor !== null; + const loadOlder = history.loadOlder; + const loadOlderTimelineRows = useCallback(async (): Promise => { + if (!enabled || !nextOlderCursor || !threadId || blocked) return; + const response = await loadOlder(nextOlderCursor); + if (!response) return; + setTracker((previous) => { + if ( + previous.loaded.surfaceKey !== surfaceKey || + previous.generation !== history.generation || + !areTimelinePaginationCursorsEqual({ + left: previous.loaded.olderCursor, + right: nextOlderCursor, + }) + ) { + return previous; + } + return { + ...previous, + loaded: { + ...previous.loaded, + olderCursor: response.timelinePage.olderCursor, + rows: prependOlderTimelineRows({ + loadedRows: previous.loaded.rows, + olderRows: response.rows, + }), + }, + }; + }); }, [ enabled, - isLoadingOlderTimelineRows, - latestTimeline, nextOlderCursor, - refetchLatestTimeline, - surfaceKey, threadId, - updateLoadedTimeline, + blocked, + loadOlder, + surfaceKey, + history.generation, ]); - const timelineRows = - loadedTimeline.surfaceKey === surfaceKey && loadedTimeline.rows.length > 0 - ? loadedTimeline.rows - : (latestTimeline?.rows ?? []); + const showLatestTimeline = useCallback(() => { + if (!latestTimeline || blocked) return; + setTracker((previous) => ({ + ...previous, + loaded: mergeLoadedTimelineWithLatest({ + current: buildEmptyLoadedTimelineState(surfaceKey), + latestTimeline, + surfaceKey, + }), + unrefreshed: false, + replacementKey: null, + })); + }, [blocked, latestTimeline, surfaceKey]); + const timelineRows = blocked ? [] : loadedTimeline.rows; const timelineQueryState = useConnectionAwareQueryState({ hasResolvedData: latestTimelineQuery.data !== undefined || timelineRows.length > 0, @@ -274,8 +352,14 @@ export function useThreadTimelineController({ goal: latestTimeline?.goal ?? null, modelFallback: latestTimeline?.modelFallback ?? null, hasOlderTimelineRows, - isLoadingOlderTimelineRows, + isLoadingOlderTimelineRows: history.isLoadingOlder, loadOlderTimelineRows, + historyRefreshError: blocked ? null : history.error, + historyUnrefreshed: current.unrefreshed, + historyReplacementKey: current.replacementKey, + isRefreshingHistory: history.isFetching && !history.isLoadingOlder, + refreshHistory: history.refresh, + showLatestTimeline, pendingTodos: latestTimeline?.pendingTodos ?? null, timelineError, timelineLoading, diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index a2b10ae739a..b4349293021 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -1,10 +1,13 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { useContext } from "react"; import { getDefaultStore } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BottomAnchoredScrollBody, + TimelineReplacementScrollAnchor, + TimelineScrollRestoreRowIdContext, useBottomAnchoredScroll, } from "@/components/ui/bottom-anchored-scroll-body"; import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; @@ -204,6 +207,132 @@ function readAnchor(threadId: string) { return getDefaultStore().get(threadTimelineScrollAnchorAtomFamily(threadId)); } +interface ReplacementRow { + id: string; + height: number; +} + +function ReplacementRows({ + rows, + hiddenRows, + realizeRestoreRow, +}: { + rows: ReplacementRow[]; + hiddenRows: ReadonlySet; + realizeRestoreRow: boolean; +}) { + const restoreRowId = useContext(TimelineScrollRestoreRowIdContext); + let top = 0; + return ( +
sum + row.height, 0)} + > +
+ {rows.map((row) => { + const rowTop = top; + top += row.height; + if ( + hiddenRows.has(row.id) && + !(realizeRestoreRow && restoreRowId === row.id) + ) { + return null; + } + return ( +
+ {row.id} +
+ ); + })} +
+
+ ); +} + +function renderReplacementTimeline() { + let rows = ["a", "b", "c", "d"].map((id) => ({ id, height: 100 })); + let replacementKey = {}; + let hiddenRows: ReadonlySet = new Set(); + let realizeRestoreRow = true; + const timeline = () => ( + + + + + + ); + const view = render(timeline()); + const scrollArea = requireHTMLElement( + view.container.querySelector(`.${SCROLL_AREA_CLASS}`), + ); + Object.defineProperty(scrollArea, "scrollHeight", { + configurable: true, + get: () => + Number( + view.container + .querySelector("[data-model-height]") + ?.getAttribute("data-model-height"), + ), + }); + Object.defineProperty(scrollArea, "clientHeight", { + configurable: true, + value: SCROLL_AREA_HEIGHT, + }); + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if (this.dataset.modelTop !== undefined) { + return new DOMRect( + 0, + Number(this.dataset.modelTop) - scrollArea.scrollTop, + 100, + Number(this.dataset.modelRowHeight), + ); + } + return new DOMRect(0, 0, 100, SCROLL_AREA_HEIGHT); + }, + ); + act(() => getLatestResizeObserver().trigger()); + return { + scrollArea, + getByRole: view.getByRole, + detach: () => { + fireEvent.wheel(scrollArea, { deltaY: -150 }); + scrollArea.scrollTop = 150; + fireEvent.scroll(scrollArea); + }, + replace: ( + nextRows: ReplacementRow[], + options?: { + hiddenRows?: ReadonlySet; + realizeRestoreRow?: boolean; + keepReplacementKey?: boolean; + }, + ) => { + rows = nextRows; + hiddenRows = options?.hiddenRows ?? new Set(); + realizeRestoreRow = options?.realizeRestoreRow ?? true; + if (!options?.keepReplacementKey) replacementKey = {}; + view.rerender(timeline()); + }, + }; +} + beforeEach(() => { ResizeObserverMock.instances = []; vi.stubGlobal("ResizeObserver", ResizeObserverMock); @@ -222,6 +351,132 @@ afterEach(() => { }); describe("BottomAnchoredScrollBody scroll preservation", () => { + it("preserves the visible row offset when refreshed content above it shrinks", () => { + const view = renderReplacementTimeline(); + view.detach(); + + view.replace([ + { id: "a", height: 40 }, + { id: "b", height: 100 }, + { id: "c", height: 100 }, + { id: "d", height: 100 }, + ]); + act(() => getLatestResizeObserver().trigger()); + + expect(view.scrollArea.scrollTop).toBe(90); + }); + + it("restores the nearest surviving row after the visible row is deleted", () => { + const view = renderReplacementTimeline(); + view.detach(); + + view.replace([ + { id: "a", height: 100 }, + { id: "c", height: 100 }, + { id: "d", height: 100 }, + ]); + act(() => getLatestResizeObserver().trigger()); + + expect(view.scrollArea.scrollTop).toBe(100); + }); + + it("clamps the saved offset when the visible row itself shrinks", () => { + const view = renderReplacementTimeline(); + view.detach(); + + view.replace([ + { id: "a", height: 100 }, + { id: "b", height: 40 }, + { id: "c", height: 100 }, + { id: "d", height: 100 }, + ]); + + expect(view.scrollArea.scrollTop).toBe(139); + }); + + it("realizes a replaced anchor outside the virtualized range before restoring it", () => { + const view = renderReplacementTimeline(); + view.detach(); + + view.replace( + [ + { id: "older", height: 300 }, + ...["a", "b", "c", "d"].map((id) => ({ id, height: 100 })), + ], + { hiddenRows: new Set(["b"]) }, + ); + + expect(view.scrollArea.scrollTop).toBe(450); + }); + + it("keeps the detached position when no refreshed row survives", () => { + const view = renderReplacementTimeline(); + view.detach(); + + view.replace([ + { id: "new-a", height: 300 }, + { id: "new-b", height: 300 }, + ]); + for (let attempt = 0; attempt < 8; attempt += 1) { + act(() => getLatestResizeObserver().trigger()); + } + + expect(view.scrollArea.scrollTop).toBe(150); + }); + + it("lets user scrolling cancel a pending replacement restore", () => { + const view = renderReplacementTimeline(); + view.detach(); + const replacement = ["a", "b", "c", "d"].map((id) => ({ + id, + height: 100, + })); + view.replace(replacement, { + hiddenRows: new Set(["b"]), + realizeRestoreRow: false, + }); + + fireEvent.wheel(view.scrollArea, { deltaY: -120 }); + view.scrollArea.scrollTop = 30; + fireEvent.scroll(view.scrollArea); + view.replace(replacement, { keepReplacementKey: true }); + act(() => getLatestResizeObserver().trigger()); + + expect(view.scrollArea.scrollTop).toBe(30); + }); + + it("lets explicit navigation to the bottom cancel a pending replacement restore", () => { + const view = renderReplacementTimeline(); + view.detach(); + const replacement = ["a", "b", "c", "d"].map((id) => ({ + id, + height: 100, + })); + view.replace(replacement, { + hiddenRows: new Set(["b"]), + realizeRestoreRow: false, + }); + + fireEvent.click(view.getByRole("button", { name: "Bottom" })); + view.replace(replacement, { keepReplacementKey: true }); + act(() => getLatestResizeObserver().trigger()); + + expect(view.scrollArea.scrollTop).toBe(300); + }); + + it("continues following the bottom through a history replacement", () => { + const view = renderReplacementTimeline(); + + view.replace([ + { id: "a", height: 100 }, + { id: "b", height: 100 }, + { id: "c", height: 100 }, + ]); + act(() => getLatestResizeObserver().trigger()); + + expect(view.scrollArea.scrollTop).toBe(200); + }); + it("shows the thread scrollbar only while scroll events are active", () => { vi.useFakeTimers(); const { scrollArea } = renderTimeline({ diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index a11dc0a1e3e..88f645b580d 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -1,4 +1,5 @@ import { + Component, createContext, useCallback, useContext, @@ -8,7 +9,7 @@ import { useRef, useState, } from "react"; -import type { ReactNode } from "react"; +import type { ContextType, ReactNode } from "react"; import { useStore } from "jotai"; import { cn } from "@bb/shared-ui/lib/utils"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; @@ -87,6 +88,50 @@ export const TimelineScrollRestoreRowIdContext = createContext( null, ); +interface TimelineReplacementScrollAnchorProps { + rows: readonly { id: string }[]; + replacementKey: object | null; +} + +interface TimelineReplacementSnapshot { + anchor: ScrollAnchor | null; + scrollTop: number; +} + +const TimelineReplacementAnchorContext = createContext<{ + capture: ( + previousRows: readonly { id: string }[], + nextRows: readonly { id: string }[], + ) => TimelineReplacementSnapshot | null; + restore: (snapshot: TimelineReplacementSnapshot) => void; +} | null>(null); + +export class TimelineReplacementScrollAnchor extends Component< + TimelineReplacementScrollAnchorProps, + Record, + TimelineReplacementSnapshot | null +> { + static contextType = TimelineReplacementAnchorContext; + declare context: ContextType; + + getSnapshotBeforeUpdate(previousProps: TimelineReplacementScrollAnchorProps) { + if (previousProps.replacementKey === this.props.replacementKey) return null; + return this.context?.capture(previousProps.rows, this.props.rows) ?? null; + } + + componentDidUpdate( + _previousProps: TimelineReplacementScrollAnchorProps, + _previousState: Readonly>, + snapshot: TimelineReplacementSnapshot | null, + ) { + if (snapshot !== null) this.context?.restore(snapshot); + } + + render() { + return null; + } +} + export function useBottomAnchoredScroll(): BottomAnchorContextValue | null { return useContext(BottomAnchorContext); } @@ -249,7 +294,11 @@ export function BottomAnchoredScrollBody({ anchor: ScrollAnchor; attemptsRemaining: number; lastAppliedScrollTop: number | null; + kind: "navigation" | "replacement"; } | null>(null); + const preserveDetachedReplacementRef = useRef(false); + const [replacementScrollRestoreRowId, setReplacementScrollRestoreRowId] = + useState(null); const scrollAnchorCaptureThrottleRef = useRef<{ lastWriteAt: number; trailingTimeout: number | null; @@ -291,6 +340,7 @@ export function BottomAnchoredScrollBody({ const cancelPendingScrollRestore = useCallback(() => { pendingScrollRestoreRef.current = null; + setReplacementScrollRestoreRowId(null); }, []); const cancelQueuedRestore = useCallback(() => { @@ -355,6 +405,7 @@ export function BottomAnchoredScrollBody({ const scrollToBottom = useCallback(() => { const scrollArea = scrollAreaRef.current; cancelPendingScrollRestore(); + preserveDetachedReplacementRef.current = false; userScrollIntentUntilRef.current = 0; pointerScrollIntentRef.current = false; userDetachedFromBottomRef.current = false; @@ -368,6 +419,8 @@ export function BottomAnchoredScrollBody({ const scrollElementIntoView = useCallback( ({ element, options }: ScrollElementIntoViewArgs) => { + cancelPendingScrollRestore(); + preserveDetachedReplacementRef.current = false; const scrollArea = scrollAreaRef.current; if ( scrollArea && @@ -380,11 +433,13 @@ export function BottomAnchoredScrollBody({ cancelQueuedRestore(); element.scrollIntoView(options); }, - [cancelQueuedRestore], + [cancelPendingScrollRestore, cancelQueuedRestore], ); const scrollElementIntoViewClampedToMaxScroll = useCallback( ({ element }: ScrollElementIntoViewClampedToMaxScrollArgs) => { + cancelPendingScrollRestore(); + preserveDetachedReplacementRef.current = false; const scrollArea = scrollAreaRef.current; if (!scrollArea) { element.scrollIntoView({ block: "start", inline: "nearest" }); @@ -411,7 +466,12 @@ export function BottomAnchoredScrollBody({ cancelQueuedRestore(); }, - [cancelQueuedRestore, queueBottomRestore, refreshMaxScrollOffset], + [ + cancelPendingScrollRestore, + cancelQueuedRestore, + queueBottomRestore, + refreshMaxScrollOffset, + ], ); const captureScrollAnchor = useCallback(() => { @@ -475,7 +535,7 @@ export function BottomAnchoredScrollBody({ const recentUserIntent = hasRecentUserScrollIntent(); const anchorAtom = threadTimelineScrollAnchorAtomFamily(scrollAnchorThreadId); - if (atBottomByGeometry) { + if (atBottomByGeometry && !preserveDetachedReplacementRef.current) { userDetachedFromBottomRef.current = false; store.set(anchorAtom, { rowId: "", @@ -542,7 +602,7 @@ export function BottomAnchoredScrollBody({ }, [scrollAnchorCaptureThrottleMs, scrollAnchorThreadId, writeScrollAnchor]); const applyScrollRestore = useCallback( - (anchor: ScrollAnchor): number | null => { + (anchor: ScrollAnchor, clampWithinRow: boolean): number | null => { const scrollArea = scrollAreaRef.current; if (!scrollArea) return null; const rowElement = findTimelineRowElement(scrollArea, anchor.rowId); @@ -556,7 +616,13 @@ export function BottomAnchoredScrollBody({ }); const targetScrollTop = Math.min( refreshMaxScrollOffset(scrollArea), - revealOffset + anchor.offsetWithinRow, + revealOffset + + (clampWithinRow + ? Math.min( + anchor.offsetWithinRow, + Math.max(0, rowElement.getBoundingClientRect().height - 1), + ) + : anchor.offsetWithinRow), ); scrollArea.scrollTop = targetScrollTop; return targetScrollTop; @@ -565,10 +631,12 @@ export function BottomAnchoredScrollBody({ ); const markUserScrollIntent = useCallback(() => { + cancelPendingScrollRestore(); + preserveDetachedReplacementRef.current = false; userScrollInputPendingRef.current = true; userScrollIntentUntilRef.current = window.performance.now() + USER_SCROLL_INTENT_MS; - }, []); + }, [cancelPendingScrollRestore]); const markWheelScrollIntent = useCallback( (event: WheelEvent) => { @@ -603,8 +671,10 @@ export function BottomAnchoredScrollBody({ }, [markUserScrollIntent]); const startPointerScrollIntent = useCallback(() => { + cancelPendingScrollRestore(); + preserveDetachedReplacementRef.current = false; pointerScrollIntentRef.current = true; - }, []); + }, [cancelPendingScrollRestore]); const endPointerScrollIntent = useCallback(() => { pointerScrollIntentRef.current = false; @@ -624,12 +694,13 @@ export function BottomAnchoredScrollBody({ ); const attachToBottom = useCallback(() => { + preserveDetachedReplacementRef.current = false; userDetachedFromBottomRef.current = false; shouldStickToBottomRef.current = true; userScrollIntentUntilRef.current = 0; setIsAtBottom(true); - pendingScrollRestoreRef.current = null; - }, []); + cancelPendingScrollRestore(); + }, [cancelPendingScrollRestore]); const syncBottomStateFromScroll = useCallback(() => { const scrollArea = scrollAreaRef.current; @@ -638,6 +709,13 @@ export function BottomAnchoredScrollBody({ userScrollInputPendingRef.current || pointerScrollIntentRef.current; userScrollInputPendingRef.current = false; + if ( + pendingScrollRestoreRef.current?.kind === "replacement" && + !hasDirectUserScrollInput + ) { + return; + } + if ( pendingPrependAnchorRef.current !== null && hasRecentUserScrollIntent() @@ -667,7 +745,7 @@ export function BottomAnchoredScrollBody({ ); } - if (nearBottom) { + if (nearBottom && !preserveDetachedReplacementRef.current) { attachToBottom(); return; } @@ -678,9 +756,10 @@ export function BottomAnchoredScrollBody({ shouldStickToBottomRef.current = false; setIsAtBottom(false); cancelQueuedRestore(); - pendingScrollRestoreRef.current = null; + cancelPendingScrollRestore(); }, [ attachToBottom, + cancelPendingScrollRestore, cancelQueuedRestore, hasRecentUserScrollIntent, readMaxScrollOffset, @@ -696,24 +775,120 @@ export function BottomAnchoredScrollBody({ const pending = pendingScrollRestoreRef.current; if (!pending) return false; pending.attemptsRemaining -= 1; - const appliedScrollTop = applyScrollRestore(pending.anchor); + const appliedScrollTop = applyScrollRestore( + pending.anchor, + pending.kind === "replacement", + ); if (appliedScrollTop !== null) { if (pending.lastAppliedScrollTop === appliedScrollTop) { - pendingScrollRestoreRef.current = null; + cancelPendingScrollRestore(); return true; } pending.lastAppliedScrollTop = appliedScrollTop; } if (pending.attemptsRemaining <= 0) { - pendingScrollRestoreRef.current = null; - if (appliedScrollTop === null) { + cancelPendingScrollRestore(); + if (appliedScrollTop === null && pending.kind === "navigation") { shouldStickToBottomRef.current = true; setIsAtBottom(true); queueBottomRestore(); } } return true; - }, [applyScrollRestore, queueBottomRestore]); + }, [applyScrollRestore, cancelPendingScrollRestore, queueBottomRestore]); + + const captureReplacementAnchor = useCallback( + ( + previousRows: readonly { id: string }[], + nextRows: readonly { id: string }[], + ): TimelineReplacementSnapshot | null => { + const scrollArea = scrollAreaRef.current; + if ( + !scrollArea || + shouldStickToBottomRef.current || + pointerScrollIntentRef.current || + userScrollInputPendingRef.current + ) { + return null; + } + const visible = getTopMostVisibleRow( + scrollArea, + getScrollAnchorRows(scrollArea).rows, + ); + const nextIds = new Set(nextRows.map((row) => row.id)); + let rowId = visible?.rowId; + let offsetWithinRow = visible?.offsetWithinRow ?? 0; + if (rowId !== undefined && !nextIds.has(rowId)) { + const index = previousRows.findIndex((row) => row.id === rowId); + rowId = undefined; + offsetWithinRow = 0; + for (let distance = 1; distance < previousRows.length; distance += 1) { + const next = previousRows[index + distance]; + const previous = previousRows[index - distance]; + if (next !== undefined && nextIds.has(next.id)) { + rowId = next.id; + break; + } + if (previous !== undefined && nextIds.has(previous.id)) { + rowId = previous.id; + break; + } + } + } + return { + anchor: + rowId === undefined + ? null + : { rowId, offsetWithinRow, atBottom: false }, + scrollTop: scrollArea.scrollTop, + }; + }, + [], + ); + + const restoreReplacementAnchor = useCallback( + (snapshot: TimelineReplacementSnapshot) => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + pendingPrependAnchorRef.current = null; + scrollAnchorRowsRef.current = null; + preserveDetachedReplacementRef.current = true; + shouldStickToBottomRef.current = false; + userDetachedFromBottomRef.current = true; + setIsAtBottom(false); + cancelQueuedRestore(); + if (snapshot.anchor === null) { + cancelPendingScrollRestore(); + scrollArea.scrollTop = Math.min( + snapshot.scrollTop, + refreshMaxScrollOffset(scrollArea), + ); + return; + } + pendingScrollRestoreRef.current = { + anchor: snapshot.anchor, + attemptsRemaining: SCROLL_ANCHOR_RESTORE_MAX_ATTEMPTS, + lastAppliedScrollTop: null, + kind: "replacement", + }; + setReplacementScrollRestoreRowId(snapshot.anchor.rowId); + }, + [cancelPendingScrollRestore, cancelQueuedRestore, refreshMaxScrollOffset], + ); + + const replacementAnchorContextValue = useMemo( + () => ({ + capture: captureReplacementAnchor, + restore: restoreReplacementAnchor, + }), + [captureReplacementAnchor, restoreReplacementAnchor], + ); + + useLayoutEffect(() => { + if (pendingScrollRestoreRef.current?.kind === "replacement") { + advancePendingScrollRestore(); + } + }); const handleScrollAreaResize = useCallback( (entries: ResizeObserverEntry[]) => { @@ -750,6 +925,7 @@ export function BottomAnchoredScrollBody({ resizeObserverHasDeliveredRef.current = true; shrankOntoBottomWhileDetached = cacheWasAuthoritative && + !preserveDetachedReplacementRef.current && !shouldStickToBottomRef.current && maxScrollOffset < previousMaxScrollOffset && isScrolledNearBottom(maxScrollOffset, scrollArea.scrollTop); @@ -782,6 +958,7 @@ export function BottomAnchoredScrollBody({ anchor, attemptsRemaining: SCROLL_ANCHOR_RESTORE_MAX_ATTEMPTS, lastAppliedScrollTop: null, + kind: "navigation", }; advancePendingScrollRestore(); }, [scrollAnchorThreadId, store, advancePendingScrollRestore]); @@ -907,7 +1084,7 @@ export function BottomAnchoredScrollBody({ return (
- {children} + + {children} +
{footer ? ( diff --git a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts index 35ed5c84301..5c9bee4286d 100644 --- a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts @@ -21,6 +21,10 @@ import type { ThreadArg, } from "../cache-effect-types"; import { invalidateQueryKeys } from "./cache-effect-utils"; +import { + invalidateThreadHistory, + removeThreadHistory, +} from "./thread-history-cache-owner"; import { getProjectListInvalidationQueryKeys, getProjectPromptHistoryInvalidationQueryKeys, @@ -221,6 +225,7 @@ export function invalidateThreadHistoryRewriteQueries({ queryClient, threadId, }: ThreadArg): void { + void invalidateThreadHistory({ queryClient, threadId }); invalidateThreadAcceptedMessageQueriesWithoutRealtime({ queryClient, threadId, @@ -276,6 +281,7 @@ export function removeThreadScopedQueries({ queryClient, threadId, }: ThreadArg): void { + removeThreadHistory({ queryClient, threadId }); queryClient.removeQueries({ queryKey: threadQueryKey(threadId) }); queryClient.removeQueries({ queryKey: threadTimelineQueryKeyPrefix(threadId), diff --git a/apps/app/src/hooks/cache-owners/project-cache-owner.ts b/apps/app/src/hooks/cache-owners/project-cache-owner.ts index 9603f8afc57..59bffc237ab 100644 --- a/apps/app/src/hooks/cache-owners/project-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/project-cache-owner.ts @@ -3,12 +3,24 @@ import type { ProjectResponse, ProjectWithThreadsResponse, SidebarBootstrapResponse, + ThreadResponse, + ThreadWithIncludesResponse, } from "@bb/server-contract"; import { + allThreadDetailBootstrapQueryKeyPrefix, + allThreadQueryKeyPrefix, projectsQueryKey, sidebarNavigationQueryKey, + threadHistoryQueryKeyPrefix, + threadsQueryKey, } from "../queries/query-keys"; import { invalidateProjectDeleteQueries } from "./mutation-cache-effects"; +import { getCachedSidebarNavigationThreads } from "./query-cache"; +import { + getCachedThreadLists, + iterateThreadListCacheEntries, +} from "./thread-list-cache-data"; +import { removeThreadHistory } from "./thread-history-cache-owner"; interface ApplyProjectCreateResultArgs { project: ProjectResponse; @@ -128,6 +140,7 @@ export function applyProjectDeleteResult({ projectId, queryClient, }: ApplyProjectDeleteResultArgs): void { + removeProjectThreadHistory({ projectId, queryClient }); queryClient.setQueryData( projectsQueryKey(), (currentProjects) => @@ -144,3 +157,45 @@ export function applyProjectDeleteResult({ ); invalidateProjectDeleteQueries({ queryClient }); } + +export function collectCachedThreadIdsForProject({ + projectId, + queryClient, +}: ApplyProjectDeleteResultArgs): string[] { + const cachedHistoryIds = new Set( + queryClient + .getQueryCache() + .findAll({ queryKey: threadHistoryQueryKeyPrefix() }) + .map((query) => query.queryKey[1]), + ); + const ids = new Set(); + for (const queryKey of [ + allThreadQueryKeyPrefix(), + allThreadDetailBootstrapQueryKeyPrefix(), + ]) { + for (const [, thread] of queryClient.getQueriesData< + ThreadResponse | ThreadWithIncludesResponse + >({ queryKey })) { + if (thread?.projectId === projectId) ids.add(thread.id); + } + } + for (const { data } of getCachedThreadLists(queryClient, { + queryKey: threadsQueryKey(), + })) { + for (const thread of iterateThreadListCacheEntries(data)) { + if (thread.projectId === projectId) ids.add(thread.id); + } + } + for (const thread of getCachedSidebarNavigationThreads(queryClient)) { + if (thread.projectId === projectId) ids.add(thread.id); + } + return [...ids].filter((id) => cachedHistoryIds.has(id)); +} + +export function removeProjectThreadHistory( + args: ApplyProjectDeleteResultArgs, +): void { + for (const threadId of collectCachedThreadIdsForProject(args)) { + removeThreadHistory({ queryClient: args.queryClient, threadId }); + } +} diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 9719570b0db..384c4eb8ed8 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -27,6 +27,11 @@ import { } from "./query-cache"; import { bumpDiffPatchFreshnessGeneration } from "./environment-diff-patch-cache-owner"; import { invalidateSystemExecutionOptions } from "./system-cache-effects"; +import { + invalidateThreadHistory, + removeThreadHistory, +} from "./thread-history-cache-owner"; +import { removeProjectThreadHistory } from "./project-cache-owner"; import { getCachedThreadLists, iterateThreadListCacheEntries, @@ -336,6 +341,7 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { "thread-deleted": { flush: "debounced", dirty: [ + removeDeletedThreadHistory, dirtyThreadListQueries, dirtyThreadDetailQueries, dirtyThreadTimelineQueries, @@ -360,6 +366,7 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { dirtyThreadDetailQueries, dirtyThreadSearchQueries, getThreadTimelineInvalidationQueryKeys, + dirtyThreadHistory, getThreadQueueContentInvalidationQueryKeys, dirtyProjectPromptHistoryQueries, getThreadPendingInteractionInvalidationQueryKeys, @@ -379,7 +386,12 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { }, "title-changed": { flush: "debounced", - dirty: [dirtyActiveThreadListQueries, dirtyThreadDetailQueries], + dirty: [ + dirtyActiveThreadListQueries, + dirtyThreadDetailQueries, + getThreadTimelineInvalidationQueryKeys, + dirtyThreadHistory, + ], }, "queue-changed": { flush: "debounced", @@ -411,6 +423,8 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { dirtyThreadDetailQueries, dirtyThreadDefaultExecutionOptionsQueries, dirtyThreadStorageQueriesForThread, + getThreadTimelineInvalidationQueryKeys, + dirtyThreadHistory, ], }, "read-state-changed": { @@ -453,6 +467,7 @@ export const REALTIME_ENVIRONMENT_CHANGE_REGISTRY = { dirtyEnvironmentBranchListQueries, dirtyEnvironmentThreadListQueries, dirtyThreadSearchQueries, + dirtyEnvironmentThreadHistory, ], }, "status-changed": { @@ -484,7 +499,10 @@ export const REALTIME_PROJECT_CHANGE_REGISTRY = { dirty: [getProjectListInvalidationQueryKeys], }, "project-deleted": { - dirty: [getProjectListInvalidationQueryKeys], + dirty: [ + removeDeletedProjectThreadHistory, + getProjectListInvalidationQueryKeys, + ], }, "project-sources-changed": { dirty: [getProjectSourceDependentInvalidationQueryKeys], @@ -528,6 +546,7 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = { dirtySystemConfigQueries, dirtyMachineEnvironmentQueries, dirtyAllThreadTimelineQueries, + dirtyAllThreadHistory, dirtySystemProviderQueries, dirtySystemExecutionOptionQueries, dirtyEnvironmentProviderQueries, @@ -544,7 +563,12 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = { ], }, "provider-registrations-changed": { - dirty: [dirtySystemProviderQueries, dirtySystemExecutionOptionQueries], + dirty: [ + dirtySystemProviderQueries, + dirtySystemExecutionOptionQueries, + dirtyAllThreadTimelineQueries, + dirtyAllThreadHistory, + ], }, "environment-availability-changed": { dirty: [dirtyEnvironmentProviderQueries], @@ -824,6 +848,49 @@ function dirtyThreadDetailQueries({ return getThreadDetailInvalidationQueryKeys({ threadId }); } +function dirtyThreadHistory({ + flushOnce, + queryClient, + threadId, +}: ThreadRealtimeDirtyContext): void { + if (flushOnce(`thread-history:${threadId ?? "all"}`)) { + void invalidateThreadHistory({ queryClient, threadId }); + } +} + +function removeDeletedThreadHistory({ + queryClient, + threadId, +}: ThreadRealtimeDirtyContext): void { + if (threadId !== undefined) removeThreadHistory({ queryClient, threadId }); +} + +function dirtyEnvironmentThreadHistory({ + getCachedThreadIdsForEnvironment, + queryClient, +}: EnvironmentRealtimeDirtyContext): void { + for (const threadId of getCachedThreadIdsForEnvironment()) { + for (const queryKey of getThreadTimelineInvalidationQueryKeys({ + threadId, + })) { + void queryClient.invalidateQueries({ queryKey }); + } + void invalidateThreadHistory({ queryClient, threadId }); + } +} + +function removeDeletedProjectThreadHistory({ + projectId, + queryClient, +}: ProjectRealtimeDirtyContext): void { + if (projectId !== undefined) + removeProjectThreadHistory({ projectId, queryClient }); +} + +function dirtyAllThreadHistory({ queryClient }: RealtimeDirtyContext): void { + void invalidateThreadHistory({ queryClient }); +} + function dirtyThreadDefaultExecutionOptionsQueries({ threadId, }: ThreadRealtimeDirtyContext): QueryKey[] { diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index fca2bc85e2d..9826f991deb 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -36,6 +36,7 @@ import { sidebarNavigationQueryKey, systemConfigQueryKey, threadPromptHistoryQueryKeyPrefix, + threadHistoryQueryKeyPrefix, threadSearchQueryKeyPrefix, threadsQueryKey, } from "../queries/query-keys"; @@ -44,6 +45,10 @@ import type { QueryClientArg } from "../cache-effect-types"; import { clearCachedModelCatalogs } from "@/lib/model-catalog-cache"; import { bumpAllDiffPatchEvictionGenerations } from "./environment-diff-patch-cache-owner"; import { invalidateSystemVersion } from "./system-version-cache-owner"; +import { + invalidateThreadHistory, + type ThreadHistoryChain, +} from "./thread-history-cache-owner"; import { invalidateQueryKeys, refetchFailedActiveQueryKeys, @@ -70,6 +75,11 @@ export function invalidateRealtimeQueriesAfterServerReconnect({ { cancelRefetch: false }, ); } + invalidateThreadHistoryBefore({ + queryClient, + timestamp: disconnectedAt, + includeUnfetched: true, + }); invalidateSystemVersion({ queryClient }); bumpAllDiffPatchEvictionGenerations(); queryClient.removeQueries({ @@ -82,7 +92,10 @@ export function refetchErroredRealtimeQueriesOnInitialConnect({ }: QueryClientArg): void { refetchFailedActiveQueryKeys({ queryClient, - queryKeys: getServerReconnectInvalidationQueryKeys(), + queryKeys: [ + ...getServerReconnectInvalidationQueryKeys(), + threadHistoryQueryKeyPrefix(), + ], }); } @@ -102,6 +115,38 @@ export function invalidateRealtimeQueriesFetchedBeforeInitialConnect({ query.state.dataUpdatedAt < connectedAt, }); } + invalidateThreadHistoryBefore({ + queryClient, + timestamp: connectedAt, + includeUnfetched: false, + }); +} + +function invalidateThreadHistoryBefore({ + queryClient, + timestamp, + includeUnfetched, +}: QueryClientArg & { timestamp: number; includeUnfetched: boolean }): void { + const threadIds = new Set(); + for (const [ + queryKey, + chain, + ] of queryClient.getQueriesData({ + queryKey: threadHistoryQueryKeyPrefix(), + })) { + const threadId = queryKey[1]; + if ( + typeof threadId === "string" && + (chain === undefined + ? includeUnfetched + : chain.pages.some((page) => page.validatedAt < timestamp)) + ) { + threadIds.add(threadId); + } + } + for (const threadId of threadIds) { + void invalidateThreadHistory({ queryClient, threadId }); + } } export function invalidateSystemConfig({ queryClient }: QueryClientArg): void { @@ -170,6 +215,7 @@ export function invalidateGeneralSettingsDependencies({ allThreadTimelineTurnSummaryDetailsQueryKeyPrefix(), ], }); + void invalidateThreadHistory({ queryClient }); } export function resetModelCatalogsAfterStreamerModeChange({ diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts new file mode 100644 index 00000000000..d4c7fe6c91e --- /dev/null +++ b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts @@ -0,0 +1,225 @@ +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OPTIMISTIC_TIMELINE_ROW_ID_PREFIX } from "@bb/client-core"; +import { createDeferredPromise } from "@bb/test-helpers"; +import { BbHttpError } from "@/lib/sdk"; +import { makeThreadTimelineResponse } from "@/test/fixtures/thread-responses"; +import { systemRow } from "@/test/fixtures/thread-timeline-rows"; +import { + threadHistoryQueryKey, + threadHistoryQueryKeyPrefix, + threadQueryKey, + threadTimelineQueryKey, +} from "../queries/query-keys"; +import { HEAVY_PAYLOAD_GC_TIME_MS } from "../queries/query-policies"; +import { + compactThreadHistory, + createThreadHistoryPage, + getThreadHistoryGeneration, + pruneThreadHistory, + removeThreadHistory, + THREAD_HISTORY_MAX_BYTES, + type ThreadHistoryChain, +} from "./thread-history-cache-owner"; + +afterEach(() => vi.useRealTimers()); + +function chain(pageCount = 1): ThreadHistoryChain { + return { + surfaceKey: "thread-1:collapse", + pages: Array.from({ length: pageCount }, (_, index) => + createThreadHistoryPage( + makeThreadTimelineResponse({ + rows: [ + systemRow({ + id: `row-${index}`, + seq: index, + title: "Row", + detail: null, + }), + ], + }), + index === 0 ? null : { anchorId: `anchor-${index}`, anchorSeq: index }, + 100, + ), + ), + }; +} + +describe("thread history cache ownership", () => { + it("retains a contiguous prefix without renewing page validation", () => { + const current = chain(7); + const compacted = compactThreadHistory(current); + expect(compacted?.pages).toEqual(current.pages.slice(0, 5)); + expect(compacted?.pages[0]).toBe(current.pages[0]); + expect(compacted?.pages.every((page) => page.validatedAt === 100)).toBe( + true, + ); + current.pages[2]!.byteSize = THREAD_HISTORY_MAX_BYTES; + expect(compactThreadHistory(current)?.pages).toEqual( + current.pages.slice(0, 2), + ); + current.pages[0]!.byteSize = THREAD_HISTORY_MAX_BYTES + 1; + expect(compactThreadHistory(current)).toBeUndefined(); + }); + + it("excludes optimistic rows from reusable history", () => { + const serverRow = systemRow({ + id: "server", + seq: 1, + title: "Server", + detail: null, + }); + const optimisticRow = systemRow({ + id: `${OPTIMISTIC_TIMELINE_ROW_ID_PREFIX}pending`, + seq: 2, + title: "Pending", + detail: null, + }); + const response = makeThreadTimelineResponse({ + rows: [serverRow, optimisticRow], + }); + expect(createThreadHistoryPage(response, null).response.rows).toEqual([ + serverRow, + ]); + expect(response.rows).toEqual([serverRow, optimisticRow]); + }); + + it("prunes only inactive history identities and keeps their prior timestamps", () => { + const queryClient = new QueryClient(); + const activeKey = threadHistoryQueryKey("active", "active", 20); + queryClient.setQueryData(activeKey, chain(), { updatedAt: 1 }); + const observer = new QueryObserver(queryClient, { + queryKey: activeKey, + staleTime: Infinity, + }); + const unsubscribe = observer.subscribe(() => {}); + const unrelatedKey = threadTimelineQueryKey("unrelated"); + queryClient.setQueryData(unrelatedKey, makeThreadTimelineResponse()); + for (let index = 0; index < 12; index += 1) { + queryClient.setQueryData( + threadHistoryQueryKey(`inactive-${index}`, "surface", 20), + chain(7), + { updatedAt: 100 + index }, + ); + } + + pruneThreadHistory(queryClient); + + expect( + queryClient + .getQueryCache() + .findAll({ queryKey: threadHistoryQueryKeyPrefix() }), + ).toHaveLength(11); + expect(queryClient.getQueryData(activeKey)).toBeDefined(); + expect(queryClient.getQueryData(unrelatedKey)).toBeDefined(); + expect( + queryClient.getQueryData( + threadHistoryQueryKey("inactive-0", "surface", 20), + ), + ).toBeUndefined(); + const retained = threadHistoryQueryKey("inactive-11", "surface", 20); + expect(queryClient.getQueryState(retained)?.dataUpdatedAt).toBe(111); + expect( + queryClient.getQueryData(retained)?.pages, + ).toHaveLength(5); + unsubscribe(); + queryClient.clear(); + }); + + it("keeps eviction blocked through manual writes until an authoritative fetch", async () => { + const queryClient = new QueryClient(); + getThreadHistoryGeneration(queryClient, "thread-1"); + removeThreadHistory({ queryClient, threadId: "thread-1" }); + queryClient.setQueryData( + threadTimelineQueryKey("thread-1"), + makeThreadTimelineResponse(), + ); + expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( + true, + ); + await queryClient.fetchQuery({ + queryKey: threadTimelineQueryKey("thread-1"), + queryFn: async () => makeThreadTimelineResponse({ maxSeq: 2 }), + staleTime: 0, + }); + expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( + false, + ); + expect(getThreadHistoryGeneration(queryClient, "thread-1").eviction).toBe( + 1, + ); + queryClient.clear(); + }); + + it("uses the existing inactivity collection interval", async () => { + vi.useFakeTimers(); + const queryClient = new QueryClient(); + const key = threadHistoryQueryKey("thread-1", "surface", 20); + const observer = new QueryObserver(queryClient, { + queryKey: key, + initialData: chain(), + staleTime: Infinity, + gcTime: HEAVY_PAYLOAD_GC_TIME_MS, + }); + const unsubscribe = observer.subscribe(() => {}); + unsubscribe(); + await vi.advanceTimersByTimeAsync(HEAVY_PAYLOAD_GC_TIME_MS - 1); + expect(queryClient.getQueryData(key)).toBeDefined(); + await vi.advanceTimersByTimeAsync(1); + expect(queryClient.getQueryData(key)).toBeUndefined(); + queryClient.clear(); + }); + + it("cancels a latest read started before eviction so its late success cannot unblock", async () => { + const queryClient = new QueryClient(); + const pending = + createDeferredPromise>(); + let signal: AbortSignal | undefined; + const read = queryClient + .fetchQuery({ + queryKey: threadTimelineQueryKey("thread-1"), + queryFn: ({ signal: requestSignal }) => { + signal = requestSignal; + return pending.promise; + }, + }) + .catch(() => undefined); + removeThreadHistory({ queryClient, threadId: "thread-1" }); + expect(signal?.aborted).toBe(true); + pending.resolve(makeThreadTimelineResponse()); + await read; + expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( + true, + ); + expect( + queryClient.getQueryData(threadTimelineQueryKey("thread-1")), + ).toBeUndefined(); + queryClient.clear(); + }); + + it("purges cached history when route metadata is denied", async () => { + const queryClient = new QueryClient(); + const key = threadHistoryQueryKey("thread-1", "surface", 20); + queryClient.setQueryData(key, chain()); + getThreadHistoryGeneration(queryClient, "thread-1"); + const error = new BbHttpError({ + status: 403, + body: null, + code: null, + message: "Access denied", + }); + await expect( + queryClient.fetchQuery({ + queryKey: threadQueryKey("thread-1"), + queryFn: () => Promise.reject(error), + retry: false, + }), + ).rejects.toBe(error); + expect(queryClient.getQueryData(key)).toBeUndefined(); + expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( + true, + ); + queryClient.clear(); + }); +}); diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts new file mode 100644 index 00000000000..e3ca2369ec1 --- /dev/null +++ b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts @@ -0,0 +1,193 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { isOptimisticTimelineRowId } from "@bb/client-core"; +import { BbHttpError } from "@/lib/sdk"; +import type { + ThreadTimelineResponse, + TimelinePaginationCursor, +} from "@bb/server-contract"; +import { + threadHistoryQueryKeyPrefix, + allThreadTimelineQueryKeyPrefix, + threadTimelineQueryKeyPrefix, + threadDetailBootstrapQueryKey, + THREAD_QUERY_KEY, + THREAD_TIMELINE_QUERY_KEY, +} from "../queries/query-keys"; + +export const THREAD_HISTORY_MAX_PAGES = 5; +export const THREAD_HISTORY_MAX_BYTES = 8 * 1024 * 1024; +export const THREAD_HISTORY_MAX_INACTIVE_ENTRIES = 10; + +export interface ThreadHistoryPage { + response: ThreadTimelineResponse; + requestCursor: TimelinePaginationCursor | null; + validatedAt: number; + byteSize: number; +} + +export interface ThreadHistoryChain { + pages: ThreadHistoryPage[]; + surfaceKey: string; +} + +interface ThreadHistoryGeneration { + request: number; + eviction: number; + blocked: boolean; + error: Error | null; +} + +const generations = new WeakMap< + QueryClient, + Map +>(); + +export function getThreadHistoryGeneration( + queryClient: QueryClient, + threadId: string, +): ThreadHistoryGeneration { + let threads = generations.get(queryClient); + if (!threads) { + threads = new Map(); + generations.set(queryClient, threads); + queryClient.getQueryCache().subscribe((event) => { + if (event.type !== "updated") return; + const id = event.query.queryKey[1]; + if (typeof id !== "string") return; + const root = event.query.queryKey[0]; + if ( + event.action.type === "error" && + (root === THREAD_QUERY_KEY || + root === THREAD_TIMELINE_QUERY_KEY || + root === threadDetailBootstrapQueryKey("")[0]) && + event.action.error instanceof BbHttpError && + [401, 403, 404].includes(event.action.error.status) + ) { + removeThreadHistory({ + queryClient, + threadId: id, + error: event.action.error, + }); + return; + } + if ( + event.action.type !== "success" || + event.action.manual || + root !== THREAD_TIMELINE_QUERY_KEY + ) + return; + const generation = generations.get(queryClient)?.get(id); + if (generation) { + generation.blocked = false; + generation.error = null; + } + }); + } + let generation = threads.get(threadId); + if (!generation) { + generation = { request: 0, eviction: 0, blocked: false, error: null }; + threads.set(threadId, generation); + } + return generation; +} + +export function createThreadHistoryPage( + response: ThreadTimelineResponse, + requestCursor: TimelinePaginationCursor | null, + validatedAt = Date.now(), +): ThreadHistoryPage { + const rows = response.rows.filter( + (row) => !isOptimisticTimelineRowId(row.id), + ); + const serverResponse = + rows.length === response.rows.length ? response : { ...response, rows }; + return { + response: serverResponse, + requestCursor, + validatedAt, + byteSize: new TextEncoder().encode(JSON.stringify(serverResponse)) + .byteLength, + }; +} + +export function compactThreadHistory( + chain: ThreadHistoryChain, +): ThreadHistoryChain | undefined { + let bytes = 0; + const pages: ThreadHistoryPage[] = []; + for (const page of chain.pages.slice(0, THREAD_HISTORY_MAX_PAGES)) { + if (bytes + page.byteSize > THREAD_HISTORY_MAX_BYTES) break; + pages.push(page); + bytes += page.byteSize; + } + if (pages.length === 0) return undefined; + return pages.length === chain.pages.length ? chain : { ...chain, pages }; +} + +export function pruneThreadHistory(queryClient: QueryClient): void { + const inactive = queryClient + .getQueryCache() + .findAll({ queryKey: threadHistoryQueryKeyPrefix(), type: "inactive" }) + .filter((query) => query.getObserversCount() === 0) + .sort( + (left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt, + ); + for (const [index, query] of inactive.entries()) { + const chain = queryClient.getQueryData(query.queryKey); + const compacted = chain && compactThreadHistory(chain); + if (index >= THREAD_HISTORY_MAX_INACTIVE_ENTRIES || !compacted) { + queryClient.removeQueries({ queryKey: query.queryKey, exact: true }); + } else if (compacted !== chain) { + queryClient.setQueryData(query.queryKey, compacted, { + updatedAt: query.state.dataUpdatedAt, + }); + } + } +} + +interface ThreadHistoryOwnerArgs { + queryClient: QueryClient; + threadId?: string; + error?: Error; +} + +function advanceThreadHistoryGeneration( + { queryClient, threadId, error }: ThreadHistoryOwnerArgs, + evict: boolean, +): void { + const threadIds = + threadId === undefined + ? [...(generations.get(queryClient)?.keys() ?? [])] + : [threadId]; + for (const id of threadIds) { + const generation = getThreadHistoryGeneration(queryClient, id); + generation.request += 1; + if (evict) { + generation.eviction += 1; + generation.blocked = true; + generation.error = error ?? null; + } + } +} + +export async function invalidateThreadHistory( + args: ThreadHistoryOwnerArgs, +): Promise { + advanceThreadHistoryGeneration(args, false); + const filters = { queryKey: threadHistoryQueryKeyPrefix(args.threadId) }; + await args.queryClient.cancelQueries(filters); + await args.queryClient.invalidateQueries(filters, { cancelRefetch: false }); +} + +export function removeThreadHistory(args: ThreadHistoryOwnerArgs): void { + advanceThreadHistoryGeneration(args, true); + const filters = { queryKey: threadHistoryQueryKeyPrefix(args.threadId) }; + void args.queryClient.cancelQueries(filters); + void args.queryClient.cancelQueries({ + queryKey: + args.threadId === undefined + ? allThreadTimelineQueryKeyPrefix() + : threadTimelineQueryKeyPrefix(args.threadId), + }); + args.queryClient.removeQueries(filters); +} diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 4a3a124fcc6..b80e59028b2 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -52,6 +52,7 @@ const ENVIRONMENT_DIFF_FILE_QUERY_KEY = "environmentDiffFile"; const ENVIRONMENT_FILE_PREVIEW_QUERY_KEY = "environmentFilePreview"; const ENVIRONMENT_PATHS_QUERY_KEY = "environmentPaths"; export const THREAD_TIMELINE_QUERY_KEY = "threadTimeline"; +export const THREAD_HISTORY_QUERY_KEY = "threadHistory"; const THREAD_CONVERSATION_OUTLINE_QUERY_KEY = "threadConversationOutline"; const THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY = "threadTimelineTurnSummaryDetails"; @@ -925,6 +926,20 @@ export function threadTimelineQueryKey( return [THREAD_TIMELINE_QUERY_KEY, threadId]; } +export function threadHistoryQueryKey( + threadId: string, + surfaceKey: string, + segmentLimit: number, +) { + return [THREAD_HISTORY_QUERY_KEY, threadId, surfaceKey, segmentLimit] as const; +} + +export function threadHistoryQueryKeyPrefix(threadId?: string) { + return threadId === undefined + ? ([THREAD_HISTORY_QUERY_KEY] as const) + : ([THREAD_HISTORY_QUERY_KEY, threadId] as const); +} + export function threadConversationOutlineQueryKey( threadId: string, ): ThreadConversationOutlineQueryKey { diff --git a/apps/app/src/hooks/queries/thread-history-query.test.tsx b/apps/app/src/hooks/queries/thread-history-query.test.tsx new file mode 100644 index 00000000000..1918eef9987 --- /dev/null +++ b/apps/app/src/hooks/queries/thread-history-query.test.tsx @@ -0,0 +1,465 @@ +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { QueryClient } from "@tanstack/react-query"; +import type { ThreadTimelineResponse } from "@bb/server-contract"; +import { resolveLoadedTimelineSurfaceKey } from "@bb/client-core"; +import { createDeferredPromise } from "@bb/test-helpers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BbHttpError, sdk } from "@/lib/sdk"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { makeThreadTimelineResponse } from "@/test/fixtures/thread-responses"; +import { systemRow } from "@/test/fixtures/thread-timeline-rows"; +import { createBrowserLifecycleFetchController } from "../cache-owners/browser-lifecycle-cache-owner"; +import { + createThreadHistoryPage, + invalidateThreadHistory, + removeThreadHistory, + type ThreadHistoryChain, +} from "../cache-owners/thread-history-cache-owner"; +import { threadHistoryQueryKey, threadTimelineQueryKey } from "./query-keys"; +import { useThreadHistory } from "./thread-history-query"; +import { useThreadTimeline } from "./thread-queries"; + +vi.mock("@/lib/sdk", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sdk: { threads: { timeline: vi.fn() } } }; +}); + +vi.mock("@/hooks/useRealtimeSubscription", () => ({ + useThreadDetailRealtimeSubscription: vi.fn(), +})); + +afterEach(() => { + cleanup(); + vi.mocked(sdk.threads.timeline).mockReset(); + vi.restoreAllMocks(); +}); + +function page( + sequence: number, + options: { + kind?: "latest" | "older"; + snapshot?: string; + final?: boolean; + } = {}, +): ThreadTimelineResponse { + const snapshot = options.snapshot ?? "old"; + return makeThreadTimelineResponse({ + rows: [ + systemRow({ + id: `row-${sequence}`, + seq: sequence, + title: `${sequence}`, + detail: null, + }), + ], + maxSeq: snapshot === "old" ? 100 : 200, + timelinePage: { + kind: options.kind ?? "latest", + historySnapshot: snapshot, + returnedSegmentCount: 1, + hasOlderRows: !options.final, + olderCursor: options.final + ? null + : { + anchorId: `${snapshot}:${sequence}`, + anchorSeq: sequence, + }, + }, + }); +} + +function seedHistory( + queryClient: QueryClient, + pages: ThreadTimelineResponse[], + updatedAt = Date.now(), +) { + const latest = pages[0]!; + const surfaceKey = resolveLoadedTimelineSurfaceKey("thread-1", latest); + const key = threadHistoryQueryKey( + "thread-1", + surfaceKey, + latest.timelinePage.segmentLimit, + ); + const chain: ThreadHistoryChain = { + surfaceKey, + pages: pages.map((response, index) => + createThreadHistoryPage( + response, + index === 0 ? null : pages[index - 1]!.timelinePage.olderCursor, + updatedAt, + ), + ), + }; + queryClient.setQueryData(threadTimelineQueryKey("thread-1"), latest); + queryClient.setQueryData(key, chain, { updatedAt }); + return { chain, key }; +} + +describe("useThreadHistory", () => { + it("returns fresh cached history immediately without a network read", () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + const { chain } = seedHistory(queryClient, [ + latest, + page(20, { kind: "older" }), + ]); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + + expect(result.current.data).toBe(chain); + expect(sdk.threads.timeline).not.toHaveBeenCalled(); + }); + + it("preserves the initial miss path without fetching latest twice", async () => { + const latest = page(30); + vi.mocked(sdk.threads.timeline).mockResolvedValue(latest); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => { + const timeline = useThreadTimeline("thread-1"); + return useThreadHistory({ + threadId: "thread-1", + latestTimeline: timeline.data, + }); + }, + { wrapper }, + ); + + await waitFor(() => expect(result.current.data?.pages).toHaveLength(1)); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(1); + await act(async () => { + await result.current.refresh(); + }); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); + }); + + it("keeps stale rows visible and rebuilds with fresh opaque cursors", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + const { chain } = seedHistory( + queryClient, + [latest, page(20, { kind: "older" })], + Date.now() - 10_000, + ); + const freshLatest = createDeferredPromise(); + const freshOlder = page(25, { kind: "older", snapshot: "fresh" }); + vi.mocked(sdk.threads.timeline) + .mockReturnValueOnce(freshLatest.promise) + .mockResolvedValueOnce(freshOlder); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + + expect(result.current.data).toBe(chain); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); + await act(async () => { + freshLatest.resolve(page(40, { snapshot: "fresh" })); + }); + await waitFor(() => + expect(result.current.data?.pages[1]?.response).toEqual(freshOlder), + ); + expect(sdk.threads.timeline).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + beforeAnchorId: "fresh:40", + beforeAnchorSeq: "40", + signal: expect.any(AbortSignal), + }), + ); + }); + + it("retains the successful chain and validation times on refresh failure", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + const { chain } = seedHistory( + queryClient, + [latest, page(20, { kind: "older" })], + Date.now() - 10_000, + ); + const failure = new Error("Failed to fetch"); + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) + .mockRejectedValueOnce(failure); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + + await waitFor(() => expect(result.current.error).toBe(failure)); + expect(result.current.data).toBe(chain); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); + act(() => { + queryClient.getQueryCache().onFocus(); + queryClient.getQueryCache().onOnline(); + }); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) + .mockResolvedValueOnce(page(25, { kind: "older", snapshot: "fresh" })); + await act(async () => { + await result.current.refresh(); + }); + await waitFor(() => expect(result.current.error).toBeNull()); + expect(result.current.data?.pages[1]?.response.rows[0]?.id).toBe("row-25"); + }); + + it("deduplicates two readers and does not cancel when one unmounts", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + seedHistory(queryClient, [latest]); + const pending = createDeferredPromise(); + vi.mocked(sdk.threads.timeline).mockReturnValue(pending.promise); + const first = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + const second = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + const cursor = latest.timelinePage.olderCursor!; + let firstRead!: Promise; + let secondRead!: Promise; + act(() => { + firstRead = first.result.current.loadOlder(cursor); + secondRead = second.result.current.loadOlder(cursor); + }); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); + const signal = vi.mocked(sdk.threads.timeline).mock.calls[0]![0].signal; + first.unmount(); + expect(signal?.aborted).toBe(false); + const older = page(20, { kind: "older" }); + await act(async () => { + pending.resolve(older); + expect(await firstRead).toBe(older); + expect(await secondRead).toBe(older); + }); + await waitFor(() => + expect(second.result.current.data?.pages).toHaveLength(2), + ); + }); + + it("suspends and resumes an active older read without dropping its caller", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + seedHistory(queryClient, [latest]); + const pending = createDeferredPromise(); + const older = page(20, { kind: "older" }); + vi.mocked(sdk.threads.timeline) + .mockReturnValueOnce(pending.promise) + .mockResolvedValueOnce(older); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + let read!: Promise; + act(() => { + read = result.current.loadOlder(latest.timelinePage.olderCursor!); + }); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); + const signal = vi.mocked(sdk.threads.timeline).mock.calls[0]![0].signal; + const lifecycle = createBrowserLifecycleFetchController(queryClient); + act(() => lifecycle.suspend()); + expect(signal?.aborted).toBe(true); + await act(async () => { + lifecycle.resume(); + expect(await read).toBe(older); + }); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); + expect(result.current.error).toBeNull(); + pending.resolve(page(10, { kind: "older" })); + }); + + it("purges pending work and ignores late results and optimistic writes", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + const { key } = seedHistory(queryClient, [latest]); + const pending = createDeferredPromise(); + vi.mocked(sdk.threads.timeline).mockReturnValueOnce(pending.promise); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + let read!: Promise; + act(() => { + read = result.current.loadOlder(latest.timelinePage.olderCursor!); + }); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); + await act(async () => { + removeThreadHistory({ queryClient, threadId: "thread-1" }); + expect(await read).toBeUndefined(); + }); + expect(result.current.isBlocked).toBe(true); + expect(result.current.generation).toBe(1); + await act(async () => { + pending.resolve(page(20, { kind: "older" })); + }); + act(() => + queryClient.setQueryData(threadTimelineQueryKey("thread-1"), page(40)), + ); + expect(result.current.data).toBeUndefined(); + expect(result.current.isBlocked).toBe(true); + expect(queryClient.getQueryData(key)).toBeUndefined(); + }); + + it("bounds reusable pages while returning deep foreground content", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(50); + seedHistory(queryClient, [latest]); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + let cursor = latest.timelinePage.olderCursor!; + for (const sequence of [40, 30, 20, 10, 0]) { + const older = page(sequence, { kind: "older", final: sequence === 0 }); + vi.mocked(sdk.threads.timeline).mockResolvedValueOnce(older); + await act(async () => { + expect(await result.current.loadOlder(cursor)).toBe(older); + }); + if (older.timelinePage.olderCursor) + cursor = older.timelinePage.olderCursor; + } + expect(result.current.data?.pages).toHaveLength(5); + expect(result.current.data?.pages.at(-1)?.response.rows[0]?.id).toBe( + "row-10", + ); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(5); + }); + + it.each([401, 403, 404])( + "clears cached history on an older read returning %s", + async (status) => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + seedHistory(queryClient, [latest]); + const failure = new BbHttpError({ + body: null, + code: null, + message: "Unavailable", + status, + }); + vi.mocked(sdk.threads.timeline).mockRejectedValueOnce(failure); + const { result } = renderHook( + () => + useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + await act(async () => { + expect( + await result.current.loadOlder(latest.timelinePage.olderCursor!), + ).toBeUndefined(); + }); + expect(result.current.isBlocked).toBe(true); + expect(result.current.data).toBeUndefined(); + expect(result.current.error).toBe(failure); + }, + ); + + it("services a foreground cursor before restarting an interrupted background chain", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + const { chain } = seedHistory( + queryClient, + [latest, page(20, { kind: "older" })], + Date.now() - 10_000, + ); + const background = createDeferredPromise(); + const nextLatest = createDeferredPromise(); + const foreground = page(10, { kind: "older" }); + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) + .mockReturnValueOnce(background.promise) + .mockResolvedValueOnce(foreground) + .mockReturnValueOnce(nextLatest.promise); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(2)); + const backgroundSignal = vi.mocked(sdk.threads.timeline).mock.calls[1]![0] + .signal; + await act(async () => { + expect( + await result.current.loadOlder( + chain.pages[1]!.response.timelinePage.olderCursor!, + ), + ).toBe(foreground); + }); + expect(backgroundSignal?.aborted).toBe(true); + expect(sdk.threads.timeline).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ beforeAnchorId: "old:20" }), + ); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(4)); + background.resolve(page(25, { kind: "older", snapshot: "fresh" })); + expect(result.current.data?.pages[2]?.response).toEqual(foreground); + }); + + it("recovers an invalid cursor once and exposes a second failure", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + const { chain } = seedHistory(queryClient, [ + latest, + page(20, { kind: "older" }), + ]); + const invalid = new BbHttpError({ + body: null, + code: "invalid_request", + message: "Invalid cursor", + status: 400, + }); + vi.mocked(sdk.threads.timeline) + .mockRejectedValueOnce(invalid) + .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) + .mockRejectedValueOnce(invalid); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + await act(async () => { + await expect( + result.current.loadOlder( + chain.pages[1]!.response.timelinePage.olderCursor!, + ), + ).rejects.toBe(invalid); + }); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(3); + expect(result.current.data).toBe(chain); + await waitFor(() => expect(result.current.error).toBe(invalid)); + }); + + it("invalidates an obsolete refresh without publishing its result", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(30); + seedHistory( + queryClient, + [latest, page(20, { kind: "older" })], + Date.now() - 10_000, + ); + const obsolete = createDeferredPromise(); + vi.mocked(sdk.threads.timeline) + .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) + .mockReturnValueOnce(obsolete.promise) + .mockResolvedValueOnce(page(50, { snapshot: "newest" })) + .mockResolvedValueOnce(page(35, { kind: "older", snapshot: "newest" })); + const { result } = renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ); + await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(2)); + await act(async () => { + await invalidateThreadHistory({ queryClient, threadId: "thread-1" }); + }); + await act(async () => { + obsolete.resolve(page(25, { kind: "older", snapshot: "fresh" })); + }); + expect(result.current.data?.pages[0]?.response.rows[0]?.id).toBe("row-50"); + expect(result.current.data?.pages[1]?.response.rows[0]?.id).toBe("row-35"); + }); +}); diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts new file mode 100644 index 00000000000..40edc225f20 --- /dev/null +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -0,0 +1,463 @@ +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; +import { + CancelledError, + useQuery, + useQueryClient, + type QueryClient, +} from "@tanstack/react-query"; +import type { + ThreadTimelineResponse, + TimelinePaginationCursor, +} from "@bb/server-contract"; +import { + areTimelinePaginationCursorsEqual, + resolveLoadedTimelineSurfaceKey, +} from "@bb/client-core"; +import { BbHttpError, sdk } from "@/lib/sdk"; +import { + compactThreadHistory, + createThreadHistoryPage, + getThreadHistoryGeneration, + pruneThreadHistory, + removeThreadHistory, + THREAD_HISTORY_MAX_BYTES, + THREAD_HISTORY_MAX_PAGES, + type ThreadHistoryChain, +} from "../cache-owners/thread-history-cache-owner"; +import { HEAVY_PAYLOAD_QUERY_POLICY } from "./query-policies"; +import { threadHistoryQueryKey, threadTimelineQueryKey } from "./query-keys"; +import { fetchThreadTimeline } from "./thread-queries"; + +const HISTORY_STALE_TIME_MS = 2_000; + +interface ForegroundRead { + cursor: TimelinePaginationCursor; + generation: number; + promise: Promise; + resolve: (response: ThreadTimelineResponse | undefined) => void; + reject: (error: unknown) => void; +} + +interface HistoryReadState { + foreground: ForegroundRead | undefined; + refreshAfterForeground: boolean; + forceRefresh: boolean; +} + +const reads = new WeakMap>(); + +function historyReadState( + queryClient: QueryClient, + identity: string, +): HistoryReadState { + let entries = reads.get(queryClient); + if (!entries) { + entries = new Map(); + reads.set(queryClient, entries); + } + let state = entries.get(identity); + if (!state) { + state = { + foreground: undefined, + refreshAfterForeground: false, + forceRefresh: false, + }; + entries.set(identity, state); + } + return state; +} + +function isStaleCursor(error: unknown): boolean { + return ( + error instanceof BbHttpError && + error.status === 400 && + error.code === "invalid_request" + ); +} + +function isAccessFailure(error: unknown): error is BbHttpError { + return ( + error instanceof BbHttpError && + (error.status === 401 || error.status === 403 || error.status === 404) + ); +} + +function oldestSequence(chain: ThreadHistoryChain): number | undefined { + return chain.pages.at(-1)?.response.rows[0]?.sourceSeqStart; +} + +interface UseThreadHistoryArgs { + threadId: string; + latestTimeline: ThreadTimelineResponse | undefined; + enabled?: boolean; +} + +export function useThreadHistory({ + threadId, + latestTimeline, + enabled = true, +}: UseThreadHistoryArgs) { + const queryClient = useQueryClient(); + const surfaceKey = resolveLoadedTimelineSurfaceKey(threadId, latestTimeline); + const segmentLimit = latestTimeline?.timelinePage.segmentLimit ?? 20; + const queryKey = useMemo( + () => threadHistoryQueryKey(threadId, surfaceKey, segmentLimit), + [threadId, surfaceKey, segmentLimit], + ); + const identity = JSON.stringify(queryKey); + const state = historyReadState(queryClient, identity); + const subscribe = useCallback( + (listener: () => void) => queryClient.getQueryCache().subscribe(listener), + [queryClient], + ); + const getGeneration = useCallback(() => { + const owner = getThreadHistoryGeneration(queryClient, threadId); + return `${owner.eviction}:${owner.blocked}`; + }, [queryClient, threadId]); + useSyncExternalStore(subscribe, getGeneration, getGeneration); + const owner = getThreadHistoryGeneration(queryClient, threadId); + const canRead = + enabled && + Boolean(threadId) && + latestTimeline !== undefined && + !owner.blocked; + + const query = useQuery({ + queryKey, + enabled: canRead, + ...HEAVY_PAYLOAD_QUERY_POLICY, + initialData: () => + latestTimeline && !owner.blocked + ? compactThreadHistory({ + surfaceKey, + pages: [ + createThreadHistoryPage( + latestTimeline, + null, + queryClient.getQueryState(threadTimelineQueryKey(threadId)) + ?.dataUpdatedAt ?? Date.now(), + ), + ], + }) + : undefined, + initialDataUpdatedAt: () => + queryClient.getQueryState(threadTimelineQueryKey(threadId)) + ?.dataUpdatedAt, + staleTime: (cached) => { + const pages = cached.state.data?.pages; + if (!pages?.length) return HISTORY_STALE_TIME_MS; + const validatedAt = Math.min(...pages.map((page) => page.validatedAt)); + return Math.max( + 0, + validatedAt + HISTORY_STALE_TIME_MS - cached.state.dataUpdatedAt, + ); + }, + refetchOnMount: true, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: false, + queryFn: async ({ signal }) => { + const requestGeneration = owner.request; + const current = queryClient.getQueryData(queryKey); + const forceRefresh = + state.forceRefresh || + queryClient.getQueryState(queryKey)?.isInvalidated === true; + state.forceRefresh = false; + const foreground = + state.foreground?.generation === requestGeneration + ? state.foreground + : undefined; + if (state.foreground && !foreground) { + state.foreground.resolve(undefined); + state.foreground = undefined; + } + const finishForeground = ( + response: ThreadTimelineResponse | undefined, + ) => { + if (!foreground) return; + if (state.foreground === foreground) state.foreground = undefined; + foreground.resolve(response); + }; + const assertCurrent = () => { + if (signal.aborted || owner.request !== requestGeneration) { + throw new CancelledError({ revert: true }); + } + }; + const rebuild = async (): Promise => { + assertCurrent(); + const latest = await queryClient.fetchQuery({ + queryKey: threadTimelineQueryKey(threadId), + queryFn: ({ signal: latestSignal }) => + fetchThreadTimeline({ + queryClient, + signal: latestSignal, + threadId, + }), + staleTime: 0, + retry: false, + }); + assertCurrent(); + if ( + resolveLoadedTimelineSurfaceKey(threadId, latest) !== surfaceKey || + latest.timelinePage.segmentLimit !== segmentLimit + ) { + throw new CancelledError({ revert: true }); + } + const retained = current && compactThreadHistory(current); + const targetPages = retained?.pages.length ?? 1; + const targetSequence = retained && oldestSequence(retained); + const pages = [ + createThreadHistoryPage( + latest, + null, + Math.max(Date.now(), (current?.pages[0]?.validatedAt ?? 0) + 1), + ), + ]; + let bytes = pages[0].byteSize; + while (pages.length < Math.min(targetPages, THREAD_HISTORY_MAX_PAGES)) { + assertCurrent(); + if (state.foreground && state.foreground !== foreground) { + throw new CancelledError({ revert: true }); + } + const previous = pages.at(-1)!; + const cursor = previous.response.timelinePage.olderCursor; + if (!cursor || bytes >= THREAD_HISTORY_MAX_BYTES) break; + const firstSequence = previous.response.rows[0]?.sourceSeqStart; + if ( + targetSequence !== undefined && + firstSequence !== undefined && + firstSequence < targetSequence + ) + break; + const response = await sdk.threads.timeline({ + threadId, + beforeAnchorId: cursor.anchorId, + beforeAnchorSeq: String(cursor.anchorSeq), + signal, + }); + assertCurrent(); + const page = createThreadHistoryPage(response, cursor); + if (bytes + page.byteSize > THREAD_HISTORY_MAX_BYTES) break; + pages.push(page); + bytes += page.byteSize; + } + return ( + compactThreadHistory({ surfaceKey, pages }) ?? { + surfaceKey, + pages: [], + } + ); + }; + try { + if (foreground) { + const response = await sdk.threads.timeline({ + threadId, + beforeAnchorId: foreground.cursor.anchorId, + beforeAnchorSeq: String(foreground.cursor.anchorSeq), + signal, + }); + assertCurrent(); + finishForeground(response); + const previous = current?.pages.at(-1); + if ( + current && + previous && + areTimelinePaginationCursorsEqual({ + left: previous.response.timelinePage.olderCursor, + right: foreground.cursor, + }) + ) { + return ( + compactThreadHistory({ + ...current, + pages: [ + ...current.pages, + createThreadHistoryPage(response, foreground.cursor), + ], + }) ?? { surfaceKey, pages: [] } + ); + } + return current ?? { surfaceKey, pages: [] }; + } + if ((!current || current.pages.length <= 1) && !forceRefresh) { + const latest = + queryClient.getQueryData( + threadTimelineQueryKey(threadId), + ) ?? latestTimeline; + assertCurrent(); + return latest + ? (compactThreadHistory({ + surfaceKey, + pages: [createThreadHistoryPage(latest, null)], + }) ?? { surfaceKey, pages: [] }) + : { surfaceKey, pages: [] }; + } + return await rebuild(); + } catch (error) { + if (isAccessFailure(error)) { + removeThreadHistory({ queryClient, threadId, error }); + finishForeground(undefined); + throw error; + } + if (signal.aborted || owner.request !== requestGeneration) { + if (owner.request !== requestGeneration) finishForeground(undefined); + throw new CancelledError({ revert: true }); + } + if (!isStaleCursor(error)) { + if (foreground) { + if (state.foreground === foreground) state.foreground = undefined; + foreground.reject(error); + } + throw error; + } + try { + const rebuilt = await rebuild(); + finishForeground(undefined); + return rebuilt; + } catch (recoveryError) { + if (isAccessFailure(recoveryError)) { + removeThreadHistory({ + queryClient, + threadId, + error: recoveryError, + }); + finishForeground(undefined); + throw recoveryError; + } + if ( + signal.aborted || + owner.request !== requestGeneration || + recoveryError instanceof CancelledError + ) { + if (owner.request !== requestGeneration) + finishForeground(undefined); + throw new CancelledError({ revert: true }); + } + if (foreground) { + if (state.foreground === foreground) state.foreground = undefined; + foreground.reject(recoveryError); + } + throw recoveryError; + } + } finally { + pruneThreadHistory(queryClient); + } + }, + }); + + const refetch = query.refetch; + const loadOlder = useCallback( + async function loadOlderPage( + cursor: TimelinePaginationCursor, + ): Promise { + if (!canRead) return undefined; + const cached = queryClient.getQueryData(queryKey); + const page = cached?.pages.find((entry) => + areTimelinePaginationCursorsEqual({ + left: entry.requestCursor, + right: cursor, + }), + ); + if (page) return page.response; + if (state.foreground) { + const existing = state.foreground; + if ( + areTimelinePaginationCursorsEqual({ + left: existing.cursor, + right: cursor, + }) + ) + return existing.promise; + await existing.promise; + return loadOlderPage(cursor); + } + let resolve!: ForegroundRead["resolve"]; + let reject!: ForegroundRead["reject"]; + const promise = new Promise( + (onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }, + ); + const foreground: ForegroundRead = { + cursor, + generation: owner.request, + promise, + resolve, + reject, + }; + state.refreshAfterForeground ||= + queryClient.getQueryState(queryKey)?.fetchStatus === "fetching"; + state.foreground = foreground; + void (async () => { + await queryClient.cancelQueries({ queryKey, exact: true }); + if (owner.request !== foreground.generation) { + if (state.foreground === foreground) state.foreground = undefined; + foreground.resolve(undefined); + return; + } + try { + await refetch({ cancelRefetch: false }); + } finally { + if (owner.request !== foreground.generation) { + if (state.foreground === foreground) state.foreground = undefined; + foreground.resolve(undefined); + } + } + })(); + return promise; + }, + [canRead, owner, queryClient, queryKey, refetch, state], + ); + + const refresh = useCallback(async () => { + if (!canRead) return; + if (state.foreground) { + await state.foreground.promise.catch(() => undefined); + } + state.foreground = undefined; + state.refreshAfterForeground = false; + state.forceRefresh = true; + await refetch({ cancelRefetch: false }); + }, [canRead, refetch, state]); + + useEffect(() => { + if ( + !canRead || + query.isFetching || + state.foreground || + !state.refreshAfterForeground + ) + return; + state.refreshAfterForeground = false; + state.forceRefresh = true; + void refetch({ cancelRefetch: false }); + }, [canRead, query.isFetching, refetch, state]); + + useEffect(() => { + pruneThreadHistory(queryClient); + return () => { + queueMicrotask(() => { + const cached = queryClient + .getQueryCache() + .find({ queryKey, exact: true }); + if (cached && cached.getObserversCount() > 0) return; + state.foreground?.resolve(undefined); + state.foreground = undefined; + reads.get(queryClient)?.delete(identity); + pruneThreadHistory(queryClient); + }); + }; + }, [queryClient, identity, queryKey, state]); + + return { + data: canRead ? query.data : undefined, + generation: owner.eviction, + isBlocked: owner.blocked, + isFetching: query.isFetching, + isLoadingOlder: query.isFetching && state.foreground !== undefined, + error: owner.error ?? query.error, + loadOlder, + refresh, + }; +} diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 0922143f951..670052d3b40 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -905,13 +905,13 @@ interface FetchThreadTimelineArgs { export const COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT = 8; -function resolveThreadTimelineSegmentLimit(): number | undefined { +export function resolveThreadTimelineSegmentLimit(): number | undefined { return getMediaQuerySnapshot(COMPACT_VIEWPORT_QUERY) ? COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT : undefined; } -async function fetchThreadTimeline({ +export async function fetchThreadTimeline({ queryClient, signal, threadId, diff --git a/apps/app/src/hooks/thread-history-cache-effects.test.ts b/apps/app/src/hooks/thread-history-cache-effects.test.ts new file mode 100644 index 00000000000..e3e200cb03c --- /dev/null +++ b/apps/app/src/hooks/thread-history-cache-effects.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryObserver } from "@tanstack/react-query"; +import type { ThreadChangeKind } from "@bb/domain"; +import { + createThreadHistoryPage, + getThreadHistoryGeneration, + type ThreadHistoryChain, +} from "./cache-owners/thread-history-cache-owner"; +import { + createFlushOncePredicate, + executeRealtimeDirtyHandlers, + REALTIME_PROJECT_CHANGE_REGISTRY, + REALTIME_SYSTEM_CHANGE_REGISTRY, + REALTIME_THREAD_CHANGE_REGISTRY, +} from "./cache-owners/realtime-cache-registry"; +import { + invalidateRealtimeQueriesAfterServerReconnect, + invalidateRealtimeQueriesFetchedBeforeInitialConnect, +} from "./cache-owners/system-cache-effects"; +import { + invalidateThreadHistoryRewriteQueries, + removeThreadScopedQueries, +} from "./cache-owners/mutation-cache-effects"; +import { applyProjectDeleteResult } from "./cache-owners/project-cache-owner"; +import { + sidebarNavigationQueryKey, + threadDetailBootstrapQueryKey, + threadHistoryQueryKey, + threadQueryKey, + threadsQueryKey, + threadTimelineQueryKey, +} from "./queries/query-keys"; + +function historyChain(validatedAt: number[] = [1]): ThreadHistoryChain { + return { + surfaceKey: "default", + pages: validatedAt.map((timestamp) => + createThreadHistoryPage( + { + rows: [], + contextBoundarySeq: null, + completedTurnDisplay: "collapse", + activePromptMode: null, + activeThinking: null, + activeWorkflows: [], + activeBackgroundCommands: [], + pendingTodos: null, + goal: null, + modelFallback: null, + maxSeq: 1, + timelinePage: { + kind: "latest", + segmentLimit: 20, + returnedSegmentCount: 0, + hasOlderRows: false, + olderCursor: null, + }, + }, + null, + timestamp, + ), + ), + }; +} + +function historyKey(threadId: string) { + return threadHistoryQueryKey(threadId, "default", 20); +} + +function queryClient() { + return new QueryClient({ + defaultOptions: { queries: { gcTime: Infinity, retry: false } }, + }); +} + +function applyThreadChange(client: QueryClient, change: ThreadChangeKind) { + executeRealtimeDirtyHandlers({ + context: { + queryClient: client, + threadId: "thread-1", + projectId: "project-1", + backgroundActivityChanged: undefined, + eventTypes: ["turn/completed"] as const, + flushOnce: createFlushOncePredicate(), + hasPendingInteraction: undefined, + statusChange: undefined, + }, + handlers: REALTIME_THREAD_CHANGE_REGISTRY[change].dirty, + }); +} + +describe("thread history cache effects", () => { + it.each([ + "history-rewritten", + "title-changed", + "environment-changed", + ] as const)( + "refreshes history and latest data after %s without clearing readable rows", + async (change) => { + const client = queryClient(); + const data = historyChain(); + client.setQueryData(historyKey("thread-1"), data); + client.setQueryData( + threadTimelineQueryKey("thread-1"), + data.pages[0]!.response, + ); + const generation = getThreadHistoryGeneration(client, "thread-1"); + + applyThreadChange(client, change); + + expect(generation.request).toBe(1); + await vi.waitFor(() => + expect( + client.getQueryState(historyKey("thread-1"))?.isInvalidated, + ).toBe(true), + ); + expect(client.getQueryData(historyKey("thread-1"))).toBe(data); + expect( + client.getQueryState(threadTimelineQueryKey("thread-1"))?.isInvalidated, + ).toBe(true); + client.clear(); + }, + ); + + it("keeps completed turns from rebuilding active historical pages", async () => { + const client = queryClient(); + const data = historyChain(); + const key = historyKey("thread-1"); + client.setQueryData(key, data); + const fetchHistory = vi.fn(async () => data); + const observer = new QueryObserver(client, { + queryKey: key, + queryFn: fetchHistory, + staleTime: Infinity, + }); + const unsubscribe = observer.subscribe(() => {}); + const generation = getThreadHistoryGeneration(client, "thread-1"); + + applyThreadChange(client, "events-appended"); + await Promise.resolve(); + + expect(fetchHistory).not.toHaveBeenCalled(); + expect(generation.request).toBe(0); + expect(client.getQueryState(key)?.isInvalidated).toBe(false); + unsubscribe(); + client.clear(); + }); + + it("invalidates retained history for rendering configuration changes and local rewrites", async () => { + const client = queryClient(); + client.setQueryData(historyKey("thread-1"), historyChain()); + client.setQueryData(historyKey("thread-2"), historyChain()); + const first = getThreadHistoryGeneration(client, "thread-1"); + const second = getThreadHistoryGeneration(client, "thread-2"); + + invalidateThreadHistoryRewriteQueries({ + queryClient: client, + threadId: "thread-1", + }); + await vi.waitFor(() => + expect(client.getQueryState(historyKey("thread-1"))?.isInvalidated).toBe( + true, + ), + ); + expect(client.getQueryState(historyKey("thread-2"))?.isInvalidated).toBe( + false, + ); + expect(first.request).toBe(1); + + executeRealtimeDirtyHandlers({ + context: { queryClient: client }, + handlers: REALTIME_SYSTEM_CHANGE_REGISTRY["config-changed"].dirty, + }); + + await vi.waitFor(() => + expect(client.getQueryState(historyKey("thread-2"))?.isInvalidated).toBe( + true, + ), + ); + expect(second.request).toBe(1); + client.clear(); + }); + + it.each(["reconnect", "initial connect"] as const)( + "uses page validation times on %s even after a newer page was appended", + async (event) => { + const client = queryClient(); + const timestamp = Date.now(); + const staleKey = historyKey("thread-1"); + const freshKey = historyKey("thread-2"); + client.setQueryData( + staleKey, + historyChain([timestamp - 500, timestamp + 500]), + { updatedAt: timestamp + 500 }, + ); + client.setQueryData(freshKey, historyChain([timestamp + 500]), { + updatedAt: timestamp + 500, + }); + + if (event === "reconnect") { + invalidateRealtimeQueriesAfterServerReconnect({ + queryClient: client, + disconnectedAt: timestamp, + }); + } else { + invalidateRealtimeQueriesFetchedBeforeInitialConnect({ + queryClient: client, + connectedAt: timestamp, + }); + } + + await vi.waitFor(() => + expect(client.getQueryState(staleKey)?.isInvalidated).toBe(true), + ); + expect(client.getQueryState(freshKey)?.isInvalidated).toBe(false); + client.clear(); + }, + ); + + it.each(["local", "realtime"] as const)( + "purges only deleted thread history through %s deletion", + (source) => { + const client = queryClient(); + client.setQueryData(historyKey("thread-1"), historyChain()); + client.setQueryData(historyKey("thread-2"), historyChain()); + const generation = getThreadHistoryGeneration(client, "thread-1"); + + if (source === "local") + removeThreadScopedQueries({ + queryClient: client, + threadId: "thread-1", + }); + else applyThreadChange(client, "thread-deleted"); + + expect(client.getQueryData(historyKey("thread-1"))).toBeUndefined(); + expect(client.getQueryData(historyKey("thread-2"))).toBeDefined(); + expect(generation.eviction).toBe(1); + expect(generation.blocked).toBe(true); + client.clear(); + }, + ); + + it.each(["local", "realtime"] as const)( + "targets project history from existing cached ownership during %s deletion", + (source) => { + const client = queryClient(); + const affectedIds = ["detail", "bootstrap", "list", "sidebar"]; + for (const id of [...affectedIds, "other"]) + client.setQueryData(historyKey(id), historyChain()); + client.setQueryData(threadQueryKey("detail"), { + id: "detail", + projectId: "project-1", + }); + client.setQueryData(threadDetailBootstrapQueryKey("bootstrap"), { + id: "bootstrap", + projectId: "project-1", + }); + client.setQueryData(threadsQueryKey(), { + pages: [ + [ + { id: "list", projectId: "project-1" }, + { id: "other", projectId: "project-2" }, + ], + ], + pageParams: [null], + }); + client.setQueryData(sidebarNavigationQueryKey(), { + projects: [ + { + id: "project-1", + threads: [{ id: "sidebar", projectId: "project-1" }], + }, + ], + personalProject: { id: "personal", threads: [] }, + }); + + if (source === "local") { + applyProjectDeleteResult({ + queryClient: client, + projectId: "project-1", + }); + } else { + executeRealtimeDirtyHandlers({ + context: { queryClient: client, projectId: "project-1" }, + handlers: REALTIME_PROJECT_CHANGE_REGISTRY["project-deleted"].dirty, + }); + } + + for (const id of affectedIds) { + expect(client.getQueryData(historyKey(id))).toBeUndefined(); + expect(getThreadHistoryGeneration(client, id).blocked).toBe(true); + } + expect(client.getQueryData(historyKey("other"))).toBeDefined(); + client.clear(); + }, + ); +}); diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index b0604c451b7..87a2f51d81e 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -867,8 +867,14 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { contextWindowUsage, goal, hasOlderTimelineRows, + historyRefreshError, + historyUnrefreshed, + historyReplacementKey, isLoadingOlderTimelineRows, + isRefreshingHistory, loadOlderTimelineRows, + refreshHistory, + showLatestTimeline, modelFallback, pendingTodos, timelineError, @@ -2956,8 +2962,12 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { contextBoundarySeq, threadOriginKind, hasOlderTimelineRows, + historyRefreshError, + historyUnrefreshed, + historyReplacementKey, hostConnectionNotice, isLoadingOlderTimelineRows, + isRefreshingHistory, isThreadTimelinePending, timelineError: Boolean(timelineError), onForkMessage: isForkAvailable ? handleForkMessage : undefined, @@ -2969,6 +2979,8 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { onSendToMainMessage: handleSendToMainMessage, onSelectionAddToChat: handleSelectionAddToChat, onLoadOlderRows: loadOlderTimelineRows, + onRefreshHistory: refreshHistory, + onShowLatestTimeline: showLatestTimeline, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, onOpenPluginPanel: handleOpenTimelinePluginPanel, diff --git a/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx b/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx new file mode 100644 index 00000000000..b08c3f763bb --- /dev/null +++ b/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, expect, it, vi } from "vitest"; +import { BottomAnchorContext } from "@/components/ui/bottom-anchored-scroll-body"; +import { ThreadTimelineLatestContext } from "@/components/thread/timeline/ThreadTimelineLatestContext"; +import { ThreadTimelineScrollToBottomButton } from "./ThreadTimelineScrollToBottomButton"; + +afterEach(cleanup); + +it("switches held history to the latest rows before scrolling the footer to the bottom", () => { + const displayedAtScroll: string[] = []; + const showLatestTimeline = vi.fn(); + const bottomAnchor = { + captureScrollAnchor: vi.fn(), + getScrollElement: () => null, + isAtBottom: true, + scrollElementIntoView: vi.fn(), + scrollElementIntoViewClampedToMaxScroll: vi.fn(), + scrollToBottom: () => { + displayedAtScroll.push(screen.getByTestId("rows").textContent ?? ""); + }, + }; + function Timeline() { + const [historyUnrefreshed, setHistoryUnrefreshed] = useState(true); + return ( + + { + showLatestTimeline(); + setHistoryUnrefreshed(false); + }, + }} + > +
+ {historyUnrefreshed ? "Saved window" : "Current latest window"} +
+ +
+
+ ); + } + render(); + + fireEvent.click( + screen.getByRole("button", { name: "Scroll to latest event" }), + ); + + expect(showLatestTimeline).toHaveBeenCalledTimes(1); + expect(displayedAtScroll).toEqual(["Current latest window"]); +}); diff --git a/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx b/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx index 2f8e0dd6a2c..cece8557ded 100644 --- a/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx +++ b/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx @@ -1,5 +1,7 @@ +import { useContext, useLayoutEffect, useState } from "react"; import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body.js"; import { ScrollToBottomButton } from "@/components/ui/scroll-to-bottom-button.js"; +import { ThreadTimelineLatestContext } from "@/components/thread/timeline/ThreadTimelineLatestContext"; export function ThreadTimelineScrollToBottomButton({ active, @@ -7,13 +9,31 @@ export function ThreadTimelineScrollToBottomButton({ active: boolean; }) { const bottomAnchor = useBottomAnchoredScroll(); + const latestTimeline = useContext(ThreadTimelineLatestContext); + const [scrollAfterReplacement, setScrollAfterReplacement] = useState(false); + + useLayoutEffect(() => { + if (!scrollAfterReplacement) return; + bottomAnchor?.scrollToBottom(); + setScrollAfterReplacement(false); + }, [bottomAnchor, scrollAfterReplacement]); + if (!bottomAnchor) return null; return ( { + if (latestTimeline?.historyUnrefreshed) { + latestTimeline.showLatestTimeline(); + setScrollAfterReplacement(true); + return; + } + bottomAnchor.scrollToBottom(); + }} /> ); } diff --git a/packages/client-core/src/timeline/timeline-merge.ts b/packages/client-core/src/timeline/timeline-merge.ts index b5cea2c0062..917ae499250 100644 --- a/packages/client-core/src/timeline/timeline-merge.ts +++ b/packages/client-core/src/timeline/timeline-merge.ts @@ -72,6 +72,15 @@ interface RecoverLoadedTimelineAfterStaleCursorArgs { surfaceKey: string; } +interface BuildLoadedTimelineFromPagesArgs { + pages: readonly ThreadTimelineResponse[]; + surfaceKey: string; +} + +interface ReconcileLoadedTimelineWithHistoryPagesArgs extends BuildLoadedTimelineFromPagesArgs { + current: LoadedTimelineState; +} + export function resolveLoadedTimelineSurfaceKey( baseSurfaceKey: string, latestTimeline: @@ -428,11 +437,11 @@ function loadedTimelineStateFromLatest( }; } -export function mergeLoadedTimelineWithLatest({ +export function tryMergeLoadedTimelineWithLatest({ current, latestTimeline, surfaceKey, -}: MergeLoadedTimelineWithLatestArgs): LoadedTimelineState { +}: MergeLoadedTimelineWithLatestArgs): LoadedTimelineState | null { const latestHistorySnapshot = latestTimeline.timelinePage.historySnapshot; if ( current.surfaceKey !== surfaceKey || @@ -440,7 +449,7 @@ export function mergeLoadedTimelineWithLatest({ (latestHistorySnapshot === undefined) || !timelineWindowsAreContiguous(current, latestTimeline) ) { - return loadedTimelineStateFromLatest(latestTimeline, surfaceKey); + return null; } const currentRowsById = new Map(current.rows.map((row) => [row.id, row])); @@ -470,7 +479,7 @@ export function mergeLoadedTimelineWithLatest({ latestTimeline, }); if (!latestMerge.canMerge) { - return loadedTimelineStateFromLatest(latestTimeline, surfaceKey); + return null; } return { @@ -485,6 +494,238 @@ export function mergeLoadedTimelineWithLatest({ }; } +export function mergeLoadedTimelineWithLatest( + args: MergeLoadedTimelineWithLatestArgs, +): LoadedTimelineState { + return ( + tryMergeLoadedTimelineWithLatest(args) ?? + loadedTimelineStateFromLatest(args.latestTimeline, args.surfaceKey) + ); +} + +export function buildLoadedTimelineFromPages({ + pages, + surfaceKey, +}: BuildLoadedTimelineFromPagesArgs): LoadedTimelineState | null { + const latest = pages[0]; + if (latest === undefined || latest.timelinePage.kind !== "latest") { + return null; + } + let rows = latest.rows; + let previous = latest; + for (const page of pages.slice(1)) { + const previousCursor = previous.timelinePage.olderCursor; + const nextCursor = page.timelinePage.olderCursor; + const previousContent = previous.timelinePage.contentPage; + const nextContent = page.timelinePage.contentPage; + if ( + previousCursor === null || + page.timelinePage.kind !== "older" || + page.timelinePage.historySnapshot !== + latest.timelinePage.historySnapshot || + page.completedTurnDisplay !== latest.completedTurnDisplay || + page.contextBoundarySeq !== latest.contextBoundarySeq || + page.maxSeq !== latest.maxSeq || + (nextCursor !== null && + (nextCursor.anchorSeq > previousCursor.anchorSeq || + areTimelinePaginationCursorsEqual({ + left: previousCursor, + right: nextCursor, + }))) || + (previousContent !== undefined && + previousContent.start > 0 && + nextContent?.anchorSeq === previousContent.anchorSeq && + (nextContent.end !== previousContent.start || + nextContent.total !== previousContent.total)) + ) { + return null; + } + rows = prependOlderTimelineRows({ loadedRows: rows, olderRows: page.rows }); + previous = page; + } + return { + ...loadedTimelineStateFromLatest(latest, surfaceKey, rows), + olderCursor: previous.timelinePage.olderCursor, + }; +} + +function timelineRowChildren(row: TimelineRow): readonly TimelineRow[] | null { + if (row.kind === "turn" && row.children?.length) return row.children; + if ( + row.kind === "work" && + row.workKind === "delegation" && + row.childRows.length + ) { + return row.childRows; + } + return null; +} + +function timelineRowWithChildren( + row: TimelineRow, + children: TimelineRow[], +): TimelineRow { + if (row.kind === "turn") return { ...row, children }; + if (row.kind === "work" && row.workKind === "delegation") { + return { ...row, childRows: children }; + } + return row; +} + +function preserveNestedTimelineRowIdentity({ + nextRows, + previousRows, +}: PreserveTimelineRowIdentityArgs): TimelineRow[] { + const previousById = new Map(previousRows.map((row) => [row.id, row])); + return preserveTimelineRowIdentity({ + previousRows, + nextRows: nextRows.map((row) => { + const previous = previousById.get(row.id); + if (previous === undefined || previous === row) return row; + const nextChildren = timelineRowChildren(row); + const previousChildren = timelineRowChildren(previous); + if (nextChildren === null || previousChildren === null) return row; + const children = preserveNestedTimelineRowIdentity({ + nextRows: nextChildren, + previousRows: previousChildren, + }); + return areTimelineRowReferencesEqual({ + left: nextChildren, + right: children, + }) + ? row + : timelineRowWithChildren(row, children); + }), + }); +} + +function retainTimelinePrefixBeforeLeaf( + rows: readonly TimelineRow[], + leafId: string, + contentStart: number, + anchorSeq: number, +): TimelineRow[] | null { + let reachedBoundary = false; + let retainedContentLeaves = 0; + const retain = ( + items: readonly TimelineRow[], + segmentSequence?: number, + ): TimelineRow[] => + items.flatMap((row) => { + if (reachedBoundary || isOptimisticTimelineRowId(row.id)) return []; + const sequence = segmentSequence ?? row.sourceSeqStart; + const children = timelineRowChildren(row); + if (children !== null) { + const retained = retain(children, sequence); + if (retained.length === 0) return []; + return [ + areTimelineRowReferencesEqual({ left: children, right: retained }) + ? row + : timelineRowWithChildren(row, retained), + ]; + } + if (row.id === leafId) { + reachedBoundary = true; + return []; + } + if (sequence >= anchorSeq) retainedContentLeaves += 1; + return [row]; + }); + const retained = retain(rows); + return reachedBoundary && retainedContentLeaves === contentStart + ? retained + : null; +} + +function firstTimelineLeaf( + rows: readonly TimelineRow[], +): TimelineRow | undefined { + const first = rows[0]; + if (first === undefined) return undefined; + const children = timelineRowChildren(first); + return children === null ? first : firstTimelineLeaf(children); +} + +export function reconcileLoadedTimelineWithHistoryPages({ + current, + pages, + surfaceKey, +}: ReconcileLoadedTimelineWithHistoryPagesArgs): LoadedTimelineState | null { + const replacement = buildLoadedTimelineFromPages({ pages, surfaceKey }); + const oldest = pages.at(-1); + const latest = pages[0]; + if (replacement === null || oldest === undefined || latest === undefined) + return null; + if (current.rows.length === 0) return replacement; + if (current.surfaceKey !== surfaceKey) return null; + const combined = { + ...latest, + rows: replacement.rows, + timelinePage: { ...oldest.timelinePage, kind: "latest" as const }, + }; + if ( + (current.historySnapshot === undefined) !== + (replacement.historySnapshot === undefined) || + !timelineWindowsAreContiguous(current, combined) + ) { + return null; + } + const { contentPage, olderRowsSourceSeqEnd } = oldest.timelinePage; + const partialBoundary = contentPage !== undefined && contentPage.start > 0; + const coversCurrent = + replacement.olderCursor === null || + (!partialBoundary && + current.olderCursor !== null && + replacement.olderCursor.anchorSeq <= current.olderCursor.anchorSeq); + if ( + !coversCurrent && + current.historySnapshot !== replacement.historySnapshot && + (olderRowsSourceSeqEnd === undefined || + (olderRowsSourceSeqEnd !== null && + olderRowsSourceSeqEnd > (current.latestWindowEndSequence ?? 0))) + ) { + return null; + } + let rows = replacement.rows; + if (!coversCurrent && partialBoundary) { + if ( + current.olderCursor !== null && + current.olderCursor.anchorSeq >= contentPage.anchorSeq + ) { + return null; + } + const firstLeaf = firstTimelineLeaf(rows); + if (firstLeaf === undefined) return null; + const prefix = retainTimelinePrefixBeforeLeaf( + current.rows, + firstLeaf.id, + contentPage.start, + contentPage.anchorSeq, + ); + if (prefix === null) return null; + rows = prependOlderTimelineRows({ olderRows: prefix, loadedRows: rows }); + } else if (!coversCurrent) { + const merge = mergeLatestTimelineRows({ + latestRows: rows, + loadedRows: current.rows, + latestWindowStartSequence: timelineWindowStartSequence(combined), + }); + if (!merge.canMerge) return null; + rows = merge.rows; + } + rows = preserveNestedTimelineRowIdentity({ + nextRows: rows, + previousRows: current.rows, + }); + return { + ...replacement, + olderCursor: coversCurrent ? replacement.olderCursor : current.olderCursor, + rows: areTimelineRowReferencesEqual({ left: current.rows, right: rows }) + ? current.rows + : rows, + }; +} + export function recoverLoadedTimelineAfterStaleCursor({ current, latestTimeline, diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts index 13ed0f86631..f9ef36ff149 100644 --- a/packages/client-core/test/timeline-merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -3,16 +3,20 @@ import { applyTimelineDelta } from "@bb/server-contract"; import type { ThreadTimelineResponse, TimelineCommandWorkRow, + TimelineDelegationWorkRow, TimelinePaginationCursor, TimelineRow, TimelineTurnRow, TimelineUserConversationRow, } from "@bb/server-contract"; import { + buildLoadedTimelineFromPages, mergeLoadedTimelineWithLatest, mergeLatestTimelineRows, prependOlderTimelineRows, recoverLoadedTimelineAfterStaleCursor, + reconcileLoadedTimelineWithHistoryPages, + tryMergeLoadedTimelineWithLatest, type LoadedTimelineState, } from "../src/timeline/timeline-merge.js"; @@ -102,6 +106,32 @@ function turnSummaryRow(args: TimelineTurnTestRowArgs): TimelineTurnRow { }; } +function delegationRow( + args: TimelineTurnTestRowArgs, +): TimelineDelegationWorkRow { + return { + id: args.id, + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: args.sequence, + sourceSeqEnd: args.endSequence ?? args.sequence, + startedAt: args.sequence, + createdAt: args.sequence, + kind: "work", + workKind: "delegation", + status: "completed", + callId: args.id, + toolName: "agent", + childRef: "child-thread", + background: false, + subagentType: null, + description: null, + output: "", + completedAt: args.sequence, + childRows: args.children ?? [], + }; +} + function makeTimelineResponse( rows: TimelineRow[], olderCursor: TimelinePaginationCursor | null, @@ -987,3 +1017,374 @@ describe("snapshot content pagination", () => { ).toEqual([{ ...summary, children: [child] }]); }); }); + +describe("retained history refresh", () => { + const surfaceKey = "thread-1:default"; + + function snapshotResponse( + rows: TimelineRow[], + olderCursor: TimelinePaginationCursor | null, + maxSeq = 30, + ): ThreadTimelineResponse { + const response = makeTimelineResponse(rows, olderCursor, maxSeq); + response.timelinePage.historySnapshot = "fresh"; + response.timelinePage.olderRowsSourceSeqEnd = null; + return response; + } + + it("assembles contiguous leaf pages without dropping split nested content", () => { + const children = [11, 12, 13, 14].map((sequence) => + commandRow({ id: `command-${sequence}`, sequence }), + ); + const summary = turnSummaryRow({ + id: "summary", + sequence: 10, + endSequence: 20, + children, + }); + const latest = snapshotResponse( + [{ ...summary, children: children.slice(2) }], + timelineCursor({ id: "leaf-2", sequence: 10 }), + ); + latest.timelinePage.contentPage = { + anchorSeq: 10, + start: 2, + end: 4, + total: 4, + }; + latest.timelinePage.segmentLimit = 8; + const older = snapshotResponse( + [{ ...summary, children: children.slice(0, 2) }], + null, + ); + older.timelinePage.kind = "older"; + older.timelinePage.contentPage = { + anchorSeq: 10, + start: 0, + end: 2, + total: 4, + }; + + const result = buildLoadedTimelineFromPages({ + pages: [latest, older], + surfaceKey, + }); + + expect(result?.rows).toEqual([summary]); + expect(result?.olderCursor).toBeNull(); + expect(result?.historySnapshot).toBe("fresh"); + expect( + buildLoadedTimelineFromPages({ + pages: [ + latest, + { + ...older, + timelinePage: { ...older.timelinePage, historySnapshot: "old" }, + }, + ], + surfaceKey, + }), + ).toBeNull(); + expect( + buildLoadedTimelineFromPages({ + pages: [ + latest, + { + ...older, + timelinePage: { + ...older.timelinePage, + contentPage: { anchorSeq: 10, start: 0, end: 1, total: 4 }, + }, + }, + ], + surfaceKey, + }), + ).toBeNull(); + }); + + it("replaces covered deletions and edits while retaining deeper rows and unchanged child identity", () => { + const deep = userRow({ id: "deep", sequence: 1 }); + const unchanged = commandRow({ id: "unchanged", sequence: 11 }); + const removed = commandRow({ id: "removed", sequence: 12 }); + const edited = commandRow({ id: "edited", sequence: 13 }); + const summary = turnSummaryRow({ + id: "summary", + sequence: 10, + endSequence: 15, + children: [unchanged, removed, edited], + }); + const tail = userRow({ id: "tail", sequence: 20 }); + const current = { + ...makeLoadedTimelineState( + [deep, summary, commandRow({ id: "deleted-row", sequence: 19 }), tail], + timelineCursor({ id: "deep-cursor", sequence: 1 }), + 20, + ), + historySnapshot: "old", + }; + const fresh = snapshotResponse( + [ + { + ...summary, + children: [{ ...unchanged }, { ...edited, output: "new output" }], + }, + { ...tail }, + ], + timelineCursor({ id: "fresh-cursor", sequence: 10 }), + ); + + const result = reconcileLoadedTimelineWithHistoryPages({ + current, + pages: [fresh], + surfaceKey, + }); + + expect(result?.rows.map((row) => row.id)).toEqual([ + "deep", + "summary", + "tail", + ]); + expect(result?.rows[0]).toBe(deep); + expect(result?.rows[2]).toBe(tail); + const refreshedSummary = result?.rows[1]; + expect(refreshedSummary?.kind).toBe("turn"); + if (refreshedSummary?.kind !== "turn") throw new Error("Expected summary"); + expect(refreshedSummary.children?.map((row) => row.id)).toEqual([ + "unchanged", + "edited", + ]); + expect(refreshedSummary.children?.[0]).toBe(unchanged); + expect(refreshedSummary.children?.[1]).toMatchObject({ + output: "new output", + }); + expect(result?.olderCursor).toBe(current.olderCursor); + }); + + it.each(["turn", "delegation"] as const)( + "preserves uncovered %s leaves while replacing the authoritative suffix", + (kind) => { + const prefix = commandRow({ id: "prefix", sequence: 11 }); + const edited = commandRow({ id: "edited", sequence: 12 }); + const deleted = commandRow({ id: "deleted", sequence: 13 }); + const unchanged = commandRow({ id: "unchanged", sequence: 14 }); + const makeRow = kind === "turn" ? turnSummaryRow : delegationRow; + const current = { + ...makeLoadedTimelineState( + [ + makeRow({ + id: "nested", + sequence: 10, + endSequence: 20, + children: [prefix, edited, deleted, unchanged], + }), + ], + null, + 20, + ), + historySnapshot: "old", + }; + const fresh = snapshotResponse( + [ + makeRow({ + id: "nested", + sequence: 10, + endSequence: 20, + children: [{ ...edited, output: "new" }, { ...unchanged }], + }), + ], + timelineCursor({ id: "fresh-content", sequence: 10 }), + ); + fresh.timelinePage.contentPage = { + anchorSeq: 10, + start: 1, + end: 3, + total: 3, + }; + fresh.timelinePage.olderRowsSourceSeqEnd = prefix.sourceSeqEnd; + + const result = reconcileLoadedTimelineWithHistoryPages({ + current, + pages: [fresh], + surfaceKey, + }); + const row = result?.rows[0]; + const children = + row?.kind === "turn" + ? row.children + : row?.kind === "work" && row.workKind === "delegation" + ? row.childRows + : null; + + expect(children?.map((child) => child.id)).toEqual([ + "prefix", + "edited", + "unchanged", + ]); + expect(children?.[0]).toBe(prefix); + expect(children?.[1]).toMatchObject({ output: "new" }); + expect(children?.[2]).toBe(unchanged); + expect(result?.olderCursor).toBeNull(); + }, + ); + + it("refuses a shifted partial boundary instead of retaining a deleted leaf", () => { + const children = [11, 12, 13].map((sequence) => + commandRow({ id: `child-${sequence}`, sequence }), + ); + const current = { + ...makeLoadedTimelineState( + [ + turnSummaryRow({ + id: "summary", + sequence: 10, + endSequence: 20, + children, + }), + ], + null, + 20, + ), + historySnapshot: "old", + }; + const fresh = snapshotResponse( + [ + turnSummaryRow({ + id: "summary", + sequence: 10, + endSequence: 20, + children: [children[2]!], + }), + ], + timelineCursor({ id: "fresh-content", sequence: 10 }), + ); + fresh.timelinePage.contentPage = { + anchorSeq: 10, + start: 1, + end: 2, + total: 2, + }; + fresh.timelinePage.olderRowsSourceSeqEnd = children[0]!.sourceSeqEnd; + + expect( + reconcileLoadedTimelineWithHistoryPages({ + current, + pages: [fresh], + surfaceKey, + }), + ).toBeNull(); + expect(current.rows[0]).toMatchObject({ children }); + }); + + it("refuses a partial prefix the current window has not fully loaded", () => { + const child = commandRow({ id: "child", sequence: 13 }); + const row = turnSummaryRow({ + id: "summary", + sequence: 10, + children: [child], + }); + const cursor = timelineCursor({ id: "old-content", sequence: 10 }); + const current = { + ...makeLoadedTimelineState([row], cursor, 20), + historySnapshot: "old", + }; + const fresh = snapshotResponse( + [row], + timelineCursor({ id: "fresh-content", sequence: 10 }), + ); + fresh.timelinePage.contentPage = { + anchorSeq: 10, + start: 2, + end: 3, + total: 3, + }; + + expect( + reconcileLoadedTimelineWithHistoryPages({ + current, + pages: [fresh], + surfaceKey, + }), + ).toBeNull(); + }); + + it("reports a gap without changing the legacy fallback or detached rows", () => { + const current = { + ...makeLoadedTimelineState( + [userRow({ id: "old", sequence: 1 })], + null, + 10, + ), + historySnapshot: "old", + }; + const latestTimeline = snapshotResponse( + [userRow({ id: "fresh", sequence: 20 })], + timelineCursor({ id: "fresh-cursor", sequence: 20 }), + ); + + expect( + tryMergeLoadedTimelineWithLatest({ current, latestTimeline, surfaceKey }), + ).toBeNull(); + expect( + reconcileLoadedTimelineWithHistoryPages({ + current, + pages: [latestTimeline], + surfaceKey, + }), + ).toBeNull(); + expect( + mergeLoadedTimelineWithLatest({ current, latestTimeline, surfaceKey }) + .rows, + ).toBe(latestTimeline.rows); + expect(current.rows.map((row) => row.id)).toEqual(["old"]); + }); + + it("refuses a splice when the refreshed snapshot changed uncovered history", () => { + const latest = userRow({ id: "latest", sequence: 10 }); + const current = { + ...makeLoadedTimelineState( + [userRow({ id: "older", sequence: 1 }), latest], + null, + 20, + ), + historySnapshot: "old", + }; + const fresh = snapshotResponse( + [latest], + timelineCursor({ id: "fresh-cursor", sequence: 10 }), + ); + fresh.timelinePage.olderRowsSourceSeqEnd = 21; + + expect( + reconcileLoadedTimelineWithHistoryPages({ + current, + pages: [fresh], + surfaceKey, + }), + ).toBeNull(); + }); + + it("keeps the current array when a coherent refreshed window is unchanged", () => { + const row = userRow({ id: "same", sequence: 1 }); + const current = { + ...makeLoadedTimelineState([row], null, 30), + historySnapshot: "old", + }; + const fresh = snapshotResponse([{ ...row }], null); + + expect( + reconcileLoadedTimelineWithHistoryPages({ + current, + pages: [fresh], + surfaceKey, + })?.rows, + ).toBe(current.rows); + expect(buildLoadedTimelineFromPages({ pages: [], surfaceKey })).toBeNull(); + expect( + reconcileLoadedTimelineWithHistoryPages({ + current: makeLoadedTimelineState([], null, 0), + pages: [fresh], + surfaceKey, + })?.rows, + ).toBe(fresh.rows); + }); +}); From ba30ba6ac4ec6819b8f48d5764594219a61967f0 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:11:46 -0400 Subject: [PATCH 05/23] Settle history pagination after cache admission --- .../useThreadTimelineController.test.tsx | 31 ++++++++++ .../timeline/useThreadTimelineController.ts | 9 ++- ...d-scroll-body.scroll-preservation.test.tsx | 27 +++++++++ .../cache-owners/cache-owner-registry.test.ts | 13 ++++ .../thread-history-cache-owner.ts | 12 +++- .../queries/thread-history-query.test.tsx | 6 +- .../src/hooks/queries/thread-history-query.ts | 60 +++++++++++++++---- 7 files changed, 142 insertions(+), 16 deletions(-) diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index c14668ac9cf..9a1a6e101bb 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -1167,6 +1167,37 @@ describe("retained thread history", () => { expect(result.current.timeline.historyUnrefreshed).toBe(false); }); + it("loads a cache miss when a detached scroll anchor remains after history eviction", async () => { + const latest = createDeferredPromise(); + vi.mocked(sdk.threads.timeline).mockReturnValueOnce(latest.promise); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => ({ + timeline: useThreadTimelineController({ threadId: "thread-1" }), + store: useStore(), + }), + { wrapper }, + ); + act(() => + result.current.store.set( + threadTimelineScrollAnchorAtomFamily("thread-1"), + { + rowId: olderPageRow.id, + offsetWithinRow: 12, + atBottom: false, + }, + ), + ); + expect(result.current.timeline.timelineLoading).toBe(true); + latest.resolve( + makeTimelineResponse({ rows: [newestLoadedRow], maxSeq: 1 }), + ); + await waitFor(() => + expect(rowIds(result.current.timeline)).toEqual([newestLoadedRow.id]), + ); + expect(result.current.timeline.historyUnrefreshed).toBe(false); + }); + it("clears controller-held rows when history access is revoked", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); seedHistory(queryClient); diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index d7e22877bed..6064ab7b215 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -144,8 +144,9 @@ export function useThreadTimelineController({ }); if (merged) return { loaded: merged, unrefreshed: false }; if ( + loaded.rows.length > 0 && store.get(threadTimelineScrollAnchorAtomFamily(threadId))?.atBottom === - false + false ) { return { loaded, unrefreshed: true }; } @@ -249,7 +250,7 @@ export function useThreadTimelineController({ }); if (merged) { loaded = merged; - } else if (!detached) { + } else if (!detached || loaded.rows.length === 0) { loaded = mergeLoadedTimelineWithLatest({ current: loaded, latestTimeline, @@ -277,7 +278,8 @@ export function useThreadTimelineController({ const hasOlderTimelineRows = nextOlderCursor !== null; const loadOlder = history.loadOlder; const loadOlderTimelineRows = useCallback(async (): Promise => { - if (!enabled || !nextOlderCursor || !threadId || blocked) return; + if (!enabled || !latestTimeline || !nextOlderCursor || !threadId || blocked) + return; const response = await loadOlder(nextOlderCursor); if (!response) return; setTracker((previous) => { @@ -305,6 +307,7 @@ export function useThreadTimelineController({ }); }, [ enabled, + latestTimeline, nextOlderCursor, threadId, blocked, diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index b4349293021..7d1127abe5f 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -294,6 +294,23 @@ function renderReplacementTimeline() { configurable: true, value: SCROLL_AREA_HEIGHT, }); + let scrollTop = scrollArea.scrollTop; + Object.defineProperty(scrollArea, "scrollTop", { + configurable: true, + get: () => { + scrollTop = Math.max( + 0, + Math.min(scrollTop, scrollArea.scrollHeight - scrollArea.clientHeight), + ); + return scrollTop; + }, + set: (value: number) => { + scrollTop = Math.max( + 0, + Math.min(value, scrollArea.scrollHeight - scrollArea.clientHeight), + ); + }, + }); vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( function (this: HTMLElement) { if (this.dataset.modelTop !== undefined) { @@ -475,6 +492,16 @@ describe("BottomAnchoredScrollBody scroll preservation", () => { act(() => getLatestResizeObserver().trigger()); expect(view.scrollArea.scrollTop).toBe(200); + + view.replace([ + { id: "a", height: 100 }, + { id: "b", height: 100 }, + { id: "c", height: 100 }, + { id: "d", height: 200 }, + ]); + act(() => getLatestResizeObserver().trigger()); + + expect(view.scrollArea.scrollTop).toBe(400); }); it("shows the thread scrollbar only while scroll events are active", () => { diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index d61247921c9..ff8f67fe8b9 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -107,8 +107,12 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadsQueryKey", ], "hooks/cache-owners/project-cache-owner.ts": [ + "allThreadDetailBootstrapQueryKeyPrefix", + "allThreadQueryKeyPrefix", "projectsQueryKey", "sidebarNavigationQueryKey", + "threadHistoryQueryKeyPrefix", + "threadsQueryKey", ], "hooks/cache-owners/query-cache.ts": [ "ARCHIVED_THREADS_LIST_KIND", @@ -213,6 +217,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "serverMoveStatusQueryKey", "sidebarNavigationQueryKey", "systemConfigQueryKey", + "threadHistoryQueryKeyPrefix", "threadPromptHistoryQueryKeyPrefix", "threadSearchQueryKeyPrefix", "threadsQueryKey", @@ -238,6 +243,14 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "hostsQueryKey", "threadQueryKey", ], + "hooks/cache-owners/thread-history-cache-owner.ts": [ + "allThreadTimelineQueryKeyPrefix", + "threadDetailBootstrapQueryKey", + "threadHistoryQueryKeyPrefix", + "threadTimelineQueryKeyPrefix", + "THREAD_QUERY_KEY", + "THREAD_TIMELINE_QUERY_KEY", + ], "hooks/cache-owners/thread-tabs-cache-owner.ts": ["threadTabsQueryKey"], "hooks/cache-owners/ui-preferences-cache-owner.ts": ["uiPreferencesQueryKey"], "hooks/cache-owners/thread-runtime-cache-owner.ts": [ diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts index e3ca2369ec1..add268c977f 100644 --- a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { isOptimisticTimelineRowId } from "@bb/client-core"; import { BbHttpError } from "@/lib/sdk"; import type { @@ -179,6 +179,16 @@ export async function invalidateThreadHistory( await args.queryClient.invalidateQueries(filters, { cancelRefetch: false }); } +export function cancelThreadHistoryRead({ + queryClient, + queryKey, +}: { + queryClient: QueryClient; + queryKey: QueryKey; +}): Promise { + return queryClient.cancelQueries({ queryKey, exact: true }); +} + export function removeThreadHistory(args: ThreadHistoryOwnerArgs): void { advanceThreadHistoryGeneration(args, true); const filters = { queryKey: threadHistoryQueryKeyPrefix(args.threadId) }; diff --git a/apps/app/src/hooks/queries/thread-history-query.test.tsx b/apps/app/src/hooks/queries/thread-history-query.test.tsx index 1918eef9987..fd904d9dc15 100644 --- a/apps/app/src/hooks/queries/thread-history-query.test.tsx +++ b/apps/app/src/hooks/queries/thread-history-query.test.tsx @@ -310,7 +310,7 @@ describe("useThreadHistory", () => { it("bounds reusable pages while returning deep foreground content", async () => { const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(50); - seedHistory(queryClient, [latest]); + const { key } = seedHistory(queryClient, [latest]); const { result } = renderHook( () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), { wrapper }, @@ -321,6 +321,10 @@ describe("useThreadHistory", () => { vi.mocked(sdk.threads.timeline).mockResolvedValueOnce(older); await act(async () => { expect(await result.current.loadOlder(cursor)).toBe(older); + expect(queryClient.getQueryState(key)?.fetchStatus).toBe("idle"); + expect( + queryClient.getQueryData(key)?.pages, + ).toHaveLength(Math.min(6 - sequence / 10, 5)); }); if (older.timelinePage.olderCursor) cursor = older.timelinePage.olderCursor; diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index 40edc225f20..734283177b2 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -16,6 +16,7 @@ import { import { BbHttpError, sdk } from "@/lib/sdk"; import { compactThreadHistory, + cancelThreadHistoryRead, createThreadHistoryPage, getThreadHistoryGeneration, pruneThreadHistory, @@ -36,6 +37,10 @@ interface ForegroundRead { promise: Promise; resolve: (response: ThreadTimelineResponse | undefined) => void; reject: (error: unknown) => void; + outcome: + | { status: "success"; response: ThreadTimelineResponse | undefined } + | { status: "error"; error: unknown } + | undefined; } interface HistoryReadState { @@ -107,13 +112,48 @@ export function useThreadHistory({ const identity = JSON.stringify(queryKey); const state = historyReadState(queryClient, identity); const subscribe = useCallback( - (listener: () => void) => queryClient.getQueryCache().subscribe(listener), - [queryClient], + (listener: () => void) => + queryClient.getQueryCache().subscribe((event) => { + if (JSON.stringify(event.query.queryKey) === identity) { + const foreground = state.foreground; + if ( + foreground && + (event.type === "removed" || + foreground.generation !== + getThreadHistoryGeneration(queryClient, threadId).request) + ) { + state.foreground = undefined; + foreground.resolve(undefined); + } else if ( + foreground?.outcome?.status === "success" && + event.type === "updated" && + event.action.type === "success" && + !event.action.manual + ) { + state.foreground = undefined; + foreground.resolve(foreground.outcome.response); + } else if ( + foreground && + event.type === "updated" && + event.action.type === "error" + ) { + if (event.action.error instanceof CancelledError) { + foreground.outcome = undefined; + } else if (foreground.outcome?.status === "error") { + state.foreground = undefined; + foreground.reject(foreground.outcome.error); + } + } + } + listener(); + }), + [queryClient, threadId, identity, state], ); const getGeneration = useCallback(() => { const owner = getThreadHistoryGeneration(queryClient, threadId); - return `${owner.eviction}:${owner.blocked}`; - }, [queryClient, threadId]); + const fetchStatus = queryClient.getQueryState(queryKey)?.fetchStatus; + return `${owner.eviction}:${owner.blocked}:${fetchStatus}`; + }, [queryClient, threadId, queryKey]); useSyncExternalStore(subscribe, getGeneration, getGeneration); const owner = getThreadHistoryGeneration(queryClient, threadId); const canRead = @@ -175,8 +215,7 @@ export function useThreadHistory({ response: ThreadTimelineResponse | undefined, ) => { if (!foreground) return; - if (state.foreground === foreground) state.foreground = undefined; - foreground.resolve(response); + foreground.outcome = { status: "success", response }; }; const assertCurrent = () => { if (signal.aborted || owner.request !== requestGeneration) { @@ -305,8 +344,7 @@ export function useThreadHistory({ } if (!isStaleCursor(error)) { if (foreground) { - if (state.foreground === foreground) state.foreground = undefined; - foreground.reject(error); + foreground.outcome = { status: "error", error }; } throw error; } @@ -334,8 +372,7 @@ export function useThreadHistory({ throw new CancelledError({ revert: true }); } if (foreground) { - if (state.foreground === foreground) state.foreground = undefined; - foreground.reject(recoveryError); + foreground.outcome = { status: "error", error: recoveryError }; } throw recoveryError; } @@ -385,12 +422,13 @@ export function useThreadHistory({ promise, resolve, reject, + outcome: undefined, }; state.refreshAfterForeground ||= queryClient.getQueryState(queryKey)?.fetchStatus === "fetching"; state.foreground = foreground; void (async () => { - await queryClient.cancelQueries({ queryKey, exact: true }); + await cancelThreadHistoryRead({ queryClient, queryKey }); if (owner.request !== foreground.generation) { if (state.foreground === foreground) state.foreground = undefined; foreground.resolve(undefined); From 382b1937ae4a42a0ae1b7e5cb277a1011e693858 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:15:57 -0400 Subject: [PATCH 06/23] Avoid extra commits when seeding cached history --- apps/app/src/hooks/queries/thread-history-query.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index 734283177b2..e8ed46c466d 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -151,7 +151,8 @@ export function useThreadHistory({ ); const getGeneration = useCallback(() => { const owner = getThreadHistoryGeneration(queryClient, threadId); - const fetchStatus = queryClient.getQueryState(queryKey)?.fetchStatus; + const fetchStatus = + queryClient.getQueryState(queryKey)?.fetchStatus ?? "idle"; return `${owner.eviction}:${owner.blocked}:${fetchStatus}`; }, [queryClient, threadId, queryKey]); useSyncExternalStore(subscribe, getGeneration, getGeneration); From df2c1ca8144780201048bd8a1898e1c150ff8de3 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 01:29:00 -0400 Subject: [PATCH 07/23] Realize restored history when virtual scrolling settles --- .../timeline/TimelineWindowedItems.test.tsx | 61 ++++++++++++++++++- .../thread/timeline/TimelineWindowedItems.tsx | 11 ++-- 2 files changed, 63 insertions(+), 9 deletions(-) diff --git a/apps/app/src/components/thread/timeline/TimelineWindowedItems.test.tsx b/apps/app/src/components/thread/timeline/TimelineWindowedItems.test.tsx index 28ed4bda9d4..f059248efc3 100644 --- a/apps/app/src/components/thread/timeline/TimelineWindowedItems.test.tsx +++ b/apps/app/src/components/thread/timeline/TimelineWindowedItems.test.tsx @@ -35,9 +35,36 @@ function rect(top: number, height: number): DOMRect { } class ResizeObserverStub implements ResizeObserver { - disconnect(): void {} - observe(): void {} - unobserve(): void {} + static instances: ResizeObserverStub[] = []; + readonly targets = new Set(); + readonly callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + ResizeObserverStub.instances.push(this); + } + disconnect(): void { + this.targets.clear(); + } + observe(target: Element): void { + this.targets.add(target); + } + unobserve(target: Element): void { + this.targets.delete(target); + } + trigger(target: Element, height: number): void { + this.callback( + [ + { + target, + contentRect: new DOMRect(0, 0, 320, height), + borderBoxSize: [{ blockSize: height, inlineSize: 320 }], + contentBoxSize: [{ blockSize: height, inlineSize: 320 }], + devicePixelContentBoxSize: [{ blockSize: height, inlineSize: 320 }], + }, + ], + this, + ); + } } function renderWindowedItems(options?: { @@ -90,6 +117,7 @@ function renderWindowedItems(options?: { } beforeEach(() => { + ResizeObserverStub.instances = []; itemHeights = new Map(); scrollElement = document.createElement("div"); document.body.append(scrollElement); @@ -236,6 +264,33 @@ describe("TimelineWindowedItems", () => { expect(screen.getByTestId("content-50")).toBeTruthy(); }); + it("realizes the viewport around a restored anchor when measurements settle without new data", async () => { + vi.useFakeTimers(); + renderWindowedItems({ alwaysMountedKeys: new Set(["row-50"]) }); + await act(async () => {}); + + scrollElement.scrollTop = 1_600; + fireEvent.scroll(scrollElement); + await act(async () => {}); + + expect(screen.getByTestId("content-50")).toBeTruthy(); + expect(screen.queryByTestId("content-51")).toBeNull(); + const anchor = screen.getByTestId("wrapper-50"); + const observer = ResizeObserverStub.instances.find((candidate) => + candidate.targets.has(anchor), + ); + if (!observer) throw new Error("Expected the anchor to be measured"); + + act(() => { + itemHeights.set(50, 33); + observer.trigger(anchor, 33); + }); + + expect(screen.getByTestId("content-51")).toBeTruthy(); + expect(screen.getByTestId("content-52")).toBeTruthy(); + expect(scrollElement.scrollTop).toBe(1_600); + }); + it("seeds its size model from measurements retained by the thread", async () => { const measurements = new Map([["row-50", 64]]); renderWindowedItems({ measurements }); diff --git a/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx index 23a2348a141..2b2f8230c0d 100644 --- a/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx +++ b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx @@ -29,7 +29,6 @@ const GET_NO_SCROLL_ELEMENT = () => null; interface ScrollSample { at: number; - fast: boolean; offset: number; } @@ -71,10 +70,10 @@ export function TimelineWindowedItems({ const [scrollRootUsable, setScrollRootUsable] = useState(true); const [scrollMargin, setScrollMargin] = useState(0); const [interactionPins, setInteractionPins] = useState([]); + const [fastScrolling, setFastScrolling] = useState(false); const containerElementRef = useRef(null); const scrollSampleRef = useRef({ at: 0, - fast: false, offset: 0, }); const windowingEnabled = configured && scrollRootUsable; @@ -144,7 +143,7 @@ export function TimelineWindowedItems({ ) => { const sample = scrollSampleRef.current; if (!scrolling) { - sample.fast = false; + setFastScrolling(false); sample.at = 0; sample.offset = instance.scrollOffset ?? sample.offset; return; @@ -154,9 +153,10 @@ export function TimelineWindowedItems({ const elapsed = sample.at === 0 ? 0 : now - sample.at; const distance = Math.abs(offset - sample.offset); const viewportSize = instance.scrollRect?.height ?? 0; - sample.fast = + setFastScrolling( (sample.at === 0 || elapsed <= 100) && - distance >= Math.max(200, viewportSize * 0.5); + distance >= Math.max(200, viewportSize * 0.5), + ); sample.at = now; sample.offset = offset; }, @@ -269,7 +269,6 @@ export function TimelineWindowedItems({ ); } - const fastScrolling = scrollSampleRef.current.fast; const virtualItemsByIndex = new Map( virtualizer.getVirtualItems().map((item) => [item.index, item]), ); From 0929a98faa7f6cfeffdaf795daf9a077a41598bb Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 02:23:18 -0400 Subject: [PATCH 08/23] Simplify history cache helpers and regression coverage --- .../hooks/cache-owners/project-cache-owner.ts | 16 +++---- .../thread-history-cache-owner.test.ts | 24 +--------- .../thread-history-cache-owner.ts | 12 +---- .../queries/thread-history-query.test.tsx | 16 ------- .../src/hooks/queries/thread-history-query.ts | 44 +++++-------------- apps/app/src/hooks/queries/thread-queries.ts | 2 +- .../thread-history-cache-effects.test.ts | 22 +--------- 7 files changed, 22 insertions(+), 114 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/project-cache-owner.ts b/apps/app/src/hooks/cache-owners/project-cache-owner.ts index 59bffc237ab..ee7067e94ce 100644 --- a/apps/app/src/hooks/cache-owners/project-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/project-cache-owner.ts @@ -158,10 +158,10 @@ export function applyProjectDeleteResult({ invalidateProjectDeleteQueries({ queryClient }); } -export function collectCachedThreadIdsForProject({ +export function removeProjectThreadHistory({ projectId, queryClient, -}: ApplyProjectDeleteResultArgs): string[] { +}: ApplyProjectDeleteResultArgs): void { const cachedHistoryIds = new Set( queryClient .getQueryCache() @@ -189,13 +189,9 @@ export function collectCachedThreadIdsForProject({ for (const thread of getCachedSidebarNavigationThreads(queryClient)) { if (thread.projectId === projectId) ids.add(thread.id); } - return [...ids].filter((id) => cachedHistoryIds.has(id)); -} - -export function removeProjectThreadHistory( - args: ApplyProjectDeleteResultArgs, -): void { - for (const threadId of collectCachedThreadIdsForProject(args)) { - removeThreadHistory({ queryClient: args.queryClient, threadId }); + for (const threadId of ids) { + if (cachedHistoryIds.has(threadId)) { + removeThreadHistory({ queryClient, threadId }); + } } } diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts index d4c7fe6c91e..c7ac7cc8fcf 100644 --- a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts +++ b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts @@ -1,5 +1,5 @@ import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { OPTIMISTIC_TIMELINE_ROW_ID_PREFIX } from "@bb/client-core"; import { createDeferredPromise } from "@bb/test-helpers"; import { BbHttpError } from "@/lib/sdk"; @@ -11,7 +11,6 @@ import { threadQueryKey, threadTimelineQueryKey, } from "../queries/query-keys"; -import { HEAVY_PAYLOAD_GC_TIME_MS } from "../queries/query-policies"; import { compactThreadHistory, createThreadHistoryPage, @@ -22,8 +21,6 @@ import { type ThreadHistoryChain, } from "./thread-history-cache-owner"; -afterEach(() => vi.useRealTimers()); - function chain(pageCount = 1): ThreadHistoryChain { return { surfaceKey: "thread-1:collapse", @@ -152,25 +149,6 @@ describe("thread history cache ownership", () => { queryClient.clear(); }); - it("uses the existing inactivity collection interval", async () => { - vi.useFakeTimers(); - const queryClient = new QueryClient(); - const key = threadHistoryQueryKey("thread-1", "surface", 20); - const observer = new QueryObserver(queryClient, { - queryKey: key, - initialData: chain(), - staleTime: Infinity, - gcTime: HEAVY_PAYLOAD_GC_TIME_MS, - }); - const unsubscribe = observer.subscribe(() => {}); - unsubscribe(); - await vi.advanceTimersByTimeAsync(HEAVY_PAYLOAD_GC_TIME_MS - 1); - expect(queryClient.getQueryData(key)).toBeDefined(); - await vi.advanceTimersByTimeAsync(1); - expect(queryClient.getQueryData(key)).toBeUndefined(); - queryClient.clear(); - }); - it("cancels a latest read started before eviction so its late success cannot unblock", async () => { const queryClient = new QueryClient(); const pending = diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts index add268c977f..e3ca2369ec1 100644 --- a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts @@ -1,4 +1,4 @@ -import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import type { QueryClient } from "@tanstack/react-query"; import { isOptimisticTimelineRowId } from "@bb/client-core"; import { BbHttpError } from "@/lib/sdk"; import type { @@ -179,16 +179,6 @@ export async function invalidateThreadHistory( await args.queryClient.invalidateQueries(filters, { cancelRefetch: false }); } -export function cancelThreadHistoryRead({ - queryClient, - queryKey, -}: { - queryClient: QueryClient; - queryKey: QueryKey; -}): Promise { - return queryClient.cancelQueries({ queryKey, exact: true }); -} - export function removeThreadHistory(args: ThreadHistoryOwnerArgs): void { advanceThreadHistoryGeneration(args, true); const filters = { queryKey: threadHistoryQueryKeyPrefix(args.threadId) }; diff --git a/apps/app/src/hooks/queries/thread-history-query.test.tsx b/apps/app/src/hooks/queries/thread-history-query.test.tsx index fd904d9dc15..91e98c47fa1 100644 --- a/apps/app/src/hooks/queries/thread-history-query.test.tsx +++ b/apps/app/src/hooks/queries/thread-history-query.test.tsx @@ -98,22 +98,6 @@ function seedHistory( } describe("useThreadHistory", () => { - it("returns fresh cached history immediately without a network read", () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); - const latest = page(30); - const { chain } = seedHistory(queryClient, [ - latest, - page(20, { kind: "older" }), - ]); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); - - expect(result.current.data).toBe(chain); - expect(sdk.threads.timeline).not.toHaveBeenCalled(); - }); - it("preserves the initial miss path without fetching latest twice", async () => { const latest = page(30); vi.mocked(sdk.threads.timeline).mockResolvedValue(latest); diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index e8ed46c466d..a43f4b0be40 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -16,7 +16,6 @@ import { import { BbHttpError, sdk } from "@/lib/sdk"; import { compactThreadHistory, - cancelThreadHistoryRead, createThreadHistoryPage, getThreadHistoryGeneration, pruneThreadHistory, @@ -87,10 +86,6 @@ function isAccessFailure(error: unknown): error is BbHttpError { ); } -function oldestSequence(chain: ThreadHistoryChain): number | undefined { - return chain.pages.at(-1)?.response.rows[0]?.sourceSeqStart; -} - interface UseThreadHistoryArgs { threadId: string; latestTimeline: ThreadTimelineResponse | undefined; @@ -245,7 +240,8 @@ export function useThreadHistory({ } const retained = current && compactThreadHistory(current); const targetPages = retained?.pages.length ?? 1; - const targetSequence = retained && oldestSequence(retained); + const targetSequence = + retained?.pages.at(-1)?.response.rows[0]?.sourceSeqStart; const pages = [ createThreadHistoryPage( latest, @@ -334,48 +330,30 @@ export function useThreadHistory({ } return await rebuild(); } catch (error) { - if (isAccessFailure(error)) { - removeThreadHistory({ queryClient, threadId, error }); - finishForeground(undefined); - throw error; - } - if (signal.aborted || owner.request !== requestGeneration) { - if (owner.request !== requestGeneration) finishForeground(undefined); - throw new CancelledError({ revert: true }); - } - if (!isStaleCursor(error)) { - if (foreground) { - foreground.outcome = { status: "error", error }; - } - throw error; - } try { + if (!isStaleCursor(error)) throw error; const rebuilt = await rebuild(); finishForeground(undefined); return rebuilt; - } catch (recoveryError) { - if (isAccessFailure(recoveryError)) { - removeThreadHistory({ - queryClient, - threadId, - error: recoveryError, - }); + } catch (readError) { + if (isAccessFailure(readError)) { + removeThreadHistory({ queryClient, threadId, error: readError }); finishForeground(undefined); - throw recoveryError; + throw readError; } if ( signal.aborted || owner.request !== requestGeneration || - recoveryError instanceof CancelledError + readError instanceof CancelledError ) { if (owner.request !== requestGeneration) finishForeground(undefined); throw new CancelledError({ revert: true }); } if (foreground) { - foreground.outcome = { status: "error", error: recoveryError }; + foreground.outcome = { status: "error", error: readError }; } - throw recoveryError; + throw readError; } } finally { pruneThreadHistory(queryClient); @@ -429,7 +407,7 @@ export function useThreadHistory({ queryClient.getQueryState(queryKey)?.fetchStatus === "fetching"; state.foreground = foreground; void (async () => { - await cancelThreadHistoryRead({ queryClient, queryKey }); + await queryClient.cancelQueries({ queryKey, exact: true }); if (owner.request !== foreground.generation) { if (state.foreground === foreground) state.foreground = undefined; foreground.resolve(undefined); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 670052d3b40..beb426cadcf 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -905,7 +905,7 @@ interface FetchThreadTimelineArgs { export const COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT = 8; -export function resolveThreadTimelineSegmentLimit(): number | undefined { +function resolveThreadTimelineSegmentLimit(): number | undefined { return getMediaQuerySnapshot(COMPACT_VIEWPORT_QUERY) ? COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT : undefined; diff --git a/apps/app/src/hooks/thread-history-cache-effects.test.ts b/apps/app/src/hooks/thread-history-cache-effects.test.ts index e3e200cb03c..b9bc45d1c16 100644 --- a/apps/app/src/hooks/thread-history-cache-effects.test.ts +++ b/apps/app/src/hooks/thread-history-cache-effects.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { QueryClient, QueryObserver } from "@tanstack/react-query"; import type { ThreadChangeKind } from "@bb/domain"; +import { makeThreadTimelineResponse } from "@/test/fixtures/thread-responses"; import { createThreadHistoryPage, getThreadHistoryGeneration, @@ -36,26 +37,7 @@ function historyChain(validatedAt: number[] = [1]): ThreadHistoryChain { surfaceKey: "default", pages: validatedAt.map((timestamp) => createThreadHistoryPage( - { - rows: [], - contextBoundarySeq: null, - completedTurnDisplay: "collapse", - activePromptMode: null, - activeThinking: null, - activeWorkflows: [], - activeBackgroundCommands: [], - pendingTodos: null, - goal: null, - modelFallback: null, - maxSeq: 1, - timelinePage: { - kind: "latest", - segmentLimit: 20, - returnedSegmentCount: 0, - hasOlderRows: false, - olderCursor: null, - }, - }, + makeThreadTimelineResponse({ maxSeq: 1 }), null, timestamp, ), From e93d7bdf23befc59058188ed9d19dedc12eb56e5 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 02:27:41 -0400 Subject: [PATCH 09/23] Keep history cancellation inside its cache owner --- .../hooks/cache-owners/thread-history-cache-owner.ts | 12 +++++++++++- apps/app/src/hooks/queries/thread-history-query.ts | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts index e3ca2369ec1..add268c977f 100644 --- a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts @@ -1,4 +1,4 @@ -import type { QueryClient } from "@tanstack/react-query"; +import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { isOptimisticTimelineRowId } from "@bb/client-core"; import { BbHttpError } from "@/lib/sdk"; import type { @@ -179,6 +179,16 @@ export async function invalidateThreadHistory( await args.queryClient.invalidateQueries(filters, { cancelRefetch: false }); } +export function cancelThreadHistoryRead({ + queryClient, + queryKey, +}: { + queryClient: QueryClient; + queryKey: QueryKey; +}): Promise { + return queryClient.cancelQueries({ queryKey, exact: true }); +} + export function removeThreadHistory(args: ThreadHistoryOwnerArgs): void { advanceThreadHistoryGeneration(args, true); const filters = { queryKey: threadHistoryQueryKeyPrefix(args.threadId) }; diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index a43f4b0be40..f2c6e1c49d8 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -16,6 +16,7 @@ import { import { BbHttpError, sdk } from "@/lib/sdk"; import { compactThreadHistory, + cancelThreadHistoryRead, createThreadHistoryPage, getThreadHistoryGeneration, pruneThreadHistory, @@ -407,7 +408,7 @@ export function useThreadHistory({ queryClient.getQueryState(queryKey)?.fetchStatus === "fetching"; state.foreground = foreground; void (async () => { - await queryClient.cancelQueries({ queryKey, exact: true }); + await cancelThreadHistoryRead({ queryClient, queryKey }); if (owner.request !== foreground.generation) { if (state.foreground === foreground) state.foreground = undefined; foreground.resolve(undefined); From 5377264c3e7aff51d9da1ec30d513cd5d473a939 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:13:48 -0400 Subject: [PATCH 10/23] Fix deep history recovery and expose latest navigation --- .../timeline/ThreadTimelineSurface.test.tsx | 27 +++++++ .../thread/timeline/ThreadTimelineSurface.tsx | 14 +++- .../useThreadTimelineController.test.tsx | 78 +++++++++++++++++++ .../timeline/useThreadTimelineController.ts | 13 ++++ .../thread-history-cache-owner.ts | 1 + .../src/hooks/queries/thread-history-query.ts | 4 +- .../src/hooks/queries/thread-queries.test.tsx | 36 +++++++++ apps/app/src/hooks/queries/thread-queries.ts | 5 +- 8 files changed, 174 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx index 80cc31bcdf8..1aed8b38f90 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx @@ -34,6 +34,33 @@ afterEach(() => { }); describe("ThreadTimelineSurface load-older control", () => { + it("offers Show latest for held history without a composer or bottom-anchor context", () => { + const showLatest = vi.fn(); + const surface = (historyUnrefreshed: boolean) => ( + + ); + const view = render(surface(true)); + expect(screen.getByText("Previously loaded reply")).not.toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show latest" })); + expect(showLatest).toHaveBeenCalledTimes(1); + view.rerender(surface(false)); + expect(screen.queryByRole("button", { name: "Show latest" })).toBeNull(); + }); + it("keeps cached messages readable when refresh fails and offers a bounded retry", () => { const refresh = vi.fn().mockResolvedValue(undefined); const surface = (isRefreshingHistory: boolean) => ( diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx index 61151b0e3e9..496da881965 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx @@ -171,6 +171,7 @@ export function ThreadTimelineSurface({ includePluginMessageActions, onLoadOlderRows, onRefreshHistory, + onShowLatestTimeline, onOpenLink, onOpenLocalFileLink, onOpenPluginPanel, @@ -224,7 +225,7 @@ export function ThreadTimelineSurface({ (historyRefreshError !== null || historyUnrefreshed) ? (
{historyRefreshError !== null @@ -243,6 +244,17 @@ export function ThreadTimelineSurface({ {isRefreshingHistory ? "Refreshing…" : "Retry"} ) : null} + {historyUnrefreshed && onShowLatestTimeline ? ( + + ) : null}
) : null} {showLoadOlderRows ? ( diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index 9a1a6e101bb..bd8ff91c2e7 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -39,6 +39,7 @@ import { import { createThreadHistoryPage, removeThreadHistory, + type ThreadHistoryChain, } from "@/hooks/cache-owners/thread-history-cache-owner"; import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; @@ -819,6 +820,83 @@ describe("useThreadTimelineController", () => { }); }); + it("advances beyond deep history after a cursor-invalidating rename with five pages retained", async () => { + let revision = 1; + const page = (sequence: number, kind: "latest" | "older") => + makeTimelineResponse({ + rows: [makeUserRow(`row-${sequence}`, sequence)], + maxSeq: 9, + timelinePage: { + kind, + historySnapshot: `snapshot-${revision}`, + olderRowsSourceSeqEnd: sequence - 1, + hasOlderRows: sequence > 0, + olderCursor: + sequence > 0 + ? { anchorId: `${revision}:${sequence}`, anchorSeq: sequence } + : null, + }, + }); + vi.mocked(sdk.threads.timeline).mockImplementation(async (request) => { + if (!request.beforeAnchorId) return page(9, "latest"); + if (!request.beforeAnchorId.startsWith(`${revision}:`)) { + throw new BbHttpError({ + body: null, + code: "invalid_request", + message: "Timeline pagination cursor is no longer available", + status: 400, + }); + } + return page(Number(request.beforeAnchorSeq) - 1, "older"); + }); + const { queryClient, wrapper } = createQueryClientTestHarness(); + const latest = page(9, "latest"); + queryClient.setQueryData(TIMELINE_QUERY_KEY, latest); + const key = threadHistoryQueryKey( + "thread-1", + resolveLoadedTimelineSurfaceKey("thread-1", latest), + latest.timelinePage.segmentLimit, + ); + const { result } = renderHook( + () => useThreadTimelineController({ threadId: "thread-1" }), + { wrapper }, + ); + for (let index = 0; index < 6; index += 1) { + await act(async () => result.current.loadOlderTimelineRows()); + } + const loadedIds = rowIds(result.current); + expect(loadedIds).toEqual([ + "row-3", + "row-4", + "row-5", + "row-6", + "row-7", + "row-8", + "row-9", + ]); + expect(queryClient.getQueryData(key)?.pages).toHaveLength(5); + + revision = 2; + await act(async () => result.current.loadOlderTimelineRows()); + expect(rowIds(result.current)).toEqual(loadedIds); + expect(sdk.threads.timeline).toHaveBeenCalledTimes(12); + for (let index = 0; index < 3; index += 1) { + await act(async () => result.current.loadOlderTimelineRows()); + } + + expect(rowIds(result.current)).toEqual(["row-2", ...loadedIds]); + const requests = vi.mocked(sdk.threads.timeline).mock.calls; + expect(requests.slice(12).map(([request]) => request.beforeAnchorId)).toEqual([ + "2:5", + "2:4", + "2:3", + ]); + expect( + requests.filter(([request]) => request.beforeAnchorId === "1:3"), + ).toHaveLength(1); + expect(queryClient.getQueryData(key)?.pages).toHaveLength(5); + }); + it("keeps auto-loading when an older page settles before its loading state renders", async () => { const { anchor, emitIntersection, sentinel } = installAutoLoadEnvironment(); vi.mocked(sdk.threads.timeline) diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index 6064ab7b215..b9ca042aba2 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -262,6 +262,19 @@ export function useThreadTimelineController({ unrefreshed = true; } } + if ( + history.data?.recoveredFromCursor && + areTimelinePaginationCursorsEqual({ + left: loaded.olderCursor, + right: history.data.recoveredFromCursor, + }) + ) { + loaded = { + ...loaded, + olderCursor: + history.data.pages.at(-1)?.response.timelinePage.olderCursor ?? null, + }; + } } current = { latestTimeline, diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts index add268c977f..2baec576e66 100644 --- a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts @@ -28,6 +28,7 @@ export interface ThreadHistoryPage { export interface ThreadHistoryChain { pages: ThreadHistoryPage[]; surfaceKey: string; + recoveredFromCursor?: TimelinePaginationCursor; } interface ThreadHistoryGeneration { diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index f2c6e1c49d8..8e9fa2f21dc 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -335,7 +335,9 @@ export function useThreadHistory({ if (!isStaleCursor(error)) throw error; const rebuilt = await rebuild(); finishForeground(undefined); - return rebuilt; + return foreground + ? { ...rebuilt, recoveredFromCursor: foreground.cursor } + : rebuilt; } catch (readError) { if (isAccessFailure(readError)) { removeThreadHistory({ queryClient, threadId, error: readError }); diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index a7be0d344e8..635d222b923 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -37,7 +37,9 @@ import { useThreadQueuedMessages, useThreadStorageLocation, useThreadTimeline, + useThreadTimelineTurnSummaryDetails, } from "./thread-queries"; +import { commandRow } from "@/test/fixtures/thread-timeline-rows"; import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, @@ -64,6 +66,7 @@ vi.mock("@/lib/sdk", () => ({ interactions: { list: vi.fn() }, storageLocation: vi.fn(), timeline: vi.fn(), + timelineTurnSummaryDetails: vi.fn(), }, }, })); @@ -161,6 +164,39 @@ beforeEach(() => { }); }); +describe("useThreadTimelineTurnSummaryDetails", () => { + it("loads older turn details without duplicate rows", async () => { + const older = commandRow({ id: "older-command", command: "pwd", seq: 1 }); + const latest = commandRow({ id: "latest-command", command: "ls", seq: 2 }); + vi.mocked(sdk.threads.timelineTurnSummaryDetails) + .mockResolvedValueOnce({ rows: [latest], olderCursor: "older-page" }) + .mockResolvedValueOnce({ rows: [older, latest], olderCursor: null }); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => + useThreadTimelineTurnSummaryDetails({ + threadId: "thread-1", + turnId: "turn-1", + sourceSeqStart: 1, + sourceSeqEnd: 2, + }), + { wrapper }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(result.current.data).toEqual({ + rows: [older, latest], + olderCursor: null, + }); + expect(sdk.threads.timelineTurnSummaryDetails).toHaveBeenCalledTimes(2); + expect(sdk.threads.timelineTurnSummaryDetails).toHaveBeenLastCalledWith( + expect.objectContaining({ beforeCursor: "older-page" }), + ); + }); +}); + describe("useThreadDetailBootstrap", () => { it("starts the timeline request before the thread bootstrap settles", async () => { let resolveThread: diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index beb426cadcf..b10726eb2e2 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -1,4 +1,3 @@ -import { prependOlderTimelineRows } from "@bb/client-core"; import { useInfiniteQuery, useQuery, @@ -1010,8 +1009,10 @@ export function useThreadTimelineTurnSummaryDetails( signal, }; const response = await sdk.threads.timelineTurnSummaryDetails(input); - let rows = response.rows; let cursor = response.olderCursor; + if (!cursor) return { ...response, olderCursor: null }; + const { prependOlderTimelineRows } = await import("@bb/client-core"); + let rows = response.rows; while (cursor) { const older = await sdk.threads.timelineTurnSummaryDetails({ ...input, From 6344dba3b76f020dac062d943a16612c4d470f3a Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Fri, 18 Sep 2026 23:19:05 -0400 Subject: [PATCH 11/23] Keep timeline reconciliation behind the thread route --- .../thread/timeline/useThreadTimelineController.ts | 2 +- apps/app/src/hooks/queries/thread-history-query.ts | 2 +- apps/app/src/hooks/queries/thread-queries.ts | 4 +++- packages/client-core/package.json | 5 +++++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index b9ca042aba2..3af17089d07 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -15,7 +15,7 @@ import { prependOlderTimelineRows, resolveLoadedTimelineSurfaceKey, type LoadedTimelineState, -} from "@bb/client-core"; +} from "@bb/client-core/timeline"; import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-query-state"; import { threadTimelineQueryKey } from "@/hooks/queries/query-keys"; import { isTransientReadError } from "@/hooks/queries/query-helpers"; diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index 8e9fa2f21dc..0f2e71ebb8b 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -12,7 +12,7 @@ import type { import { areTimelinePaginationCursorsEqual, resolveLoadedTimelineSurfaceKey, -} from "@bb/client-core"; +} from "@bb/client-core/timeline"; import { BbHttpError, sdk } from "@/lib/sdk"; import { compactThreadHistory, diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index b10726eb2e2..a264af71e6a 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -1011,7 +1011,9 @@ export function useThreadTimelineTurnSummaryDetails( const response = await sdk.threads.timelineTurnSummaryDetails(input); let cursor = response.olderCursor; if (!cursor) return { ...response, olderCursor: null }; - const { prependOlderTimelineRows } = await import("@bb/client-core"); + const { prependOlderTimelineRows } = await import( + "@bb/client-core/timeline" + ); let rows = response.rows; while (cursor) { const older = await sdk.threads.timelineTurnSummaryDetails({ diff --git a/packages/client-core/package.json b/packages/client-core/package.json index e9d336cff26..d3879db0f59 100644 --- a/packages/client-core/package.json +++ b/packages/client-core/package.json @@ -7,6 +7,11 @@ "source": "./src/index.ts", "types": "./src/index.ts", "default": "./src/index.ts" + }, + "./timeline": { + "source": "./src/timeline/timeline-merge.ts", + "types": "./src/timeline/timeline-merge.ts", + "default": "./src/timeline/timeline-merge.ts" } }, "types": "./src/index.ts", From d11c38e88c302d23858eec520e6d2e753179b692 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 00:28:36 -0400 Subject: [PATCH 12/23] Trim duplicate timeline test setup and coverage --- .../ThreadTimelinePanelContent.test.tsx | 24 +-------------- .../queries/thread-history-query.test.tsx | 29 ------------------- 2 files changed, 1 insertion(+), 52 deletions(-) diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx index cd23a35ef5c..e7696edabb6 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx @@ -44,29 +44,7 @@ vi.mock("./ThreadTimelineSurface.js", () => ({ })); vi.mock("./useThreadTimelineController.js", () => ({ - useThreadTimelineController: () => ({ - activePromptMode: null, - activeThinking: null, - activeWorkflows: [], - activeBackgroundCommands: [], - contextBoundarySeq: null, - contextWindowUsage: undefined, - goal: null, - modelFallback: null, - hasOlderTimelineRows: false, - historyRefreshError: null, - historyUnrefreshed: false, - historyReplacementKey: null, - isLoadingOlderTimelineRows: false, - isRefreshingHistory: false, - loadOlderTimelineRows: vi.fn(), - refreshHistory: vi.fn().mockResolvedValue(undefined), - showLatestTimeline: vi.fn(), - pendingTodos: null, - timelineError: null, - timelineLoading: false, - timelineRows: [], - }), + useThreadTimelineController: () => baseTimeline(), })); vi.mock("@/components/ui/conversation.js", () => ({ diff --git a/apps/app/src/hooks/queries/thread-history-query.test.tsx b/apps/app/src/hooks/queries/thread-history-query.test.tsx index 91e98c47fa1..72e9705f7d6 100644 --- a/apps/app/src/hooks/queries/thread-history-query.test.tsx +++ b/apps/app/src/hooks/queries/thread-history-query.test.tsx @@ -291,35 +291,6 @@ describe("useThreadHistory", () => { expect(queryClient.getQueryData(key)).toBeUndefined(); }); - it("bounds reusable pages while returning deep foreground content", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); - const latest = page(50); - const { key } = seedHistory(queryClient, [latest]); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); - let cursor = latest.timelinePage.olderCursor!; - for (const sequence of [40, 30, 20, 10, 0]) { - const older = page(sequence, { kind: "older", final: sequence === 0 }); - vi.mocked(sdk.threads.timeline).mockResolvedValueOnce(older); - await act(async () => { - expect(await result.current.loadOlder(cursor)).toBe(older); - expect(queryClient.getQueryState(key)?.fetchStatus).toBe("idle"); - expect( - queryClient.getQueryData(key)?.pages, - ).toHaveLength(Math.min(6 - sequence / 10, 5)); - }); - if (older.timelinePage.olderCursor) - cursor = older.timelinePage.olderCursor; - } - expect(result.current.data?.pages).toHaveLength(5); - expect(result.current.data?.pages.at(-1)?.response.rows[0]?.id).toBe( - "row-10", - ); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(5); - }); - it.each([401, 403, 404])( "clears cached history on an older read returning %s", async (status) => { From c7fa336f30b5f4a0809a3823017990e68f2e0825 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 08:40:34 -0400 Subject: [PATCH 13/23] Unify cached history reconciliation and query completion --- .../timeline/useThreadTimelineController.ts | 72 +++++-------------- .../src/hooks/queries/thread-history-query.ts | 67 ++++++----------- 2 files changed, 40 insertions(+), 99 deletions(-) diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index 3af17089d07..47f2489ab3e 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -123,47 +123,12 @@ export function useThreadTimelineController({ const store = useStore(); const accessDenied = isAccessError(latestTimelineQuery.error); const blocked = accessDenied || history.isBlocked; - const makeInitialLoaded = () => { - if (blocked) - return { - loaded: buildEmptyLoadedTimelineState(surfaceKey), - unrefreshed: false, - }; - const retained = - history.data && - buildLoadedTimelineFromPages({ - pages: history.data.pages.map((page) => page.response), - surfaceKey, - }); - const loaded = retained ?? buildEmptyLoadedTimelineState(surfaceKey); - if (!latestTimeline) return { loaded, unrefreshed: false }; - const merged = tryMergeLoadedTimelineWithLatest({ - current: loaded, - latestTimeline, - surfaceKey, - }); - if (merged) return { loaded: merged, unrefreshed: false }; - if ( - loaded.rows.length > 0 && - store.get(threadTimelineScrollAnchorAtomFamily(threadId))?.atBottom === - false - ) { - return { loaded, unrefreshed: true }; - } - return { - loaded: mergeLoadedTimelineWithLatest({ - current: loaded, - latestTimeline, - surfaceKey, - }), - unrefreshed: false, - }; - }; const [tracker, setTracker] = useState(() => ({ - latestTimeline, - history: history.data, + latestTimeline: undefined, + history: undefined, generation: history.generation, - ...makeInitialLoaded(), + loaded: buildEmptyLoadedTimelineState(surfaceKey), + unrefreshed: false, replacementKey: null, })); let current = tracker; @@ -174,8 +139,13 @@ export function useThreadTimelineController({ tracker.loaded.surfaceKey !== surfaceKey || (blocked && tracker.loaded.rows.length > 0) ) { - let loaded = tracker.loaded; - let unrefreshed = tracker.unrefreshed; + const reset = + tracker.loaded.surfaceKey !== surfaceKey || + tracker.generation !== history.generation; + let loaded = reset + ? buildEmptyLoadedTimelineState(surfaceKey) + : tracker.loaded; + let unrefreshed = !reset && tracker.unrefreshed; let replacementKey = tracker.replacementKey; const detached = store.get(threadTimelineScrollAnchorAtomFamily(threadId))?.atBottom === @@ -183,18 +153,10 @@ export function useThreadTimelineController({ if (blocked) { loaded = buildEmptyLoadedTimelineState(surfaceKey); unrefreshed = false; - } else if ( - loaded.surfaceKey !== surfaceKey || - tracker.generation !== history.generation - ) { - const initial = makeInitialLoaded(); - loaded = initial.loaded; - unrefreshed = initial.unrefreshed; - replacementKey = history.data?.pages[0] ?? latestTimeline ?? null; } else { - if (history.data && tracker.history !== history.data) { + if (history.data && (reset || tracker.history !== history.data)) { const head = history.data.pages[0]; - const replaced = head !== tracker.history?.pages[0]; + const replaced = reset || head !== tracker.history?.pages[0]; const refreshed = replaced ? reconcileLoadedTimelineWithHistoryPages({ current: loaded, @@ -240,7 +202,8 @@ export function useThreadTimelineController({ } if ( latestTimeline && - (tracker.latestTimeline !== latestTimeline || + (reset || + tracker.latestTimeline !== latestTimeline || tracker.history !== history.data) ) { const merged = tryMergeLoadedTimelineWithLatest({ @@ -282,7 +245,10 @@ export function useThreadTimelineController({ generation: history.generation, loaded, unrefreshed, - replacementKey, + replacementKey: + tracker.latestTimeline === undefined && tracker.history === undefined + ? null + : replacementKey, }; setTracker(current); } diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index 0f2e71ebb8b..b19bb0cd2a9 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -37,10 +37,7 @@ interface ForegroundRead { promise: Promise; resolve: (response: ThreadTimelineResponse | undefined) => void; reject: (error: unknown) => void; - outcome: - | { status: "success"; response: ThreadTimelineResponse | undefined } - | { status: "error"; error: unknown } - | undefined; + response: ThreadTimelineResponse | undefined; } interface HistoryReadState { @@ -121,23 +118,23 @@ export function useThreadHistory({ state.foreground = undefined; foreground.resolve(undefined); } else if ( - foreground?.outcome?.status === "success" && + foreground && event.type === "updated" && event.action.type === "success" && !event.action.manual ) { state.foreground = undefined; - foreground.resolve(foreground.outcome.response); + foreground.resolve(foreground.response); } else if ( foreground && event.type === "updated" && event.action.type === "error" ) { if (event.action.error instanceof CancelledError) { - foreground.outcome = undefined; - } else if (foreground.outcome?.status === "error") { + foreground.response = undefined; + } else { state.foreground = undefined; - foreground.reject(foreground.outcome.error); + foreground.reject(event.action.error); } } } @@ -208,17 +205,21 @@ export function useThreadHistory({ state.foreground.resolve(undefined); state.foreground = undefined; } - const finishForeground = ( - response: ThreadTimelineResponse | undefined, - ) => { - if (!foreground) return; - foreground.outcome = { status: "success", response }; - }; const assertCurrent = () => { if (signal.aborted || owner.request !== requestGeneration) { throw new CancelledError({ revert: true }); } }; + const fetchOlder = async (cursor: TimelinePaginationCursor) => { + const response = await sdk.threads.timeline({ + threadId, + beforeAnchorId: cursor.anchorId, + beforeAnchorSeq: String(cursor.anchorSeq), + signal, + }); + assertCurrent(); + return response; + }; const rebuild = async (): Promise => { assertCurrent(); const latest = await queryClient.fetchQuery({ @@ -266,13 +267,7 @@ export function useThreadHistory({ firstSequence < targetSequence ) break; - const response = await sdk.threads.timeline({ - threadId, - beforeAnchorId: cursor.anchorId, - beforeAnchorSeq: String(cursor.anchorSeq), - signal, - }); - assertCurrent(); + const response = await fetchOlder(cursor); const page = createThreadHistoryPage(response, cursor); if (bytes + page.byteSize > THREAD_HISTORY_MAX_BYTES) break; pages.push(page); @@ -287,14 +282,8 @@ export function useThreadHistory({ }; try { if (foreground) { - const response = await sdk.threads.timeline({ - threadId, - beforeAnchorId: foreground.cursor.anchorId, - beforeAnchorSeq: String(foreground.cursor.anchorSeq), - signal, - }); - assertCurrent(); - finishForeground(response); + const response = await fetchOlder(foreground.cursor); + foreground.response = response; const previous = current?.pages.at(-1); if ( current && @@ -334,14 +323,12 @@ export function useThreadHistory({ try { if (!isStaleCursor(error)) throw error; const rebuilt = await rebuild(); - finishForeground(undefined); return foreground ? { ...rebuilt, recoveredFromCursor: foreground.cursor } : rebuilt; } catch (readError) { if (isAccessFailure(readError)) { removeThreadHistory({ queryClient, threadId, error: readError }); - finishForeground(undefined); throw readError; } if ( @@ -349,13 +336,8 @@ export function useThreadHistory({ owner.request !== requestGeneration || readError instanceof CancelledError ) { - if (owner.request !== requestGeneration) - finishForeground(undefined); throw new CancelledError({ revert: true }); } - if (foreground) { - foreground.outcome = { status: "error", error: readError }; - } throw readError; } } finally { @@ -404,7 +386,7 @@ export function useThreadHistory({ promise, resolve, reject, - outcome: undefined, + response: undefined, }; state.refreshAfterForeground ||= queryClient.getQueryState(queryKey)?.fetchStatus === "fetching"; @@ -416,14 +398,7 @@ export function useThreadHistory({ foreground.resolve(undefined); return; } - try { - await refetch({ cancelRefetch: false }); - } finally { - if (owner.request !== foreground.generation) { - if (state.foreground === foreground) state.foreground = undefined; - foreground.resolve(undefined); - } - } + await refetch({ cancelRefetch: false }); })(); return promise; }, From c630d09524525966b3b5e75e79fa8a9792299bfc Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 09:18:29 -0400 Subject: [PATCH 14/23] Invalidate cached timeline cursors after thread renames --- .../src/hooks/queries/thread-history-query.ts | 8 +++++-- apps/server/src/routes/threads/data.ts | 3 ++- ...ublic-thread-timeline-epoch-cursor.test.ts | 23 ++++++++++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts index b19bb0cd2a9..e817a45207a 100644 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ b/apps/app/src/hooks/queries/thread-history-query.ts @@ -38,6 +38,7 @@ interface ForegroundRead { resolve: (response: ThreadTimelineResponse | undefined) => void; reject: (error: unknown) => void; response: ThreadTimelineResponse | undefined; + finished: boolean; } interface HistoryReadState { @@ -118,7 +119,7 @@ export function useThreadHistory({ state.foreground = undefined; foreground.resolve(undefined); } else if ( - foreground && + foreground?.finished && event.type === "updated" && event.action.type === "success" && !event.action.manual @@ -132,7 +133,8 @@ export function useThreadHistory({ ) { if (event.action.error instanceof CancelledError) { foreground.response = undefined; - } else { + foreground.finished = false; + } else if (foreground.finished) { state.foreground = undefined; foreground.reject(event.action.error); } @@ -341,6 +343,7 @@ export function useThreadHistory({ throw readError; } } finally { + if (foreground && !signal.aborted) foreground.finished = true; pruneThreadHistory(queryClient); } }, @@ -387,6 +390,7 @@ export function useThreadHistory({ resolve, reject, response: undefined, + finished: false, }; state.refreshAfterForeground ||= queryClient.getQueryState(queryKey)?.fetchStatus === "fetching"; diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index a91f9ceb452..5e3dce7e985 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -330,7 +330,8 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { deps.hub.onChangedMessage((message) => { if ( message.entity === "thread" && - message.changes.includes("history-rewritten") + (message.changes.includes("history-rewritten") || + message.changes.includes("title-changed")) ) { clearTimelineOrderingContextCache(deps.db); timelineCache.invalidateThread(message.id); diff --git a/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts b/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts index 92698ff24ba..dc2e8fab5d2 100644 --- a/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts +++ b/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts @@ -11,7 +11,7 @@ import { seedEvent, seedThreadFixture } from "../helpers/seed.js"; import { withTestHarness } from "../helpers/test-app.js"; describe("timeline content continuation at the history epoch", () => { - it("accepts every cursor it returns while paging the oldest nested turn", async () => { + it.each([false, true])("pages through renamed=%s history", async (rename) => { await withTestHarness( { featureFlags: { ...defaultFeatureFlags, timelineWindowEventBudget: 2 }, @@ -58,6 +58,27 @@ describe("timeline content continuation at the history epoch", () => { type: "turn/completed", data: { status: "completed" }, }); + if (rename) { + const route = `/api/v1/threads/${thread.id}/timeline?includeNestedRows=true`; + const cached = threadTimelineResponseSchema.parse( + await readJson(await harness.app.request(route)), + ); + const renamed = await harness.app.request( + `/api/v1/threads/${thread.id}`, + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Renamed cached thread" }), + }, + ); + expect(renamed.status).toBe(200); + const refreshed = threadTimelineResponseSchema.parse( + await readJson(await harness.app.request(route)), + ); + expect(refreshed.timelinePage.olderCursor).not.toEqual( + cached.timelinePage.olderCursor, + ); + } let cursor: TimelinePaginationCursor | null = null; let rows: TimelineRow[] = []; let sawEpochCursor = false; From 12110a72f84572138e96c859616bb3e12550e36f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 12:54:28 -0400 Subject: [PATCH 15/23] Consolidate history refresh branches and query test setup --- .../timeline/useThreadTimelineController.ts | 29 +++--- .../queries/thread-history-query.test.tsx | 98 ++++++------------- 2 files changed, 43 insertions(+), 84 deletions(-) diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index 47f2489ab3e..12843197795 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -157,13 +157,6 @@ export function useThreadTimelineController({ if (history.data && (reset || tracker.history !== history.data)) { const head = history.data.pages[0]; const replaced = reset || head !== tracker.history?.pages[0]; - const refreshed = replaced - ? reconcileLoadedTimelineWithHistoryPages({ - current: loaded, - pages: history.data.pages.map((page) => page.response), - surfaceKey, - }) - : null; if (!replaced) { for (const page of history.data.pages.slice(1)) { if ( @@ -182,17 +175,19 @@ export function useThreadTimelineController({ }; } } - } else if (refreshed) { - loaded = refreshed; - unrefreshed = false; - replacementKey = head ?? null; } else { - const rebuilt = buildLoadedTimelineFromPages({ - pages: history.data.pages.map((page) => page.response), - surfaceKey, - }); - if (!detached && rebuilt) { - loaded = rebuilt; + const pages = history.data.pages.map((page) => page.response); + const refreshed = + reconcileLoadedTimelineWithHistoryPages({ + current: loaded, + pages, + surfaceKey, + }) ?? + (detached + ? null + : buildLoadedTimelineFromPages({ pages, surfaceKey })); + if (refreshed) { + loaded = refreshed; unrefreshed = false; replacementKey = head ?? null; } else { diff --git a/apps/app/src/hooks/queries/thread-history-query.test.tsx b/apps/app/src/hooks/queries/thread-history-query.test.tsx index 72e9705f7d6..aa34ed4e3ab 100644 --- a/apps/app/src/hooks/queries/thread-history-query.test.tsx +++ b/apps/app/src/hooks/queries/thread-history-query.test.tsx @@ -1,7 +1,6 @@ // @vitest-environment jsdom import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import type { QueryClient } from "@tanstack/react-query"; import type { ThreadTimelineResponse } from "@bb/server-contract"; import { resolveLoadedTimelineSurfaceKey } from "@bb/client-core"; import { createDeferredPromise } from "@bb/test-helpers"; @@ -70,11 +69,11 @@ function page( }); } -function seedHistory( - queryClient: QueryClient, +function createHistoryHarness( pages: ThreadTimelineResponse[], updatedAt = Date.now(), ) { + const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = pages[0]!; const surfaceKey = resolveLoadedTimelineSurfaceKey("thread-1", latest); const key = threadHistoryQueryKey( @@ -94,7 +93,16 @@ function seedHistory( }; queryClient.setQueryData(threadTimelineQueryKey("thread-1"), latest); queryClient.setQueryData(key, chain, { updatedAt }); - return { chain, key }; + return { + queryClient, + chain, + key, + renderHistory: () => + renderHook( + () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), + { wrapper }, + ), + }; } describe("useThreadHistory", () => { @@ -122,10 +130,8 @@ describe("useThreadHistory", () => { }); it("keeps stale rows visible and rebuilds with fresh opaque cursors", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - const { chain } = seedHistory( - queryClient, + const { chain, renderHistory } = createHistoryHarness( [latest, page(20, { kind: "older" })], Date.now() - 10_000, ); @@ -134,10 +140,7 @@ describe("useThreadHistory", () => { vi.mocked(sdk.threads.timeline) .mockReturnValueOnce(freshLatest.promise) .mockResolvedValueOnce(freshOlder); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); expect(result.current.data).toBe(chain); await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); @@ -158,10 +161,8 @@ describe("useThreadHistory", () => { }); it("retains the successful chain and validation times on refresh failure", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - const { chain } = seedHistory( - queryClient, + const { queryClient, chain, renderHistory } = createHistoryHarness( [latest, page(20, { kind: "older" })], Date.now() - 10_000, ); @@ -169,10 +170,7 @@ describe("useThreadHistory", () => { vi.mocked(sdk.threads.timeline) .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) .mockRejectedValueOnce(failure); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); await waitFor(() => expect(result.current.error).toBe(failure)); expect(result.current.data).toBe(chain); @@ -193,19 +191,12 @@ describe("useThreadHistory", () => { }); it("deduplicates two readers and does not cancel when one unmounts", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - seedHistory(queryClient, [latest]); + const { renderHistory } = createHistoryHarness([latest]); const pending = createDeferredPromise(); vi.mocked(sdk.threads.timeline).mockReturnValue(pending.promise); - const first = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); - const second = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const first = renderHistory(); + const second = renderHistory(); const cursor = latest.timelinePage.olderCursor!; let firstRead!: Promise; let secondRead!: Promise; @@ -229,18 +220,14 @@ describe("useThreadHistory", () => { }); it("suspends and resumes an active older read without dropping its caller", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - seedHistory(queryClient, [latest]); + const { queryClient, renderHistory } = createHistoryHarness([latest]); const pending = createDeferredPromise(); const older = page(20, { kind: "older" }); vi.mocked(sdk.threads.timeline) .mockReturnValueOnce(pending.promise) .mockResolvedValueOnce(older); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); let read!: Promise; act(() => { read = result.current.loadOlder(latest.timelinePage.olderCursor!); @@ -260,15 +247,11 @@ describe("useThreadHistory", () => { }); it("purges pending work and ignores late results and optimistic writes", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - const { key } = seedHistory(queryClient, [latest]); + const { queryClient, key, renderHistory } = createHistoryHarness([latest]); const pending = createDeferredPromise(); vi.mocked(sdk.threads.timeline).mockReturnValueOnce(pending.promise); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); let read!: Promise; act(() => { read = result.current.loadOlder(latest.timelinePage.olderCursor!); @@ -294,9 +277,8 @@ describe("useThreadHistory", () => { it.each([401, 403, 404])( "clears cached history on an older read returning %s", async (status) => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - seedHistory(queryClient, [latest]); + const { renderHistory } = createHistoryHarness([latest]); const failure = new BbHttpError({ body: null, code: null, @@ -304,11 +286,7 @@ describe("useThreadHistory", () => { status, }); vi.mocked(sdk.threads.timeline).mockRejectedValueOnce(failure); - const { result } = renderHook( - () => - useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); await act(async () => { expect( await result.current.loadOlder(latest.timelinePage.olderCursor!), @@ -321,10 +299,8 @@ describe("useThreadHistory", () => { ); it("services a foreground cursor before restarting an interrupted background chain", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - const { chain } = seedHistory( - queryClient, + const { chain, renderHistory } = createHistoryHarness( [latest, page(20, { kind: "older" })], Date.now() - 10_000, ); @@ -336,10 +312,7 @@ describe("useThreadHistory", () => { .mockReturnValueOnce(background.promise) .mockResolvedValueOnce(foreground) .mockReturnValueOnce(nextLatest.promise); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(2)); const backgroundSignal = vi.mocked(sdk.threads.timeline).mock.calls[1]![0] .signal; @@ -361,9 +334,8 @@ describe("useThreadHistory", () => { }); it("recovers an invalid cursor once and exposes a second failure", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - const { chain } = seedHistory(queryClient, [ + const { chain, renderHistory } = createHistoryHarness([ latest, page(20, { kind: "older" }), ]); @@ -377,10 +349,7 @@ describe("useThreadHistory", () => { .mockRejectedValueOnce(invalid) .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) .mockRejectedValueOnce(invalid); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); await act(async () => { await expect( result.current.loadOlder( @@ -394,10 +363,8 @@ describe("useThreadHistory", () => { }); it("invalidates an obsolete refresh without publishing its result", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); const latest = page(30); - seedHistory( - queryClient, + const { queryClient, renderHistory } = createHistoryHarness( [latest, page(20, { kind: "older" })], Date.now() - 10_000, ); @@ -407,10 +374,7 @@ describe("useThreadHistory", () => { .mockReturnValueOnce(obsolete.promise) .mockResolvedValueOnce(page(50, { snapshot: "newest" })) .mockResolvedValueOnce(page(35, { kind: "older", snapshot: "newest" })); - const { result } = renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ); + const { result } = renderHistory(); await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(2)); await act(async () => { await invalidateThreadHistory({ queryClient, threadId: "thread-1" }); From 9d6921c66d8167684340f7142c0fefe75dcbeb34 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 13:35:21 -0400 Subject: [PATCH 16/23] Reduce page resilience to existing cached query reads --- .../embedded-chat/EmbeddedThreadChat.test.tsx | 13 +- .../embedded-chat/EmbeddedThreadChat.tsx | 114 ++--- .../timeline/ThreadTimelineLatestContext.ts | 6 - .../ThreadTimelinePanelContent.test.tsx | 52 +- .../timeline/ThreadTimelinePanelContent.tsx | 19 +- .../thread/timeline/ThreadTimelineRows.tsx | 6 - .../ThreadTimelineRows.windowing.test.tsx | 63 +-- .../timeline/ThreadTimelineSurface.test.tsx | 87 +--- .../thread/timeline/ThreadTimelineSurface.tsx | 55 +-- .../timeline/TimelineWindowedItems.test.tsx | 61 +-- .../thread/timeline/TimelineWindowedItems.tsx | 11 +- .../useThreadTimelineController.test.tsx | 308 +----------- .../timeline/useThreadTimelineController.ts | 331 +++++-------- ...d-scroll-body.scroll-preservation.test.tsx | 284 +---------- .../ui/bottom-anchored-scroll-body.tsx | 219 +-------- .../cache-owners/cache-owner-registry.test.ts | 13 - .../cache-owners/mutation-cache-effects.ts | 6 - .../hooks/cache-owners/project-cache-owner.ts | 51 -- .../cache-owners/realtime-cache-registry.ts | 73 +-- .../cache-owners/system-cache-effects.ts | 48 +- .../thread-history-cache-owner.test.ts | 203 -------- .../thread-history-cache-owner.ts | 204 -------- apps/app/src/hooks/queries/query-keys.ts | 15 - .../queries/sidebar-navigation-query.test.tsx | 12 +- .../queries/thread-history-query.test.tsx | 388 --------------- .../src/hooks/queries/thread-history-query.ts | 462 ------------------ .../src/hooks/queries/thread-queries.test.tsx | 36 -- apps/app/src/hooks/queries/thread-queries.ts | 9 +- .../thread-history-cache-effects.test.ts | 279 ----------- .../system-config-atoms.local-access.test.ts | 25 +- .../views/ToolsView.plugin-detail.test.tsx | 9 +- .../views/thread-detail/ThreadDetailView.tsx | 12 - ...hreadTimelineScrollToBottomButton.test.tsx | 54 -- .../ThreadTimelineScrollToBottomButton.tsx | 24 +- apps/server/src/routes/threads/data.ts | 3 +- ...ublic-thread-timeline-epoch-cursor.test.ts | 23 +- packages/client-core/package.json | 5 - .../src/timeline/timeline-merge.ts | 249 +--------- .../client-core/test/timeline-merge.test.ts | 401 --------------- 39 files changed, 281 insertions(+), 3952 deletions(-) delete mode 100644 apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts delete mode 100644 apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts delete mode 100644 apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts delete mode 100644 apps/app/src/hooks/queries/thread-history-query.test.tsx delete mode 100644 apps/app/src/hooks/queries/thread-history-query.ts delete mode 100644 apps/app/src/hooks/thread-history-cache-effects.test.ts delete mode 100644 apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx index 9b76e22020b..e3da67a42a0 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.test.tsx @@ -34,7 +34,6 @@ const mocks = vi.hoisted(() => ({ timelinePanelProps: [] as Array>, timelineProjectIds: [] as Array, resolveMentionLink: vi.fn(), - showLatestTimeline: vi.fn(), })); const hostDraftMocks = vi.hoisted(() => ({ @@ -162,11 +161,6 @@ vi.mock("@/components/ui/overflow-fade", () => ({ vi.mock("@/components/thread/timeline", () => ({ isRunningThreadRuntimeDisplayStatus: (status: string) => status === "active", - useThreadTimelineController: () => ({ - historyUnrefreshed: false, - showLatestTimeline: mocks.showLatestTimeline, - timelineRows: mocks.timelineRows, - }), ThreadTimelinePanelContent: (props: Record) => { mocks.timelinePanelProps.push(props); mocks.injectedTimelineProps.push(props.timeline); @@ -496,12 +490,7 @@ describe("EmbeddedThreadChat", () => { const rows = screen.getAllByTestId("embedded-chat-timeline-row"); expect(rows).toHaveLength(2); expect(rows[1]?.textContent).toBe("Streamed later"); - expect(mocks.injectedTimelineProps.at(-1)).toEqual( - expect.objectContaining({ - timelineRows: mocks.timelineRows, - showLatestTimeline: mocks.showLatestTimeline, - }), - ); + expect(mocks.injectedTimelineProps.at(-1)).toBeUndefined(); }); it("queues the submitted draft itself while the thread runtime is active", async () => { diff --git a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx index 064342f298c..63a54c76816 100644 --- a/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx +++ b/apps/app/src/components/thread/embedded-chat/EmbeddedThreadChat.tsx @@ -39,14 +39,12 @@ import { OverflowFade } from "@/components/ui/overflow-fade"; import { ThreadTimelinePanelContent, ThreadTimelineSurface, - useThreadTimelineController, type ThreadTimelineAddToChatHandler, type ThreadTimelineConsumerMessageAction, type ThreadTimelineLinkHandler, type ThreadTimelineLocalFileLinkHandler, type ThreadTimelineSurfaceProps, } from "@/components/thread/timeline"; -import { ThreadTimelineLatestContext } from "@/components/thread/timeline/ThreadTimelineLatestContext"; import { useThreadCreationOptions } from "@/hooks/useThreadCreationOptions"; import { getLatestPendingInteraction, @@ -66,7 +64,6 @@ import { useMarkThreadRead } from "@/hooks/mutations/thread-state-mutations"; import { useThreadReadTracking } from "@/hooks/useThreadReadTracking"; import { useComposerTextEffects } from "@/lib/composer-text-effects"; import { showMutationErrorToast } from "@/lib/mutation-errors"; -import { BbHttpError } from "@/lib/sdk"; import type { PromptDraftScope } from "@/hooks/usePromptDraftStorage"; import { appToast } from "@/components/ui/app-toast"; import { @@ -195,35 +192,23 @@ function EmbeddedThreadChatHostedFooter({ scrollOverlay, surface, }: EmbeddedThreadChatHostedFooterProps) { - const latestTimeline = useMemo( - () => - surface.onShowLatestTimeline === undefined - ? null - : { - historyUnrefreshed: surface.historyUnrefreshed ?? false, - showLatestTimeline: surface.onShowLatestTimeline, - }, - [surface.historyUnrefreshed, surface.onShowLatestTimeline], - ); return (
- - - - - + + +
); } @@ -255,14 +240,6 @@ function EmbeddedThreadChatWithComposer({ const sendThreadMessage = useSendThreadMessage(); const createQueuedMessage = useCreateThreadQueuedMessage(); const threadQuery = useThread(threadId); - const timeline = useThreadTimelineController({ - enabled: !( - threadQuery.error instanceof BbHttpError && - [401, 403, 404].includes(threadQuery.error.status) - ), - surfaceKey, - threadId, - }); const pendingInteractionsQuery = useThreadPendingInteractions(threadId); const activePendingInteraction = getLatestPendingInteraction( pendingInteractionsQuery.data, @@ -1184,7 +1161,6 @@ function EmbeddedThreadChatWithComposer({ const maxWidthClassName = measure === "page" ? "max-w-[760px]" : "max-w-none"; const timelineBody = ( +
-
- {timelineBody} -
-
{footer}
+ {timelineBody}
- +
{footer}
+
); } return ( - -
+ - - {timelineBody} - -
-
+ {timelineBody} + +
); } diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts b/apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts deleted file mode 100644 index a12f581d72c..00000000000 --- a/apps/app/src/components/thread/timeline/ThreadTimelineLatestContext.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createContext } from "react"; - -export const ThreadTimelineLatestContext = createContext<{ - historyUnrefreshed: boolean; - showLatestTimeline: () => void; -} | null>(null); diff --git a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx index e7696edabb6..1b54f485c71 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelinePanelContent.test.tsx @@ -7,13 +7,11 @@ import type { ThreadRuntimeDisplayStatus } from "@bb/domain"; import type { TimelineWorkflowWorkRow } from "@bb/server-contract"; import { ThreadTimelinePanelContent } from "./ThreadTimelinePanelContent.js"; import type { UseThreadTimelineControllerResult } from "./useThreadTimelineController.js"; -import { BbHttpError } from "@/lib/sdk"; const mocks = vi.hoisted(() => ({ activeBackgroundAgentCount: 0, displayStatus: "idle" as ThreadRuntimeDisplayStatus, threadStatus: "idle", - threadError: null as Error | null, })); vi.mock("@/hooks/queries/thread-queries", () => ({ @@ -23,7 +21,7 @@ vi.mock("@/hooks/queries/thread-queries", () => ({ runtime: { displayStatus: mocks.displayStatus }, status: mocks.threadStatus, }, - error: mocks.threadError, + error: null, }), })); @@ -44,7 +42,23 @@ vi.mock("./ThreadTimelineSurface.js", () => ({ })); vi.mock("./useThreadTimelineController.js", () => ({ - useThreadTimelineController: () => baseTimeline(), + useThreadTimelineController: () => ({ + activePromptMode: null, + activeThinking: null, + activeWorkflows: [], + activeBackgroundCommands: [], + contextBoundarySeq: null, + contextWindowUsage: undefined, + goal: null, + modelFallback: null, + hasOlderTimelineRows: false, + isLoadingOlderTimelineRows: false, + loadOlderTimelineRows: vi.fn(), + pendingTodos: null, + timelineError: null, + timelineLoading: false, + timelineRows: [], + }), })); vi.mock("@/components/ui/conversation.js", () => ({ @@ -90,14 +104,8 @@ function baseTimeline( contextWindowUsage: undefined, goal: null, hasOlderTimelineRows: false, - historyRefreshError: null, - historyUnrefreshed: false, - historyReplacementKey: null, isLoadingOlderTimelineRows: false, - isRefreshingHistory: false, loadOlderTimelineRows: vi.fn(), - refreshHistory: vi.fn().mockResolvedValue(undefined), - showLatestTimeline: vi.fn(), pendingTodos: null, timelineError: null, timelineLoading: false, @@ -113,33 +121,9 @@ afterEach(() => { mocks.activeBackgroundAgentCount = 0; mocks.displayStatus = "idle"; mocks.threadStatus = "idle"; - mocks.threadError = null; }); describe("ThreadTimelinePanelContent", () => { - it.each([401, 403, 404])( - "hides retained history after a %s access failure", - (status) => { - mocks.threadError = new BbHttpError({ - body: null, - code: null, - message: "Unavailable", - status, - }); - render( - , - ); - - expect( - screen.getByText("This thread is no longer available."), - ).not.toBeNull(); - expect(screen.queryByText("Background work running")).toBeNull(); - }, - ); - it("shows a background-only working indicator while runtime is idle", () => { render( {leadingContent} @@ -101,11 +100,7 @@ export function ThreadTimelinePanelContent({ activeThinking={resolvedTimeline.activeThinking} contextBoundarySeq={resolvedTimeline.contextBoundarySeq} hasOlderTimelineRows={resolvedTimeline.hasOlderTimelineRows} - historyRefreshError={resolvedTimeline.historyRefreshError} - historyUnrefreshed={resolvedTimeline.historyUnrefreshed} - historyReplacementKey={resolvedTimeline.historyReplacementKey} isLoadingOlderTimelineRows={resolvedTimeline.isLoadingOlderTimelineRows} - isRefreshingHistory={resolvedTimeline.isRefreshingHistory} isThreadTimelinePending={ resolvedTimeline.timelineLoading && timelineRows.length === 0 && @@ -121,8 +116,6 @@ export function ThreadTimelinePanelContent({ consumerMessageActions={consumerMessageActions} includePluginMessageActions={includePluginMessageActions} onLoadOlderRows={resolvedTimeline.loadOlderTimelineRows} - onRefreshHistory={resolvedTimeline.refreshHistory} - onShowLatestTimeline={resolvedTimeline.showLatestTimeline} onOpenLink={onOpenLink} onOpenLocalFileLink={onOpenLocalFileLink} projectId={projectId} diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx index b5465a869ad..ca21e8c4ba3 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx @@ -100,7 +100,6 @@ import { } from "./PluginTimelineRendererBody.js"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import { - TimelineReplacementScrollAnchor, TimelineScrollRestoreRowIdContext, useBottomAnchoredScroll, } from "@/components/ui/bottom-anchored-scroll-body.js"; @@ -168,7 +167,6 @@ export interface ThreadTimelineRowsProps { resolveImageViewSrc?: ThreadTimelineImageViewSrcResolver; resolveUserAttachmentImageSrc?: UserAttachmentImageSrcResolver; hasOlderTimelineRows?: boolean; - historyReplacementKey?: object | null; isLoadingOlderTimelineRows?: boolean; onLoadOlderRows?: () => Promise | void; timelineRows: TimelineRow[]; @@ -2182,10 +2180,6 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) { snapRevision={heightSnapRevision} animateGrowth={!scopeActive} > - { }); describe("ThreadTimelineRows windowing experiment", () => { - it("uses rendered group IDs to preserve the nearest survivor when a refreshed bundle dissolves", () => { - const initialRows = [ - conversationRow({ id: "before", role: "user", text: "Inspect files" }), - fileReadRow({ id: "read-a", path: "a.ts", seq: 2 }), - fileReadRow({ id: "read-b", path: "b.ts", seq: 3 }), - conversationRow({ id: "after", text: "Result", seq: 4 }), - conversationRow({ id: "tail", role: "user", text: "Continue", seq: 5 }), - ]; - const queryClient = new QueryClient(); - const timeline = (rows: typeof initialRows, replacementKey: object) => ( - - - - - - - - ); - const view = render(timeline(initialRows, {})); - const scrollArea = view.container.querySelector( - ".replacement-scroll", - ); - if (!scrollArea) throw new Error("Expected a scroll area"); - Object.defineProperty(scrollArea, "clientHeight", { value: 100 }); - Object.defineProperty(scrollArea, "scrollHeight", { value: 400 }); - vi.mocked(HTMLElement.prototype.getBoundingClientRect).mockImplementation( - function (this: HTMLElement) { - const topLevelRows = view.container.querySelectorAll( - '[data-timeline-row-list="top-level"] > [data-timeline-row-id]', - ); - const index = Array.from(topLevelRows).indexOf(this); - return rect(index < 0 ? 0 : index * 100 - scrollArea.scrollTop, 100); - }, - ); - const groupId = buildTimelineViewRows(initialRows)[1]?.id; - expect(groupId).toContain("work-summary"); - scrollArea.scrollTop = 150; - fireEvent.wheel(scrollArea, { deltaY: -150 }); - fireEvent.scroll(scrollArea); - - view.rerender( - timeline(initialRows.filter((row) => row.id !== "read-a"), {}), - ); - - expect(scrollArea.scrollTop).toBe(200); - expect( - view.container.querySelector(`[data-timeline-row-id="${groupId}"]`), - ).toBeNull(); - }); - it("keeps the control timeline fully mounted", () => { const view = renderDelegation(false); const nestedList = view.container.querySelector( diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx index 1aed8b38f90..dc1521fd48f 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.test.tsx @@ -1,105 +1,20 @@ // @vitest-environment jsdom -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { TimelineRow } from "@bb/server-contract"; import { BottomAnchorContext } from "@/components/ui/bottom-anchored-scroll-body.js"; -import { conversationRow } from "@/test/fixtures/thread-timeline-rows"; import { ThreadTimelineSurface } from "./ThreadTimelineSurface"; vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ data: undefined }), })); -vi.mock("./ThreadTimelineRows.js", () => ({ - ThreadTimelineRows: ({ timelineRows }: { timelineRows: TimelineRow[] }) => ( -
- {timelineRows.map((row) => ( -
{row.kind === "conversation" ? row.text : row.id}
- ))} -
- ), -})); - afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); describe("ThreadTimelineSurface load-older control", () => { - it("offers Show latest for held history without a composer or bottom-anchor context", () => { - const showLatest = vi.fn(); - const surface = (historyUnrefreshed: boolean) => ( - - ); - const view = render(surface(true)); - expect(screen.getByText("Previously loaded reply")).not.toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Show latest" })); - expect(showLatest).toHaveBeenCalledTimes(1); - view.rerender(surface(false)); - expect(screen.queryByRole("button", { name: "Show latest" })).toBeNull(); - }); - - it("keeps cached messages readable when refresh fails and offers a bounded retry", () => { - const refresh = vi.fn().mockResolvedValue(undefined); - const surface = (isRefreshingHistory: boolean) => ( - - ); - const view = render(surface(false)); - - expect(screen.getByText("Previously loaded reply")).not.toBeNull(); - expect(screen.queryByText("Failed to load timeline")).toBeNull(); - expect(screen.getByRole("status").textContent).toContain( - "Couldn't refresh history", - ); - fireEvent.click(screen.getByRole("button", { name: "Retry" })); - expect(refresh).toHaveBeenCalledTimes(1); - - view.rerender(surface(true)); - expect( - screen.getByRole("button", { name: "Refreshing…" }) - .disabled, - ).toBe(true); - expect(screen.getByText("Previously loaded reply")).not.toBeNull(); - }); - it("resumes auto-loading after a context boundary replaces a timeline whose older page failed", async () => { const intersectionCallbacks: IntersectionObserverCallback[] = []; vi.stubGlobal( diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx index 496da881965..e8f81465f1f 100644 --- a/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx +++ b/apps/app/src/components/thread/timeline/ThreadTimelineSurface.tsx @@ -43,12 +43,8 @@ export interface ThreadTimelineSurfaceProps { contextBoundarySeq: number | null; threadOriginKind?: ThreadOriginKind | null; hasOlderTimelineRows?: boolean; - historyRefreshError?: Error | null; - historyUnrefreshed?: boolean; - historyReplacementKey?: object | null; hostConnectionNotice?: HostConnectionNotice | null; isLoadingOlderTimelineRows?: boolean; - isRefreshingHistory?: boolean; isThreadTimelinePending: boolean; timelineError: boolean; loadingContent?: ReactNode; @@ -62,8 +58,6 @@ export interface ThreadTimelineSurfaceProps { consumerMessageActions?: readonly ThreadTimelineConsumerMessageAction[]; includePluginMessageActions?: boolean; onLoadOlderRows?: () => Promise | void; - onRefreshHistory?: () => Promise; - onShowLatestTimeline?: () => void; onOpenLink?: ThreadTimelineLinkHandler; onOpenLocalFileLink?: ThreadTimelineLocalFileLinkHandler; onOpenPluginPanel?: ThreadTimelineOpenPluginPanelHandler; @@ -151,12 +145,8 @@ export function ThreadTimelineSurface({ contextBoundarySeq, threadOriginKind = null, hasOlderTimelineRows = false, - historyRefreshError = null, - historyUnrefreshed = false, - historyReplacementKey = null, hostConnectionNotice, isLoadingOlderTimelineRows = false, - isRefreshingHistory = false, isThreadTimelinePending, timelineError, loadingContent, @@ -170,8 +160,6 @@ export function ThreadTimelineSurface({ consumerMessageActions, includePluginMessageActions, onLoadOlderRows, - onRefreshHistory, - onShowLatestTimeline, onOpenLink, onOpenLocalFileLink, onOpenPluginPanel, @@ -215,48 +203,12 @@ export function ThreadTimelineSurface({ hasOlderTimelineRows && onLoadOlderRows !== undefined && !isThreadTimelinePending && - (!timelineError || timelineRowsWithPendingStop.length > 0); + !timelineError; return ( {leadingContent} - {timelineRowsWithPendingStop.length > 0 && - (historyRefreshError !== null || historyUnrefreshed) ? ( -
- - {historyRefreshError !== null - ? "Couldn't refresh history. Showing saved messages." - : "This history hasn't been refreshed yet."} - - {onRefreshHistory ? ( - - ) : null} - {historyUnrefreshed && onShowLatestTimeline ? ( - - ) : null} -
- ) : null} {showLoadOlderRows ? ( ) : null} - {isThreadTimelinePending && timelineRowsWithPendingStop.length === 0 ? ( + {isThreadTimelinePending ? ( (loadingContent ?? ) - ) : timelineError && timelineRowsWithPendingStop.length === 0 ? ( + ) : timelineError ? ( ) : timelineRowsWithPendingStop.length > 0 ? ( (); - readonly callback: ResizeObserverCallback; - constructor(callback: ResizeObserverCallback) { - this.callback = callback; - ResizeObserverStub.instances.push(this); - } - disconnect(): void { - this.targets.clear(); - } - observe(target: Element): void { - this.targets.add(target); - } - unobserve(target: Element): void { - this.targets.delete(target); - } - trigger(target: Element, height: number): void { - this.callback( - [ - { - target, - contentRect: new DOMRect(0, 0, 320, height), - borderBoxSize: [{ blockSize: height, inlineSize: 320 }], - contentBoxSize: [{ blockSize: height, inlineSize: 320 }], - devicePixelContentBoxSize: [{ blockSize: height, inlineSize: 320 }], - }, - ], - this, - ); - } + disconnect(): void {} + observe(): void {} + unobserve(): void {} } function renderWindowedItems(options?: { @@ -117,7 +90,6 @@ function renderWindowedItems(options?: { } beforeEach(() => { - ResizeObserverStub.instances = []; itemHeights = new Map(); scrollElement = document.createElement("div"); document.body.append(scrollElement); @@ -264,33 +236,6 @@ describe("TimelineWindowedItems", () => { expect(screen.getByTestId("content-50")).toBeTruthy(); }); - it("realizes the viewport around a restored anchor when measurements settle without new data", async () => { - vi.useFakeTimers(); - renderWindowedItems({ alwaysMountedKeys: new Set(["row-50"]) }); - await act(async () => {}); - - scrollElement.scrollTop = 1_600; - fireEvent.scroll(scrollElement); - await act(async () => {}); - - expect(screen.getByTestId("content-50")).toBeTruthy(); - expect(screen.queryByTestId("content-51")).toBeNull(); - const anchor = screen.getByTestId("wrapper-50"); - const observer = ResizeObserverStub.instances.find((candidate) => - candidate.targets.has(anchor), - ); - if (!observer) throw new Error("Expected the anchor to be measured"); - - act(() => { - itemHeights.set(50, 33); - observer.trigger(anchor, 33); - }); - - expect(screen.getByTestId("content-51")).toBeTruthy(); - expect(screen.getByTestId("content-52")).toBeTruthy(); - expect(scrollElement.scrollTop).toBe(1_600); - }); - it("seeds its size model from measurements retained by the thread", async () => { const measurements = new Map([["row-50", 64]]); renderWindowedItems({ measurements }); diff --git a/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx index 2b2f8230c0d..23a2348a141 100644 --- a/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx +++ b/apps/app/src/components/thread/timeline/TimelineWindowedItems.tsx @@ -29,6 +29,7 @@ const GET_NO_SCROLL_ELEMENT = () => null; interface ScrollSample { at: number; + fast: boolean; offset: number; } @@ -70,10 +71,10 @@ export function TimelineWindowedItems({ const [scrollRootUsable, setScrollRootUsable] = useState(true); const [scrollMargin, setScrollMargin] = useState(0); const [interactionPins, setInteractionPins] = useState([]); - const [fastScrolling, setFastScrolling] = useState(false); const containerElementRef = useRef(null); const scrollSampleRef = useRef({ at: 0, + fast: false, offset: 0, }); const windowingEnabled = configured && scrollRootUsable; @@ -143,7 +144,7 @@ export function TimelineWindowedItems({ ) => { const sample = scrollSampleRef.current; if (!scrolling) { - setFastScrolling(false); + sample.fast = false; sample.at = 0; sample.offset = instance.scrollOffset ?? sample.offset; return; @@ -153,10 +154,9 @@ export function TimelineWindowedItems({ const elapsed = sample.at === 0 ? 0 : now - sample.at; const distance = Math.abs(offset - sample.offset); const viewportSize = instance.scrollRect?.height ?? 0; - setFastScrolling( + sample.fast = (sample.at === 0 || elapsed <= 100) && - distance >= Math.max(200, viewportSize * 0.5), - ); + distance >= Math.max(200, viewportSize * 0.5); sample.at = now; sample.offset = offset; }, @@ -269,6 +269,7 @@ export function TimelineWindowedItems({ ); } + const fastScrolling = scrollSampleRef.current.fast; const virtualItemsByIndex = new Map( virtualizer.getVirtualItems().map((item) => [item.index, item]), ); diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx index bd8ff91c2e7..836c3982912 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.test.tsx @@ -13,17 +13,13 @@ import { type ProfilerOnRenderCallback, type ReactNode, } from "react"; -import { useStore } from "jotai"; import { MemoryRouter } from "react-router-dom"; import type { QueryClient } from "@tanstack/react-query"; import type { ThreadTimelineResponse, TimelineUserConversationRow, } from "@bb/server-contract"; -import { - mergeLatestTimelineRows, - resolveLoadedTimelineSurfaceKey, -} from "@bb/client-core"; +import { mergeLatestTimelineRows } from "@bb/client-core"; import { createDeferredPromise, type DeferredPromise } from "@bb/test-helpers"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -32,16 +28,7 @@ import { } from "@/components/ui/bottom-anchored-scroll-body.js"; import { BbHttpError, sdk } from "@/lib/sdk"; import { OPTIMISTIC_TIMELINE_ROW_ID_PREFIX } from "@bb/client-core"; -import { - threadHistoryQueryKey, - threadTimelineQueryKey, -} from "@/hooks/queries/query-keys"; -import { - createThreadHistoryPage, - removeThreadHistory, - type ThreadHistoryChain, -} from "@/hooks/cache-owners/thread-history-cache-owner"; -import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; +import { threadTimelineQueryKey } from "@/hooks/queries/query-keys"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { systemRow } from "@/test/fixtures/thread-timeline-rows"; import { useAutoLoadOlderRows } from "./useAutoLoadOlderRows"; @@ -736,7 +723,7 @@ describe("useThreadTimelineController", () => { expect(result.current.hasOlderTimelineRows).toBe(true); }); - it("rebuilds retained pages once from fresh cursors after a stale older-page cursor", async () => { + it("adopts the refetched latest cursor after a stale older-page cursor", async () => { vi.mocked(sdk.threads.timeline) .mockResolvedValueOnce( makeTimelineResponse({ @@ -770,13 +757,7 @@ describe("useThreadTimelineController", () => { }), ) .mockResolvedValueOnce(makeSameSnapshotRealtimeResponse()) - .mockResolvedValueOnce( - makeTimelineResponse({ - rows: [olderPageRow], - maxSeq: 2, - timelinePage: { kind: "older", historySnapshot: "snapshot-1" }, - }), - ); + .mockReturnValueOnce(new Promise(() => {})); const { wrapper } = createQueryClientTestHarness(); const { result } = renderHook( @@ -812,91 +793,20 @@ describe("useThreadTimelineController", () => { }); expect(timelineRequests[3]?.[0]).toMatchObject({ afterSequence: "1" }); expect(result.current.isLoadingOlderTimelineRows).toBe(false); - expect(result.current.hasOlderTimelineRows).toBe(false); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(5); + expect(result.current.hasOlderTimelineRows).toBe(true); + + act(() => { + void result.current.loadOlderTimelineRows(); + }); + await waitFor(() => { + expect(sdk.threads.timeline).toHaveBeenCalledTimes(5); + }); expect(vi.mocked(sdk.threads.timeline).mock.calls[4]?.[0]).toMatchObject({ beforeAnchorId: newestLoadedRow.id, beforeAnchorSeq: "1", }); }); - it("advances beyond deep history after a cursor-invalidating rename with five pages retained", async () => { - let revision = 1; - const page = (sequence: number, kind: "latest" | "older") => - makeTimelineResponse({ - rows: [makeUserRow(`row-${sequence}`, sequence)], - maxSeq: 9, - timelinePage: { - kind, - historySnapshot: `snapshot-${revision}`, - olderRowsSourceSeqEnd: sequence - 1, - hasOlderRows: sequence > 0, - olderCursor: - sequence > 0 - ? { anchorId: `${revision}:${sequence}`, anchorSeq: sequence } - : null, - }, - }); - vi.mocked(sdk.threads.timeline).mockImplementation(async (request) => { - if (!request.beforeAnchorId) return page(9, "latest"); - if (!request.beforeAnchorId.startsWith(`${revision}:`)) { - throw new BbHttpError({ - body: null, - code: "invalid_request", - message: "Timeline pagination cursor is no longer available", - status: 400, - }); - } - return page(Number(request.beforeAnchorSeq) - 1, "older"); - }); - const { queryClient, wrapper } = createQueryClientTestHarness(); - const latest = page(9, "latest"); - queryClient.setQueryData(TIMELINE_QUERY_KEY, latest); - const key = threadHistoryQueryKey( - "thread-1", - resolveLoadedTimelineSurfaceKey("thread-1", latest), - latest.timelinePage.segmentLimit, - ); - const { result } = renderHook( - () => useThreadTimelineController({ threadId: "thread-1" }), - { wrapper }, - ); - for (let index = 0; index < 6; index += 1) { - await act(async () => result.current.loadOlderTimelineRows()); - } - const loadedIds = rowIds(result.current); - expect(loadedIds).toEqual([ - "row-3", - "row-4", - "row-5", - "row-6", - "row-7", - "row-8", - "row-9", - ]); - expect(queryClient.getQueryData(key)?.pages).toHaveLength(5); - - revision = 2; - await act(async () => result.current.loadOlderTimelineRows()); - expect(rowIds(result.current)).toEqual(loadedIds); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(12); - for (let index = 0; index < 3; index += 1) { - await act(async () => result.current.loadOlderTimelineRows()); - } - - expect(rowIds(result.current)).toEqual(["row-2", ...loadedIds]); - const requests = vi.mocked(sdk.threads.timeline).mock.calls; - expect(requests.slice(12).map(([request]) => request.beforeAnchorId)).toEqual([ - "2:5", - "2:4", - "2:3", - ]); - expect( - requests.filter(([request]) => request.beforeAnchorId === "1:3"), - ).toHaveLength(1); - expect(queryClient.getQueryData(key)?.pages).toHaveLength(5); - }); - it("keeps auto-loading when an older page settles before its loading state renders", async () => { const { anchor, emitIntersection, sentinel } = installAutoLoadEnvironment(); vi.mocked(sdk.threads.timeline) @@ -1102,200 +1012,6 @@ describe("useThreadTimelineController", () => { }); }); -describe("retained thread history", () => { - function seedHistory(queryClient: QueryClient, validatedAt = Date.now()) { - const latest = makeTimelineResponse({ - rows: [newestLoadedRow], - maxSeq: 1, - timelinePage: { - historySnapshot: "snapshot-1", - hasOlderRows: true, - olderCursor: { anchorId: newestLoadedRow.id, anchorSeq: 1 }, - }, - }); - const older = makeTimelineResponse({ - rows: [olderPageRow], - maxSeq: 1, - timelinePage: { kind: "older", historySnapshot: "snapshot-1" }, - }); - queryClient.setQueryData(TIMELINE_QUERY_KEY, latest); - const surfaceKey = resolveLoadedTimelineSurfaceKey("thread-1", latest); - queryClient.setQueryData( - threadHistoryQueryKey( - "thread-1", - surfaceKey, - latest.timelinePage.segmentLimit, - ), - { - surfaceKey, - pages: [ - createThreadHistoryPage(latest, null, validatedAt), - createThreadHistoryPage( - older, - latest.timelinePage.olderCursor, - validatedAt, - ), - ], - }, - { updatedAt: validatedAt }, - ); - return { latest, older }; - } - - it("shows retained rows immediately on a warm return without fetching fresh pages", () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); - seedHistory(queryClient); - const first = renderHook( - () => useThreadTimelineController({ threadId: "thread-1" }), - { wrapper }, - ); - expect(rowIds(first.result.current)).toEqual([ - olderPageRow.id, - newestLoadedRow.id, - ]); - first.unmount(); - const returned = renderHook( - () => useThreadTimelineController({ threadId: "thread-1" }), - { wrapper }, - ); - expect(rowIds(returned.result.current)).toEqual([ - olderPageRow.id, - newestLoadedRow.id, - ]); - expect(sdk.threads.timeline).not.toHaveBeenCalled(); - }); - - it("keeps cached rows during a background failure and applies a successful retry", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); - const { latest, older } = seedHistory(queryClient, Date.now() - 10_000); - const refresh = createDeferredPromise(); - vi.mocked(sdk.threads.timeline).mockReturnValueOnce(refresh.promise); - const { result } = renderHook( - () => useThreadTimelineController({ threadId: "thread-1" }), - { wrapper }, - ); - expect(rowIds(result.current)).toEqual([ - olderPageRow.id, - newestLoadedRow.id, - ]); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); - refresh.reject(makeServerError()); - await waitFor(() => - expect(result.current.historyRefreshError).not.toBeNull(), - ); - expect(rowIds(result.current)).toEqual([ - olderPageRow.id, - newestLoadedRow.id, - ]); - const editedOlder = { ...olderPageRow, text: "Updated older message" }; - vi.mocked(sdk.threads.timeline) - .mockResolvedValueOnce(latest) - .mockResolvedValueOnce({ ...older, rows: [editedOlder] }); - await act(async () => { - await result.current.refreshHistory(); - }); - await waitFor(() => - expect(result.current.timelineRows[0]).toEqual(editedOlder), - ); - expect(result.current.historyRefreshError).toBeNull(); - expect(result.current.timelineLoading).toBe(false); - }); - - it("holds detached history when the latest window has a gap until the reader selects latest", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); - seedHistory(queryClient); - const { result } = renderHook( - () => ({ - timeline: useThreadTimelineController({ threadId: "thread-1" }), - store: useStore(), - }), - { wrapper }, - ); - act(() => { - result.current.store.set( - threadTimelineScrollAnchorAtomFamily("thread-1"), - { - rowId: olderPageRow.id, - offsetWithinRow: 12, - atBottom: false, - }, - ); - queryClient.setQueryData( - TIMELINE_QUERY_KEY, - makeTimelineResponse({ - rows: [contextClearRow], - maxSeq: 10, - timelinePage: { - historySnapshot: "snapshot-2", - hasOlderRows: true, - olderCursor: { anchorId: contextClearRow.id, anchorSeq: 10 }, - }, - }), - ); - }); - await waitFor(() => - expect(result.current.timeline.historyUnrefreshed).toBe(true), - ); - expect(rowIds(result.current.timeline)).toEqual([ - olderPageRow.id, - newestLoadedRow.id, - ]); - act(() => result.current.timeline.showLatestTimeline()); - expect(rowIds(result.current.timeline)).toEqual([contextClearRow.id]); - expect(result.current.timeline.historyUnrefreshed).toBe(false); - }); - - it("loads a cache miss when a detached scroll anchor remains after history eviction", async () => { - const latest = createDeferredPromise(); - vi.mocked(sdk.threads.timeline).mockReturnValueOnce(latest.promise); - const { wrapper } = createQueryClientTestHarness(); - const { result } = renderHook( - () => ({ - timeline: useThreadTimelineController({ threadId: "thread-1" }), - store: useStore(), - }), - { wrapper }, - ); - act(() => - result.current.store.set( - threadTimelineScrollAnchorAtomFamily("thread-1"), - { - rowId: olderPageRow.id, - offsetWithinRow: 12, - atBottom: false, - }, - ), - ); - expect(result.current.timeline.timelineLoading).toBe(true); - latest.resolve( - makeTimelineResponse({ rows: [newestLoadedRow], maxSeq: 1 }), - ); - await waitFor(() => - expect(rowIds(result.current.timeline)).toEqual([newestLoadedRow.id]), - ); - expect(result.current.timeline.historyUnrefreshed).toBe(false); - }); - - it("clears controller-held rows when history access is revoked", async () => { - const { queryClient, wrapper } = createQueryClientTestHarness(); - seedHistory(queryClient); - const { result } = renderHook( - () => useThreadTimelineController({ threadId: "thread-1" }), - { wrapper }, - ); - expect(rowIds(result.current)).toHaveLength(2); - act(() => removeThreadHistory({ queryClient, threadId: "thread-1" })); - await waitFor(() => expect(rowIds(result.current)).toEqual([])); - act(() => - queryClient.setQueryData( - TIMELINE_QUERY_KEY, - makeTimelineResponse({ rows: [realtimeRow], maxSeq: 2 }), - ), - ); - expect(rowIds(result.current)).toEqual([]); - }); -}); - describe("useThreadTimelineController commits", () => { it.each([ { diff --git a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts index 12843197795..e91ac8f1a94 100644 --- a/apps/app/src/components/thread/timeline/useThreadTimelineController.ts +++ b/apps/app/src/components/thread/timeline/useThreadTimelineController.ts @@ -1,5 +1,4 @@ import { useCallback, useState } from "react"; -import { useStore } from "jotai"; import { useQueryClient, type QueryObserverResult, @@ -8,22 +7,17 @@ import type { ThreadTimelineResponse, TimelineRow } from "@bb/server-contract"; import { areTimelinePaginationCursorsEqual, buildLoadedTimelineState, - buildLoadedTimelineFromPages, - reconcileLoadedTimelineWithHistoryPages, - tryMergeLoadedTimelineWithLatest, mergeLoadedTimelineWithLatest, prependOlderTimelineRows, + recoverLoadedTimelineAfterStaleCursor, resolveLoadedTimelineSurfaceKey, type LoadedTimelineState, -} from "@bb/client-core/timeline"; +} from "@bb/client-core"; import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-query-state"; import { threadTimelineQueryKey } from "@/hooks/queries/query-keys"; import { isTransientReadError } from "@/hooks/queries/query-helpers"; import { useThreadTimeline } from "@/hooks/queries/thread-queries"; -import { BbHttpError } from "@/lib/sdk"; -import { useThreadHistory } from "@/hooks/queries/thread-history-query"; -import type { ThreadHistoryChain } from "@/hooks/cache-owners/thread-history-cache-owner"; -import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; +import { BbHttpError, sdk } from "@/lib/sdk"; type TimelineQueryResultProp = keyof QueryObserverResult; @@ -56,12 +50,6 @@ export interface UseThreadTimelineControllerResult { hasOlderTimelineRows: boolean; isLoadingOlderTimelineRows: boolean; loadOlderTimelineRows: () => Promise; - historyRefreshError: Error | null; - historyUnrefreshed: boolean; - historyReplacementKey: object | null; - isRefreshingHistory: boolean; - refreshHistory: () => Promise; - showLatestTimeline: () => void; pendingTodos: ThreadTimelineResponse["pendingTodos"]; timelineError: Error | null; timelineLoading: boolean; @@ -70,17 +58,20 @@ export interface UseThreadTimelineControllerResult { interface LoadedTimelineTracker { latestTimeline: ThreadTimelineResponse | undefined; - history: ThreadHistoryChain | undefined; - generation: number; loaded: LoadedTimelineState; - unrefreshed: boolean; - replacementKey: object | null; } -function isAccessError(error: Error | null): boolean { +interface ReconcileLoadedTimelineArgs { + current: LoadedTimelineState; + latestTimeline: ThreadTimelineResponse | undefined; + surfaceKey: string; +} + +function isStaleTimelinePaginationCursorError(error: Error): boolean { return ( error instanceof BbHttpError && - (error.status === 401 || error.status === 403 || error.status === 404) + error.status === 400 && + error.code === "invalid_request" ); } @@ -95,6 +86,24 @@ function buildEmptyLoadedTimelineState( }); } +function reconcileLoadedTimeline({ + current, + latestTimeline, + surfaceKey, +}: ReconcileLoadedTimelineArgs): LoadedTimelineState { + if (!latestTimeline) { + return current.surfaceKey === surfaceKey + ? current + : buildEmptyLoadedTimelineState(surfaceKey); + } + + return mergeLoadedTimelineWithLatest({ + current, + latestTimeline, + surfaceKey, + }); +} + export function useThreadTimelineController({ enabled = true, surfaceKey: explicitSurfaceKey, @@ -119,190 +128,126 @@ export function useThreadTimelineController({ explicitSurfaceKey ?? threadId, latestTimeline, ); - const history = useThreadHistory({ threadId, latestTimeline, enabled }); - const store = useStore(); - const accessDenied = isAccessError(latestTimelineQuery.error); - const blocked = accessDenied || history.isBlocked; - const [tracker, setTracker] = useState(() => ({ - latestTimeline: undefined, - history: undefined, - generation: history.generation, - loaded: buildEmptyLoadedTimelineState(surfaceKey), - unrefreshed: false, - replacementKey: null, - })); - let current = tracker; + const [loadedTimelineTracker, setLoadedTimelineTracker] = + useState(() => ({ + latestTimeline, + loaded: reconcileLoadedTimeline({ + current: buildEmptyLoadedTimelineState(surfaceKey), + latestTimeline, + surfaceKey, + }), + })); + let loadedTimeline = loadedTimelineTracker.loaded; if ( - tracker.latestTimeline !== latestTimeline || - tracker.history !== history.data || - tracker.generation !== history.generation || - tracker.loaded.surfaceKey !== surfaceKey || - (blocked && tracker.loaded.rows.length > 0) + loadedTimelineTracker.latestTimeline !== latestTimeline || + loadedTimeline.surfaceKey !== surfaceKey ) { - const reset = - tracker.loaded.surfaceKey !== surfaceKey || - tracker.generation !== history.generation; - let loaded = reset - ? buildEmptyLoadedTimelineState(surfaceKey) - : tracker.loaded; - let unrefreshed = !reset && tracker.unrefreshed; - let replacementKey = tracker.replacementKey; - const detached = - store.get(threadTimelineScrollAnchorAtomFamily(threadId))?.atBottom === - false; - if (blocked) { - loaded = buildEmptyLoadedTimelineState(surfaceKey); - unrefreshed = false; - } else { - if (history.data && (reset || tracker.history !== history.data)) { - const head = history.data.pages[0]; - const replaced = reset || head !== tracker.history?.pages[0]; - if (!replaced) { - for (const page of history.data.pages.slice(1)) { - if ( - areTimelinePaginationCursorsEqual({ - left: loaded.olderCursor, - right: page.requestCursor, - }) - ) { - loaded = { - ...loaded, - olderCursor: page.response.timelinePage.olderCursor, - rows: prependOlderTimelineRows({ - loadedRows: loaded.rows, - olderRows: page.response.rows, - }), - }; - } - } - } else { - const pages = history.data.pages.map((page) => page.response); - const refreshed = - reconcileLoadedTimelineWithHistoryPages({ - current: loaded, - pages, - surfaceKey, - }) ?? - (detached - ? null - : buildLoadedTimelineFromPages({ pages, surfaceKey })); - if (refreshed) { - loaded = refreshed; - unrefreshed = false; - replacementKey = head ?? null; - } else { - unrefreshed = true; - } - } - } - if ( - latestTimeline && - (reset || - tracker.latestTimeline !== latestTimeline || - tracker.history !== history.data) - ) { - const merged = tryMergeLoadedTimelineWithLatest({ - current: loaded, - latestTimeline, - surfaceKey, - }); - if (merged) { - loaded = merged; - } else if (!detached || loaded.rows.length === 0) { - loaded = mergeLoadedTimelineWithLatest({ - current: loaded, - latestTimeline, - surfaceKey, - }); - unrefreshed = false; - replacementKey = latestTimeline; - } else { - unrefreshed = true; - } - } - if ( - history.data?.recoveredFromCursor && - areTimelinePaginationCursorsEqual({ - left: loaded.olderCursor, - right: history.data.recoveredFromCursor, - }) - ) { - loaded = { - ...loaded, - olderCursor: - history.data.pages.at(-1)?.response.timelinePage.olderCursor ?? null, - }; - } - } - current = { + loadedTimeline = reconcileLoadedTimeline({ + current: loadedTimelineTracker.loaded, latestTimeline, - history: history.data, - generation: history.generation, - loaded, - unrefreshed, - replacementKey: - tracker.latestTimeline === undefined && tracker.history === undefined - ? null - : replacementKey, - }; - setTracker(current); + surfaceKey, + }); + setLoadedTimelineTracker({ latestTimeline, loaded: loadedTimeline }); } - const loadedTimeline = current.loaded; - const nextOlderCursor = blocked ? null : loadedTimeline.olderCursor; + const updateLoadedTimeline = useCallback( + (update: (current: LoadedTimelineState) => LoadedTimelineState) => { + setLoadedTimelineTracker((current) => { + const loaded = update(current.loaded); + return loaded === current.loaded ? current : { ...current, loaded }; + }); + }, + [], + ); + const [isLoadingOlderTimelineRows, setIsLoadingOlderTimelineRows] = + useState(false); + const refetchLatestTimeline = latestTimelineQuery.refetch; + + const nextOlderCursor = + loadedTimeline.surfaceKey === surfaceKey + ? loadedTimeline.olderCursor + : null; const hasOlderTimelineRows = nextOlderCursor !== null; - const loadOlder = history.loadOlder; const loadOlderTimelineRows = useCallback(async (): Promise => { - if (!enabled || !latestTimeline || !nextOlderCursor || !threadId || blocked) + if ( + !enabled || + !nextOlderCursor || + !threadId || + isLoadingOlderTimelineRows + ) { return; - const response = await loadOlder(nextOlderCursor); - if (!response) return; - setTracker((previous) => { - if ( - previous.loaded.surfaceKey !== surfaceKey || - previous.generation !== history.generation || - !areTimelinePaginationCursorsEqual({ - left: previous.loaded.olderCursor, - right: nextOlderCursor, - }) - ) { - return previous; - } - return { - ...previous, - loaded: { - ...previous.loaded, + } + + setIsLoadingOlderTimelineRows(true); + try { + const response = await sdk.threads.timeline({ + beforeAnchorId: nextOlderCursor.anchorId, + beforeAnchorSeq: String(nextOlderCursor.anchorSeq), + threadId, + }); + const olderRows = [...response.rows]; + updateLoadedTimeline((current) => { + if ( + current.surfaceKey !== surfaceKey || + !areTimelinePaginationCursorsEqual({ + left: current.olderCursor, + right: nextOlderCursor, + }) + ) { + return current; + } + return { + ...current, olderCursor: response.timelinePage.olderCursor, rows: prependOlderTimelineRows({ - loadedRows: previous.loaded.rows, - olderRows: response.rows, + loadedRows: current.rows, + olderRows, }), - }, - }; - }); + }; + }); + } catch (error) { + if ( + !(error instanceof Error) || + !isStaleTimelinePaginationCursorError(error) + ) { + throw error; + } + + const latestTimelineResult = await refetchLatestTimeline(); + const recoveredLatestTimeline = + latestTimelineResult.data ?? latestTimeline; + updateLoadedTimeline((current) => { + if (current.surfaceKey !== surfaceKey) { + return current; + } + if (!recoveredLatestTimeline) { + return { + ...current, + olderCursor: null, + }; + } + return recoverLoadedTimelineAfterStaleCursor({ + current, + latestTimeline: recoveredLatestTimeline, + surfaceKey, + }); + }); + } finally { + setIsLoadingOlderTimelineRows(false); + } }, [ enabled, + isLoadingOlderTimelineRows, latestTimeline, nextOlderCursor, - threadId, - blocked, - loadOlder, + refetchLatestTimeline, surfaceKey, - history.generation, + threadId, + updateLoadedTimeline, ]); - const showLatestTimeline = useCallback(() => { - if (!latestTimeline || blocked) return; - setTracker((previous) => ({ - ...previous, - loaded: mergeLoadedTimelineWithLatest({ - current: buildEmptyLoadedTimelineState(surfaceKey), - latestTimeline, - surfaceKey, - }), - unrefreshed: false, - replacementKey: null, - })); - }, [blocked, latestTimeline, surfaceKey]); - const timelineRows = blocked ? [] : loadedTimeline.rows; + const timelineRows = + loadedTimeline.surfaceKey === surfaceKey && loadedTimeline.rows.length > 0 + ? loadedTimeline.rows + : (latestTimeline?.rows ?? []); const timelineQueryState = useConnectionAwareQueryState({ hasResolvedData: latestTimelineQuery.data !== undefined || timelineRows.length > 0, @@ -329,14 +274,8 @@ export function useThreadTimelineController({ goal: latestTimeline?.goal ?? null, modelFallback: latestTimeline?.modelFallback ?? null, hasOlderTimelineRows, - isLoadingOlderTimelineRows: history.isLoadingOlder, + isLoadingOlderTimelineRows, loadOlderTimelineRows, - historyRefreshError: blocked ? null : history.error, - historyUnrefreshed: current.unrefreshed, - historyReplacementKey: current.replacementKey, - isRefreshingHistory: history.isFetching && !history.isLoadingOlder, - refreshHistory: history.refresh, - showLatestTimeline, pendingTodos: latestTimeline?.pendingTodos ?? null, timelineError, timelineLoading, diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx index 7d1127abe5f..a2b10ae739a 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.scroll-preservation.test.tsx @@ -1,13 +1,10 @@ // @vitest-environment jsdom -import { act, cleanup, fireEvent, render } from "@testing-library/react"; -import { useContext } from "react"; +import { cleanup, fireEvent, render } from "@testing-library/react"; import { getDefaultStore } from "jotai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { BottomAnchoredScrollBody, - TimelineReplacementScrollAnchor, - TimelineScrollRestoreRowIdContext, useBottomAnchoredScroll, } from "@/components/ui/bottom-anchored-scroll-body"; import { threadTimelineScrollAnchorAtomFamily } from "@/lib/thread-timeline-scroll-anchor"; @@ -207,149 +204,6 @@ function readAnchor(threadId: string) { return getDefaultStore().get(threadTimelineScrollAnchorAtomFamily(threadId)); } -interface ReplacementRow { - id: string; - height: number; -} - -function ReplacementRows({ - rows, - hiddenRows, - realizeRestoreRow, -}: { - rows: ReplacementRow[]; - hiddenRows: ReadonlySet; - realizeRestoreRow: boolean; -}) { - const restoreRowId = useContext(TimelineScrollRestoreRowIdContext); - let top = 0; - return ( -
sum + row.height, 0)} - > -
- {rows.map((row) => { - const rowTop = top; - top += row.height; - if ( - hiddenRows.has(row.id) && - !(realizeRestoreRow && restoreRowId === row.id) - ) { - return null; - } - return ( -
- {row.id} -
- ); - })} -
-
- ); -} - -function renderReplacementTimeline() { - let rows = ["a", "b", "c", "d"].map((id) => ({ id, height: 100 })); - let replacementKey = {}; - let hiddenRows: ReadonlySet = new Set(); - let realizeRestoreRow = true; - const timeline = () => ( - - - - - - ); - const view = render(timeline()); - const scrollArea = requireHTMLElement( - view.container.querySelector(`.${SCROLL_AREA_CLASS}`), - ); - Object.defineProperty(scrollArea, "scrollHeight", { - configurable: true, - get: () => - Number( - view.container - .querySelector("[data-model-height]") - ?.getAttribute("data-model-height"), - ), - }); - Object.defineProperty(scrollArea, "clientHeight", { - configurable: true, - value: SCROLL_AREA_HEIGHT, - }); - let scrollTop = scrollArea.scrollTop; - Object.defineProperty(scrollArea, "scrollTop", { - configurable: true, - get: () => { - scrollTop = Math.max( - 0, - Math.min(scrollTop, scrollArea.scrollHeight - scrollArea.clientHeight), - ); - return scrollTop; - }, - set: (value: number) => { - scrollTop = Math.max( - 0, - Math.min(value, scrollArea.scrollHeight - scrollArea.clientHeight), - ); - }, - }); - vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( - function (this: HTMLElement) { - if (this.dataset.modelTop !== undefined) { - return new DOMRect( - 0, - Number(this.dataset.modelTop) - scrollArea.scrollTop, - 100, - Number(this.dataset.modelRowHeight), - ); - } - return new DOMRect(0, 0, 100, SCROLL_AREA_HEIGHT); - }, - ); - act(() => getLatestResizeObserver().trigger()); - return { - scrollArea, - getByRole: view.getByRole, - detach: () => { - fireEvent.wheel(scrollArea, { deltaY: -150 }); - scrollArea.scrollTop = 150; - fireEvent.scroll(scrollArea); - }, - replace: ( - nextRows: ReplacementRow[], - options?: { - hiddenRows?: ReadonlySet; - realizeRestoreRow?: boolean; - keepReplacementKey?: boolean; - }, - ) => { - rows = nextRows; - hiddenRows = options?.hiddenRows ?? new Set(); - realizeRestoreRow = options?.realizeRestoreRow ?? true; - if (!options?.keepReplacementKey) replacementKey = {}; - view.rerender(timeline()); - }, - }; -} - beforeEach(() => { ResizeObserverMock.instances = []; vi.stubGlobal("ResizeObserver", ResizeObserverMock); @@ -368,142 +222,6 @@ afterEach(() => { }); describe("BottomAnchoredScrollBody scroll preservation", () => { - it("preserves the visible row offset when refreshed content above it shrinks", () => { - const view = renderReplacementTimeline(); - view.detach(); - - view.replace([ - { id: "a", height: 40 }, - { id: "b", height: 100 }, - { id: "c", height: 100 }, - { id: "d", height: 100 }, - ]); - act(() => getLatestResizeObserver().trigger()); - - expect(view.scrollArea.scrollTop).toBe(90); - }); - - it("restores the nearest surviving row after the visible row is deleted", () => { - const view = renderReplacementTimeline(); - view.detach(); - - view.replace([ - { id: "a", height: 100 }, - { id: "c", height: 100 }, - { id: "d", height: 100 }, - ]); - act(() => getLatestResizeObserver().trigger()); - - expect(view.scrollArea.scrollTop).toBe(100); - }); - - it("clamps the saved offset when the visible row itself shrinks", () => { - const view = renderReplacementTimeline(); - view.detach(); - - view.replace([ - { id: "a", height: 100 }, - { id: "b", height: 40 }, - { id: "c", height: 100 }, - { id: "d", height: 100 }, - ]); - - expect(view.scrollArea.scrollTop).toBe(139); - }); - - it("realizes a replaced anchor outside the virtualized range before restoring it", () => { - const view = renderReplacementTimeline(); - view.detach(); - - view.replace( - [ - { id: "older", height: 300 }, - ...["a", "b", "c", "d"].map((id) => ({ id, height: 100 })), - ], - { hiddenRows: new Set(["b"]) }, - ); - - expect(view.scrollArea.scrollTop).toBe(450); - }); - - it("keeps the detached position when no refreshed row survives", () => { - const view = renderReplacementTimeline(); - view.detach(); - - view.replace([ - { id: "new-a", height: 300 }, - { id: "new-b", height: 300 }, - ]); - for (let attempt = 0; attempt < 8; attempt += 1) { - act(() => getLatestResizeObserver().trigger()); - } - - expect(view.scrollArea.scrollTop).toBe(150); - }); - - it("lets user scrolling cancel a pending replacement restore", () => { - const view = renderReplacementTimeline(); - view.detach(); - const replacement = ["a", "b", "c", "d"].map((id) => ({ - id, - height: 100, - })); - view.replace(replacement, { - hiddenRows: new Set(["b"]), - realizeRestoreRow: false, - }); - - fireEvent.wheel(view.scrollArea, { deltaY: -120 }); - view.scrollArea.scrollTop = 30; - fireEvent.scroll(view.scrollArea); - view.replace(replacement, { keepReplacementKey: true }); - act(() => getLatestResizeObserver().trigger()); - - expect(view.scrollArea.scrollTop).toBe(30); - }); - - it("lets explicit navigation to the bottom cancel a pending replacement restore", () => { - const view = renderReplacementTimeline(); - view.detach(); - const replacement = ["a", "b", "c", "d"].map((id) => ({ - id, - height: 100, - })); - view.replace(replacement, { - hiddenRows: new Set(["b"]), - realizeRestoreRow: false, - }); - - fireEvent.click(view.getByRole("button", { name: "Bottom" })); - view.replace(replacement, { keepReplacementKey: true }); - act(() => getLatestResizeObserver().trigger()); - - expect(view.scrollArea.scrollTop).toBe(300); - }); - - it("continues following the bottom through a history replacement", () => { - const view = renderReplacementTimeline(); - - view.replace([ - { id: "a", height: 100 }, - { id: "b", height: 100 }, - { id: "c", height: 100 }, - ]); - act(() => getLatestResizeObserver().trigger()); - - expect(view.scrollArea.scrollTop).toBe(200); - - view.replace([ - { id: "a", height: 100 }, - { id: "b", height: 100 }, - { id: "c", height: 100 }, - { id: "d", height: 200 }, - ]); - act(() => getLatestResizeObserver().trigger()); - - expect(view.scrollArea.scrollTop).toBe(400); - }); - it("shows the thread scrollbar only while scroll events are active", () => { vi.useFakeTimers(); const { scrollArea } = renderTimeline({ diff --git a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx index 88f645b580d..a11dc0a1e3e 100644 --- a/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx +++ b/apps/app/src/components/ui/bottom-anchored-scroll-body.tsx @@ -1,5 +1,4 @@ import { - Component, createContext, useCallback, useContext, @@ -9,7 +8,7 @@ import { useRef, useState, } from "react"; -import type { ContextType, ReactNode } from "react"; +import type { ReactNode } from "react"; import { useStore } from "jotai"; import { cn } from "@bb/shared-ui/lib/utils"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; @@ -88,50 +87,6 @@ export const TimelineScrollRestoreRowIdContext = createContext( null, ); -interface TimelineReplacementScrollAnchorProps { - rows: readonly { id: string }[]; - replacementKey: object | null; -} - -interface TimelineReplacementSnapshot { - anchor: ScrollAnchor | null; - scrollTop: number; -} - -const TimelineReplacementAnchorContext = createContext<{ - capture: ( - previousRows: readonly { id: string }[], - nextRows: readonly { id: string }[], - ) => TimelineReplacementSnapshot | null; - restore: (snapshot: TimelineReplacementSnapshot) => void; -} | null>(null); - -export class TimelineReplacementScrollAnchor extends Component< - TimelineReplacementScrollAnchorProps, - Record, - TimelineReplacementSnapshot | null -> { - static contextType = TimelineReplacementAnchorContext; - declare context: ContextType; - - getSnapshotBeforeUpdate(previousProps: TimelineReplacementScrollAnchorProps) { - if (previousProps.replacementKey === this.props.replacementKey) return null; - return this.context?.capture(previousProps.rows, this.props.rows) ?? null; - } - - componentDidUpdate( - _previousProps: TimelineReplacementScrollAnchorProps, - _previousState: Readonly>, - snapshot: TimelineReplacementSnapshot | null, - ) { - if (snapshot !== null) this.context?.restore(snapshot); - } - - render() { - return null; - } -} - export function useBottomAnchoredScroll(): BottomAnchorContextValue | null { return useContext(BottomAnchorContext); } @@ -294,11 +249,7 @@ export function BottomAnchoredScrollBody({ anchor: ScrollAnchor; attemptsRemaining: number; lastAppliedScrollTop: number | null; - kind: "navigation" | "replacement"; } | null>(null); - const preserveDetachedReplacementRef = useRef(false); - const [replacementScrollRestoreRowId, setReplacementScrollRestoreRowId] = - useState(null); const scrollAnchorCaptureThrottleRef = useRef<{ lastWriteAt: number; trailingTimeout: number | null; @@ -340,7 +291,6 @@ export function BottomAnchoredScrollBody({ const cancelPendingScrollRestore = useCallback(() => { pendingScrollRestoreRef.current = null; - setReplacementScrollRestoreRowId(null); }, []); const cancelQueuedRestore = useCallback(() => { @@ -405,7 +355,6 @@ export function BottomAnchoredScrollBody({ const scrollToBottom = useCallback(() => { const scrollArea = scrollAreaRef.current; cancelPendingScrollRestore(); - preserveDetachedReplacementRef.current = false; userScrollIntentUntilRef.current = 0; pointerScrollIntentRef.current = false; userDetachedFromBottomRef.current = false; @@ -419,8 +368,6 @@ export function BottomAnchoredScrollBody({ const scrollElementIntoView = useCallback( ({ element, options }: ScrollElementIntoViewArgs) => { - cancelPendingScrollRestore(); - preserveDetachedReplacementRef.current = false; const scrollArea = scrollAreaRef.current; if ( scrollArea && @@ -433,13 +380,11 @@ export function BottomAnchoredScrollBody({ cancelQueuedRestore(); element.scrollIntoView(options); }, - [cancelPendingScrollRestore, cancelQueuedRestore], + [cancelQueuedRestore], ); const scrollElementIntoViewClampedToMaxScroll = useCallback( ({ element }: ScrollElementIntoViewClampedToMaxScrollArgs) => { - cancelPendingScrollRestore(); - preserveDetachedReplacementRef.current = false; const scrollArea = scrollAreaRef.current; if (!scrollArea) { element.scrollIntoView({ block: "start", inline: "nearest" }); @@ -466,12 +411,7 @@ export function BottomAnchoredScrollBody({ cancelQueuedRestore(); }, - [ - cancelPendingScrollRestore, - cancelQueuedRestore, - queueBottomRestore, - refreshMaxScrollOffset, - ], + [cancelQueuedRestore, queueBottomRestore, refreshMaxScrollOffset], ); const captureScrollAnchor = useCallback(() => { @@ -535,7 +475,7 @@ export function BottomAnchoredScrollBody({ const recentUserIntent = hasRecentUserScrollIntent(); const anchorAtom = threadTimelineScrollAnchorAtomFamily(scrollAnchorThreadId); - if (atBottomByGeometry && !preserveDetachedReplacementRef.current) { + if (atBottomByGeometry) { userDetachedFromBottomRef.current = false; store.set(anchorAtom, { rowId: "", @@ -602,7 +542,7 @@ export function BottomAnchoredScrollBody({ }, [scrollAnchorCaptureThrottleMs, scrollAnchorThreadId, writeScrollAnchor]); const applyScrollRestore = useCallback( - (anchor: ScrollAnchor, clampWithinRow: boolean): number | null => { + (anchor: ScrollAnchor): number | null => { const scrollArea = scrollAreaRef.current; if (!scrollArea) return null; const rowElement = findTimelineRowElement(scrollArea, anchor.rowId); @@ -616,13 +556,7 @@ export function BottomAnchoredScrollBody({ }); const targetScrollTop = Math.min( refreshMaxScrollOffset(scrollArea), - revealOffset + - (clampWithinRow - ? Math.min( - anchor.offsetWithinRow, - Math.max(0, rowElement.getBoundingClientRect().height - 1), - ) - : anchor.offsetWithinRow), + revealOffset + anchor.offsetWithinRow, ); scrollArea.scrollTop = targetScrollTop; return targetScrollTop; @@ -631,12 +565,10 @@ export function BottomAnchoredScrollBody({ ); const markUserScrollIntent = useCallback(() => { - cancelPendingScrollRestore(); - preserveDetachedReplacementRef.current = false; userScrollInputPendingRef.current = true; userScrollIntentUntilRef.current = window.performance.now() + USER_SCROLL_INTENT_MS; - }, [cancelPendingScrollRestore]); + }, []); const markWheelScrollIntent = useCallback( (event: WheelEvent) => { @@ -671,10 +603,8 @@ export function BottomAnchoredScrollBody({ }, [markUserScrollIntent]); const startPointerScrollIntent = useCallback(() => { - cancelPendingScrollRestore(); - preserveDetachedReplacementRef.current = false; pointerScrollIntentRef.current = true; - }, [cancelPendingScrollRestore]); + }, []); const endPointerScrollIntent = useCallback(() => { pointerScrollIntentRef.current = false; @@ -694,13 +624,12 @@ export function BottomAnchoredScrollBody({ ); const attachToBottom = useCallback(() => { - preserveDetachedReplacementRef.current = false; userDetachedFromBottomRef.current = false; shouldStickToBottomRef.current = true; userScrollIntentUntilRef.current = 0; setIsAtBottom(true); - cancelPendingScrollRestore(); - }, [cancelPendingScrollRestore]); + pendingScrollRestoreRef.current = null; + }, []); const syncBottomStateFromScroll = useCallback(() => { const scrollArea = scrollAreaRef.current; @@ -709,13 +638,6 @@ export function BottomAnchoredScrollBody({ userScrollInputPendingRef.current || pointerScrollIntentRef.current; userScrollInputPendingRef.current = false; - if ( - pendingScrollRestoreRef.current?.kind === "replacement" && - !hasDirectUserScrollInput - ) { - return; - } - if ( pendingPrependAnchorRef.current !== null && hasRecentUserScrollIntent() @@ -745,7 +667,7 @@ export function BottomAnchoredScrollBody({ ); } - if (nearBottom && !preserveDetachedReplacementRef.current) { + if (nearBottom) { attachToBottom(); return; } @@ -756,10 +678,9 @@ export function BottomAnchoredScrollBody({ shouldStickToBottomRef.current = false; setIsAtBottom(false); cancelQueuedRestore(); - cancelPendingScrollRestore(); + pendingScrollRestoreRef.current = null; }, [ attachToBottom, - cancelPendingScrollRestore, cancelQueuedRestore, hasRecentUserScrollIntent, readMaxScrollOffset, @@ -775,120 +696,24 @@ export function BottomAnchoredScrollBody({ const pending = pendingScrollRestoreRef.current; if (!pending) return false; pending.attemptsRemaining -= 1; - const appliedScrollTop = applyScrollRestore( - pending.anchor, - pending.kind === "replacement", - ); + const appliedScrollTop = applyScrollRestore(pending.anchor); if (appliedScrollTop !== null) { if (pending.lastAppliedScrollTop === appliedScrollTop) { - cancelPendingScrollRestore(); + pendingScrollRestoreRef.current = null; return true; } pending.lastAppliedScrollTop = appliedScrollTop; } if (pending.attemptsRemaining <= 0) { - cancelPendingScrollRestore(); - if (appliedScrollTop === null && pending.kind === "navigation") { + pendingScrollRestoreRef.current = null; + if (appliedScrollTop === null) { shouldStickToBottomRef.current = true; setIsAtBottom(true); queueBottomRestore(); } } return true; - }, [applyScrollRestore, cancelPendingScrollRestore, queueBottomRestore]); - - const captureReplacementAnchor = useCallback( - ( - previousRows: readonly { id: string }[], - nextRows: readonly { id: string }[], - ): TimelineReplacementSnapshot | null => { - const scrollArea = scrollAreaRef.current; - if ( - !scrollArea || - shouldStickToBottomRef.current || - pointerScrollIntentRef.current || - userScrollInputPendingRef.current - ) { - return null; - } - const visible = getTopMostVisibleRow( - scrollArea, - getScrollAnchorRows(scrollArea).rows, - ); - const nextIds = new Set(nextRows.map((row) => row.id)); - let rowId = visible?.rowId; - let offsetWithinRow = visible?.offsetWithinRow ?? 0; - if (rowId !== undefined && !nextIds.has(rowId)) { - const index = previousRows.findIndex((row) => row.id === rowId); - rowId = undefined; - offsetWithinRow = 0; - for (let distance = 1; distance < previousRows.length; distance += 1) { - const next = previousRows[index + distance]; - const previous = previousRows[index - distance]; - if (next !== undefined && nextIds.has(next.id)) { - rowId = next.id; - break; - } - if (previous !== undefined && nextIds.has(previous.id)) { - rowId = previous.id; - break; - } - } - } - return { - anchor: - rowId === undefined - ? null - : { rowId, offsetWithinRow, atBottom: false }, - scrollTop: scrollArea.scrollTop, - }; - }, - [], - ); - - const restoreReplacementAnchor = useCallback( - (snapshot: TimelineReplacementSnapshot) => { - const scrollArea = scrollAreaRef.current; - if (!scrollArea) return; - pendingPrependAnchorRef.current = null; - scrollAnchorRowsRef.current = null; - preserveDetachedReplacementRef.current = true; - shouldStickToBottomRef.current = false; - userDetachedFromBottomRef.current = true; - setIsAtBottom(false); - cancelQueuedRestore(); - if (snapshot.anchor === null) { - cancelPendingScrollRestore(); - scrollArea.scrollTop = Math.min( - snapshot.scrollTop, - refreshMaxScrollOffset(scrollArea), - ); - return; - } - pendingScrollRestoreRef.current = { - anchor: snapshot.anchor, - attemptsRemaining: SCROLL_ANCHOR_RESTORE_MAX_ATTEMPTS, - lastAppliedScrollTop: null, - kind: "replacement", - }; - setReplacementScrollRestoreRowId(snapshot.anchor.rowId); - }, - [cancelPendingScrollRestore, cancelQueuedRestore, refreshMaxScrollOffset], - ); - - const replacementAnchorContextValue = useMemo( - () => ({ - capture: captureReplacementAnchor, - restore: restoreReplacementAnchor, - }), - [captureReplacementAnchor, restoreReplacementAnchor], - ); - - useLayoutEffect(() => { - if (pendingScrollRestoreRef.current?.kind === "replacement") { - advancePendingScrollRestore(); - } - }); + }, [applyScrollRestore, queueBottomRestore]); const handleScrollAreaResize = useCallback( (entries: ResizeObserverEntry[]) => { @@ -925,7 +750,6 @@ export function BottomAnchoredScrollBody({ resizeObserverHasDeliveredRef.current = true; shrankOntoBottomWhileDetached = cacheWasAuthoritative && - !preserveDetachedReplacementRef.current && !shouldStickToBottomRef.current && maxScrollOffset < previousMaxScrollOffset && isScrolledNearBottom(maxScrollOffset, scrollArea.scrollTop); @@ -958,7 +782,6 @@ export function BottomAnchoredScrollBody({ anchor, attemptsRemaining: SCROLL_ANCHOR_RESTORE_MAX_ATTEMPTS, lastAppliedScrollTop: null, - kind: "navigation", }; advancePendingScrollRestore(); }, [scrollAnchorThreadId, store, advancePendingScrollRestore]); @@ -1084,7 +907,7 @@ export function BottomAnchoredScrollBody({ return (
- - {children} - + {children}
{footer ? ( diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts index ff8f67fe8b9..d61247921c9 100644 --- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts +++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts @@ -107,12 +107,8 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadsQueryKey", ], "hooks/cache-owners/project-cache-owner.ts": [ - "allThreadDetailBootstrapQueryKeyPrefix", - "allThreadQueryKeyPrefix", "projectsQueryKey", "sidebarNavigationQueryKey", - "threadHistoryQueryKeyPrefix", - "threadsQueryKey", ], "hooks/cache-owners/query-cache.ts": [ "ARCHIVED_THREADS_LIST_KIND", @@ -217,7 +213,6 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "serverMoveStatusQueryKey", "sidebarNavigationQueryKey", "systemConfigQueryKey", - "threadHistoryQueryKeyPrefix", "threadPromptHistoryQueryKeyPrefix", "threadSearchQueryKeyPrefix", "threadsQueryKey", @@ -243,14 +238,6 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "hostsQueryKey", "threadQueryKey", ], - "hooks/cache-owners/thread-history-cache-owner.ts": [ - "allThreadTimelineQueryKeyPrefix", - "threadDetailBootstrapQueryKey", - "threadHistoryQueryKeyPrefix", - "threadTimelineQueryKeyPrefix", - "THREAD_QUERY_KEY", - "THREAD_TIMELINE_QUERY_KEY", - ], "hooks/cache-owners/thread-tabs-cache-owner.ts": ["threadTabsQueryKey"], "hooks/cache-owners/ui-preferences-cache-owner.ts": ["uiPreferencesQueryKey"], "hooks/cache-owners/thread-runtime-cache-owner.ts": [ diff --git a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts index 5c9bee4286d..35ed5c84301 100644 --- a/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/mutation-cache-effects.ts @@ -21,10 +21,6 @@ import type { ThreadArg, } from "../cache-effect-types"; import { invalidateQueryKeys } from "./cache-effect-utils"; -import { - invalidateThreadHistory, - removeThreadHistory, -} from "./thread-history-cache-owner"; import { getProjectListInvalidationQueryKeys, getProjectPromptHistoryInvalidationQueryKeys, @@ -225,7 +221,6 @@ export function invalidateThreadHistoryRewriteQueries({ queryClient, threadId, }: ThreadArg): void { - void invalidateThreadHistory({ queryClient, threadId }); invalidateThreadAcceptedMessageQueriesWithoutRealtime({ queryClient, threadId, @@ -281,7 +276,6 @@ export function removeThreadScopedQueries({ queryClient, threadId, }: ThreadArg): void { - removeThreadHistory({ queryClient, threadId }); queryClient.removeQueries({ queryKey: threadQueryKey(threadId) }); queryClient.removeQueries({ queryKey: threadTimelineQueryKeyPrefix(threadId), diff --git a/apps/app/src/hooks/cache-owners/project-cache-owner.ts b/apps/app/src/hooks/cache-owners/project-cache-owner.ts index ee7067e94ce..9603f8afc57 100644 --- a/apps/app/src/hooks/cache-owners/project-cache-owner.ts +++ b/apps/app/src/hooks/cache-owners/project-cache-owner.ts @@ -3,24 +3,12 @@ import type { ProjectResponse, ProjectWithThreadsResponse, SidebarBootstrapResponse, - ThreadResponse, - ThreadWithIncludesResponse, } from "@bb/server-contract"; import { - allThreadDetailBootstrapQueryKeyPrefix, - allThreadQueryKeyPrefix, projectsQueryKey, sidebarNavigationQueryKey, - threadHistoryQueryKeyPrefix, - threadsQueryKey, } from "../queries/query-keys"; import { invalidateProjectDeleteQueries } from "./mutation-cache-effects"; -import { getCachedSidebarNavigationThreads } from "./query-cache"; -import { - getCachedThreadLists, - iterateThreadListCacheEntries, -} from "./thread-list-cache-data"; -import { removeThreadHistory } from "./thread-history-cache-owner"; interface ApplyProjectCreateResultArgs { project: ProjectResponse; @@ -140,7 +128,6 @@ export function applyProjectDeleteResult({ projectId, queryClient, }: ApplyProjectDeleteResultArgs): void { - removeProjectThreadHistory({ projectId, queryClient }); queryClient.setQueryData( projectsQueryKey(), (currentProjects) => @@ -157,41 +144,3 @@ export function applyProjectDeleteResult({ ); invalidateProjectDeleteQueries({ queryClient }); } - -export function removeProjectThreadHistory({ - projectId, - queryClient, -}: ApplyProjectDeleteResultArgs): void { - const cachedHistoryIds = new Set( - queryClient - .getQueryCache() - .findAll({ queryKey: threadHistoryQueryKeyPrefix() }) - .map((query) => query.queryKey[1]), - ); - const ids = new Set(); - for (const queryKey of [ - allThreadQueryKeyPrefix(), - allThreadDetailBootstrapQueryKeyPrefix(), - ]) { - for (const [, thread] of queryClient.getQueriesData< - ThreadResponse | ThreadWithIncludesResponse - >({ queryKey })) { - if (thread?.projectId === projectId) ids.add(thread.id); - } - } - for (const { data } of getCachedThreadLists(queryClient, { - queryKey: threadsQueryKey(), - })) { - for (const thread of iterateThreadListCacheEntries(data)) { - if (thread.projectId === projectId) ids.add(thread.id); - } - } - for (const thread of getCachedSidebarNavigationThreads(queryClient)) { - if (thread.projectId === projectId) ids.add(thread.id); - } - for (const threadId of ids) { - if (cachedHistoryIds.has(threadId)) { - removeThreadHistory({ queryClient, threadId }); - } - } -} diff --git a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts index 384c4eb8ed8..9719570b0db 100644 --- a/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts +++ b/apps/app/src/hooks/cache-owners/realtime-cache-registry.ts @@ -27,11 +27,6 @@ import { } from "./query-cache"; import { bumpDiffPatchFreshnessGeneration } from "./environment-diff-patch-cache-owner"; import { invalidateSystemExecutionOptions } from "./system-cache-effects"; -import { - invalidateThreadHistory, - removeThreadHistory, -} from "./thread-history-cache-owner"; -import { removeProjectThreadHistory } from "./project-cache-owner"; import { getCachedThreadLists, iterateThreadListCacheEntries, @@ -341,7 +336,6 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { "thread-deleted": { flush: "debounced", dirty: [ - removeDeletedThreadHistory, dirtyThreadListQueries, dirtyThreadDetailQueries, dirtyThreadTimelineQueries, @@ -366,7 +360,6 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { dirtyThreadDetailQueries, dirtyThreadSearchQueries, getThreadTimelineInvalidationQueryKeys, - dirtyThreadHistory, getThreadQueueContentInvalidationQueryKeys, dirtyProjectPromptHistoryQueries, getThreadPendingInteractionInvalidationQueryKeys, @@ -386,12 +379,7 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { }, "title-changed": { flush: "debounced", - dirty: [ - dirtyActiveThreadListQueries, - dirtyThreadDetailQueries, - getThreadTimelineInvalidationQueryKeys, - dirtyThreadHistory, - ], + dirty: [dirtyActiveThreadListQueries, dirtyThreadDetailQueries], }, "queue-changed": { flush: "debounced", @@ -423,8 +411,6 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = { dirtyThreadDetailQueries, dirtyThreadDefaultExecutionOptionsQueries, dirtyThreadStorageQueriesForThread, - getThreadTimelineInvalidationQueryKeys, - dirtyThreadHistory, ], }, "read-state-changed": { @@ -467,7 +453,6 @@ export const REALTIME_ENVIRONMENT_CHANGE_REGISTRY = { dirtyEnvironmentBranchListQueries, dirtyEnvironmentThreadListQueries, dirtyThreadSearchQueries, - dirtyEnvironmentThreadHistory, ], }, "status-changed": { @@ -499,10 +484,7 @@ export const REALTIME_PROJECT_CHANGE_REGISTRY = { dirty: [getProjectListInvalidationQueryKeys], }, "project-deleted": { - dirty: [ - removeDeletedProjectThreadHistory, - getProjectListInvalidationQueryKeys, - ], + dirty: [getProjectListInvalidationQueryKeys], }, "project-sources-changed": { dirty: [getProjectSourceDependentInvalidationQueryKeys], @@ -546,7 +528,6 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = { dirtySystemConfigQueries, dirtyMachineEnvironmentQueries, dirtyAllThreadTimelineQueries, - dirtyAllThreadHistory, dirtySystemProviderQueries, dirtySystemExecutionOptionQueries, dirtyEnvironmentProviderQueries, @@ -563,12 +544,7 @@ export const REALTIME_SYSTEM_CHANGE_REGISTRY = { ], }, "provider-registrations-changed": { - dirty: [ - dirtySystemProviderQueries, - dirtySystemExecutionOptionQueries, - dirtyAllThreadTimelineQueries, - dirtyAllThreadHistory, - ], + dirty: [dirtySystemProviderQueries, dirtySystemExecutionOptionQueries], }, "environment-availability-changed": { dirty: [dirtyEnvironmentProviderQueries], @@ -848,49 +824,6 @@ function dirtyThreadDetailQueries({ return getThreadDetailInvalidationQueryKeys({ threadId }); } -function dirtyThreadHistory({ - flushOnce, - queryClient, - threadId, -}: ThreadRealtimeDirtyContext): void { - if (flushOnce(`thread-history:${threadId ?? "all"}`)) { - void invalidateThreadHistory({ queryClient, threadId }); - } -} - -function removeDeletedThreadHistory({ - queryClient, - threadId, -}: ThreadRealtimeDirtyContext): void { - if (threadId !== undefined) removeThreadHistory({ queryClient, threadId }); -} - -function dirtyEnvironmentThreadHistory({ - getCachedThreadIdsForEnvironment, - queryClient, -}: EnvironmentRealtimeDirtyContext): void { - for (const threadId of getCachedThreadIdsForEnvironment()) { - for (const queryKey of getThreadTimelineInvalidationQueryKeys({ - threadId, - })) { - void queryClient.invalidateQueries({ queryKey }); - } - void invalidateThreadHistory({ queryClient, threadId }); - } -} - -function removeDeletedProjectThreadHistory({ - projectId, - queryClient, -}: ProjectRealtimeDirtyContext): void { - if (projectId !== undefined) - removeProjectThreadHistory({ projectId, queryClient }); -} - -function dirtyAllThreadHistory({ queryClient }: RealtimeDirtyContext): void { - void invalidateThreadHistory({ queryClient }); -} - function dirtyThreadDefaultExecutionOptionsQueries({ threadId, }: ThreadRealtimeDirtyContext): QueryKey[] { diff --git a/apps/app/src/hooks/cache-owners/system-cache-effects.ts b/apps/app/src/hooks/cache-owners/system-cache-effects.ts index 9826f991deb..fca2bc85e2d 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -36,7 +36,6 @@ import { sidebarNavigationQueryKey, systemConfigQueryKey, threadPromptHistoryQueryKeyPrefix, - threadHistoryQueryKeyPrefix, threadSearchQueryKeyPrefix, threadsQueryKey, } from "../queries/query-keys"; @@ -45,10 +44,6 @@ import type { QueryClientArg } from "../cache-effect-types"; import { clearCachedModelCatalogs } from "@/lib/model-catalog-cache"; import { bumpAllDiffPatchEvictionGenerations } from "./environment-diff-patch-cache-owner"; import { invalidateSystemVersion } from "./system-version-cache-owner"; -import { - invalidateThreadHistory, - type ThreadHistoryChain, -} from "./thread-history-cache-owner"; import { invalidateQueryKeys, refetchFailedActiveQueryKeys, @@ -75,11 +70,6 @@ export function invalidateRealtimeQueriesAfterServerReconnect({ { cancelRefetch: false }, ); } - invalidateThreadHistoryBefore({ - queryClient, - timestamp: disconnectedAt, - includeUnfetched: true, - }); invalidateSystemVersion({ queryClient }); bumpAllDiffPatchEvictionGenerations(); queryClient.removeQueries({ @@ -92,10 +82,7 @@ export function refetchErroredRealtimeQueriesOnInitialConnect({ }: QueryClientArg): void { refetchFailedActiveQueryKeys({ queryClient, - queryKeys: [ - ...getServerReconnectInvalidationQueryKeys(), - threadHistoryQueryKeyPrefix(), - ], + queryKeys: getServerReconnectInvalidationQueryKeys(), }); } @@ -115,38 +102,6 @@ export function invalidateRealtimeQueriesFetchedBeforeInitialConnect({ query.state.dataUpdatedAt < connectedAt, }); } - invalidateThreadHistoryBefore({ - queryClient, - timestamp: connectedAt, - includeUnfetched: false, - }); -} - -function invalidateThreadHistoryBefore({ - queryClient, - timestamp, - includeUnfetched, -}: QueryClientArg & { timestamp: number; includeUnfetched: boolean }): void { - const threadIds = new Set(); - for (const [ - queryKey, - chain, - ] of queryClient.getQueriesData({ - queryKey: threadHistoryQueryKeyPrefix(), - })) { - const threadId = queryKey[1]; - if ( - typeof threadId === "string" && - (chain === undefined - ? includeUnfetched - : chain.pages.some((page) => page.validatedAt < timestamp)) - ) { - threadIds.add(threadId); - } - } - for (const threadId of threadIds) { - void invalidateThreadHistory({ queryClient, threadId }); - } } export function invalidateSystemConfig({ queryClient }: QueryClientArg): void { @@ -215,7 +170,6 @@ export function invalidateGeneralSettingsDependencies({ allThreadTimelineTurnSummaryDetailsQueryKeyPrefix(), ], }); - void invalidateThreadHistory({ queryClient }); } export function resetModelCatalogsAfterStreamerModeChange({ diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts deleted file mode 100644 index c7ac7cc8fcf..00000000000 --- a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import { describe, expect, it } from "vitest"; -import { OPTIMISTIC_TIMELINE_ROW_ID_PREFIX } from "@bb/client-core"; -import { createDeferredPromise } from "@bb/test-helpers"; -import { BbHttpError } from "@/lib/sdk"; -import { makeThreadTimelineResponse } from "@/test/fixtures/thread-responses"; -import { systemRow } from "@/test/fixtures/thread-timeline-rows"; -import { - threadHistoryQueryKey, - threadHistoryQueryKeyPrefix, - threadQueryKey, - threadTimelineQueryKey, -} from "../queries/query-keys"; -import { - compactThreadHistory, - createThreadHistoryPage, - getThreadHistoryGeneration, - pruneThreadHistory, - removeThreadHistory, - THREAD_HISTORY_MAX_BYTES, - type ThreadHistoryChain, -} from "./thread-history-cache-owner"; - -function chain(pageCount = 1): ThreadHistoryChain { - return { - surfaceKey: "thread-1:collapse", - pages: Array.from({ length: pageCount }, (_, index) => - createThreadHistoryPage( - makeThreadTimelineResponse({ - rows: [ - systemRow({ - id: `row-${index}`, - seq: index, - title: "Row", - detail: null, - }), - ], - }), - index === 0 ? null : { anchorId: `anchor-${index}`, anchorSeq: index }, - 100, - ), - ), - }; -} - -describe("thread history cache ownership", () => { - it("retains a contiguous prefix without renewing page validation", () => { - const current = chain(7); - const compacted = compactThreadHistory(current); - expect(compacted?.pages).toEqual(current.pages.slice(0, 5)); - expect(compacted?.pages[0]).toBe(current.pages[0]); - expect(compacted?.pages.every((page) => page.validatedAt === 100)).toBe( - true, - ); - current.pages[2]!.byteSize = THREAD_HISTORY_MAX_BYTES; - expect(compactThreadHistory(current)?.pages).toEqual( - current.pages.slice(0, 2), - ); - current.pages[0]!.byteSize = THREAD_HISTORY_MAX_BYTES + 1; - expect(compactThreadHistory(current)).toBeUndefined(); - }); - - it("excludes optimistic rows from reusable history", () => { - const serverRow = systemRow({ - id: "server", - seq: 1, - title: "Server", - detail: null, - }); - const optimisticRow = systemRow({ - id: `${OPTIMISTIC_TIMELINE_ROW_ID_PREFIX}pending`, - seq: 2, - title: "Pending", - detail: null, - }); - const response = makeThreadTimelineResponse({ - rows: [serverRow, optimisticRow], - }); - expect(createThreadHistoryPage(response, null).response.rows).toEqual([ - serverRow, - ]); - expect(response.rows).toEqual([serverRow, optimisticRow]); - }); - - it("prunes only inactive history identities and keeps their prior timestamps", () => { - const queryClient = new QueryClient(); - const activeKey = threadHistoryQueryKey("active", "active", 20); - queryClient.setQueryData(activeKey, chain(), { updatedAt: 1 }); - const observer = new QueryObserver(queryClient, { - queryKey: activeKey, - staleTime: Infinity, - }); - const unsubscribe = observer.subscribe(() => {}); - const unrelatedKey = threadTimelineQueryKey("unrelated"); - queryClient.setQueryData(unrelatedKey, makeThreadTimelineResponse()); - for (let index = 0; index < 12; index += 1) { - queryClient.setQueryData( - threadHistoryQueryKey(`inactive-${index}`, "surface", 20), - chain(7), - { updatedAt: 100 + index }, - ); - } - - pruneThreadHistory(queryClient); - - expect( - queryClient - .getQueryCache() - .findAll({ queryKey: threadHistoryQueryKeyPrefix() }), - ).toHaveLength(11); - expect(queryClient.getQueryData(activeKey)).toBeDefined(); - expect(queryClient.getQueryData(unrelatedKey)).toBeDefined(); - expect( - queryClient.getQueryData( - threadHistoryQueryKey("inactive-0", "surface", 20), - ), - ).toBeUndefined(); - const retained = threadHistoryQueryKey("inactive-11", "surface", 20); - expect(queryClient.getQueryState(retained)?.dataUpdatedAt).toBe(111); - expect( - queryClient.getQueryData(retained)?.pages, - ).toHaveLength(5); - unsubscribe(); - queryClient.clear(); - }); - - it("keeps eviction blocked through manual writes until an authoritative fetch", async () => { - const queryClient = new QueryClient(); - getThreadHistoryGeneration(queryClient, "thread-1"); - removeThreadHistory({ queryClient, threadId: "thread-1" }); - queryClient.setQueryData( - threadTimelineQueryKey("thread-1"), - makeThreadTimelineResponse(), - ); - expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( - true, - ); - await queryClient.fetchQuery({ - queryKey: threadTimelineQueryKey("thread-1"), - queryFn: async () => makeThreadTimelineResponse({ maxSeq: 2 }), - staleTime: 0, - }); - expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( - false, - ); - expect(getThreadHistoryGeneration(queryClient, "thread-1").eviction).toBe( - 1, - ); - queryClient.clear(); - }); - - it("cancels a latest read started before eviction so its late success cannot unblock", async () => { - const queryClient = new QueryClient(); - const pending = - createDeferredPromise>(); - let signal: AbortSignal | undefined; - const read = queryClient - .fetchQuery({ - queryKey: threadTimelineQueryKey("thread-1"), - queryFn: ({ signal: requestSignal }) => { - signal = requestSignal; - return pending.promise; - }, - }) - .catch(() => undefined); - removeThreadHistory({ queryClient, threadId: "thread-1" }); - expect(signal?.aborted).toBe(true); - pending.resolve(makeThreadTimelineResponse()); - await read; - expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( - true, - ); - expect( - queryClient.getQueryData(threadTimelineQueryKey("thread-1")), - ).toBeUndefined(); - queryClient.clear(); - }); - - it("purges cached history when route metadata is denied", async () => { - const queryClient = new QueryClient(); - const key = threadHistoryQueryKey("thread-1", "surface", 20); - queryClient.setQueryData(key, chain()); - getThreadHistoryGeneration(queryClient, "thread-1"); - const error = new BbHttpError({ - status: 403, - body: null, - code: null, - message: "Access denied", - }); - await expect( - queryClient.fetchQuery({ - queryKey: threadQueryKey("thread-1"), - queryFn: () => Promise.reject(error), - retry: false, - }), - ).rejects.toBe(error); - expect(queryClient.getQueryData(key)).toBeUndefined(); - expect(getThreadHistoryGeneration(queryClient, "thread-1").blocked).toBe( - true, - ); - queryClient.clear(); - }); -}); diff --git a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts b/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts deleted file mode 100644 index 2baec576e66..00000000000 --- a/apps/app/src/hooks/cache-owners/thread-history-cache-owner.ts +++ /dev/null @@ -1,204 +0,0 @@ -import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { isOptimisticTimelineRowId } from "@bb/client-core"; -import { BbHttpError } from "@/lib/sdk"; -import type { - ThreadTimelineResponse, - TimelinePaginationCursor, -} from "@bb/server-contract"; -import { - threadHistoryQueryKeyPrefix, - allThreadTimelineQueryKeyPrefix, - threadTimelineQueryKeyPrefix, - threadDetailBootstrapQueryKey, - THREAD_QUERY_KEY, - THREAD_TIMELINE_QUERY_KEY, -} from "../queries/query-keys"; - -export const THREAD_HISTORY_MAX_PAGES = 5; -export const THREAD_HISTORY_MAX_BYTES = 8 * 1024 * 1024; -export const THREAD_HISTORY_MAX_INACTIVE_ENTRIES = 10; - -export interface ThreadHistoryPage { - response: ThreadTimelineResponse; - requestCursor: TimelinePaginationCursor | null; - validatedAt: number; - byteSize: number; -} - -export interface ThreadHistoryChain { - pages: ThreadHistoryPage[]; - surfaceKey: string; - recoveredFromCursor?: TimelinePaginationCursor; -} - -interface ThreadHistoryGeneration { - request: number; - eviction: number; - blocked: boolean; - error: Error | null; -} - -const generations = new WeakMap< - QueryClient, - Map ->(); - -export function getThreadHistoryGeneration( - queryClient: QueryClient, - threadId: string, -): ThreadHistoryGeneration { - let threads = generations.get(queryClient); - if (!threads) { - threads = new Map(); - generations.set(queryClient, threads); - queryClient.getQueryCache().subscribe((event) => { - if (event.type !== "updated") return; - const id = event.query.queryKey[1]; - if (typeof id !== "string") return; - const root = event.query.queryKey[0]; - if ( - event.action.type === "error" && - (root === THREAD_QUERY_KEY || - root === THREAD_TIMELINE_QUERY_KEY || - root === threadDetailBootstrapQueryKey("")[0]) && - event.action.error instanceof BbHttpError && - [401, 403, 404].includes(event.action.error.status) - ) { - removeThreadHistory({ - queryClient, - threadId: id, - error: event.action.error, - }); - return; - } - if ( - event.action.type !== "success" || - event.action.manual || - root !== THREAD_TIMELINE_QUERY_KEY - ) - return; - const generation = generations.get(queryClient)?.get(id); - if (generation) { - generation.blocked = false; - generation.error = null; - } - }); - } - let generation = threads.get(threadId); - if (!generation) { - generation = { request: 0, eviction: 0, blocked: false, error: null }; - threads.set(threadId, generation); - } - return generation; -} - -export function createThreadHistoryPage( - response: ThreadTimelineResponse, - requestCursor: TimelinePaginationCursor | null, - validatedAt = Date.now(), -): ThreadHistoryPage { - const rows = response.rows.filter( - (row) => !isOptimisticTimelineRowId(row.id), - ); - const serverResponse = - rows.length === response.rows.length ? response : { ...response, rows }; - return { - response: serverResponse, - requestCursor, - validatedAt, - byteSize: new TextEncoder().encode(JSON.stringify(serverResponse)) - .byteLength, - }; -} - -export function compactThreadHistory( - chain: ThreadHistoryChain, -): ThreadHistoryChain | undefined { - let bytes = 0; - const pages: ThreadHistoryPage[] = []; - for (const page of chain.pages.slice(0, THREAD_HISTORY_MAX_PAGES)) { - if (bytes + page.byteSize > THREAD_HISTORY_MAX_BYTES) break; - pages.push(page); - bytes += page.byteSize; - } - if (pages.length === 0) return undefined; - return pages.length === chain.pages.length ? chain : { ...chain, pages }; -} - -export function pruneThreadHistory(queryClient: QueryClient): void { - const inactive = queryClient - .getQueryCache() - .findAll({ queryKey: threadHistoryQueryKeyPrefix(), type: "inactive" }) - .filter((query) => query.getObserversCount() === 0) - .sort( - (left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt, - ); - for (const [index, query] of inactive.entries()) { - const chain = queryClient.getQueryData(query.queryKey); - const compacted = chain && compactThreadHistory(chain); - if (index >= THREAD_HISTORY_MAX_INACTIVE_ENTRIES || !compacted) { - queryClient.removeQueries({ queryKey: query.queryKey, exact: true }); - } else if (compacted !== chain) { - queryClient.setQueryData(query.queryKey, compacted, { - updatedAt: query.state.dataUpdatedAt, - }); - } - } -} - -interface ThreadHistoryOwnerArgs { - queryClient: QueryClient; - threadId?: string; - error?: Error; -} - -function advanceThreadHistoryGeneration( - { queryClient, threadId, error }: ThreadHistoryOwnerArgs, - evict: boolean, -): void { - const threadIds = - threadId === undefined - ? [...(generations.get(queryClient)?.keys() ?? [])] - : [threadId]; - for (const id of threadIds) { - const generation = getThreadHistoryGeneration(queryClient, id); - generation.request += 1; - if (evict) { - generation.eviction += 1; - generation.blocked = true; - generation.error = error ?? null; - } - } -} - -export async function invalidateThreadHistory( - args: ThreadHistoryOwnerArgs, -): Promise { - advanceThreadHistoryGeneration(args, false); - const filters = { queryKey: threadHistoryQueryKeyPrefix(args.threadId) }; - await args.queryClient.cancelQueries(filters); - await args.queryClient.invalidateQueries(filters, { cancelRefetch: false }); -} - -export function cancelThreadHistoryRead({ - queryClient, - queryKey, -}: { - queryClient: QueryClient; - queryKey: QueryKey; -}): Promise { - return queryClient.cancelQueries({ queryKey, exact: true }); -} - -export function removeThreadHistory(args: ThreadHistoryOwnerArgs): void { - advanceThreadHistoryGeneration(args, true); - const filters = { queryKey: threadHistoryQueryKeyPrefix(args.threadId) }; - void args.queryClient.cancelQueries(filters); - void args.queryClient.cancelQueries({ - queryKey: - args.threadId === undefined - ? allThreadTimelineQueryKeyPrefix() - : threadTimelineQueryKeyPrefix(args.threadId), - }); - args.queryClient.removeQueries(filters); -} diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index b80e59028b2..4a3a124fcc6 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -52,7 +52,6 @@ const ENVIRONMENT_DIFF_FILE_QUERY_KEY = "environmentDiffFile"; const ENVIRONMENT_FILE_PREVIEW_QUERY_KEY = "environmentFilePreview"; const ENVIRONMENT_PATHS_QUERY_KEY = "environmentPaths"; export const THREAD_TIMELINE_QUERY_KEY = "threadTimeline"; -export const THREAD_HISTORY_QUERY_KEY = "threadHistory"; const THREAD_CONVERSATION_OUTLINE_QUERY_KEY = "threadConversationOutline"; const THREAD_TIMELINE_TURN_SUMMARY_DETAILS_QUERY_KEY = "threadTimelineTurnSummaryDetails"; @@ -926,20 +925,6 @@ export function threadTimelineQueryKey( return [THREAD_TIMELINE_QUERY_KEY, threadId]; } -export function threadHistoryQueryKey( - threadId: string, - surfaceKey: string, - segmentLimit: number, -) { - return [THREAD_HISTORY_QUERY_KEY, threadId, surfaceKey, segmentLimit] as const; -} - -export function threadHistoryQueryKeyPrefix(threadId?: string) { - return threadId === undefined - ? ([THREAD_HISTORY_QUERY_KEY] as const) - : ([THREAD_HISTORY_QUERY_KEY, threadId] as const); -} - export function threadConversationOutlineQueryKey( threadId: string, ): ThreadConversationOutlineQueryKey { diff --git a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx index 6d9565605b7..42901f897dc 100644 --- a/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx +++ b/apps/app/src/hooks/queries/sidebar-navigation-query.test.tsx @@ -6,6 +6,7 @@ import { type SidebarBootstrapResponse, } from "@bb/server-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDeferredPromise } from "@bb/test-helpers"; import { request } from "@/lib/api"; import { MAX_CACHED_SIDEBAR_THREADS_PER_PROJECT, @@ -77,13 +78,8 @@ describe("useSidebarNavigation", () => { SIDEBAR_BOOTSTRAP_CACHE_KEY, JSON.stringify(BOOTSTRAP), ); - let complete!: (value: SidebarBootstrapResponse) => void; - vi.mocked(request).mockImplementation( - () => - new Promise((resolve) => { - complete = resolve; - }), - ); + const refresh = createDeferredPromise(); + vi.mocked(request).mockReturnValue(refresh.promise); const { queryClient, wrapper } = createQueryClientTestHarness(); const { result } = renderHook( () => ({ @@ -101,7 +97,7 @@ describe("useSidebarNavigation", () => { projects: [{ ...BOOTSTRAP.projects[0]!, name: "Refreshed project" }], }; await act(async () => { - complete(updated); + refresh.resolve(updated); }); await waitFor(() => expect(result.current.name).toBe("Refreshed project")); expect(queryClient.getQueryData(sidebarNavigationQueryKey())).toEqual( diff --git a/apps/app/src/hooks/queries/thread-history-query.test.tsx b/apps/app/src/hooks/queries/thread-history-query.test.tsx deleted file mode 100644 index aa34ed4e3ab..00000000000 --- a/apps/app/src/hooks/queries/thread-history-query.test.tsx +++ /dev/null @@ -1,388 +0,0 @@ -// @vitest-environment jsdom - -import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import type { ThreadTimelineResponse } from "@bb/server-contract"; -import { resolveLoadedTimelineSurfaceKey } from "@bb/client-core"; -import { createDeferredPromise } from "@bb/test-helpers"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { BbHttpError, sdk } from "@/lib/sdk"; -import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { makeThreadTimelineResponse } from "@/test/fixtures/thread-responses"; -import { systemRow } from "@/test/fixtures/thread-timeline-rows"; -import { createBrowserLifecycleFetchController } from "../cache-owners/browser-lifecycle-cache-owner"; -import { - createThreadHistoryPage, - invalidateThreadHistory, - removeThreadHistory, - type ThreadHistoryChain, -} from "../cache-owners/thread-history-cache-owner"; -import { threadHistoryQueryKey, threadTimelineQueryKey } from "./query-keys"; -import { useThreadHistory } from "./thread-history-query"; -import { useThreadTimeline } from "./thread-queries"; - -vi.mock("@/lib/sdk", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, sdk: { threads: { timeline: vi.fn() } } }; -}); - -vi.mock("@/hooks/useRealtimeSubscription", () => ({ - useThreadDetailRealtimeSubscription: vi.fn(), -})); - -afterEach(() => { - cleanup(); - vi.mocked(sdk.threads.timeline).mockReset(); - vi.restoreAllMocks(); -}); - -function page( - sequence: number, - options: { - kind?: "latest" | "older"; - snapshot?: string; - final?: boolean; - } = {}, -): ThreadTimelineResponse { - const snapshot = options.snapshot ?? "old"; - return makeThreadTimelineResponse({ - rows: [ - systemRow({ - id: `row-${sequence}`, - seq: sequence, - title: `${sequence}`, - detail: null, - }), - ], - maxSeq: snapshot === "old" ? 100 : 200, - timelinePage: { - kind: options.kind ?? "latest", - historySnapshot: snapshot, - returnedSegmentCount: 1, - hasOlderRows: !options.final, - olderCursor: options.final - ? null - : { - anchorId: `${snapshot}:${sequence}`, - anchorSeq: sequence, - }, - }, - }); -} - -function createHistoryHarness( - pages: ThreadTimelineResponse[], - updatedAt = Date.now(), -) { - const { queryClient, wrapper } = createQueryClientTestHarness(); - const latest = pages[0]!; - const surfaceKey = resolveLoadedTimelineSurfaceKey("thread-1", latest); - const key = threadHistoryQueryKey( - "thread-1", - surfaceKey, - latest.timelinePage.segmentLimit, - ); - const chain: ThreadHistoryChain = { - surfaceKey, - pages: pages.map((response, index) => - createThreadHistoryPage( - response, - index === 0 ? null : pages[index - 1]!.timelinePage.olderCursor, - updatedAt, - ), - ), - }; - queryClient.setQueryData(threadTimelineQueryKey("thread-1"), latest); - queryClient.setQueryData(key, chain, { updatedAt }); - return { - queryClient, - chain, - key, - renderHistory: () => - renderHook( - () => useThreadHistory({ threadId: "thread-1", latestTimeline: latest }), - { wrapper }, - ), - }; -} - -describe("useThreadHistory", () => { - it("preserves the initial miss path without fetching latest twice", async () => { - const latest = page(30); - vi.mocked(sdk.threads.timeline).mockResolvedValue(latest); - const { wrapper } = createQueryClientTestHarness(); - const { result } = renderHook( - () => { - const timeline = useThreadTimeline("thread-1"); - return useThreadHistory({ - threadId: "thread-1", - latestTimeline: timeline.data, - }); - }, - { wrapper }, - ); - - await waitFor(() => expect(result.current.data?.pages).toHaveLength(1)); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(1); - await act(async () => { - await result.current.refresh(); - }); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); - }); - - it("keeps stale rows visible and rebuilds with fresh opaque cursors", async () => { - const latest = page(30); - const { chain, renderHistory } = createHistoryHarness( - [latest, page(20, { kind: "older" })], - Date.now() - 10_000, - ); - const freshLatest = createDeferredPromise(); - const freshOlder = page(25, { kind: "older", snapshot: "fresh" }); - vi.mocked(sdk.threads.timeline) - .mockReturnValueOnce(freshLatest.promise) - .mockResolvedValueOnce(freshOlder); - const { result } = renderHistory(); - - expect(result.current.data).toBe(chain); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); - await act(async () => { - freshLatest.resolve(page(40, { snapshot: "fresh" })); - }); - await waitFor(() => - expect(result.current.data?.pages[1]?.response).toEqual(freshOlder), - ); - expect(sdk.threads.timeline).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - beforeAnchorId: "fresh:40", - beforeAnchorSeq: "40", - signal: expect.any(AbortSignal), - }), - ); - }); - - it("retains the successful chain and validation times on refresh failure", async () => { - const latest = page(30); - const { queryClient, chain, renderHistory } = createHistoryHarness( - [latest, page(20, { kind: "older" })], - Date.now() - 10_000, - ); - const failure = new Error("Failed to fetch"); - vi.mocked(sdk.threads.timeline) - .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) - .mockRejectedValueOnce(failure); - const { result } = renderHistory(); - - await waitFor(() => expect(result.current.error).toBe(failure)); - expect(result.current.data).toBe(chain); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); - act(() => { - queryClient.getQueryCache().onFocus(); - queryClient.getQueryCache().onOnline(); - }); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); - vi.mocked(sdk.threads.timeline) - .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) - .mockResolvedValueOnce(page(25, { kind: "older", snapshot: "fresh" })); - await act(async () => { - await result.current.refresh(); - }); - await waitFor(() => expect(result.current.error).toBeNull()); - expect(result.current.data?.pages[1]?.response.rows[0]?.id).toBe("row-25"); - }); - - it("deduplicates two readers and does not cancel when one unmounts", async () => { - const latest = page(30); - const { renderHistory } = createHistoryHarness([latest]); - const pending = createDeferredPromise(); - vi.mocked(sdk.threads.timeline).mockReturnValue(pending.promise); - const first = renderHistory(); - const second = renderHistory(); - const cursor = latest.timelinePage.olderCursor!; - let firstRead!: Promise; - let secondRead!: Promise; - act(() => { - firstRead = first.result.current.loadOlder(cursor); - secondRead = second.result.current.loadOlder(cursor); - }); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); - const signal = vi.mocked(sdk.threads.timeline).mock.calls[0]![0].signal; - first.unmount(); - expect(signal?.aborted).toBe(false); - const older = page(20, { kind: "older" }); - await act(async () => { - pending.resolve(older); - expect(await firstRead).toBe(older); - expect(await secondRead).toBe(older); - }); - await waitFor(() => - expect(second.result.current.data?.pages).toHaveLength(2), - ); - }); - - it("suspends and resumes an active older read without dropping its caller", async () => { - const latest = page(30); - const { queryClient, renderHistory } = createHistoryHarness([latest]); - const pending = createDeferredPromise(); - const older = page(20, { kind: "older" }); - vi.mocked(sdk.threads.timeline) - .mockReturnValueOnce(pending.promise) - .mockResolvedValueOnce(older); - const { result } = renderHistory(); - let read!: Promise; - act(() => { - read = result.current.loadOlder(latest.timelinePage.olderCursor!); - }); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); - const signal = vi.mocked(sdk.threads.timeline).mock.calls[0]![0].signal; - const lifecycle = createBrowserLifecycleFetchController(queryClient); - act(() => lifecycle.suspend()); - expect(signal?.aborted).toBe(true); - await act(async () => { - lifecycle.resume(); - expect(await read).toBe(older); - }); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(2); - expect(result.current.error).toBeNull(); - pending.resolve(page(10, { kind: "older" })); - }); - - it("purges pending work and ignores late results and optimistic writes", async () => { - const latest = page(30); - const { queryClient, key, renderHistory } = createHistoryHarness([latest]); - const pending = createDeferredPromise(); - vi.mocked(sdk.threads.timeline).mockReturnValueOnce(pending.promise); - const { result } = renderHistory(); - let read!: Promise; - act(() => { - read = result.current.loadOlder(latest.timelinePage.olderCursor!); - }); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(1)); - await act(async () => { - removeThreadHistory({ queryClient, threadId: "thread-1" }); - expect(await read).toBeUndefined(); - }); - expect(result.current.isBlocked).toBe(true); - expect(result.current.generation).toBe(1); - await act(async () => { - pending.resolve(page(20, { kind: "older" })); - }); - act(() => - queryClient.setQueryData(threadTimelineQueryKey("thread-1"), page(40)), - ); - expect(result.current.data).toBeUndefined(); - expect(result.current.isBlocked).toBe(true); - expect(queryClient.getQueryData(key)).toBeUndefined(); - }); - - it.each([401, 403, 404])( - "clears cached history on an older read returning %s", - async (status) => { - const latest = page(30); - const { renderHistory } = createHistoryHarness([latest]); - const failure = new BbHttpError({ - body: null, - code: null, - message: "Unavailable", - status, - }); - vi.mocked(sdk.threads.timeline).mockRejectedValueOnce(failure); - const { result } = renderHistory(); - await act(async () => { - expect( - await result.current.loadOlder(latest.timelinePage.olderCursor!), - ).toBeUndefined(); - }); - expect(result.current.isBlocked).toBe(true); - expect(result.current.data).toBeUndefined(); - expect(result.current.error).toBe(failure); - }, - ); - - it("services a foreground cursor before restarting an interrupted background chain", async () => { - const latest = page(30); - const { chain, renderHistory } = createHistoryHarness( - [latest, page(20, { kind: "older" })], - Date.now() - 10_000, - ); - const background = createDeferredPromise(); - const nextLatest = createDeferredPromise(); - const foreground = page(10, { kind: "older" }); - vi.mocked(sdk.threads.timeline) - .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) - .mockReturnValueOnce(background.promise) - .mockResolvedValueOnce(foreground) - .mockReturnValueOnce(nextLatest.promise); - const { result } = renderHistory(); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(2)); - const backgroundSignal = vi.mocked(sdk.threads.timeline).mock.calls[1]![0] - .signal; - await act(async () => { - expect( - await result.current.loadOlder( - chain.pages[1]!.response.timelinePage.olderCursor!, - ), - ).toBe(foreground); - }); - expect(backgroundSignal?.aborted).toBe(true); - expect(sdk.threads.timeline).toHaveBeenNthCalledWith( - 3, - expect.objectContaining({ beforeAnchorId: "old:20" }), - ); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(4)); - background.resolve(page(25, { kind: "older", snapshot: "fresh" })); - expect(result.current.data?.pages[2]?.response).toEqual(foreground); - }); - - it("recovers an invalid cursor once and exposes a second failure", async () => { - const latest = page(30); - const { chain, renderHistory } = createHistoryHarness([ - latest, - page(20, { kind: "older" }), - ]); - const invalid = new BbHttpError({ - body: null, - code: "invalid_request", - message: "Invalid cursor", - status: 400, - }); - vi.mocked(sdk.threads.timeline) - .mockRejectedValueOnce(invalid) - .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) - .mockRejectedValueOnce(invalid); - const { result } = renderHistory(); - await act(async () => { - await expect( - result.current.loadOlder( - chain.pages[1]!.response.timelinePage.olderCursor!, - ), - ).rejects.toBe(invalid); - }); - expect(sdk.threads.timeline).toHaveBeenCalledTimes(3); - expect(result.current.data).toBe(chain); - await waitFor(() => expect(result.current.error).toBe(invalid)); - }); - - it("invalidates an obsolete refresh without publishing its result", async () => { - const latest = page(30); - const { queryClient, renderHistory } = createHistoryHarness( - [latest, page(20, { kind: "older" })], - Date.now() - 10_000, - ); - const obsolete = createDeferredPromise(); - vi.mocked(sdk.threads.timeline) - .mockResolvedValueOnce(page(40, { snapshot: "fresh" })) - .mockReturnValueOnce(obsolete.promise) - .mockResolvedValueOnce(page(50, { snapshot: "newest" })) - .mockResolvedValueOnce(page(35, { kind: "older", snapshot: "newest" })); - const { result } = renderHistory(); - await waitFor(() => expect(sdk.threads.timeline).toHaveBeenCalledTimes(2)); - await act(async () => { - await invalidateThreadHistory({ queryClient, threadId: "thread-1" }); - }); - await act(async () => { - obsolete.resolve(page(25, { kind: "older", snapshot: "fresh" })); - }); - expect(result.current.data?.pages[0]?.response.rows[0]?.id).toBe("row-50"); - expect(result.current.data?.pages[1]?.response.rows[0]?.id).toBe("row-35"); - }); -}); diff --git a/apps/app/src/hooks/queries/thread-history-query.ts b/apps/app/src/hooks/queries/thread-history-query.ts deleted file mode 100644 index e817a45207a..00000000000 --- a/apps/app/src/hooks/queries/thread-history-query.ts +++ /dev/null @@ -1,462 +0,0 @@ -import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; -import { - CancelledError, - useQuery, - useQueryClient, - type QueryClient, -} from "@tanstack/react-query"; -import type { - ThreadTimelineResponse, - TimelinePaginationCursor, -} from "@bb/server-contract"; -import { - areTimelinePaginationCursorsEqual, - resolveLoadedTimelineSurfaceKey, -} from "@bb/client-core/timeline"; -import { BbHttpError, sdk } from "@/lib/sdk"; -import { - compactThreadHistory, - cancelThreadHistoryRead, - createThreadHistoryPage, - getThreadHistoryGeneration, - pruneThreadHistory, - removeThreadHistory, - THREAD_HISTORY_MAX_BYTES, - THREAD_HISTORY_MAX_PAGES, - type ThreadHistoryChain, -} from "../cache-owners/thread-history-cache-owner"; -import { HEAVY_PAYLOAD_QUERY_POLICY } from "./query-policies"; -import { threadHistoryQueryKey, threadTimelineQueryKey } from "./query-keys"; -import { fetchThreadTimeline } from "./thread-queries"; - -const HISTORY_STALE_TIME_MS = 2_000; - -interface ForegroundRead { - cursor: TimelinePaginationCursor; - generation: number; - promise: Promise; - resolve: (response: ThreadTimelineResponse | undefined) => void; - reject: (error: unknown) => void; - response: ThreadTimelineResponse | undefined; - finished: boolean; -} - -interface HistoryReadState { - foreground: ForegroundRead | undefined; - refreshAfterForeground: boolean; - forceRefresh: boolean; -} - -const reads = new WeakMap>(); - -function historyReadState( - queryClient: QueryClient, - identity: string, -): HistoryReadState { - let entries = reads.get(queryClient); - if (!entries) { - entries = new Map(); - reads.set(queryClient, entries); - } - let state = entries.get(identity); - if (!state) { - state = { - foreground: undefined, - refreshAfterForeground: false, - forceRefresh: false, - }; - entries.set(identity, state); - } - return state; -} - -function isStaleCursor(error: unknown): boolean { - return ( - error instanceof BbHttpError && - error.status === 400 && - error.code === "invalid_request" - ); -} - -function isAccessFailure(error: unknown): error is BbHttpError { - return ( - error instanceof BbHttpError && - (error.status === 401 || error.status === 403 || error.status === 404) - ); -} - -interface UseThreadHistoryArgs { - threadId: string; - latestTimeline: ThreadTimelineResponse | undefined; - enabled?: boolean; -} - -export function useThreadHistory({ - threadId, - latestTimeline, - enabled = true, -}: UseThreadHistoryArgs) { - const queryClient = useQueryClient(); - const surfaceKey = resolveLoadedTimelineSurfaceKey(threadId, latestTimeline); - const segmentLimit = latestTimeline?.timelinePage.segmentLimit ?? 20; - const queryKey = useMemo( - () => threadHistoryQueryKey(threadId, surfaceKey, segmentLimit), - [threadId, surfaceKey, segmentLimit], - ); - const identity = JSON.stringify(queryKey); - const state = historyReadState(queryClient, identity); - const subscribe = useCallback( - (listener: () => void) => - queryClient.getQueryCache().subscribe((event) => { - if (JSON.stringify(event.query.queryKey) === identity) { - const foreground = state.foreground; - if ( - foreground && - (event.type === "removed" || - foreground.generation !== - getThreadHistoryGeneration(queryClient, threadId).request) - ) { - state.foreground = undefined; - foreground.resolve(undefined); - } else if ( - foreground?.finished && - event.type === "updated" && - event.action.type === "success" && - !event.action.manual - ) { - state.foreground = undefined; - foreground.resolve(foreground.response); - } else if ( - foreground && - event.type === "updated" && - event.action.type === "error" - ) { - if (event.action.error instanceof CancelledError) { - foreground.response = undefined; - foreground.finished = false; - } else if (foreground.finished) { - state.foreground = undefined; - foreground.reject(event.action.error); - } - } - } - listener(); - }), - [queryClient, threadId, identity, state], - ); - const getGeneration = useCallback(() => { - const owner = getThreadHistoryGeneration(queryClient, threadId); - const fetchStatus = - queryClient.getQueryState(queryKey)?.fetchStatus ?? "idle"; - return `${owner.eviction}:${owner.blocked}:${fetchStatus}`; - }, [queryClient, threadId, queryKey]); - useSyncExternalStore(subscribe, getGeneration, getGeneration); - const owner = getThreadHistoryGeneration(queryClient, threadId); - const canRead = - enabled && - Boolean(threadId) && - latestTimeline !== undefined && - !owner.blocked; - - const query = useQuery({ - queryKey, - enabled: canRead, - ...HEAVY_PAYLOAD_QUERY_POLICY, - initialData: () => - latestTimeline && !owner.blocked - ? compactThreadHistory({ - surfaceKey, - pages: [ - createThreadHistoryPage( - latestTimeline, - null, - queryClient.getQueryState(threadTimelineQueryKey(threadId)) - ?.dataUpdatedAt ?? Date.now(), - ), - ], - }) - : undefined, - initialDataUpdatedAt: () => - queryClient.getQueryState(threadTimelineQueryKey(threadId)) - ?.dataUpdatedAt, - staleTime: (cached) => { - const pages = cached.state.data?.pages; - if (!pages?.length) return HISTORY_STALE_TIME_MS; - const validatedAt = Math.min(...pages.map((page) => page.validatedAt)); - return Math.max( - 0, - validatedAt + HISTORY_STALE_TIME_MS - cached.state.dataUpdatedAt, - ); - }, - refetchOnMount: true, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: false, - queryFn: async ({ signal }) => { - const requestGeneration = owner.request; - const current = queryClient.getQueryData(queryKey); - const forceRefresh = - state.forceRefresh || - queryClient.getQueryState(queryKey)?.isInvalidated === true; - state.forceRefresh = false; - const foreground = - state.foreground?.generation === requestGeneration - ? state.foreground - : undefined; - if (state.foreground && !foreground) { - state.foreground.resolve(undefined); - state.foreground = undefined; - } - const assertCurrent = () => { - if (signal.aborted || owner.request !== requestGeneration) { - throw new CancelledError({ revert: true }); - } - }; - const fetchOlder = async (cursor: TimelinePaginationCursor) => { - const response = await sdk.threads.timeline({ - threadId, - beforeAnchorId: cursor.anchorId, - beforeAnchorSeq: String(cursor.anchorSeq), - signal, - }); - assertCurrent(); - return response; - }; - const rebuild = async (): Promise => { - assertCurrent(); - const latest = await queryClient.fetchQuery({ - queryKey: threadTimelineQueryKey(threadId), - queryFn: ({ signal: latestSignal }) => - fetchThreadTimeline({ - queryClient, - signal: latestSignal, - threadId, - }), - staleTime: 0, - retry: false, - }); - assertCurrent(); - if ( - resolveLoadedTimelineSurfaceKey(threadId, latest) !== surfaceKey || - latest.timelinePage.segmentLimit !== segmentLimit - ) { - throw new CancelledError({ revert: true }); - } - const retained = current && compactThreadHistory(current); - const targetPages = retained?.pages.length ?? 1; - const targetSequence = - retained?.pages.at(-1)?.response.rows[0]?.sourceSeqStart; - const pages = [ - createThreadHistoryPage( - latest, - null, - Math.max(Date.now(), (current?.pages[0]?.validatedAt ?? 0) + 1), - ), - ]; - let bytes = pages[0].byteSize; - while (pages.length < Math.min(targetPages, THREAD_HISTORY_MAX_PAGES)) { - assertCurrent(); - if (state.foreground && state.foreground !== foreground) { - throw new CancelledError({ revert: true }); - } - const previous = pages.at(-1)!; - const cursor = previous.response.timelinePage.olderCursor; - if (!cursor || bytes >= THREAD_HISTORY_MAX_BYTES) break; - const firstSequence = previous.response.rows[0]?.sourceSeqStart; - if ( - targetSequence !== undefined && - firstSequence !== undefined && - firstSequence < targetSequence - ) - break; - const response = await fetchOlder(cursor); - const page = createThreadHistoryPage(response, cursor); - if (bytes + page.byteSize > THREAD_HISTORY_MAX_BYTES) break; - pages.push(page); - bytes += page.byteSize; - } - return ( - compactThreadHistory({ surfaceKey, pages }) ?? { - surfaceKey, - pages: [], - } - ); - }; - try { - if (foreground) { - const response = await fetchOlder(foreground.cursor); - foreground.response = response; - const previous = current?.pages.at(-1); - if ( - current && - previous && - areTimelinePaginationCursorsEqual({ - left: previous.response.timelinePage.olderCursor, - right: foreground.cursor, - }) - ) { - return ( - compactThreadHistory({ - ...current, - pages: [ - ...current.pages, - createThreadHistoryPage(response, foreground.cursor), - ], - }) ?? { surfaceKey, pages: [] } - ); - } - return current ?? { surfaceKey, pages: [] }; - } - if ((!current || current.pages.length <= 1) && !forceRefresh) { - const latest = - queryClient.getQueryData( - threadTimelineQueryKey(threadId), - ) ?? latestTimeline; - assertCurrent(); - return latest - ? (compactThreadHistory({ - surfaceKey, - pages: [createThreadHistoryPage(latest, null)], - }) ?? { surfaceKey, pages: [] }) - : { surfaceKey, pages: [] }; - } - return await rebuild(); - } catch (error) { - try { - if (!isStaleCursor(error)) throw error; - const rebuilt = await rebuild(); - return foreground - ? { ...rebuilt, recoveredFromCursor: foreground.cursor } - : rebuilt; - } catch (readError) { - if (isAccessFailure(readError)) { - removeThreadHistory({ queryClient, threadId, error: readError }); - throw readError; - } - if ( - signal.aborted || - owner.request !== requestGeneration || - readError instanceof CancelledError - ) { - throw new CancelledError({ revert: true }); - } - throw readError; - } - } finally { - if (foreground && !signal.aborted) foreground.finished = true; - pruneThreadHistory(queryClient); - } - }, - }); - - const refetch = query.refetch; - const loadOlder = useCallback( - async function loadOlderPage( - cursor: TimelinePaginationCursor, - ): Promise { - if (!canRead) return undefined; - const cached = queryClient.getQueryData(queryKey); - const page = cached?.pages.find((entry) => - areTimelinePaginationCursorsEqual({ - left: entry.requestCursor, - right: cursor, - }), - ); - if (page) return page.response; - if (state.foreground) { - const existing = state.foreground; - if ( - areTimelinePaginationCursorsEqual({ - left: existing.cursor, - right: cursor, - }) - ) - return existing.promise; - await existing.promise; - return loadOlderPage(cursor); - } - let resolve!: ForegroundRead["resolve"]; - let reject!: ForegroundRead["reject"]; - const promise = new Promise( - (onResolve, onReject) => { - resolve = onResolve; - reject = onReject; - }, - ); - const foreground: ForegroundRead = { - cursor, - generation: owner.request, - promise, - resolve, - reject, - response: undefined, - finished: false, - }; - state.refreshAfterForeground ||= - queryClient.getQueryState(queryKey)?.fetchStatus === "fetching"; - state.foreground = foreground; - void (async () => { - await cancelThreadHistoryRead({ queryClient, queryKey }); - if (owner.request !== foreground.generation) { - if (state.foreground === foreground) state.foreground = undefined; - foreground.resolve(undefined); - return; - } - await refetch({ cancelRefetch: false }); - })(); - return promise; - }, - [canRead, owner, queryClient, queryKey, refetch, state], - ); - - const refresh = useCallback(async () => { - if (!canRead) return; - if (state.foreground) { - await state.foreground.promise.catch(() => undefined); - } - state.foreground = undefined; - state.refreshAfterForeground = false; - state.forceRefresh = true; - await refetch({ cancelRefetch: false }); - }, [canRead, refetch, state]); - - useEffect(() => { - if ( - !canRead || - query.isFetching || - state.foreground || - !state.refreshAfterForeground - ) - return; - state.refreshAfterForeground = false; - state.forceRefresh = true; - void refetch({ cancelRefetch: false }); - }, [canRead, query.isFetching, refetch, state]); - - useEffect(() => { - pruneThreadHistory(queryClient); - return () => { - queueMicrotask(() => { - const cached = queryClient - .getQueryCache() - .find({ queryKey, exact: true }); - if (cached && cached.getObserversCount() > 0) return; - state.foreground?.resolve(undefined); - state.foreground = undefined; - reads.get(queryClient)?.delete(identity); - pruneThreadHistory(queryClient); - }); - }; - }, [queryClient, identity, queryKey, state]); - - return { - data: canRead ? query.data : undefined, - generation: owner.eviction, - isBlocked: owner.blocked, - isFetching: query.isFetching, - isLoadingOlder: query.isFetching && state.foreground !== undefined, - error: owner.error ?? query.error, - loadOlder, - refresh, - }; -} diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 635d222b923..a7be0d344e8 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -37,9 +37,7 @@ import { useThreadQueuedMessages, useThreadStorageLocation, useThreadTimeline, - useThreadTimelineTurnSummaryDetails, } from "./thread-queries"; -import { commandRow } from "@/test/fixtures/thread-timeline-rows"; import { makeProjectWithThreadsResponse, makeSidebarBootstrapResponse, @@ -66,7 +64,6 @@ vi.mock("@/lib/sdk", () => ({ interactions: { list: vi.fn() }, storageLocation: vi.fn(), timeline: vi.fn(), - timelineTurnSummaryDetails: vi.fn(), }, }, })); @@ -164,39 +161,6 @@ beforeEach(() => { }); }); -describe("useThreadTimelineTurnSummaryDetails", () => { - it("loads older turn details without duplicate rows", async () => { - const older = commandRow({ id: "older-command", command: "pwd", seq: 1 }); - const latest = commandRow({ id: "latest-command", command: "ls", seq: 2 }); - vi.mocked(sdk.threads.timelineTurnSummaryDetails) - .mockResolvedValueOnce({ rows: [latest], olderCursor: "older-page" }) - .mockResolvedValueOnce({ rows: [older, latest], olderCursor: null }); - const { wrapper } = createQueryClientTestHarness(); - const { result } = renderHook( - () => - useThreadTimelineTurnSummaryDetails({ - threadId: "thread-1", - turnId: "turn-1", - sourceSeqStart: 1, - sourceSeqEnd: 2, - }), - { wrapper }, - ); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - expect(result.current.data).toEqual({ - rows: [older, latest], - olderCursor: null, - }); - expect(sdk.threads.timelineTurnSummaryDetails).toHaveBeenCalledTimes(2); - expect(sdk.threads.timelineTurnSummaryDetails).toHaveBeenLastCalledWith( - expect.objectContaining({ beforeCursor: "older-page" }), - ); - }); -}); - describe("useThreadDetailBootstrap", () => { it("starts the timeline request before the thread bootstrap settles", async () => { let resolveThread: diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index a264af71e6a..0922143f951 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -1,3 +1,4 @@ +import { prependOlderTimelineRows } from "@bb/client-core"; import { useInfiniteQuery, useQuery, @@ -910,7 +911,7 @@ function resolveThreadTimelineSegmentLimit(): number | undefined { : undefined; } -export async function fetchThreadTimeline({ +async function fetchThreadTimeline({ queryClient, signal, threadId, @@ -1009,12 +1010,8 @@ export function useThreadTimelineTurnSummaryDetails( signal, }; const response = await sdk.threads.timelineTurnSummaryDetails(input); - let cursor = response.olderCursor; - if (!cursor) return { ...response, olderCursor: null }; - const { prependOlderTimelineRows } = await import( - "@bb/client-core/timeline" - ); let rows = response.rows; + let cursor = response.olderCursor; while (cursor) { const older = await sdk.threads.timelineTurnSummaryDetails({ ...input, diff --git a/apps/app/src/hooks/thread-history-cache-effects.test.ts b/apps/app/src/hooks/thread-history-cache-effects.test.ts deleted file mode 100644 index b9bc45d1c16..00000000000 --- a/apps/app/src/hooks/thread-history-cache-effects.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { QueryClient, QueryObserver } from "@tanstack/react-query"; -import type { ThreadChangeKind } from "@bb/domain"; -import { makeThreadTimelineResponse } from "@/test/fixtures/thread-responses"; -import { - createThreadHistoryPage, - getThreadHistoryGeneration, - type ThreadHistoryChain, -} from "./cache-owners/thread-history-cache-owner"; -import { - createFlushOncePredicate, - executeRealtimeDirtyHandlers, - REALTIME_PROJECT_CHANGE_REGISTRY, - REALTIME_SYSTEM_CHANGE_REGISTRY, - REALTIME_THREAD_CHANGE_REGISTRY, -} from "./cache-owners/realtime-cache-registry"; -import { - invalidateRealtimeQueriesAfterServerReconnect, - invalidateRealtimeQueriesFetchedBeforeInitialConnect, -} from "./cache-owners/system-cache-effects"; -import { - invalidateThreadHistoryRewriteQueries, - removeThreadScopedQueries, -} from "./cache-owners/mutation-cache-effects"; -import { applyProjectDeleteResult } from "./cache-owners/project-cache-owner"; -import { - sidebarNavigationQueryKey, - threadDetailBootstrapQueryKey, - threadHistoryQueryKey, - threadQueryKey, - threadsQueryKey, - threadTimelineQueryKey, -} from "./queries/query-keys"; - -function historyChain(validatedAt: number[] = [1]): ThreadHistoryChain { - return { - surfaceKey: "default", - pages: validatedAt.map((timestamp) => - createThreadHistoryPage( - makeThreadTimelineResponse({ maxSeq: 1 }), - null, - timestamp, - ), - ), - }; -} - -function historyKey(threadId: string) { - return threadHistoryQueryKey(threadId, "default", 20); -} - -function queryClient() { - return new QueryClient({ - defaultOptions: { queries: { gcTime: Infinity, retry: false } }, - }); -} - -function applyThreadChange(client: QueryClient, change: ThreadChangeKind) { - executeRealtimeDirtyHandlers({ - context: { - queryClient: client, - threadId: "thread-1", - projectId: "project-1", - backgroundActivityChanged: undefined, - eventTypes: ["turn/completed"] as const, - flushOnce: createFlushOncePredicate(), - hasPendingInteraction: undefined, - statusChange: undefined, - }, - handlers: REALTIME_THREAD_CHANGE_REGISTRY[change].dirty, - }); -} - -describe("thread history cache effects", () => { - it.each([ - "history-rewritten", - "title-changed", - "environment-changed", - ] as const)( - "refreshes history and latest data after %s without clearing readable rows", - async (change) => { - const client = queryClient(); - const data = historyChain(); - client.setQueryData(historyKey("thread-1"), data); - client.setQueryData( - threadTimelineQueryKey("thread-1"), - data.pages[0]!.response, - ); - const generation = getThreadHistoryGeneration(client, "thread-1"); - - applyThreadChange(client, change); - - expect(generation.request).toBe(1); - await vi.waitFor(() => - expect( - client.getQueryState(historyKey("thread-1"))?.isInvalidated, - ).toBe(true), - ); - expect(client.getQueryData(historyKey("thread-1"))).toBe(data); - expect( - client.getQueryState(threadTimelineQueryKey("thread-1"))?.isInvalidated, - ).toBe(true); - client.clear(); - }, - ); - - it("keeps completed turns from rebuilding active historical pages", async () => { - const client = queryClient(); - const data = historyChain(); - const key = historyKey("thread-1"); - client.setQueryData(key, data); - const fetchHistory = vi.fn(async () => data); - const observer = new QueryObserver(client, { - queryKey: key, - queryFn: fetchHistory, - staleTime: Infinity, - }); - const unsubscribe = observer.subscribe(() => {}); - const generation = getThreadHistoryGeneration(client, "thread-1"); - - applyThreadChange(client, "events-appended"); - await Promise.resolve(); - - expect(fetchHistory).not.toHaveBeenCalled(); - expect(generation.request).toBe(0); - expect(client.getQueryState(key)?.isInvalidated).toBe(false); - unsubscribe(); - client.clear(); - }); - - it("invalidates retained history for rendering configuration changes and local rewrites", async () => { - const client = queryClient(); - client.setQueryData(historyKey("thread-1"), historyChain()); - client.setQueryData(historyKey("thread-2"), historyChain()); - const first = getThreadHistoryGeneration(client, "thread-1"); - const second = getThreadHistoryGeneration(client, "thread-2"); - - invalidateThreadHistoryRewriteQueries({ - queryClient: client, - threadId: "thread-1", - }); - await vi.waitFor(() => - expect(client.getQueryState(historyKey("thread-1"))?.isInvalidated).toBe( - true, - ), - ); - expect(client.getQueryState(historyKey("thread-2"))?.isInvalidated).toBe( - false, - ); - expect(first.request).toBe(1); - - executeRealtimeDirtyHandlers({ - context: { queryClient: client }, - handlers: REALTIME_SYSTEM_CHANGE_REGISTRY["config-changed"].dirty, - }); - - await vi.waitFor(() => - expect(client.getQueryState(historyKey("thread-2"))?.isInvalidated).toBe( - true, - ), - ); - expect(second.request).toBe(1); - client.clear(); - }); - - it.each(["reconnect", "initial connect"] as const)( - "uses page validation times on %s even after a newer page was appended", - async (event) => { - const client = queryClient(); - const timestamp = Date.now(); - const staleKey = historyKey("thread-1"); - const freshKey = historyKey("thread-2"); - client.setQueryData( - staleKey, - historyChain([timestamp - 500, timestamp + 500]), - { updatedAt: timestamp + 500 }, - ); - client.setQueryData(freshKey, historyChain([timestamp + 500]), { - updatedAt: timestamp + 500, - }); - - if (event === "reconnect") { - invalidateRealtimeQueriesAfterServerReconnect({ - queryClient: client, - disconnectedAt: timestamp, - }); - } else { - invalidateRealtimeQueriesFetchedBeforeInitialConnect({ - queryClient: client, - connectedAt: timestamp, - }); - } - - await vi.waitFor(() => - expect(client.getQueryState(staleKey)?.isInvalidated).toBe(true), - ); - expect(client.getQueryState(freshKey)?.isInvalidated).toBe(false); - client.clear(); - }, - ); - - it.each(["local", "realtime"] as const)( - "purges only deleted thread history through %s deletion", - (source) => { - const client = queryClient(); - client.setQueryData(historyKey("thread-1"), historyChain()); - client.setQueryData(historyKey("thread-2"), historyChain()); - const generation = getThreadHistoryGeneration(client, "thread-1"); - - if (source === "local") - removeThreadScopedQueries({ - queryClient: client, - threadId: "thread-1", - }); - else applyThreadChange(client, "thread-deleted"); - - expect(client.getQueryData(historyKey("thread-1"))).toBeUndefined(); - expect(client.getQueryData(historyKey("thread-2"))).toBeDefined(); - expect(generation.eviction).toBe(1); - expect(generation.blocked).toBe(true); - client.clear(); - }, - ); - - it.each(["local", "realtime"] as const)( - "targets project history from existing cached ownership during %s deletion", - (source) => { - const client = queryClient(); - const affectedIds = ["detail", "bootstrap", "list", "sidebar"]; - for (const id of [...affectedIds, "other"]) - client.setQueryData(historyKey(id), historyChain()); - client.setQueryData(threadQueryKey("detail"), { - id: "detail", - projectId: "project-1", - }); - client.setQueryData(threadDetailBootstrapQueryKey("bootstrap"), { - id: "bootstrap", - projectId: "project-1", - }); - client.setQueryData(threadsQueryKey(), { - pages: [ - [ - { id: "list", projectId: "project-1" }, - { id: "other", projectId: "project-2" }, - ], - ], - pageParams: [null], - }); - client.setQueryData(sidebarNavigationQueryKey(), { - projects: [ - { - id: "project-1", - threads: [{ id: "sidebar", projectId: "project-1" }], - }, - ], - personalProject: { id: "personal", threads: [] }, - }); - - if (source === "local") { - applyProjectDeleteResult({ - queryClient: client, - projectId: "project-1", - }); - } else { - executeRealtimeDirtyHandlers({ - context: { queryClient: client, projectId: "project-1" }, - handlers: REALTIME_PROJECT_CHANGE_REGISTRY["project-deleted"].dirty, - }); - } - - for (const id of affectedIds) { - expect(client.getQueryData(historyKey(id))).toBeUndefined(); - expect(getThreadHistoryGeneration(client, id).blocked).toBe(true); - } - expect(client.getQueryData(historyKey("other"))).toBeDefined(); - client.clear(); - }, - ); -}); diff --git a/apps/app/src/lib/system-config-atoms.local-access.test.ts b/apps/app/src/lib/system-config-atoms.local-access.test.ts index e5146dd2733..05d1a3f4769 100644 --- a/apps/app/src/lib/system-config-atoms.local-access.test.ts +++ b/apps/app/src/lib/system-config-atoms.local-access.test.ts @@ -1,6 +1,7 @@ import { createStore } from "jotai"; import { QueryObserver } from "@tanstack/react-query"; import type { ChangedMessage } from "@bb/domain"; +import { createDeferredPromise } from "@bb/test-helpers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -114,13 +115,8 @@ describe("local host daemon access atoms", () => { appQueryClient.setQueryData(systemConfigQueryKey(), cached, { updatedAt: Date.now() - 120_000, }); - let complete!: (config: typeof cached) => void; - mocks.fetchSdkSystemConfig.mockImplementation( - () => - new Promise((resolve) => { - complete = resolve; - }), - ); + const refresh = createDeferredPromise(); + mocks.fetchSdkSystemConfig.mockReturnValue(refresh.promise); const store = createStore(); const unsubscribe = store.sub(localHostDaemonAccessStateAtom, () => {}); try { @@ -128,7 +124,7 @@ describe("local host daemon access atoms", () => { "permission-required", ); expect(mocks.fetchSdkSystemConfig).toHaveBeenCalledTimes(1); - complete(updated); + refresh.resolve(updated); await vi.waitFor(async () => { await expect(store.get(localHostDaemonAccessStateAtom)).resolves.toBe( "unavailable", @@ -161,19 +157,16 @@ describe("local host daemon access atoms", () => { }); it("waits on a cache miss and preserves the failed-load fallback", async () => { - let fail!: (error: Error) => void; - mocks.fetchSdkSystemConfig.mockImplementation( - () => - new Promise((_resolve, reject) => { - fail = reject; - }), - ); + const load = createDeferredPromise< + Awaited> + >(); + mocks.fetchSdkSystemConfig.mockReturnValue(load.promise); const store = createStore(); const loaded = vi.fn(); const result = store.get(localHostDaemonAccessStateAtom).then(loaded); await Promise.resolve(); expect(loaded).not.toHaveBeenCalled(); - fail(new Error("config unavailable")); + load.reject(new Error("config unavailable")); await result; expect(loaded).toHaveBeenCalledWith("unavailable"); }); diff --git a/apps/app/src/views/ToolsView.plugin-detail.test.tsx b/apps/app/src/views/ToolsView.plugin-detail.test.tsx index 09c05179439..7cda6fd3aa3 100644 --- a/apps/app/src/views/ToolsView.plugin-detail.test.tsx +++ b/apps/app/src/views/ToolsView.plugin-detail.test.tsx @@ -21,6 +21,7 @@ import { useNavigate, } from "react-router-dom"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDeferredPromise } from "@bb/test-helpers"; import { EMPTY_PLUGIN_UPDATE_STATE, type PluginListItem, @@ -166,14 +167,12 @@ describe("plugin detail page cached loads", () => { ])( "loads with cached=$cached and refresh failure=$fails", async ({ cached, fails }) => { - let complete!: (response: Response) => void; + const response = createDeferredPromise(); vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { if (String(input) === "/api/v1/plugins") { - return new Promise((resolve) => { - complete = resolve; - }); + return response.promise; } if (String(input).includes("plugin-catalog/search")) { return Response.json({ results: [], collections: [] }); @@ -204,7 +203,7 @@ describe("plugin detail page cached loads", () => { expect(screen.getByText("Loading plugin")).toBeTruthy(); } await act(async () => { - complete( + response.resolve( fails ? Response.json({ error: "refresh failed" }, { status: 503 }) : Response.json({ diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 87a2f51d81e..b0604c451b7 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -867,14 +867,8 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { contextWindowUsage, goal, hasOlderTimelineRows, - historyRefreshError, - historyUnrefreshed, - historyReplacementKey, isLoadingOlderTimelineRows, - isRefreshingHistory, loadOlderTimelineRows, - refreshHistory, - showLatestTimeline, modelFallback, pendingTodos, timelineError, @@ -2962,12 +2956,8 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { contextBoundarySeq, threadOriginKind, hasOlderTimelineRows, - historyRefreshError, - historyUnrefreshed, - historyReplacementKey, hostConnectionNotice, isLoadingOlderTimelineRows, - isRefreshingHistory, isThreadTimelinePending, timelineError: Boolean(timelineError), onForkMessage: isForkAvailable ? handleForkMessage : undefined, @@ -2979,8 +2969,6 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { onSendToMainMessage: handleSendToMainMessage, onSelectionAddToChat: handleSelectionAddToChat, onLoadOlderRows: loadOlderTimelineRows, - onRefreshHistory: refreshHistory, - onShowLatestTimeline: showLatestTimeline, onOpenLink: handleOpenTimelineLink, onOpenLocalFileLink: handleOpenTimelineLocalFileLink, onOpenPluginPanel: handleOpenTimelinePluginPanel, diff --git a/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx b/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx deleted file mode 100644 index b08c3f763bb..00000000000 --- a/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// @vitest-environment jsdom - -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { useState } from "react"; -import { afterEach, expect, it, vi } from "vitest"; -import { BottomAnchorContext } from "@/components/ui/bottom-anchored-scroll-body"; -import { ThreadTimelineLatestContext } from "@/components/thread/timeline/ThreadTimelineLatestContext"; -import { ThreadTimelineScrollToBottomButton } from "./ThreadTimelineScrollToBottomButton"; - -afterEach(cleanup); - -it("switches held history to the latest rows before scrolling the footer to the bottom", () => { - const displayedAtScroll: string[] = []; - const showLatestTimeline = vi.fn(); - const bottomAnchor = { - captureScrollAnchor: vi.fn(), - getScrollElement: () => null, - isAtBottom: true, - scrollElementIntoView: vi.fn(), - scrollElementIntoViewClampedToMaxScroll: vi.fn(), - scrollToBottom: () => { - displayedAtScroll.push(screen.getByTestId("rows").textContent ?? ""); - }, - }; - function Timeline() { - const [historyUnrefreshed, setHistoryUnrefreshed] = useState(true); - return ( - - { - showLatestTimeline(); - setHistoryUnrefreshed(false); - }, - }} - > -
- {historyUnrefreshed ? "Saved window" : "Current latest window"} -
- -
-
- ); - } - render(); - - fireEvent.click( - screen.getByRole("button", { name: "Scroll to latest event" }), - ); - - expect(showLatestTimeline).toHaveBeenCalledTimes(1); - expect(displayedAtScroll).toEqual(["Current latest window"]); -}); diff --git a/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx b/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx index cece8557ded..2f8e0dd6a2c 100644 --- a/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx +++ b/apps/app/src/views/thread-detail/ThreadTimelineScrollToBottomButton.tsx @@ -1,7 +1,5 @@ -import { useContext, useLayoutEffect, useState } from "react"; import { useBottomAnchoredScroll } from "@/components/ui/bottom-anchored-scroll-body.js"; import { ScrollToBottomButton } from "@/components/ui/scroll-to-bottom-button.js"; -import { ThreadTimelineLatestContext } from "@/components/thread/timeline/ThreadTimelineLatestContext"; export function ThreadTimelineScrollToBottomButton({ active, @@ -9,31 +7,13 @@ export function ThreadTimelineScrollToBottomButton({ active: boolean; }) { const bottomAnchor = useBottomAnchoredScroll(); - const latestTimeline = useContext(ThreadTimelineLatestContext); - const [scrollAfterReplacement, setScrollAfterReplacement] = useState(false); - - useLayoutEffect(() => { - if (!scrollAfterReplacement) return; - bottomAnchor?.scrollToBottom(); - setScrollAfterReplacement(false); - }, [bottomAnchor, scrollAfterReplacement]); - if (!bottomAnchor) return null; return ( { - if (latestTimeline?.historyUnrefreshed) { - latestTimeline.showLatestTimeline(); - setScrollAfterReplacement(true); - return; - } - bottomAnchor.scrollToBottom(); - }} + onClick={bottomAnchor.scrollToBottom} /> ); } diff --git a/apps/server/src/routes/threads/data.ts b/apps/server/src/routes/threads/data.ts index 5e3dce7e985..a91f9ceb452 100644 --- a/apps/server/src/routes/threads/data.ts +++ b/apps/server/src/routes/threads/data.ts @@ -330,8 +330,7 @@ export function registerThreadDataRoutes(app: Hono, deps: AppDeps): void { deps.hub.onChangedMessage((message) => { if ( message.entity === "thread" && - (message.changes.includes("history-rewritten") || - message.changes.includes("title-changed")) + message.changes.includes("history-rewritten") ) { clearTimelineOrderingContextCache(deps.db); timelineCache.invalidateThread(message.id); diff --git a/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts b/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts index dc2e8fab5d2..92698ff24ba 100644 --- a/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts +++ b/apps/server/test/public/public-thread-timeline-epoch-cursor.test.ts @@ -11,7 +11,7 @@ import { seedEvent, seedThreadFixture } from "../helpers/seed.js"; import { withTestHarness } from "../helpers/test-app.js"; describe("timeline content continuation at the history epoch", () => { - it.each([false, true])("pages through renamed=%s history", async (rename) => { + it("accepts every cursor it returns while paging the oldest nested turn", async () => { await withTestHarness( { featureFlags: { ...defaultFeatureFlags, timelineWindowEventBudget: 2 }, @@ -58,27 +58,6 @@ describe("timeline content continuation at the history epoch", () => { type: "turn/completed", data: { status: "completed" }, }); - if (rename) { - const route = `/api/v1/threads/${thread.id}/timeline?includeNestedRows=true`; - const cached = threadTimelineResponseSchema.parse( - await readJson(await harness.app.request(route)), - ); - const renamed = await harness.app.request( - `/api/v1/threads/${thread.id}`, - { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ title: "Renamed cached thread" }), - }, - ); - expect(renamed.status).toBe(200); - const refreshed = threadTimelineResponseSchema.parse( - await readJson(await harness.app.request(route)), - ); - expect(refreshed.timelinePage.olderCursor).not.toEqual( - cached.timelinePage.olderCursor, - ); - } let cursor: TimelinePaginationCursor | null = null; let rows: TimelineRow[] = []; let sawEpochCursor = false; diff --git a/packages/client-core/package.json b/packages/client-core/package.json index d3879db0f59..e9d336cff26 100644 --- a/packages/client-core/package.json +++ b/packages/client-core/package.json @@ -7,11 +7,6 @@ "source": "./src/index.ts", "types": "./src/index.ts", "default": "./src/index.ts" - }, - "./timeline": { - "source": "./src/timeline/timeline-merge.ts", - "types": "./src/timeline/timeline-merge.ts", - "default": "./src/timeline/timeline-merge.ts" } }, "types": "./src/index.ts", diff --git a/packages/client-core/src/timeline/timeline-merge.ts b/packages/client-core/src/timeline/timeline-merge.ts index 917ae499250..b5cea2c0062 100644 --- a/packages/client-core/src/timeline/timeline-merge.ts +++ b/packages/client-core/src/timeline/timeline-merge.ts @@ -72,15 +72,6 @@ interface RecoverLoadedTimelineAfterStaleCursorArgs { surfaceKey: string; } -interface BuildLoadedTimelineFromPagesArgs { - pages: readonly ThreadTimelineResponse[]; - surfaceKey: string; -} - -interface ReconcileLoadedTimelineWithHistoryPagesArgs extends BuildLoadedTimelineFromPagesArgs { - current: LoadedTimelineState; -} - export function resolveLoadedTimelineSurfaceKey( baseSurfaceKey: string, latestTimeline: @@ -437,11 +428,11 @@ function loadedTimelineStateFromLatest( }; } -export function tryMergeLoadedTimelineWithLatest({ +export function mergeLoadedTimelineWithLatest({ current, latestTimeline, surfaceKey, -}: MergeLoadedTimelineWithLatestArgs): LoadedTimelineState | null { +}: MergeLoadedTimelineWithLatestArgs): LoadedTimelineState { const latestHistorySnapshot = latestTimeline.timelinePage.historySnapshot; if ( current.surfaceKey !== surfaceKey || @@ -449,7 +440,7 @@ export function tryMergeLoadedTimelineWithLatest({ (latestHistorySnapshot === undefined) || !timelineWindowsAreContiguous(current, latestTimeline) ) { - return null; + return loadedTimelineStateFromLatest(latestTimeline, surfaceKey); } const currentRowsById = new Map(current.rows.map((row) => [row.id, row])); @@ -479,7 +470,7 @@ export function tryMergeLoadedTimelineWithLatest({ latestTimeline, }); if (!latestMerge.canMerge) { - return null; + return loadedTimelineStateFromLatest(latestTimeline, surfaceKey); } return { @@ -494,238 +485,6 @@ export function tryMergeLoadedTimelineWithLatest({ }; } -export function mergeLoadedTimelineWithLatest( - args: MergeLoadedTimelineWithLatestArgs, -): LoadedTimelineState { - return ( - tryMergeLoadedTimelineWithLatest(args) ?? - loadedTimelineStateFromLatest(args.latestTimeline, args.surfaceKey) - ); -} - -export function buildLoadedTimelineFromPages({ - pages, - surfaceKey, -}: BuildLoadedTimelineFromPagesArgs): LoadedTimelineState | null { - const latest = pages[0]; - if (latest === undefined || latest.timelinePage.kind !== "latest") { - return null; - } - let rows = latest.rows; - let previous = latest; - for (const page of pages.slice(1)) { - const previousCursor = previous.timelinePage.olderCursor; - const nextCursor = page.timelinePage.olderCursor; - const previousContent = previous.timelinePage.contentPage; - const nextContent = page.timelinePage.contentPage; - if ( - previousCursor === null || - page.timelinePage.kind !== "older" || - page.timelinePage.historySnapshot !== - latest.timelinePage.historySnapshot || - page.completedTurnDisplay !== latest.completedTurnDisplay || - page.contextBoundarySeq !== latest.contextBoundarySeq || - page.maxSeq !== latest.maxSeq || - (nextCursor !== null && - (nextCursor.anchorSeq > previousCursor.anchorSeq || - areTimelinePaginationCursorsEqual({ - left: previousCursor, - right: nextCursor, - }))) || - (previousContent !== undefined && - previousContent.start > 0 && - nextContent?.anchorSeq === previousContent.anchorSeq && - (nextContent.end !== previousContent.start || - nextContent.total !== previousContent.total)) - ) { - return null; - } - rows = prependOlderTimelineRows({ loadedRows: rows, olderRows: page.rows }); - previous = page; - } - return { - ...loadedTimelineStateFromLatest(latest, surfaceKey, rows), - olderCursor: previous.timelinePage.olderCursor, - }; -} - -function timelineRowChildren(row: TimelineRow): readonly TimelineRow[] | null { - if (row.kind === "turn" && row.children?.length) return row.children; - if ( - row.kind === "work" && - row.workKind === "delegation" && - row.childRows.length - ) { - return row.childRows; - } - return null; -} - -function timelineRowWithChildren( - row: TimelineRow, - children: TimelineRow[], -): TimelineRow { - if (row.kind === "turn") return { ...row, children }; - if (row.kind === "work" && row.workKind === "delegation") { - return { ...row, childRows: children }; - } - return row; -} - -function preserveNestedTimelineRowIdentity({ - nextRows, - previousRows, -}: PreserveTimelineRowIdentityArgs): TimelineRow[] { - const previousById = new Map(previousRows.map((row) => [row.id, row])); - return preserveTimelineRowIdentity({ - previousRows, - nextRows: nextRows.map((row) => { - const previous = previousById.get(row.id); - if (previous === undefined || previous === row) return row; - const nextChildren = timelineRowChildren(row); - const previousChildren = timelineRowChildren(previous); - if (nextChildren === null || previousChildren === null) return row; - const children = preserveNestedTimelineRowIdentity({ - nextRows: nextChildren, - previousRows: previousChildren, - }); - return areTimelineRowReferencesEqual({ - left: nextChildren, - right: children, - }) - ? row - : timelineRowWithChildren(row, children); - }), - }); -} - -function retainTimelinePrefixBeforeLeaf( - rows: readonly TimelineRow[], - leafId: string, - contentStart: number, - anchorSeq: number, -): TimelineRow[] | null { - let reachedBoundary = false; - let retainedContentLeaves = 0; - const retain = ( - items: readonly TimelineRow[], - segmentSequence?: number, - ): TimelineRow[] => - items.flatMap((row) => { - if (reachedBoundary || isOptimisticTimelineRowId(row.id)) return []; - const sequence = segmentSequence ?? row.sourceSeqStart; - const children = timelineRowChildren(row); - if (children !== null) { - const retained = retain(children, sequence); - if (retained.length === 0) return []; - return [ - areTimelineRowReferencesEqual({ left: children, right: retained }) - ? row - : timelineRowWithChildren(row, retained), - ]; - } - if (row.id === leafId) { - reachedBoundary = true; - return []; - } - if (sequence >= anchorSeq) retainedContentLeaves += 1; - return [row]; - }); - const retained = retain(rows); - return reachedBoundary && retainedContentLeaves === contentStart - ? retained - : null; -} - -function firstTimelineLeaf( - rows: readonly TimelineRow[], -): TimelineRow | undefined { - const first = rows[0]; - if (first === undefined) return undefined; - const children = timelineRowChildren(first); - return children === null ? first : firstTimelineLeaf(children); -} - -export function reconcileLoadedTimelineWithHistoryPages({ - current, - pages, - surfaceKey, -}: ReconcileLoadedTimelineWithHistoryPagesArgs): LoadedTimelineState | null { - const replacement = buildLoadedTimelineFromPages({ pages, surfaceKey }); - const oldest = pages.at(-1); - const latest = pages[0]; - if (replacement === null || oldest === undefined || latest === undefined) - return null; - if (current.rows.length === 0) return replacement; - if (current.surfaceKey !== surfaceKey) return null; - const combined = { - ...latest, - rows: replacement.rows, - timelinePage: { ...oldest.timelinePage, kind: "latest" as const }, - }; - if ( - (current.historySnapshot === undefined) !== - (replacement.historySnapshot === undefined) || - !timelineWindowsAreContiguous(current, combined) - ) { - return null; - } - const { contentPage, olderRowsSourceSeqEnd } = oldest.timelinePage; - const partialBoundary = contentPage !== undefined && contentPage.start > 0; - const coversCurrent = - replacement.olderCursor === null || - (!partialBoundary && - current.olderCursor !== null && - replacement.olderCursor.anchorSeq <= current.olderCursor.anchorSeq); - if ( - !coversCurrent && - current.historySnapshot !== replacement.historySnapshot && - (olderRowsSourceSeqEnd === undefined || - (olderRowsSourceSeqEnd !== null && - olderRowsSourceSeqEnd > (current.latestWindowEndSequence ?? 0))) - ) { - return null; - } - let rows = replacement.rows; - if (!coversCurrent && partialBoundary) { - if ( - current.olderCursor !== null && - current.olderCursor.anchorSeq >= contentPage.anchorSeq - ) { - return null; - } - const firstLeaf = firstTimelineLeaf(rows); - if (firstLeaf === undefined) return null; - const prefix = retainTimelinePrefixBeforeLeaf( - current.rows, - firstLeaf.id, - contentPage.start, - contentPage.anchorSeq, - ); - if (prefix === null) return null; - rows = prependOlderTimelineRows({ olderRows: prefix, loadedRows: rows }); - } else if (!coversCurrent) { - const merge = mergeLatestTimelineRows({ - latestRows: rows, - loadedRows: current.rows, - latestWindowStartSequence: timelineWindowStartSequence(combined), - }); - if (!merge.canMerge) return null; - rows = merge.rows; - } - rows = preserveNestedTimelineRowIdentity({ - nextRows: rows, - previousRows: current.rows, - }); - return { - ...replacement, - olderCursor: coversCurrent ? replacement.olderCursor : current.olderCursor, - rows: areTimelineRowReferencesEqual({ left: current.rows, right: rows }) - ? current.rows - : rows, - }; -} - export function recoverLoadedTimelineAfterStaleCursor({ current, latestTimeline, diff --git a/packages/client-core/test/timeline-merge.test.ts b/packages/client-core/test/timeline-merge.test.ts index f9ef36ff149..13ed0f86631 100644 --- a/packages/client-core/test/timeline-merge.test.ts +++ b/packages/client-core/test/timeline-merge.test.ts @@ -3,20 +3,16 @@ import { applyTimelineDelta } from "@bb/server-contract"; import type { ThreadTimelineResponse, TimelineCommandWorkRow, - TimelineDelegationWorkRow, TimelinePaginationCursor, TimelineRow, TimelineTurnRow, TimelineUserConversationRow, } from "@bb/server-contract"; import { - buildLoadedTimelineFromPages, mergeLoadedTimelineWithLatest, mergeLatestTimelineRows, prependOlderTimelineRows, recoverLoadedTimelineAfterStaleCursor, - reconcileLoadedTimelineWithHistoryPages, - tryMergeLoadedTimelineWithLatest, type LoadedTimelineState, } from "../src/timeline/timeline-merge.js"; @@ -106,32 +102,6 @@ function turnSummaryRow(args: TimelineTurnTestRowArgs): TimelineTurnRow { }; } -function delegationRow( - args: TimelineTurnTestRowArgs, -): TimelineDelegationWorkRow { - return { - id: args.id, - threadId: "thread-1", - turnId: "turn-1", - sourceSeqStart: args.sequence, - sourceSeqEnd: args.endSequence ?? args.sequence, - startedAt: args.sequence, - createdAt: args.sequence, - kind: "work", - workKind: "delegation", - status: "completed", - callId: args.id, - toolName: "agent", - childRef: "child-thread", - background: false, - subagentType: null, - description: null, - output: "", - completedAt: args.sequence, - childRows: args.children ?? [], - }; -} - function makeTimelineResponse( rows: TimelineRow[], olderCursor: TimelinePaginationCursor | null, @@ -1017,374 +987,3 @@ describe("snapshot content pagination", () => { ).toEqual([{ ...summary, children: [child] }]); }); }); - -describe("retained history refresh", () => { - const surfaceKey = "thread-1:default"; - - function snapshotResponse( - rows: TimelineRow[], - olderCursor: TimelinePaginationCursor | null, - maxSeq = 30, - ): ThreadTimelineResponse { - const response = makeTimelineResponse(rows, olderCursor, maxSeq); - response.timelinePage.historySnapshot = "fresh"; - response.timelinePage.olderRowsSourceSeqEnd = null; - return response; - } - - it("assembles contiguous leaf pages without dropping split nested content", () => { - const children = [11, 12, 13, 14].map((sequence) => - commandRow({ id: `command-${sequence}`, sequence }), - ); - const summary = turnSummaryRow({ - id: "summary", - sequence: 10, - endSequence: 20, - children, - }); - const latest = snapshotResponse( - [{ ...summary, children: children.slice(2) }], - timelineCursor({ id: "leaf-2", sequence: 10 }), - ); - latest.timelinePage.contentPage = { - anchorSeq: 10, - start: 2, - end: 4, - total: 4, - }; - latest.timelinePage.segmentLimit = 8; - const older = snapshotResponse( - [{ ...summary, children: children.slice(0, 2) }], - null, - ); - older.timelinePage.kind = "older"; - older.timelinePage.contentPage = { - anchorSeq: 10, - start: 0, - end: 2, - total: 4, - }; - - const result = buildLoadedTimelineFromPages({ - pages: [latest, older], - surfaceKey, - }); - - expect(result?.rows).toEqual([summary]); - expect(result?.olderCursor).toBeNull(); - expect(result?.historySnapshot).toBe("fresh"); - expect( - buildLoadedTimelineFromPages({ - pages: [ - latest, - { - ...older, - timelinePage: { ...older.timelinePage, historySnapshot: "old" }, - }, - ], - surfaceKey, - }), - ).toBeNull(); - expect( - buildLoadedTimelineFromPages({ - pages: [ - latest, - { - ...older, - timelinePage: { - ...older.timelinePage, - contentPage: { anchorSeq: 10, start: 0, end: 1, total: 4 }, - }, - }, - ], - surfaceKey, - }), - ).toBeNull(); - }); - - it("replaces covered deletions and edits while retaining deeper rows and unchanged child identity", () => { - const deep = userRow({ id: "deep", sequence: 1 }); - const unchanged = commandRow({ id: "unchanged", sequence: 11 }); - const removed = commandRow({ id: "removed", sequence: 12 }); - const edited = commandRow({ id: "edited", sequence: 13 }); - const summary = turnSummaryRow({ - id: "summary", - sequence: 10, - endSequence: 15, - children: [unchanged, removed, edited], - }); - const tail = userRow({ id: "tail", sequence: 20 }); - const current = { - ...makeLoadedTimelineState( - [deep, summary, commandRow({ id: "deleted-row", sequence: 19 }), tail], - timelineCursor({ id: "deep-cursor", sequence: 1 }), - 20, - ), - historySnapshot: "old", - }; - const fresh = snapshotResponse( - [ - { - ...summary, - children: [{ ...unchanged }, { ...edited, output: "new output" }], - }, - { ...tail }, - ], - timelineCursor({ id: "fresh-cursor", sequence: 10 }), - ); - - const result = reconcileLoadedTimelineWithHistoryPages({ - current, - pages: [fresh], - surfaceKey, - }); - - expect(result?.rows.map((row) => row.id)).toEqual([ - "deep", - "summary", - "tail", - ]); - expect(result?.rows[0]).toBe(deep); - expect(result?.rows[2]).toBe(tail); - const refreshedSummary = result?.rows[1]; - expect(refreshedSummary?.kind).toBe("turn"); - if (refreshedSummary?.kind !== "turn") throw new Error("Expected summary"); - expect(refreshedSummary.children?.map((row) => row.id)).toEqual([ - "unchanged", - "edited", - ]); - expect(refreshedSummary.children?.[0]).toBe(unchanged); - expect(refreshedSummary.children?.[1]).toMatchObject({ - output: "new output", - }); - expect(result?.olderCursor).toBe(current.olderCursor); - }); - - it.each(["turn", "delegation"] as const)( - "preserves uncovered %s leaves while replacing the authoritative suffix", - (kind) => { - const prefix = commandRow({ id: "prefix", sequence: 11 }); - const edited = commandRow({ id: "edited", sequence: 12 }); - const deleted = commandRow({ id: "deleted", sequence: 13 }); - const unchanged = commandRow({ id: "unchanged", sequence: 14 }); - const makeRow = kind === "turn" ? turnSummaryRow : delegationRow; - const current = { - ...makeLoadedTimelineState( - [ - makeRow({ - id: "nested", - sequence: 10, - endSequence: 20, - children: [prefix, edited, deleted, unchanged], - }), - ], - null, - 20, - ), - historySnapshot: "old", - }; - const fresh = snapshotResponse( - [ - makeRow({ - id: "nested", - sequence: 10, - endSequence: 20, - children: [{ ...edited, output: "new" }, { ...unchanged }], - }), - ], - timelineCursor({ id: "fresh-content", sequence: 10 }), - ); - fresh.timelinePage.contentPage = { - anchorSeq: 10, - start: 1, - end: 3, - total: 3, - }; - fresh.timelinePage.olderRowsSourceSeqEnd = prefix.sourceSeqEnd; - - const result = reconcileLoadedTimelineWithHistoryPages({ - current, - pages: [fresh], - surfaceKey, - }); - const row = result?.rows[0]; - const children = - row?.kind === "turn" - ? row.children - : row?.kind === "work" && row.workKind === "delegation" - ? row.childRows - : null; - - expect(children?.map((child) => child.id)).toEqual([ - "prefix", - "edited", - "unchanged", - ]); - expect(children?.[0]).toBe(prefix); - expect(children?.[1]).toMatchObject({ output: "new" }); - expect(children?.[2]).toBe(unchanged); - expect(result?.olderCursor).toBeNull(); - }, - ); - - it("refuses a shifted partial boundary instead of retaining a deleted leaf", () => { - const children = [11, 12, 13].map((sequence) => - commandRow({ id: `child-${sequence}`, sequence }), - ); - const current = { - ...makeLoadedTimelineState( - [ - turnSummaryRow({ - id: "summary", - sequence: 10, - endSequence: 20, - children, - }), - ], - null, - 20, - ), - historySnapshot: "old", - }; - const fresh = snapshotResponse( - [ - turnSummaryRow({ - id: "summary", - sequence: 10, - endSequence: 20, - children: [children[2]!], - }), - ], - timelineCursor({ id: "fresh-content", sequence: 10 }), - ); - fresh.timelinePage.contentPage = { - anchorSeq: 10, - start: 1, - end: 2, - total: 2, - }; - fresh.timelinePage.olderRowsSourceSeqEnd = children[0]!.sourceSeqEnd; - - expect( - reconcileLoadedTimelineWithHistoryPages({ - current, - pages: [fresh], - surfaceKey, - }), - ).toBeNull(); - expect(current.rows[0]).toMatchObject({ children }); - }); - - it("refuses a partial prefix the current window has not fully loaded", () => { - const child = commandRow({ id: "child", sequence: 13 }); - const row = turnSummaryRow({ - id: "summary", - sequence: 10, - children: [child], - }); - const cursor = timelineCursor({ id: "old-content", sequence: 10 }); - const current = { - ...makeLoadedTimelineState([row], cursor, 20), - historySnapshot: "old", - }; - const fresh = snapshotResponse( - [row], - timelineCursor({ id: "fresh-content", sequence: 10 }), - ); - fresh.timelinePage.contentPage = { - anchorSeq: 10, - start: 2, - end: 3, - total: 3, - }; - - expect( - reconcileLoadedTimelineWithHistoryPages({ - current, - pages: [fresh], - surfaceKey, - }), - ).toBeNull(); - }); - - it("reports a gap without changing the legacy fallback or detached rows", () => { - const current = { - ...makeLoadedTimelineState( - [userRow({ id: "old", sequence: 1 })], - null, - 10, - ), - historySnapshot: "old", - }; - const latestTimeline = snapshotResponse( - [userRow({ id: "fresh", sequence: 20 })], - timelineCursor({ id: "fresh-cursor", sequence: 20 }), - ); - - expect( - tryMergeLoadedTimelineWithLatest({ current, latestTimeline, surfaceKey }), - ).toBeNull(); - expect( - reconcileLoadedTimelineWithHistoryPages({ - current, - pages: [latestTimeline], - surfaceKey, - }), - ).toBeNull(); - expect( - mergeLoadedTimelineWithLatest({ current, latestTimeline, surfaceKey }) - .rows, - ).toBe(latestTimeline.rows); - expect(current.rows.map((row) => row.id)).toEqual(["old"]); - }); - - it("refuses a splice when the refreshed snapshot changed uncovered history", () => { - const latest = userRow({ id: "latest", sequence: 10 }); - const current = { - ...makeLoadedTimelineState( - [userRow({ id: "older", sequence: 1 }), latest], - null, - 20, - ), - historySnapshot: "old", - }; - const fresh = snapshotResponse( - [latest], - timelineCursor({ id: "fresh-cursor", sequence: 10 }), - ); - fresh.timelinePage.olderRowsSourceSeqEnd = 21; - - expect( - reconcileLoadedTimelineWithHistoryPages({ - current, - pages: [fresh], - surfaceKey, - }), - ).toBeNull(); - }); - - it("keeps the current array when a coherent refreshed window is unchanged", () => { - const row = userRow({ id: "same", sequence: 1 }); - const current = { - ...makeLoadedTimelineState([row], null, 30), - historySnapshot: "old", - }; - const fresh = snapshotResponse([{ ...row }], null); - - expect( - reconcileLoadedTimelineWithHistoryPages({ - current, - pages: [fresh], - surfaceKey, - })?.rows, - ).toBe(current.rows); - expect(buildLoadedTimelineFromPages({ pages: [], surfaceKey })).toBeNull(); - expect( - reconcileLoadedTimelineWithHistoryPages({ - current: makeLoadedTimelineState([], null, 0), - pages: [fresh], - surfaceKey, - })?.rows, - ).toBe(fresh.rows); - }); -}); From 19a8115a4056d2c652ec99f69a194082ebfffdd5 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:27:15 -0400 Subject: [PATCH 17/23] Preserve cached plugin settings and project defaults on refresh failure --- .../components/plugin/PluginSettings.test.tsx | 35 +++++++++++++------ .../hooks/queries/plugin-settings-queries.ts | 14 +++----- .../views/ProjectDetailSettingsView.test.tsx | 13 +++++-- .../src/views/ProjectDetailSettingsView.tsx | 2 +- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/apps/app/src/components/plugin/PluginSettings.test.tsx b/apps/app/src/components/plugin/PluginSettings.test.tsx index 908bd1f59d2..109454e03e6 100644 --- a/apps/app/src/components/plugin/PluginSettings.test.tsx +++ b/apps/app/src/components/plugin/PluginSettings.test.tsx @@ -738,19 +738,22 @@ describe("PluginSettingsPage", () => { expect(screen.queryByTestId("plugin-settings-skeleton")).toBeNull(); }); - it("keeps loaded settings visible when a background plugin-list refresh fails", async () => { - let pluginListRequests = 0; + it("keeps loaded settings through a failed refresh and updates on recovery", async () => { + let failRefresh = false; + let greeting = "hello"; vi.stubGlobal( "fetch", vi.fn(async (url: string) => { - if (url === "/api/v1/plugins/linear/settings") { - return jsonOk(SETTINGS_VIEW); + if (failRefresh) { + return new Response("unavailable", { status: 503 }); } - pluginListRequests += 1; - if (pluginListRequests === 1) { - return jsonOk({ plugins: [installedPlugin(true)] }); + if (url === "/api/v1/plugins/linear/settings") { + return jsonOk({ + ...SETTINGS_VIEW, + values: { ...SETTINGS_VIEW.values, greeting }, + }); } - throw new Error("offline"); + return jsonOk({ plugins: [installedPlugin(true)] }); }), ); @@ -764,12 +767,22 @@ describe("PluginSettingsPage", () => { , ); - expect(await screen.findByRole("heading", { name: "Linear" })).toBeTruthy(); - - await queryClient.invalidateQueries(); + expect(await screen.findByDisplayValue("hello")).toBeTruthy(); + failRefresh = true; + await act(async () => { + await queryClient.invalidateQueries(); + }); expect(screen.getByRole("heading", { name: "Linear" })).toBeTruthy(); + expect(screen.getByDisplayValue("hello")).toBeTruthy(); expect(screen.queryByText("Could not load plugin settings.")).toBeNull(); + + failRefresh = false; + greeting = "updated"; + await act(async () => { + await queryClient.invalidateQueries(); + }); + expect(await screen.findByDisplayValue("updated")).toBeTruthy(); }); it("keeps the not-installed message free of loading affordances", async () => { diff --git a/apps/app/src/hooks/queries/plugin-settings-queries.ts b/apps/app/src/hooks/queries/plugin-settings-queries.ts index 1fc646fced1..28176c8aed8 100644 --- a/apps/app/src/hooks/queries/plugin-settings-queries.ts +++ b/apps/app/src/hooks/queries/plugin-settings-queries.ts @@ -152,15 +152,11 @@ export interface PluginSettingsView { async function fetchPluginSettingsView( fetchImpl: FetchLike, pluginId: string, -): Promise { - try { - const result = await createPluginsClient(fetchImpl).getSettings({ - pluginId, - }); - return { schema: result.schema, values: result.values }; - } catch { - return null; - } +): Promise { + const result = await createPluginsClient(fetchImpl).getSettings({ + pluginId, + }); + return { schema: result.schema, values: result.values }; } export async function updatePluginSettings( diff --git a/apps/app/src/views/ProjectDetailSettingsView.test.tsx b/apps/app/src/views/ProjectDetailSettingsView.test.tsx index 213f102788a..ee1850b087a 100644 --- a/apps/app/src/views/ProjectDetailSettingsView.test.tsx +++ b/apps/app/src/views/ProjectDetailSettingsView.test.tsx @@ -313,7 +313,7 @@ describe("ProjectDetailSettingsView", () => { expect(remove.getAttribute("aria-disabled")).toBe("true"); }); - it("shows derived thread defaults when the project has run threads", async () => { + it("keeps loaded thread defaults when a background refresh fails", async () => { stubSidebarBootstrapFetch([ { hostId: "host_primary", path: "/Users/me/bb" }, ]); @@ -326,11 +326,20 @@ describe("ProjectDetailSettingsView", () => { }; vi.mocked(sdk.projects.defaultExecutionOptions).mockResolvedValue(defaults); - renderView(); + const { queryClient } = renderView(); expect(await screen.findByText("gpt-6-astra")).toBeDefined(); expect(screen.getByText("codex")).toBeDefined(); expect(screen.queryByText(/^No threads have run here yet/u)).toBeNull(); + + vi.mocked(sdk.projects.defaultExecutionOptions).mockRejectedValue( + new Error("refresh failed"), + ); + await act(async () => { + await queryClient.invalidateQueries(); + }); + expect(screen.getByText("gpt-6-astra")).toBeDefined(); + expect(screen.queryByText("Couldn't load thread defaults.")).toBeNull(); }); it("distinguishes a failed defaults load from an empty one", async () => { diff --git a/apps/app/src/views/ProjectDetailSettingsView.tsx b/apps/app/src/views/ProjectDetailSettingsView.tsx index 5bc88a3da8a..271b13ec353 100644 --- a/apps/app/src/views/ProjectDetailSettingsView.tsx +++ b/apps/app/src/views/ProjectDetailSettingsView.tsx @@ -470,7 +470,7 @@ export function ProjectDetailSettingsView() { title="Thread defaults" description={DEFAULTS_DESCRIPTION} > - {defaultsQuery.isError ? ( + {defaultsQuery.isError && defaultsQuery.data === undefined ? (

Couldn't load thread defaults.

From 586bdf178e87af86efd2e5897d485c35ef361a59 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:35:47 -0400 Subject: [PATCH 18/23] Keep cached registry skill content on failed refresh --- .../src/components/tools/SkillsLibrary.tsx | 3 +-- apps/app/src/views/SkillsView.test.tsx | 25 +++++++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/app/src/components/tools/SkillsLibrary.tsx b/apps/app/src/components/tools/SkillsLibrary.tsx index 833b013ea2f..cf8ea58b66c 100644 --- a/apps/app/src/components/tools/SkillsLibrary.tsx +++ b/apps/app/src/components/tools/SkillsLibrary.tsx @@ -541,8 +541,7 @@ export function SkillsLibrary() { message="Checking skill source" layout="detail" /> - ) : selectedRegistrySkill && - (registryDetailQuery.isError || registryDetail === null) ? ( + ) : selectedRegistrySkill && registryDetail === null ? ( @@ -269,6 +271,7 @@ function renderRegistrySkillRoute() { , ); + return { ...view, queryClient }; } function NavigateButton({ to, label }: { to: string; label: string }) { @@ -870,6 +873,24 @@ describe("SkillsLibrary library detail routing", () => { }); describe("SkillsLibrary registry detail lifecycle", () => { + it("keeps cached source content when a background refresh fails", async () => { + vi.spyOn(sdk.skills, "list").mockResolvedValue({ skills: [] }); + const fetchMock = stubRegistryFetch(makeRegistrySkill()); + const { queryClient } = renderRegistrySkillRoute(); + await screen.findByRole("heading", { name: "SKILL.md" }); + + fetchMock.mockImplementation(async () => new Response(null, { status: 503 })); + await act(async () => { + await queryClient.invalidateQueries(); + }); + expect(screen.getByRole("heading", { name: "SKILL.md" })).toBeTruthy(); + expect( + screen.queryByText( + "This registry skill is no longer available from its source.", + ), + ).toBeNull(); + }); + it("does not offer installation when a direct registry source is unavailable", async () => { const registrySkill = makeRegistrySkill(); vi.spyOn(sdk.skills, "list").mockResolvedValue({ skills: [] }); From 4a0b342dcb1134c9b9c202c085fbd629c50c91d6 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:48:56 -0400 Subject: [PATCH 19/23] Retain cached thread pages across longer navigation gaps --- .../src/hooks/queries/thread-queries.test.tsx | 77 ++++++++++++++++++- apps/app/src/hooks/queries/thread-queries.ts | 4 + 2 files changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index a7be0d344e8..9f240ecb053 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -13,6 +13,7 @@ import * as api from "@/lib/api"; import { sdk } from "@/lib/sdk"; import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { conversationRow } from "@/test/fixtures/thread-timeline-rows"; import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; import { sidebarNavigationQueryKey, @@ -776,7 +777,81 @@ describe("useThreadStorageLocation", () => { }); }); -describe("useThreadTimeline segment limit", () => { +describe("useThreadTimeline", () => { + it("reopens an inactive cached page immediately and only replaces it after a successful refresh", async () => { + const cached = makeThreadTimelineResponse({ + rows: [conversationRow({ id: "cached-message", text: "Cached message" })], + maxSeq: 1, + }); + vi.mocked(sdk.threads.timeline).mockResolvedValue(cached); + const { queryClient, wrapper } = createQueryClientTestHarness({ + queries: { gcTime: 5 * 60_000 }, + }); + const usePage = () => ({ + thread: useThread("thread-1"), + bootstrap: useThreadDetailBootstrap("thread-1"), + timeline: useThreadTimeline("thread-1"), + }); + const first = renderHook(usePage, { wrapper }); + await waitFor(() => + expect(first.result.current.timeline.data).toEqual(cached), + ); + await waitFor(() => + expect(first.result.current.thread.isSuccess).toBe(true), + ); + await waitFor(() => + expect(first.result.current.bootstrap.isSuccess).toBe(true), + ); + + vi.useFakeTimers(); + try { + first.unmount(); + await vi.advanceTimersByTimeAsync(6 * 60_000); + let rejectRefresh: (error: Error) => void = () => {}; + vi.mocked(sdk.threads.timeline).mockReturnValue( + new Promise((_resolve, reject) => { + rejectRefresh = reject; + }), + ); + const reopened = renderHook(usePage, { wrapper }); + expect(reopened.result.current.thread.data?.id).toBe("thread-1"); + expect(reopened.result.current.bootstrap.data).toEqual( + THREAD_WITH_INCLUDES, + ); + expect(reopened.result.current.timeline.data).toEqual(cached); + expect(reopened.result.current.timeline.isLoading).toBe(false); + expect(reopened.result.current.timeline.isFetching).toBe(true); + + await act(async () => { + rejectRefresh(new Error("HTTP 503")); + await vi.advanceTimersByTimeAsync(1); + }); + expect(reopened.result.current.timeline.isError).toBe(true); + expect(reopened.result.current.timeline.data).toEqual(cached); + + const newer = makeThreadTimelineResponse({ + rows: [ + ...cached.rows, + conversationRow({ id: "new-message", text: "New message" }), + ], + maxSeq: 2, + }); + vi.mocked(sdk.threads.timeline).mockResolvedValue(newer); + await act(async () => { + await reopened.result.current.timeline.refetch(); + await vi.advanceTimersByTimeAsync(1); + }); + expect(reopened.result.current.timeline.data).toEqual(newer); + reopened.unmount(); + await vi.advanceTimersByTimeAsync(31 * 60_000); + expect( + queryClient.getQueryData(threadTimelineQueryKey("thread-1")), + ).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); + it("asks for the compact first window on compact viewports and keeps it for deltas", async () => { mockMatchMedia([COMPACT_VIEWPORT_QUERY]); const { queryClient, wrapper } = createQueryClientTestHarness(); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 0922143f951..18c9047ac2c 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -97,6 +97,7 @@ interface QueryOptions { const THREAD_LIST_STALE_TIME_MS = 10_000; const THREAD_SEARCH_STALE_TIME_MS = 10_000; const THREAD_DETAIL_STALE_TIME_MS = 5_000; +const THREAD_PAGE_GC_TIME_MS = 30 * 60_000; const THREAD_MENTION_CANDIDATE_LIMIT = 200; const THREAD_SEARCH_DEBOUNCE_MS = 150; export const THREAD_SEARCH_LIMIT_PER_GROUP = 20; @@ -628,6 +629,7 @@ export function useThread(id: string, options?: QueryOptions) { }), enabled, staleTime: THREAD_DETAIL_STALE_TIME_MS, + gcTime: THREAD_PAGE_GC_TIME_MS, refetchOnMount: options?.refetchOnMount ?? true, retry: shouldRetryTransientReadQuery, retryDelay: TRANSIENT_READ_RETRY_DELAY_MS, @@ -693,6 +695,7 @@ export function useThreadDetailBootstrap( }, enabled, staleTime: Infinity, + gcTime: THREAD_PAGE_GC_TIME_MS, retry: shouldRetryTransientReadQuery, retryDelay: TRANSIENT_READ_RETRY_DELAY_MS, }); @@ -944,6 +947,7 @@ export function useThreadTimeline( return useQuery({ queryKey: threadTimelineQueryKey(id), + gcTime: THREAD_PAGE_GC_TIME_MS, queryFn: async ({ signal }) => { const threadId = requireThreadId(id, "useThreadTimeline"); return fetchThreadTimeline({ From ffd3aa5b6f9954416321c16561ec7ebec688766f Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 14:57:08 -0400 Subject: [PATCH 20/23] Preserve SDK error types in thread cache regression --- apps/app/src/hooks/queries/thread-queries.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index 9f240ecb053..e4ab23e5fb8 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -56,7 +56,8 @@ vi.mock("@/lib/api", async (importOriginal) => { }; }); -vi.mock("@/lib/sdk", () => ({ +vi.mock("@/lib/sdk", async (importOriginal) => ({ + ...(await importOriginal()), sdk: { threads: { get: vi.fn(), From da2f81e122bdbc91cc8429801a07821e5d8eb442 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 16:13:10 -0400 Subject: [PATCH 21/23] Keep skill and automation details visible after failed refreshes --- .../src/components/tools/SkillsLibrary.tsx | 2 +- apps/app/src/views/SkillsView.test.tsx | 43 ++++++++++++++++--- plugins/automations/app.tsx | 6 ++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/apps/app/src/components/tools/SkillsLibrary.tsx b/apps/app/src/components/tools/SkillsLibrary.tsx index cf8ea58b66c..0d160c510ac 100644 --- a/apps/app/src/components/tools/SkillsLibrary.tsx +++ b/apps/app/src/components/tools/SkillsLibrary.tsx @@ -127,7 +127,7 @@ function SkillDetailPage({ onSelectPath={setSelectedPath} content={contentQuery.data?.content ?? ""} isLoadingContent={contentQuery.isLoading} - isContentError={contentQuery.isError} + isContentError={contentQuery.isError && contentQuery.data === undefined} canEdit={editableScope !== null} canDelete={deletableScope !== null} canOpenInEditor={editableScope !== null && canOpenPreferredFileTarget} diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx index 3ab53593ca4..14715f5c14e 100644 --- a/apps/app/src/views/SkillsView.test.tsx +++ b/apps/app/src/views/SkillsView.test.tsx @@ -94,7 +94,7 @@ function LocationStateProbe() { ); } -function renderLibrarySkillRoute() { +function renderLibrarySkillRoute(skillId = "skill_missing") { vi.spyOn(sdk.providers, "list").mockResolvedValue([]); const fetchMock = vi.fn( async () => @@ -110,9 +110,10 @@ function renderLibrarySkillRoute() { ), ); vi.stubGlobal("fetch", fetchMock); - const { wrapper: QueryClientWrapper } = createQueryClientTestHarness(); + const { wrapper: QueryClientWrapper, queryClient } = + createQueryClientTestHarness(); renderDom( - + } /> @@ -120,7 +121,7 @@ function renderLibrarySkillRoute() { , ); - return fetchMock; + return { fetchMock, queryClient }; } const NO_PROVIDER_ROSTER: ReadonlyMap = new Map(); @@ -837,6 +838,38 @@ describe("SkillsOverview", () => { }); describe("SkillsLibrary library detail routing", () => { + it("keeps cached file content through a failed refresh and updates on success", async () => { + const skill = makeSkill(); + vi.spyOn(sdk.skills, "list").mockResolvedValue({ skills: [skill] }); + vi.spyOn(sdk.skills, "listFiles").mockResolvedValue({ + files: ["SKILL.md"], + truncated: false, + }); + const content = vi.spyOn(sdk.skills, "getContent").mockResolvedValue({ + content: "Cached instructions", + revision: "a".repeat(64), + }); + const { queryClient } = renderLibrarySkillRoute(skill.id); + await screen.findByText("Cached instructions"); + + content.mockRejectedValue(new Error("HTTP 503")); + await act(async () => { + await queryClient.invalidateQueries(); + }); + expect(screen.getByText("Cached instructions")).toBeTruthy(); + expect(screen.queryByText("Failed to load SKILL.md.")).toBeNull(); + + content.mockResolvedValue({ + content: "Updated instructions", + revision: "b".repeat(64), + }); + await act(async () => { + await queryClient.invalidateQueries(); + }); + expect(await screen.findByText("Updated instructions")).toBeTruthy(); + expect(screen.queryByText("Cached instructions")).toBeNull(); + }); + it("keeps a detail loading state while the skill library resolves", () => { vi.spyOn(sdk.skills, "list").mockImplementation( () => new Promise(() => {}), @@ -863,7 +896,7 @@ describe("SkillsLibrary library detail routing", () => { it("shows not found on an unknown library skill detail route", async () => { vi.spyOn(sdk.skills, "list").mockResolvedValue({ skills: [] }); - const fetchMock = renderLibrarySkillRoute(); + const { fetchMock } = renderLibrarySkillRoute(); const notFound = await screen.findByText("Skill not found."); expect(notFound.closest("[data-resource-detail-state]")).not.toBeNull(); diff --git a/plugins/automations/app.tsx b/plugins/automations/app.tsx index 9f16138abd8..ca2d37a5cf0 100644 --- a/plugins/automations/app.tsx +++ b/plugins/automations/app.tsx @@ -187,7 +187,11 @@ function useAutomation(route: DetailRoute): { }, (error: unknown) => { if (requestRef.current !== requestId) return; - setState({ automation: null, error: errorText(error) }); + setState((current) => + current.automation !== null + ? current + : { automation: null, error: errorText(error) }, + ); }, ); }, [rpc, projectId, automationId]); From 889c4245825f7164a86568829d75330f080f2343 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 16:21:27 -0400 Subject: [PATCH 22/23] Provide host roster for the skill cache regression --- apps/app/src/views/SkillsView.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx index 14715f5c14e..ae0338f1a30 100644 --- a/apps/app/src/views/SkillsView.test.tsx +++ b/apps/app/src/views/SkillsView.test.tsx @@ -840,6 +840,7 @@ describe("SkillsOverview", () => { describe("SkillsLibrary library detail routing", () => { it("keeps cached file content through a failed refresh and updates on success", async () => { const skill = makeSkill(); + vi.spyOn(sdk.hosts, "list").mockResolvedValue([]); vi.spyOn(sdk.skills, "list").mockResolvedValue({ skills: [skill] }); vi.spyOn(sdk.skills, "listFiles").mockResolvedValue({ files: ["SKILL.md"], From 94d252b6be9d870badd36172c97fb6e352dcdff3 Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Sat, 19 Sep 2026 15:08:44 -0700 Subject: [PATCH 23/23] Contain page download failures without losing app navigation --- apps/app/src/App.tsx | 5 +- apps/app/src/components/AppErrorBoundary.tsx | 26 +++++- apps/app/src/components/RouteContent.test.tsx | 89 +++++++++++++++++++ apps/app/src/components/RouteContent.tsx | 47 ++++++++++ 4 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/components/RouteContent.test.tsx create mode 100644 apps/app/src/components/RouteContent.tsx diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 4bc8707e8c0..1a673e6040b 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -68,6 +68,7 @@ import { AppCommandProvider } from "./components/commands/AppCommandProvider"; import { ProviderCliInstallLogDialogHost } from "./components/provider-cli/provider-cli-install"; import { ServerMoveOverlay } from "./components/machines/ServerMoveOverlay"; import { RouteLoadingSkeleton } from "./components/ui/route-loading-skeleton"; +import { RouteContent } from "./components/RouteContent"; const SettingsView = lazy(() => import("./views/SettingsView").then((m) => ({ @@ -264,7 +265,7 @@ export function HashNavigationScroll() { export function AppRoutes() { return ( - + - + ); } diff --git a/apps/app/src/components/AppErrorBoundary.tsx b/apps/app/src/components/AppErrorBoundary.tsx index 46c54b4aa30..57d398e56b5 100644 --- a/apps/app/src/components/AppErrorBoundary.tsx +++ b/apps/app/src/components/AppErrorBoundary.tsx @@ -2,24 +2,43 @@ import { Component, type ErrorInfo, type ReactNode } from "react"; interface AppErrorBoundaryProps { children: ReactNode; + fallback?: (error: Error) => ReactNode; + resetKey?: string; } interface AppErrorBoundaryState { error: Error | null; + resetKey?: string; } export class AppErrorBoundary extends Component< AppErrorBoundaryProps, AppErrorBoundaryState > { - override state: AppErrorBoundaryState = { error: null }; + override state: AppErrorBoundaryState = { + error: null, + resetKey: this.props.resetKey, + }; + + static getDerivedStateFromProps( + props: AppErrorBoundaryProps, + state: AppErrorBoundaryState, + ): AppErrorBoundaryState | null { + return props.resetKey === state.resetKey + ? null + : { error: null, resetKey: props.resetKey }; + } static getDerivedStateFromError(error: unknown): AppErrorBoundaryState { return { error: error instanceof Error ? error : new Error(String(error)) }; } override componentDidCatch(error: Error, info: ErrorInfo): void { - console.error("[bb] the app crashed", error, info.componentStack); + console.error( + this.props.fallback ? "[bb] a page failed to load" : "[bb] the app crashed", + error, + info.componentStack, + ); } override render(): ReactNode { @@ -27,6 +46,9 @@ export class AppErrorBoundary extends Component< if (error === null) { return this.props.children; } + if (this.props.fallback) { + return this.props.fallback(error); + } return (
diff --git a/apps/app/src/components/RouteContent.test.tsx b/apps/app/src/components/RouteContent.test.tsx new file mode 100644 index 00000000000..62759c8a038 --- /dev/null +++ b/apps/app/src/components/RouteContent.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom + +import { lazy, useState } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { Link, MemoryRouter, Route, Routes } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AppErrorBoundary } from "./AppErrorBoundary"; +import { RouteContent } from "./RouteContent"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("RouteContent", () => { + it.each([ + "Failed to fetch dynamically imported module: /assets/settings.js", + "Importing a module script failed.", + "error loading dynamically imported module: /assets/settings.js", + "Unable to preload CSS for /assets/settings.css", + ])("contains a failed page download: %s", async (message) => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const FailedPage = lazy(async () => { + throw new TypeError(message); + }); + render( + + + + + + } /> + Loaded content} /> + + + + , + ); + + expect((await screen.findByRole("alert")).textContent).toContain( + "Couldn't load this page.", + ); + expect(screen.getByRole("button", { name: "Reload page" })).toBeTruthy(); + expect(screen.queryByText("bb hit an error and stopped")).toBeNull(); + fireEvent.click(screen.getByRole("link", { name: "Working page" })); + expect( + await screen.findByRole("heading", { name: "Loaded content" }), + ).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + }); + + it("preserves mounted content across healthy navigation", () => { + function Page() { + const [count, setCount] = useState(0); + return ; + } + render( + + Change view + + + + , + ); + fireEvent.click(screen.getByRole("button", { name: "Count 0" })); + fireEvent.click(screen.getByRole("link", { name: "Change view" })); + expect(screen.getByRole("button", { name: "Count 1" })).toBeTruthy(); + }); + + it("leaves ordinary render failures to the app recovery screen", () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + function BrokenPage(): never { + throw new Error("render exploded"); + } + render( + + + + + + + , + ); + expect(screen.getByText("bb hit an error and stopped")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Reload page" })).toBeNull(); + }); +}); diff --git a/apps/app/src/components/RouteContent.tsx b/apps/app/src/components/RouteContent.tsx new file mode 100644 index 00000000000..d124ad62dbe --- /dev/null +++ b/apps/app/src/components/RouteContent.tsx @@ -0,0 +1,47 @@ +import { Suspense, useEffect, type ReactNode } from "react"; +import { useLocation } from "react-router-dom"; +import { Button } from "@bb/shared-ui/button"; +import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; +import { markRouteContentPainted } from "@/lib/route-content-paint"; +import { AppErrorBoundary } from "./AppErrorBoundary"; + +function PageLoadError() { + useEffect(() => { + markRouteContentPainted(); + }, []); + + return ( + +

Couldn't load this page.

+

Check your connection, then reload to try again.

+ +
+ ); +} + +function renderPageLoadError(error: Error): ReactNode { + if ( + !/^(Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Unable to preload CSS for)/.test( + error.message, + ) + ) { + throw error; + } + return ; +} + +export function RouteContent({ children }: { children: ReactNode }) { + const location = useLocation(); + return ( + + {children} + + ); +}