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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"ArchivedThreadsListFilters",
"ENVIRONMENT_WORK_STATUS_QUERY_KEY",
"EnvironmentWorkStatusQueryKey",
"SIDEBAR_NAVIGATION_QUERY_KEY",
"THREADS_QUERY_KEY",
"ThreadListQueryFilters",
"environmentDiffFilesQueryKeyPrefix",
Expand Down
50 changes: 49 additions & 1 deletion apps/app/src/hooks/cache-owners/query-cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { Thread, ThreadListEntry, ThreadWithRuntime } from "@bb/domain";
import type {
Thread,
ThreadListEntry,
ThreadStatusChangeMetadata,
ThreadWithRuntime,
} from "@bb/domain";
import {
applyToCachedThreadLists,
getCachedThreadLists,
Expand All @@ -23,6 +28,7 @@ import {
environmentQueryKey,
environmentWorkStatusQueryKey,
environmentWorkStatusQueryKeyPrefix,
SIDEBAR_NAVIGATION_QUERY_KEY,
sidebarNavigationQueryKey,
THREADS_QUERY_KEY,
threadQueryKey,
Expand Down Expand Up @@ -704,3 +710,45 @@ export function updateCachedThreadListPendingInteractionState(
);
});
}

/**
* Writes the row fields a lifecycle transition rewrites (status, runtime,
* activity, attention and update times) into every cached thread list and
* the sidebar bootstrap. Status never changes list membership (lists filter
* on project, parent, archive state), so a row that is not cached needs
* nothing: the query that will load it reads the current status.
*/
export function updateCachedThreadListStatusState(
queryClient: QueryClient,
threadId: string,
statusChange: ThreadStatusChangeMetadata,
): void {
applyToCachedThreadListsAndSidebarNavigation(queryClient, (list) => {
if (!list.some((thread) => thread.id === threadId)) {
return list;
}
return list.map((thread) =>
thread.id === threadId ? { ...thread, ...statusChange } : thread,
);
});
}

/**
* Thread list and sidebar queries with a fetch in flight. Such a fetch read
* the database before the change that is being patched in, so its response
* would overwrite the patch when it lands.
*/
export function getFetchingThreadListQueryKeys(
queryClient: QueryClient,
): QueryKey[] {
return queryClient
.getQueryCache()
.findAll({ fetchStatus: "fetching" })
.map((query) => query.queryKey)
.filter(
(queryKey) =>
queryKey[0] === SIDEBAR_NAVIGATION_QUERY_KEY ||
getThreadListFiltersFromQueryKey(queryKey) !== undefined ||
getArchivedThreadListFiltersFromQueryKey(queryKey) !== undefined,
);
}
40 changes: 35 additions & 5 deletions apps/app/src/hooks/cache-owners/realtime-cache-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
SystemChangeKind,
ThreadChangeKind,
ThreadEventType,
ThreadStatusChangeMetadata,
ThreadWithRuntime,
} from "@bb/domain";
import {
Expand All @@ -54,9 +55,11 @@ import {
getEnvironmentBranchListInvalidationQueryKeys,
getEnvironmentRecordInvalidationQueryKeys,
getEnvironmentWorkspaceStateInvalidationQueryKeys,
getFetchingThreadListQueryKeys,
isArchivedThreadListQueryKey,
removeEnvironmentDiffPatchQueries,
updateCachedThreadListPendingInteractionState,
updateCachedThreadListStatusState,
} from "./query-cache";
import {
getCachedThreadLists,
Expand Down Expand Up @@ -207,8 +210,7 @@ function hasActiveQueries(
queryKey: QueryKey,
): boolean {
return (
queryClient.getQueryCache().findAll({ queryKey, type: "active" }).length >
0
queryClient.getQueryCache().findAll({ queryKey, type: "active" }).length > 0
);
}

Expand Down Expand Up @@ -430,7 +432,7 @@ export const REALTIME_THREAD_CHANGE_REGISTRY = {
"status-changed": {
flush: "immediate",
dirty: [
dirtyActiveThreadListQueries, // List rows render status/runtime badges; archived pages only go stale.
patchThreadListStatusState, // List rows patch status/runtime from notification metadata; refetch only without it.
dirtyThreadDetailQueries, // Detail controls and banners depend on status.
],
},
Expand Down Expand Up @@ -649,6 +651,7 @@ interface ThreadRealtimeDirtyContext extends RealtimeDirtyContext {
flushOnce: (key: string) => boolean;
hasPendingInteraction: boolean | undefined;
projectId: string | undefined;
statusChange: ThreadStatusChangeMetadata | undefined;
threadId: string | undefined;
}

Expand Down Expand Up @@ -903,8 +906,9 @@ function dirtyThreadTimelineQueries({
const timelineQueryKeys = getThreadTimelineWindowInvalidationQueryKeys({
threadId,
});
const outlineQueryKeys =
getThreadConversationOutlineInvalidationQueryKeys({ threadId });
const outlineQueryKeys = getThreadConversationOutlineInvalidationQueryKeys({
threadId,
});
const outlineMayHaveChanged =
eventTypes === undefined || eventTypes.includes("turn/completed");
if (
Expand Down Expand Up @@ -1080,6 +1084,32 @@ function patchThreadListPendingInteractionState({
);
}

/**
* A status change rewrites a handful of row fields and never moves a thread
* between lists, so when the notification carries them the cached rows are
* patched in place. The alternative is what the fallback still does for
* pushes without the row (older servers, writers inside a transaction that
* cannot resolve the runtime): refetch every active thread list plus the
* sidebar bootstrap, which is ~1 KB per unarchived thread, twice per turn.
*
* A list fetch already in flight read the database before this transition
* and would overwrite the patch when it lands, so those queries are
* invalidated, which cancels and restarts them.
*/
function patchThreadListStatusState(
context: ThreadRealtimeDirtyContext,
): QueryKey[] {
const { queryClient, statusChange, threadId } = context;
if (!threadId || !statusChange) {
return dirtyActiveThreadListQueries(context);
}
updateCachedThreadListStatusState(queryClient, threadId, statusChange);
for (const queryKey of getFetchingThreadListQueryKeys(queryClient)) {
queryClient.invalidateQueries({ exact: true, queryKey });
}
return [threadSearchQueryKeyPrefix()]; // Result rows render status but are not list-shaped.
}

function dirtyEnvironmentRecordQueries(
context: EnvironmentRealtimeDirtyContext,
): QueryKey[] {
Expand Down
Loading
Loading