Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/app/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,12 @@ export function AppLayout({ children }: AppLayoutProps) {
// mirroring how the in-view unread signal covers every thread kind.
const currentThreadPendingInteractionsQuery = useThreadPendingInteractions(
threadId ?? "",
{ enabled: isThreadView && Boolean(threadId) },
{
// The bootstrap seeds this cache; fetching before it settles would
// issue a parallel `/interactions` request the bundle already covers.
enabled:
isThreadView && Boolean(threadId) && hasThreadDetailBootstrapSettled,
},
);
const currentThreadHasPendingInteraction =
getLatestPendingInteraction(currentThreadPendingInteractionsQuery.data) !==
Expand Down
5 changes: 5 additions & 0 deletions apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,12 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"environmentQueryKey",
"hostQueryKey",
"hostsQueryKey",
"threadDefaultExecutionOptionsQueryKey",
"threadPendingInteractionsQueryKey",
"threadPromptHistoryQueryKey",
"threadQueryKey",
"threadQueuedMessagesQueryKey",
"threadTabsQueryKey",
],
"hooks/cache-owners/thread-tabs-cache-owner.ts": ["threadTabsQueryKey"],
"hooks/cache-owners/thread-list-cache-owner.ts": [
Expand Down
47 changes: 46 additions & 1 deletion apps/app/src/hooks/cache-owners/thread-detail-cache-owner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@ import {
environmentQueryKey,
hostQueryKey,
hostsQueryKey,
threadDefaultExecutionOptionsQueryKey,
threadPendingInteractionsQueryKey,
threadPromptHistoryQueryKey,
threadQueryKey,
threadQueuedMessagesQueryKey,
threadTabsQueryKey,
} from "../queries/query-keys";

type HostList = Host[];
Expand All @@ -27,7 +32,16 @@ export interface ThreadDetailBootstrapIngestionArgs {
function stripThreadIncludes(
thread: ThreadWithIncludesResponse,
): ThreadResponse {
const { environment, host, ...threadResponse } = thread;
const {
environment,
host,
pendingInteractions,
queuedMessages,
promptHistory,
defaultExecutionOptions,
tabs,
...threadResponse
} = thread;
return threadResponse;
}

Expand Down Expand Up @@ -71,4 +85,35 @@ export function ingestThreadDetailBootstrap({
upsertHostList({ host, hosts }),
);
}

// Bundled per-thread reads. Each field is present only when the bootstrap
// requested it, and each seeds the cache its stand-alone hook reads so the
// hook mounts with data instead of issuing its own request.
if (thread.pendingInteractions !== undefined) {
queryClient.setQueryData(
threadPendingInteractionsQueryKey(thread.id),
thread.pendingInteractions,
);
}
if (thread.queuedMessages !== undefined) {
queryClient.setQueryData(
threadQueuedMessagesQueryKey(thread.id),
thread.queuedMessages,
);
}
if (thread.promptHistory !== undefined) {
queryClient.setQueryData(
threadPromptHistoryQueryKey(thread.id),
thread.promptHistory,
);
}
if (thread.defaultExecutionOptions !== undefined) {
queryClient.setQueryData(
threadDefaultExecutionOptionsQueryKey(thread.id),
thread.defaultExecutionOptions,
);
}
if (thread.tabs !== undefined) {
queryClient.setQueryData(threadTabsQueryKey(thread.id), thread.tabs);
}
}
9 changes: 9 additions & 0 deletions apps/app/src/hooks/queries/query-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ import { BbHttpError } from "@/lib/sdk";
* project-scoped variants so they age out their cached suggestions together.
*/
export const PROMPT_HISTORY_STALE_TIME_MS = 10_000;
/**
* Freshness window for the per-thread caches that
* `GET /threads/:id?include=` seeds on thread open (pending interactions,
* queued messages, default execution options, tabs). Their hooks mount right
* after the bootstrap settles; without a window every seeded hook refetches
* its own route on mount and the bundle saves nothing. Realtime invalidation
* still refetches inside the window.
*/
export const THREAD_DETAIL_SEEDED_STALE_TIME_MS = 5_000;
export const TRANSIENT_READ_RETRY_COUNT = 2;
export const TRANSIENT_READ_RETRY_DELAY_MS = 250;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useThreadDetailRealtimeSubscription } from "@/hooks/useRealtimeSubscrip
import { requireEnabledQueryArg } from "./query-helpers";
import { threadDefaultExecutionOptionsQueryKey } from "./query-keys";
import { REALTIME_OWNED_NO_FOCUS_QUERY_POLICY } from "./query-policies";
import { THREAD_DETAIL_SEEDED_STALE_TIME_MS } from "./query-helpers";

export {
allThreadDefaultExecutionOptionsQueryKeyPrefix,
Expand Down Expand Up @@ -49,6 +50,6 @@ export function useThreadDefaultExecutionOptions(
enabled,
refetchOnMount: options?.refetchOnMount ?? true,
...REALTIME_OWNED_NO_FOCUS_QUERY_POLICY,
staleTime: options?.staleTime,
staleTime: options?.staleTime ?? THREAD_DETAIL_SEEDED_STALE_TIME_MS,
});
}
48 changes: 48 additions & 0 deletions apps/app/src/hooks/queries/thread-detail-bootstrap-pending.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { hashKey, useQueryClient } from "@tanstack/react-query";
import { useCallback, useSyncExternalStore } from "react";
import { threadDetailBootstrapQueryKey } from "./query-keys";

/**
* True while the thread-detail bootstrap (`GET /threads/:id?include=`) for
* this thread is still pending, so a per-thread hook the bootstrap seeds can
* wait for the bundle instead of issuing its own request in parallel.
*
* `useQuery` decides whether to fetch when its observer subscribes, using the
* `enabled` value computed during render. Hooks that mount in the same commit
* as `useThreadDetailBootstrap` (thread tabs, the layout's pending-interaction
* probe) therefore cannot see the bootstrap fetch start; they only see the
* bootstrap query already sitting in the cache with `status: "pending"`.
* That is the signal read here.
*
* Only gate hooks that always render underneath an active
* `useThreadDetailBootstrap` for the same thread (the thread-detail tree). A
* bootstrap query left `pending` in the cache with no active observer would
* otherwise keep the gated hook disabled until it is garbage collected.
*/
export function useIsThreadDetailBootstrapPending(threadId: string): boolean {
const queryClient = useQueryClient();
const queryHash =
threadId.length > 0
? hashKey(threadDetailBootstrapQueryKey(threadId))
: null;
const subscribe = useCallback(
(onStoreChange: () => void) => {
if (queryHash === null) {
return () => {};
}
return queryClient.getQueryCache().subscribe((event) => {
if (event.query.queryHash === queryHash) {
onStoreChange();
}
});
},
[queryClient, queryHash],
);
const getSnapshot = useCallback(
() =>
queryHash !== null &&
queryClient.getQueryCache().get(queryHash)?.state.status === "pending",
[queryClient, queryHash],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
148 changes: 147 additions & 1 deletion apps/app/src/hooks/queries/thread-queries.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ThreadListEntry } from "@bb/domain";
import type {
SidebarBootstrapResponse,
ThreadTabsResponse,
ThreadTimelineResponse,
ThreadWithIncludesResponse,
} from "@bb/server-contract";
Expand All @@ -22,18 +23,23 @@ import {
threadQueryKey,
threadTimelineQueryKey,
} from "./query-keys";
import { useThreadDefaultExecutionOptions } from "./thread-default-execution-options-query";
import {
COMPACT_THREAD_TIMELINE_SEGMENT_LIMIT,
THREAD_DETAIL_BOOTSTRAP_INCLUDE,
didThreadDetailBootstrapRefreshAfterMount,
useArchivedThreads,
useChildThreads,
useThread,
useThreadDetailBootstrap,
useThreadHostFilePreview,
useThreadMentionCandidates,
useThreadPendingInteractions,
useThreadPromptHistory,
useThreadQueuedMessages,
useThreadTimeline,
} from "./thread-queries";
import { useThreadTabs } from "./thread-tabs-query";

vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
Expand All @@ -43,12 +49,17 @@ vi.mock("@/lib/api", async (importOriginal) => {
};
});

vi.mock("@/lib/sdk", () => ({
vi.mock("@/lib/sdk", async (importOriginal) => ({
BbHttpError: (await importOriginal<typeof import("@/lib/sdk")>()).BbHttpError,
sdk: {
threads: {
defaultExecutionOptions: vi.fn(),
get: vi.fn(),
interactions: { list: vi.fn() },
list: vi.fn(),
promptHistory: vi.fn(),
queuedMessages: { list: vi.fn() },
tabs: { get: vi.fn() },
timeline: vi.fn(),
},
},
Expand Down Expand Up @@ -278,6 +289,141 @@ describe("useThreadDetailBootstrap", () => {
});
});

it("seeds the bundled per-thread caches so their hooks mount without extra requests", async () => {
const bundledThread = {
...THREAD_WITH_INCLUDES,
pendingInteractions: [],
queuedMessages: [
{
id: "qmsg-1",
content: [{ type: "text", text: "Queued follow-up", mentions: [] }],
model: "gpt-5",
reasoningLevel: "medium",
permissionMode: "full",
serviceTier: "default",
groupWithNext: false,
createdAt: 1,
updatedAt: 1,
},
],
promptHistory: [],
defaultExecutionOptions: null,
tabs: { revision: 3, tabs: [{ id: "thread-info", kind: "thread-info" }] },
} satisfies ThreadWithIncludesResponse;
vi.mocked(sdk.threads.get).mockResolvedValue(bundledThread);
const { wrapper } = createQueryClientTestHarness();

const { result } = renderHook(
() => {
const bootstrap = useThreadDetailBootstrap("thread-1");
const enabled = bootstrap.isSuccess;
const pendingInteractions = useThreadPendingInteractions("thread-1", {
enabled,
});
const queuedMessages = useThreadQueuedMessages("thread-1", {
enabled,
});
const promptHistory = useThreadPromptHistory("thread-1", { enabled });
const defaultExecutionOptions = useThreadDefaultExecutionOptions(
"thread-1",
{ enabled },
);
const tabs = useThreadTabs("thread-1", { enabled });
return {
bootstrap,
defaultExecutionOptions,
pendingInteractions,
promptHistory,
queuedMessages,
tabs,
};
},
{ wrapper },
);

await waitFor(() => {
expect(result.current.bootstrap.isSuccess).toBe(true);
expect(result.current.tabs.data).toEqual(bundledThread.tabs);
});
expect(sdk.threads.get).toHaveBeenCalledWith({
include: THREAD_DETAIL_BOOTSTRAP_INCLUDE,
signal: expect.any(AbortSignal),
threadId: "thread-1",
});
expect(result.current.pendingInteractions.data).toEqual([]);
expect(result.current.queuedMessages.data).toEqual(
bundledThread.queuedMessages,
);
expect(result.current.promptHistory.data).toEqual([]);
expect(result.current.defaultExecutionOptions.isSuccess).toBe(true);
expect(result.current.defaultExecutionOptions.data).toBeNull();

// Let any mount-time refetch start before asserting none did.
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sdk.threads.interactions.list).not.toHaveBeenCalled();
expect(sdk.threads.queuedMessages.list).not.toHaveBeenCalled();
expect(sdk.threads.promptHistory).not.toHaveBeenCalled();
expect(sdk.threads.defaultExecutionOptions).not.toHaveBeenCalled();
expect(sdk.threads.tabs.get).not.toHaveBeenCalled();
expect(result.current.pendingInteractions.isFetching).toBe(false);
expect(result.current.queuedMessages.isFetching).toBe(false);
expect(result.current.defaultExecutionOptions.isFetching).toBe(false);
expect(result.current.tabs.isFetching).toBe(false);
});

it("makes thread tabs wait for a bootstrap that mounts in the same commit", async () => {
// Real mount order: `useThreadTabs` (via the fixed-panel tabs state) mounts
// alongside the bootstrap, not after it succeeds. Without the gate the
// tabs query decides to fetch before the bundle can seed it.
const tabs = {
revision: 3,
tabs: [{ id: "thread-info", kind: "thread-info" }],
} satisfies ThreadTabsResponse;
vi.mocked(sdk.threads.get).mockResolvedValue({
...THREAD_WITH_INCLUDES,
tabs,
});
vi.mocked(sdk.threads.tabs.get).mockResolvedValue(tabs);
const { wrapper } = createQueryClientTestHarness();
const { result } = renderHook(
() => ({
bootstrap: useThreadDetailBootstrap("thread-1"),
tabs: useThreadTabs("thread-1"),
}),
{ wrapper },
);

await waitFor(() => {
expect(result.current.bootstrap.isSuccess).toBe(true);
expect(result.current.tabs.data).toEqual(tabs);
});
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sdk.threads.tabs.get).not.toHaveBeenCalled();
expect(result.current.tabs.isFetching).toBe(false);
});

it("lets thread tabs fetch on their own once the bootstrap fails", async () => {
vi.mocked(sdk.threads.get).mockRejectedValue(new Error("offline"));
const tabs = { revision: 1, tabs: [] } satisfies ThreadTabsResponse;
vi.mocked(sdk.threads.tabs.get).mockResolvedValue(tabs);
const { wrapper } = createQueryClientTestHarness();
const { result } = renderHook(
() => ({
bootstrap: useThreadDetailBootstrap("thread-1"),
tabs: useThreadTabs("thread-1"),
}),
{ wrapper },
);

await waitFor(() => {
expect(result.current.bootstrap.isError).toBe(true);
});
await waitFor(() => {
expect(result.current.tabs.data).toEqual(tabs);
});
expect(sdk.threads.tabs.get).toHaveBeenCalledTimes(1);
});

it("only suppresses a thread refetch for a bootstrap fetched after mount", () => {
expect(
didThreadDetailBootstrapRefreshAfterMount({
Expand Down
Loading
Loading