diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx index 4d673b720..3818e0fed 100644 --- a/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx +++ b/apps/app/src/components/secondary-panel/SideChatTabContent.test.tsx @@ -295,6 +295,15 @@ vi.mock("@/hooks/queries/thread-queries", () => ({ error: null, status: "success", }), + useThreadQueuedMessages: () => ({ data: [] }), + useThreadTimeline: () => ({ + data: { activeThinking: null, rows: mocks.threadTimelineRows }, + isError: false, + isPending: false, + }), +})); + +vi.mock("@/hooks/queries/thread-default-execution-options-query", () => ({ useThreadDefaultExecutionOptions: () => ({ data: { model: "gpt-5", @@ -303,12 +312,6 @@ vi.mock("@/hooks/queries/thread-queries", () => ({ }, isLoading: false, }), - useThreadQueuedMessages: () => ({ data: [] }), - useThreadTimeline: () => ({ - data: { activeThinking: null, rows: mocks.threadTimelineRows }, - isError: false, - isPending: false, - }), })); vi.mock("@/hooks/mutations/thread-runtime-mutations", () => ({ diff --git a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx index 80aef1f21..c607b68f7 100644 --- a/apps/app/src/components/secondary-panel/SideChatTabContent.tsx +++ b/apps/app/src/components/secondary-panel/SideChatTabContent.tsx @@ -73,9 +73,9 @@ import { import { useHostDaemon } from "@/hooks/useHostDaemon"; import { useThread, - useThreadDefaultExecutionOptions, useThreadQueuedMessages, } from "@/hooks/queries/thread-queries"; +import { useThreadDefaultExecutionOptions } from "@/hooks/queries/thread-default-execution-options-query"; import { useCreateThreadQueuedMessage, useCreateThread, 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 eef5c973f..a27389f8f 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 @@ -161,6 +161,8 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "threadsQueryKey", ], "hooks/cache-owners/system-cache-effects.ts": [ + "allAutomationDetailQueryKeyPrefix", + "allAutomationRunsQueryKeyPrefix", "allEnvironmentDiffFilesQueryKeyPrefix", "allEnvironmentDiffPatchQueryKeyPrefix", "allEnvironmentFilePreviewQueryKeyPrefix", @@ -170,6 +172,8 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "allHostQueryKeyPrefix", "allProjectPathsQueryKeyPrefix", "allSystemExecutionOptionsQueryKeyPrefix", + "allTerminalsQueryKeyPrefix", + "allThreadHostFilePreviewQueryKeyPrefix", "allThreadPendingInteractionsQueryKeyPrefix", "allThreadQueryKeyPrefix", "allThreadQueuedMessagesQueryKeyPrefix", @@ -178,6 +182,7 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = { "allThreadStoragePathsQueryKeyPrefix", "allThreadTimelineQueryKeyPrefix", "allThreadTimelineTurnSummaryDetailsQueryKeyPrefix", + "automationsQueryKey", "hostsQueryKey", "hostPathExistenceQueryKeyPrefix", "projectsQueryKey", 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 e4b15370c..69429a3f3 100644 --- a/apps/app/src/hooks/cache-owners/system-cache-effects.ts +++ b/apps/app/src/hooks/cache-owners/system-cache-effects.ts @@ -1,6 +1,8 @@ import type { QueryKey } from "@tanstack/react-query"; import type { SystemExecutionOptionsResponse } from "@bb/server-contract"; import { + allAutomationDetailQueryKeyPrefix, + allAutomationRunsQueryKeyPrefix, allEnvironmentDiffFilesQueryKeyPrefix, allEnvironmentDiffPatchQueryKeyPrefix, allEnvironmentFilePreviewQueryKeyPrefix, @@ -10,14 +12,17 @@ import { allHostQueryKeyPrefix, allProjectPathsQueryKeyPrefix, allSystemExecutionOptionsQueryKeyPrefix, - allThreadQueuedMessagesQueryKeyPrefix, + allTerminalsQueryKeyPrefix, + allThreadHostFilePreviewQueryKeyPrefix, allThreadPendingInteractionsQueryKeyPrefix, + allThreadQueuedMessagesQueryKeyPrefix, allThreadQueryKeyPrefix, allThreadStorageFilePreviewQueryKeyPrefix, allThreadStorageFilesQueryKeyPrefix, allThreadStoragePathsQueryKeyPrefix, allThreadTimelineQueryKeyPrefix, allThreadTimelineTurnSummaryDetailsQueryKeyPrefix, + automationsQueryKey, hostPathExistenceQueryKeyPrefix, hostsQueryKey, projectsQueryKey, @@ -112,6 +117,8 @@ function getServerReconnectInvalidationQueryKeys(): QueryKey[] { allThreadStorageFilesQueryKeyPrefix(), allThreadStoragePathsQueryKeyPrefix(), allThreadStorageFilePreviewQueryKeyPrefix(), + allThreadHostFilePreviewQueryKeyPrefix(), + allTerminalsQueryKeyPrefix(), allEnvironmentQueryKeyPrefix(), allEnvironmentWorkStatusQueryKeyPrefix(), allEnvironmentMergeBaseBranchesQueryKeyPrefix(), @@ -122,6 +129,9 @@ function getServerReconnectInvalidationQueryKeys(): QueryKey[] { allEnvironmentDiffFilesQueryKeyPrefix(), allEnvironmentFilePreviewQueryKeyPrefix(), hostPathExistenceQueryKeyPrefix(), + automationsQueryKey(), + allAutomationDetailQueryKeyPrefix(), + allAutomationRunsQueryKeyPrefix(), systemProvidersQueryKey(), allSystemExecutionOptionsQueryKeyPrefix(), ]; diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts index 941a5f4ea..4f15006e2 100644 --- a/apps/app/src/hooks/queries/query-keys.ts +++ b/apps/app/src/hooks/queries/query-keys.ts @@ -273,6 +273,13 @@ export type ThreadHostFilePreviewQueryKey = readonly [ string | null | undefined, string | null, ]; +export type AllThreadHostFilePreviewQueryKeyPrefix = readonly [ + typeof THREAD_HOST_FILE_PREVIEW_QUERY_KEY, +]; +export type ThreadHostFilePreviewQueryKeyPrefix = readonly [ + typeof THREAD_HOST_FILE_PREVIEW_QUERY_KEY, + string, +]; export type EnvironmentQueryKeyPrefix = readonly [typeof ENVIRONMENT_QUERY_KEY]; export type EnvironmentQueryKey = readonly [ typeof ENVIRONMENT_QUERY_KEY, @@ -784,6 +791,16 @@ export function threadHostFilePreviewQueryKey( return [THREAD_HOST_FILE_PREVIEW_QUERY_KEY, threadId, environmentId, path]; } +export function allThreadHostFilePreviewQueryKeyPrefix(): AllThreadHostFilePreviewQueryKeyPrefix { + return [THREAD_HOST_FILE_PREVIEW_QUERY_KEY]; +} + +export function threadHostFilePreviewQueryKeyPrefix( + threadId: string, +): ThreadHostFilePreviewQueryKeyPrefix { + return [THREAD_HOST_FILE_PREVIEW_QUERY_KEY, threadId]; +} + export function allEnvironmentQueryKeyPrefix(): EnvironmentQueryKeyPrefix { return [ENVIRONMENT_QUERY_KEY]; } diff --git a/apps/app/src/hooks/queries/system-queries.test.tsx b/apps/app/src/hooks/queries/system-queries.test.tsx index ec7f03f0f..3cac5569c 100644 --- a/apps/app/src/hooks/queries/system-queries.test.tsx +++ b/apps/app/src/hooks/queries/system-queries.test.tsx @@ -2,16 +2,30 @@ import { cleanup, renderHook, waitFor } from "@testing-library/react"; import type { SystemExecutionOptionsResponse } from "@bb/server-contract"; +import type { + ProviderCliStatusResponse, + ProviderUsageResponse, +} from "@bb/host-daemon-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as api from "@/lib/api"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; -import { useSystemExecutionOptions } from "./system-queries"; +import { + hostProviderCliStatusQueryKey, + systemUsageLimitsQueryKey, +} from "./query-keys"; +import { + useHostProviderCliStatus, + useSystemExecutionOptions, + useSystemUsageLimits, +} from "./system-queries"; vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + fetchHostProviderCliStatus: vi.fn(), getSystemExecutionOptions: vi.fn(), + getSystemUsageLimits: vi.fn(), }; }); @@ -22,6 +36,13 @@ const EXECUTION_OPTIONS_RESPONSE: SystemExecutionOptionsResponse = { modelLoadError: null, }; +const PROVIDER_CLI_STATUS_RESPONSE = {} as ProviderCliStatusResponse; + +const PROVIDER_USAGE_RESPONSE: ProviderUsageResponse = { + codex: { status: "unauthenticated" }, + claudeCode: { status: "unauthenticated" }, +}; + afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -64,3 +85,60 @@ describe("useSystemExecutionOptions", () => { }); }); }); + +describe("useHostProviderCliStatus", () => { + it("refreshes stale host CLI status on focus and reconnect", async () => { + vi.mocked(api.fetchHostProviderCliStatus).mockResolvedValue( + PROVIDER_CLI_STATUS_RESPONSE, + ); + const { queryClient, wrapper } = createQueryClientTestHarness(); + + renderHook( + () => useHostProviderCliStatus({ hostId: "host-1", enabled: true }), + { wrapper }, + ); + + await waitFor(() => { + expect(api.fetchHostProviderCliStatus).toHaveBeenCalledTimes(1); + }); + + const query = queryClient.getQueryCache().find({ + queryKey: hostProviderCliStatusQueryKey("host-1"), + }); + + expect(query?.options).toEqual( + expect.objectContaining({ + refetchOnReconnect: true, + refetchOnWindowFocus: true, + staleTime: 60_000, + }), + ); + }); +}); + +describe("useSystemUsageLimits", () => { + it("refreshes stale usage data on focus and reconnect", async () => { + vi.mocked(api.getSystemUsageLimits).mockResolvedValue( + PROVIDER_USAGE_RESPONSE, + ); + const { queryClient, wrapper } = createQueryClientTestHarness(); + + renderHook(() => useSystemUsageLimits(), { wrapper }); + + await waitFor(() => { + expect(api.getSystemUsageLimits).toHaveBeenCalledTimes(1); + }); + + const query = queryClient.getQueryCache().find({ + queryKey: systemUsageLimitsQueryKey(), + }); + + expect(query?.options).toEqual( + expect.objectContaining({ + refetchOnReconnect: true, + refetchOnWindowFocus: true, + staleTime: 30_000, + }), + ); + }); +}); diff --git a/apps/app/src/hooks/queries/system-queries.ts b/apps/app/src/hooks/queries/system-queries.ts index 610245e95..d07b63883 100644 --- a/apps/app/src/hooks/queries/system-queries.ts +++ b/apps/app/src/hooks/queries/system-queries.ts @@ -92,6 +92,7 @@ export function useSystemConfig(options?: QueryOptions) { } const SYSTEM_VERSION_STALE_TIME_MS = 60 * 60 * 1000; +const HOST_PROVIDER_CLI_STATUS_STALE_TIME_MS = 60_000; export function useSystemVersion(options?: QueryOptions) { return useQuery({ @@ -125,10 +126,9 @@ export function useHostProviderCliStatus({ signal, ), enabled: (enabled ?? true) && hostId !== null, - refetchOnMount: false, - refetchOnReconnect: false, - refetchOnWindowFocus: false, - staleTime: Infinity, + refetchOnReconnect: true, + refetchOnWindowFocus: true, + staleTime: HOST_PROVIDER_CLI_STATUS_STALE_TIME_MS, }); } @@ -139,8 +139,8 @@ export function useSystemUsageLimits(options?: QueryOptions) { queryKey: systemUsageLimitsQueryKey(), queryFn: ({ signal }) => api.getSystemUsageLimits(signal), enabled: options?.enabled ?? true, - refetchOnReconnect: false, - refetchOnWindowFocus: false, + refetchOnReconnect: true, + refetchOnWindowFocus: true, staleTime: PROVIDER_USAGE_STALE_TIME_MS, }); } diff --git a/apps/app/src/hooks/queries/thread-default-execution-options-query.ts b/apps/app/src/hooks/queries/thread-default-execution-options-query.ts index b255a9520..6a322307b 100644 --- a/apps/app/src/hooks/queries/thread-default-execution-options-query.ts +++ b/apps/app/src/hooks/queries/thread-default-execution-options-query.ts @@ -4,17 +4,16 @@ import { apiClient } from "@/lib/api-server"; import { request, requestOptions } from "@/lib/api"; import { useThreadDetailRealtimeSubscription } from "@/hooks/useRealtimeSubscription"; import { requireEnabledQueryArg } from "./query-helpers"; +import { threadDefaultExecutionOptionsQueryKey } from "./query-keys"; -export const THREAD_DEFAULT_EXECUTION_OPTIONS_QUERY_KEY = - "threadDefaultExecutionOptions"; - -export type ThreadDefaultExecutionOptionsQueryKeyPrefix = readonly [ - typeof THREAD_DEFAULT_EXECUTION_OPTIONS_QUERY_KEY, -]; -export type ThreadDefaultExecutionOptionsQueryKey = readonly [ - typeof THREAD_DEFAULT_EXECUTION_OPTIONS_QUERY_KEY, - string, -]; +export { + allThreadDefaultExecutionOptionsQueryKeyPrefix, + threadDefaultExecutionOptionsQueryKey, +} from "./query-keys"; +export type { + ThreadDefaultExecutionOptionsQueryKey, + ThreadDefaultExecutionOptionsQueryKeyPrefix, +} from "./query-keys"; interface ThreadDefaultExecutionOptionsQueryOptions { enabled?: boolean; @@ -26,16 +25,6 @@ function requireThreadId(id: string, hookName: string): string { return requireEnabledQueryArg({ value: id, hookName, argName: "thread id" }); } -export function threadDefaultExecutionOptionsQueryKey( - threadId: string, -): ThreadDefaultExecutionOptionsQueryKey { - return [THREAD_DEFAULT_EXECUTION_OPTIONS_QUERY_KEY, threadId]; -} - -export function allThreadDefaultExecutionOptionsQueryKeyPrefix(): ThreadDefaultExecutionOptionsQueryKeyPrefix { - return [THREAD_DEFAULT_EXECUTION_OPTIONS_QUERY_KEY]; -} - export function fetchThreadDefaultExecutionOptions( threadId: string, signal?: AbortSignal, diff --git a/apps/app/src/hooks/queries/thread-queries.test.tsx b/apps/app/src/hooks/queries/thread-queries.test.tsx index aaf8ec628..da34fc87e 100644 --- a/apps/app/src/hooks/queries/thread-queries.test.tsx +++ b/apps/app/src/hooks/queries/thread-queries.test.tsx @@ -5,12 +5,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as api from "@/lib/api"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { ARCHIVED_THREADS_PAGE_SIZE } from "./archived-threads-page-size"; -import { useArchivedThreads } from "./thread-queries"; +import { + threadHostFilePreviewQueryKey, + threadQueuedMessagesQueryKey, +} from "./query-keys"; +import { + useArchivedThreads, + useThreadHostFilePreview, + useThreadQueuedMessages, +} from "./thread-queries"; vi.mock("@/lib/api", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + getThreadHostFilePreview: vi.fn(), + listThreadQueuedMessages: vi.fn(), listThreads: vi.fn(), }; }); @@ -27,6 +37,14 @@ afterEach(() => { beforeEach(() => { vi.mocked(api.listThreads).mockResolvedValue([]); + vi.mocked(api.listThreadQueuedMessages).mockResolvedValue([]); + vi.mocked(api.getThreadHostFilePreview).mockResolvedValue({ + kind: "text", + path: "/tmp/log.txt", + url: "/api/v1/threads/thread-1/host-files/content?path=%2Ftmp%2Flog.txt", + mimeType: "text/plain", + content: "preview", + }); }); describe("useArchivedThreads", () => { @@ -80,3 +98,56 @@ describe("useArchivedThreads", () => { }); }); }); + +describe("useThreadQueuedMessages", () => { + it("refetches stale queue data on window focus", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + + renderHook(() => useThreadQueuedMessages("thread-1"), { wrapper }); + + await waitFor(() => { + expect(api.listThreadQueuedMessages).toHaveBeenCalledTimes(1); + }); + + const query = queryClient.getQueryCache().find({ + queryKey: threadQueuedMessagesQueryKey("thread-1"), + }); + + expect(query?.options).toEqual( + expect.objectContaining({ + refetchOnMount: true, + refetchOnWindowFocus: true, + }), + ); + }); +}); + +describe("useThreadHostFilePreview", () => { + it("refetches stale host file previews on focus and reconnect", async () => { + const { queryClient, wrapper } = createQueryClientTestHarness(); + + renderHook( + () => useThreadHostFilePreview("thread-1", "env-1", "/tmp/log.txt"), + { wrapper }, + ); + + await waitFor(() => { + expect(api.getThreadHostFilePreview).toHaveBeenCalledTimes(1); + }); + + const query = queryClient.getQueryCache().find({ + queryKey: threadHostFilePreviewQueryKey( + "thread-1", + "env-1", + "/tmp/log.txt", + ), + }); + + expect(query?.options).toEqual( + expect.objectContaining({ + refetchOnReconnect: true, + refetchOnWindowFocus: true, + }), + ); + }); +}); diff --git a/apps/app/src/hooks/queries/thread-queries.ts b/apps/app/src/hooks/queries/thread-queries.ts index 542196724..ac5d9eb72 100644 --- a/apps/app/src/hooks/queries/thread-queries.ts +++ b/apps/app/src/hooks/queries/thread-queries.ts @@ -8,7 +8,6 @@ import { useMemo } from "react"; import { useDebounceValue } from "usehooks-ts"; import type { PendingInteraction, - ResolvedThreadExecutionOptions, ThreadWithRuntime, } from "@bb/domain"; import type { @@ -55,7 +54,6 @@ import { import { archivedThreadsListQueryKey, disabledThreadListQueryKey, - threadDefaultExecutionOptionsQueryKey, threadDetailBootstrapQueryKey, threadQueuedMessagesQueryKey, threadListQueryKey, @@ -101,8 +99,6 @@ type ThreadQueuedMessagesQueryOptions = QueryOptions; type ThreadPromptHistoryQueryOptions = QueryOptions; -type ThreadDefaultExecutionOptionsQueryOptions = QueryOptions; - type ThreadPendingInteractionsQueryOptions = QueryOptions; export interface UseThreadsFilters extends Omit< @@ -577,7 +573,7 @@ export function useThreadQueuedMessages( ), enabled, refetchOnMount: options?.refetchOnMount ?? true, - refetchOnWindowFocus: false, + refetchOnWindowFocus: true, staleTime: options?.staleTime, }); } @@ -602,24 +598,6 @@ export function useThreadPromptHistory( }); } -export function useThreadDefaultExecutionOptions( - id: string, - options?: ThreadDefaultExecutionOptionsQueryOptions, -) { - return useQuery({ - queryKey: threadDefaultExecutionOptionsQueryKey(id), - queryFn: ({ signal }) => - api.getThreadDefaultExecutionOptions( - requireThreadId(id, "useThreadDefaultExecutionOptions"), - signal, - ), - enabled: (options?.enabled ?? true) && Boolean(id), - refetchOnMount: options?.refetchOnMount ?? true, - refetchOnWindowFocus: false, - staleTime: options?.staleTime, - }); -} - export function useThreadPendingInteractions( id: string, options?: ThreadPendingInteractionsQueryOptions, @@ -732,7 +710,8 @@ export function useThreadHostFilePreview( signal, ), enabled, - refetchOnWindowFocus: false, + refetchOnReconnect: true, + refetchOnWindowFocus: true, }); } diff --git a/apps/app/src/hooks/system-cache-effects.test.ts b/apps/app/src/hooks/system-cache-effects.test.ts index c8209befd..56a1a31c3 100644 --- a/apps/app/src/hooks/system-cache-effects.test.ts +++ b/apps/app/src/hooks/system-cache-effects.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it, vi } from "vitest"; import { QueryObserver } from "@tanstack/react-query"; import { createAppQueryClient } from "@/lib/query-client"; import { + automationDetailQueryKey, + automationRunsQueryKey, + automationsQueryKey, environmentDiffFilesQueryKey, environmentDiffPatchQueryKey, sidebarNavigationQueryKey, systemExecutionOptionsQueryKey, + terminalsQueryKey, threadDefaultExecutionOptionsQueryKey, + threadHostFilePreviewQueryKey, threadPendingInteractionsQueryKey, threadPromptHistoryQueryKey, threadQueuedMessagesQueryKey, @@ -59,9 +64,27 @@ describe("system cache effects", () => { }); const defaultExecutionOptionsKey = threadDefaultExecutionOptionsQueryKey("thread-1"); + const threadHostFilePreviewKey = threadHostFilePreviewQueryKey( + "thread-1", + "env-1", + "/tmp/log.txt", + ); const executionOptionsKey = scopedSystemExecutionOptionsKey({ environmentId: "env-1", }); + const terminalsKey = terminalsQueryKey({ + kind: "thread", + threadId: "thread-1", + }); + const automationsKey = automationsQueryKey(); + const automationDetailKey = automationDetailQueryKey( + "project-1", + "automation-1", + ); + const automationRunsKey = automationRunsQueryKey( + "project-1", + "automation-1", + ); const sidebarNavigationKey = sidebarNavigationQueryKey(); queryClient.setQueryData(queuedMessagesKey, []); queryClient.setQueryData(promptHistoryKey, []); @@ -74,7 +97,18 @@ describe("system cache effects", () => { defaultExecutionOptionsKey, EMPTY_EXECUTION_OPTIONS, ); + queryClient.setQueryData(threadHostFilePreviewKey, { + kind: "text", + path: "/tmp/log.txt", + url: "/api/v1/threads/thread-1/host-files/content?path=%2Ftmp%2Flog.txt", + mimeType: "text/plain", + content: "old", + }); queryClient.setQueryData(executionOptionsKey, EMPTY_EXECUTION_OPTIONS); + queryClient.setQueryData(terminalsKey, { sessions: [] }); + queryClient.setQueryData(automationsKey, { automations: [] }); + queryClient.setQueryData(automationDetailKey, { id: "automation-1" }); + queryClient.setQueryData(automationRunsKey, { runs: [] }); queryClient.setQueryData(sidebarNavigationKey, { projects: [], personalProject: { threads: [] }, @@ -97,9 +131,22 @@ describe("system cache effects", () => { expect( queryClient.getQueryState(defaultExecutionOptionsKey)?.isInvalidated, ).toBe(true); + expect( + queryClient.getQueryState(threadHostFilePreviewKey)?.isInvalidated, + ).toBe(true); expect(queryClient.getQueryState(executionOptionsKey)?.isInvalidated).toBe( true, ); + expect(queryClient.getQueryState(terminalsKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(automationsKey)?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(automationDetailKey)?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(automationRunsKey)?.isInvalidated).toBe( + true, + ); expect(queryClient.getQueryState(sidebarNavigationKey)?.isInvalidated).toBe( true, ); diff --git a/docs/research/mobile-resume-query-refetch-audit.md b/docs/research/mobile-resume-query-refetch-audit.md new file mode 100644 index 000000000..9e167c262 --- /dev/null +++ b/docs/research/mobile-resume-query-refetch-audit.md @@ -0,0 +1,106 @@ +# Mobile Resume Query Refetch Audit + +Date: 2026-06-28 + +## Scope + +Inspected app query hooks and imperative query usage under `apps/app/src`, including: + +- `useQuery` / `useInfiniteQuery` hooks in `apps/app/src/hooks/queries`. +- Manual `fetchQuery` / `prefetchQuery` use. +- Query-client defaults and browser lifecycle events. +- Websocket subscription hooks, realtime cache-effect registries, and server-reconnect invalidation. +- Explicit `refetchOnWindowFocus`, `refetchOnReconnect`, `refetchOnMount`, and `staleTime` overrides. + +No `queryOptions` / `infiniteQueryOptions` factories were found in the app. + +## Global Policy + +`createAppQueryClient` defaults queries to `staleTime: 2000`, `refetchOnWindowFocus: true`, transient-read retry, and TanStack's default reconnect behavior. The app installs a custom focus listener for `visibilitychange` and `pageshow`, so mobile history restore can refetch stale active queries. It also cancels active fetches on `pagehide` and hidden `visibilitychange`. + +Realtime cache effects invalidate query families on websocket messages. On websocket reconnect, `invalidateRealtimeQueriesAfterServerReconnect` dirties broad realtime-owned families because messages may have been missed while disconnected. Observer-less diff patch entries are removed, not merely invalidated. + +## Reconnect Rules + +A query should get websocket-reconnect invalidation when it is realtime-owned: the server normally pushes dirty messages for it, so a websocket outage can mean the client missed changes. The reconnect invalidation is the catch-up baseline for those missed messages. + +A query should not get websocket-reconnect invalidation when one of these rules applies: + +- Focus-owned live reads: the value comes from a live endpoint and has no server dirty event stream. Use finite `staleTime` plus focus/reconnect refetch instead. Examples: provider CLI status, usage limits. +- Static/session-owned reads: the value is effectively fixed for the app server session. Example: system version. +- Expensive/manual reads: the UI intentionally fetches only on explicit user action or active view mount because reconnect is not a meaningful dirty signal. +- Observer-less caches: invalidation is insufficient because no query function observes the cache entry. Remove/evict instead. Example: environment diff patches. +- Typeahead/ephemeral reads: the query key changes with input and stale results are bounded by short TTL or the next keystroke. Reconnect should not churn menus. + +## Fixes Applied + +- Added websocket-reconnect catch-up invalidation for `terminals`. +- Added websocket-reconnect catch-up invalidation for `automations`, `automationDetail`, and `automationRuns`. +- Added websocket-reconnect catch-up invalidation for `threadHostFilePreview`. +- Changed `threadQueuedMessages` to refetch stale data on focus/pageshow like `threadTimeline`. +- Changed `threadHostFilePreview` to refetch stale visible previews on focus/reconnect. +- Changed `hostProviderCliStatus` from session-static to focus-owned: finite `staleTime`, focus refetch, and network reconnect refetch. +- Changed `systemUsageLimits` to focus-owned while mounted in Settings: focus/reconnect refetch with existing 30s stale window. +- Removed the duplicate legacy `useThreadDefaultExecutionOptions` hook from `thread-queries.ts`; the dedicated module now reuses the canonical query-key helpers from `query-keys.ts`. + +## Query Classification + +| Query key / hook | Current policy | Data volatility | Websocket invalidation coverage | Mobile-resume risk | Recommended policy | +| --- | --- | --- | --- | --- | --- | +| `sidebarNavigation` / `useSidebarNavigation` | `staleTime: Infinity`; default focus irrelevant while fresh | High: projects, hosts, threads, environments | Subscribes to environment, host, project, and thread lists; server reconnect invalidates | Low | Keep as realtime-owned Infinity cache | +| `sidebarNavigation` / `useProjectDisplayName` | Same key, selector-style query; no subscriptions | Medium | Relies on sidebar owner being mounted | Low in app layout; medium if used without sidebar | Keep as read-only selector; avoid making it a second ownership path | +| `hosts` / `useHosts`, `usePrimaryHost` | `staleTime: 60s`; default focus/reconnect | High: connected/disconnected changes | Host-list subscription and server reconnect invalidation | Low | Keep | +| `hostDirectory` / `useHostDirectory` | `staleTime: 30s`; default focus/reconnect | Medium: filesystem can change externally | None | Low to medium; focus refreshes once stale | Keep; no realtime exists for generic directory browsing | +| `hostPathExistence` / `useHostPathExistence` | `staleTime: 10s`; default focus/reconnect | Medium | Project-source changes and server reconnect invalidate | Low | Keep | +| `systemExecutionOptions` / `useSystemExecutionOptions` | `staleTime: 60s`; default focus/reconnect; custom retry | High: host/provider availability affects composer | System/host realtime and server reconnect invalidate | Low | Keep | +| `systemConfig` / `useSystemConfig` | `staleTime: 60s`; default focus/reconnect | Medium | System `config-changed` and server reconnect invalidate | Low | Keep | +| `systemVersion` / `useSystemVersion` | `staleTime: 1h`; `refetchOnWindowFocus: false`; `refetchOnReconnect: false` | Low within one app server session | None by design | Low | Keep as static/session-owned | +| `hostProviderCliStatus` / `useHostProviderCliStatus` | `staleTime: 60s`; focus/reconnect refetch | Medium: CLI install/update can happen outside app | None by design; this is a focus-owned live host probe, not server-pushed state | Low | Keep focus-owned; no websocket invalidation | +| `systemUsageLimits` / `useSystemUsageLimits` | `staleTime: 30s`; focus/reconnect refetch; manual refresh button remains | High: live provider quota | None by design; this is a live provider read with no server dirty event | Low while Settings is mounted | Keep focus-owned; no websocket invalidation | +| `ui-source-status` / `useUiSourceStatus` | `staleTime: 30s`; default focus/reconnect | Medium during UI rebuilds | System `ui-status-changed` invalidates | Low | Keep | +| `projectSourceBranches` / `useProjectSourceBranches` | `staleTime: 5s`; explicit focus/reconnect true | Medium to high: git refs change | Project detail subscription; project source changes and reconnect invalidate | Low | Keep | +| `projectPromptHistory` / `useProjectPromptHistory` | `staleTime: 10s`; default focus/reconnect | Medium | Project/thread changes invalidate | Low | Keep | +| `projectPaths` / `useProjectPathSuggestions` | `staleTime: 15s`; `retry: false`; `refetchOnWindowFocus: false` | Medium: filesystem/source changes | Project source changes and reconnect invalidate project path family | Low; typeahead data should not churn on focus | Keep | +| `projectFilePreview` / `useProjectFilePreview` | Default app stale time; `refetchOnWindowFocus: false` | Medium: file content changes | No specific dirty handler; key changes/remount/navigation refresh | Medium for stale open previews | Keep as expensive/manual preview; not websocket-owned | +| `projectCommands`, `projectCommandsPages` / `useProjectCommands*` | `staleTime: 15s`; `retry: false`; `refetchOnWindowFocus: false` | Medium: skill/command catalog can change | No specific dirty handler or reconnect invalidation | Low in active typeahead | Keep as typeahead/ephemeral | +| `projectDefaultExecutionOptions` / `useProjectDefaultExecutionOptions` | `staleTime: 10s`; default focus/reconnect | Medium | No specific dirty handler; focus/stale-time provides normal catch-up | Low | Keep as focus-owned project defaults | +| `threads` archived list / `useArchivedThreads` | Infinite query; `staleTime: 10s`; default focus/reconnect | High | Thread-list subscription; reconnect invalidates `threads` prefix | Low | Keep | +| `threads` / `useThreads`, `useProjectThreadSubset`, `useThreadMentionCandidates` | `staleTime: 10s`; default focus/reconnect | High | Thread-list subscription; reconnect invalidates `threads` prefix | Low | Keep | +| `threadSearch` / `useThreadSearch` | `staleTime: 10s`; default focus/reconnect | High: indexed thread content/status changes | Thread/project/environment handlers invalidate search prefix; reconnect invalidates | Low if sidebar/app subscriptions are mounted | Keep | +| `thread` / `useThread` | `staleTime: 5s`; default focus/reconnect; `refetchOnMount: true` | High | Thread-detail subscription; reconnect invalidates | Low | Keep | +| `threadDetailBootstrap` / `useThreadDetailBootstrap` | `staleTime: Infinity`; thread-detail subscription; no direct invalidation | Medium: bootstrap is an ingestion path for thread/env/host | Realtime invalidates ingested thread/env/host keys, not bootstrap key | Low; one-shot bootstrap is intentional | Keep as bootstrap-only; avoid reading it as authoritative long-term state | +| `threadTimeline` / `useThreadTimeline` | Default app stale time unless overridden; default focus/reconnect; `refetchOnMount: true` | Very high | Thread `events-appended` invalidates without canceling active fetches; reconnect invalidates | Low | Keep | +| `threadConversationOutline` / `useThreadConversationOutline` | Default app stale time unless overridden; default focus/reconnect; `refetchOnMount: true` | High | Same event-appended path as timeline; reconnect invalidates | Low | Keep | +| `threadTimelineTurnSummaryDetails` / `useThreadTimelineTurnSummaryDetails` | `staleTime: Infinity` by default; `refetchOnMount: true` | Low once a turn range is complete | Mutations can invalidate; realtime event batches deliberately exclude it | Low | Intentional immutable range cache; keep | +| `threadQueuedMessages` / `useThreadQueuedMessages` | Default app stale time; `refetchOnMount: true`; focus refetch | High while a thread is active | Queue changes and mutations invalidate/write; reconnect invalidates | Low | Fixed: align focus/pageshow behavior with timeline | +| `threadPromptHistory` / `useThreadPromptHistory` | `staleTime: 10s`; `refetchOnMount: true`; default focus/reconnect | Medium | Queue changes, turn requests, mutations, and reconnect invalidate | Low | Keep | +| `threadDefaultExecutionOptions` / `useThreadDefaultExecutionOptions` | Dedicated hook only; default app stale time unless option supplied; `refetchOnMount: true`; `refetchOnWindowFocus: false` | Medium: inherited environment/provider defaults can change | Environment-changed, accepted-message mutations, and reconnect invalidate | Low | Fixed duplication. Keep no-focus because values seed visible composer controls and should not churn a draft on focus | +| `threadPendingInteractions` / `useThreadPendingInteractions` | Default app stale time; `refetchOnMount: true`; `refetchOnWindowFocus: false` | High during pending user questions | Interactions-changed and reconnect invalidate; thread list badge patched from metadata | Low | Keep realtime-owned; no focus churn while composer is active | +| `threadStorageFiles` / `useThreadStorageFiles` | `refetchOnMount: "always"`; `refetchOnWindowFocus: false` | Medium to high: generated storage can change | Thread/environment storage changes and reconnect invalidate | Low | Keep expensive/manual storage-browser policy | +| `threadStoragePaths` / `useThreadStoragePaths` | `refetchOnMount: "always"`; `refetchOnWindowFocus: false`; placeholder previous data | Medium | Same storage invalidation as files | Low | Keep typeahead/storage-browser policy | +| `threadStorageFilePreview` / `useThreadStorageFilePreview` | `refetchOnMount: "always"`; `refetchOnWindowFocus: false` | Medium | Storage changed and reconnect invalidate | Low | Keep expensive/manual storage-preview policy | +| `threadHostFilePreview` / `useThreadHostFilePreview` | Default app stale time; focus/reconnect refetch | Medium: host file can change outside workspace | Server reconnect invalidates the family | Low | Fixed: visible volatile preview now refreshes on resume/reconnect | +| `environment` / `useEnvironment` | Caller stale time or app default; default focus/reconnect | Medium | Environment-detail subscription and reconnect invalidate | Low | Keep | +| `environmentWorkStatus` / `useEnvironmentWorkStatus` | `staleTime: 0`; `refetchOnMount: "always"`; `refetchOnWindowFocus: false` | High: git/file state | Environment live workspace changes and reconnect invalidate | Low | Keep realtime-owned expensive git-status policy | +| `environmentPullRequest` / `useEnvironmentPullRequest` | Dynamic stale time; `refetchOnMount: true`; `refetchOnWindowFocus: "always"`; polling open pending checks | High | Environment work-status changes and reconnect invalidate | Low | Keep | +| `environmentMergeBaseBranches` / `useEnvironmentMergeBaseBranches` | `staleTime: 30s`; `refetchOnWindowFocus: false` | Medium: git refs | Metadata/status/git-refs changes and reconnect invalidate | Low | Keep | +| `environmentFilePreview` / `useEnvironmentFilePreview` | Default app stale time; `refetchOnWindowFocus: false` | Medium: workspace file can change | Environment workspace changes and reconnect invalidate | Low | Keep expensive/manual preview policy | +| `environmentPaths` / `useEnvironmentPathSuggestions` | `staleTime: 15s`; `retry: false`; `refetchOnWindowFocus: false` | Medium | Environment detail subscription only; no reconnect invalidation by design | Low in typeahead | Keep typeahead/ephemeral policy | +| `environmentDiffFiles` / `useEnvironmentDiffFiles` | `staleTime: 5s`; `refetchOnMount: "always"`; `refetchOnWindowFocus: false` | High: git diff changes | Environment work/ref changes and reconnect invalidate | Medium if mounted through sleep without reconnect | Intentional no-focus; reconnect and mount coverage are good | +| `environmentDiffPatch` / `useEnvironmentDiffPatches` | Imperative observer-less cache via `setQueryData`/`getQueryData` | High | Removed on environment workspace/ref changes and reconnect | Low after recent eviction-generation handling | Keep; never replace remove with invalidate | +| `environmentDiffFile` / `useDiffFileContentsRequester` | Imperative `fetchQuery`; `staleTime: 5s` | Medium | No direct invalidation; keyed by resolved target/ref/path/side | Low; only used on explicit context expansion | Keep | +| `threadDefaultExecutionOptions` / `useForkThreadFromMessage` manual `fetchQuery` | Imperative user-action fetch with default app policy | Medium | Reuses default-options key | Low | Keep; user action fetches before navigation | +| `terminals` / `useTerminals`, `useThreadTerminals`, `useEnvironmentTerminals` | Default app stale time; `refetchOnWindowFocus: false`; default reconnect | High: sessions open/close/disconnect | Thread `terminals-changed` invalidates thread scopes; reconnect invalidates all terminal scopes | Low | Fixed reconnect catch-up | +| `automations` / `useAutomations` | App defaults | High: runs start/finish, pause/resume | Project/thread realtime invalidates; reconnect now invalidates | Low after patch | Patch applied; keep | +| `automationDetail` / `useAutomationDetail` | App defaults | High | Same as automations; reconnect now invalidates | Low after patch | Patch applied; keep | +| `automationRuns` / `useAutomationRuns` | App defaults | High | Same as automations; reconnect now invalidates | Low after patch | Patch applied; keep | + +## Resolved Suspicious Items + +| Item | Decision | +| --- | --- | +| `threadQueuedMessages` focus opt-out | Fixed. It now refetches stale queue data on focus/pageshow, matching the timeline behavior that exposed the inconsistency. | +| `threadHostFilePreview` | Fixed. It now has focus/reconnect refetch and websocket-reconnect invalidation via a real query-key prefix. | +| `hostProviderCliStatus` | Fixed as focus-owned, not websocket-owned. External CLI changes are discovered on app focus/network reconnect with a 60s stale window; websocket reconnect is not a semantic CLI dirty event. | +| `systemUsageLimits` | Fixed as focus-owned, not websocket-owned. It is a live provider read used by Settings, so mounted Settings refreshes on focus/network reconnect while retaining the manual refresh button. | +| Duplicate `useThreadDefaultExecutionOptions` | Fixed. The legacy hook was removed from `thread-queries.ts`, and the dedicated module reuses the canonical key helpers from `query-keys.ts`. The no-focus policy remains intentional because these values seed visible composer controls and should not reset draft defaults on focus. |