diff --git a/src/components/documents/__tests__/vault-utils.test.ts b/src/components/documents/__tests__/vault-utils.test.ts index d5d263600..f368012c5 100644 --- a/src/components/documents/__tests__/vault-utils.test.ts +++ b/src/components/documents/__tests__/vault-utils.test.ts @@ -22,6 +22,8 @@ import { resolveBulkShareDocuments, SHARE_LINK_MAX_DOCUMENTS, vaultFiltersToSearch, + STAGED_ATTACHMENT_POLL_MS, + stagedAttachmentPollInterval, } from "../vault-utils"; function doc(overrides: Partial = {}): InboundDocumentDto { @@ -541,3 +543,36 @@ describe("buildProcedureChoices", () => { expect(buildProcedureChoices([], null, labels)).toEqual([]); }); }); + +describe("stagedAttachmentPollInterval", () => { + const now = Date.parse("2026-10-20T10:00:00Z"); + const at = (msAgo: number) => new Date(now - msAgo).toISOString(); + + it("polls a fresh upload until its index lands", () => { + expect( + stagedAttachmentPollInterval( + { createdAt: at(30_000), hasContentIndex: false }, + now, + ), + ).toBe(STAGED_ATTACHMENT_POLL_MS); + expect( + stagedAttachmentPollInterval( + { createdAt: at(30_000), hasContentIndex: true }, + now, + ), + ).toBe(false); + }); + + it("stops polling a document that is never indexed once the window passes", () => { + expect( + stagedAttachmentPollInterval( + { createdAt: at(RECENT_UPLOAD_WINDOW_MS + 1), hasContentIndex: false }, + now, + ), + ).toBe(false); + }); + + it("does not poll before the detail has loaded", () => { + expect(stagedAttachmentPollInterval(undefined, now)).toBe(false); + }); +}); diff --git a/src/components/documents/vault-utils.ts b/src/components/documents/vault-utils.ts index 7111ca830..3e4c12175 100644 --- a/src/components/documents/vault-utils.ts +++ b/src/components/documents/vault-utils.ts @@ -167,6 +167,26 @@ export function isDocumentProcessing( return !doc.hasContentIndex && isRecentlyUploaded(doc.createdAt, now); } +/** + * How often the Coach polls a staged attachment for its content index. + * + * The same "still processing" question the vault list asks, so the same + * bounded window answers it: a document that will never be indexed (an + * unsupported format, indexing disabled) used to be polled every 2.5 s for as + * long as the Coach stayed open. `false` once the index exists or the window + * has passed. + */ +export const STAGED_ATTACHMENT_POLL_MS = 2_500; + +export function stagedAttachmentPollInterval( + doc: Pick | undefined, + now: number = Date.now(), +): number | false { + return doc && isDocumentProcessing(doc, now) + ? STAGED_ATTACHMENT_POLL_MS + : false; +} + /** * Whether ANY document in the list is still inside its processing window — * drives the vault list query's poll-while-indexing `refetchInterval`. diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index a0092b46b..1b611d329 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -21,7 +21,10 @@ import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents"; import { useModuleEnabled } from "@/hooks/use-module-enabled"; -import { parseUploadResponse } from "@/components/documents/vault-utils"; +import { + parseUploadResponse, + stagedAttachmentPollInterval, +} from "@/components/documents/vault-utils"; import { metricScopeLabelFallback, scopeSourceMetricLabelKey, @@ -399,10 +402,11 @@ export function CoachConversation({ queryFn: async () => apiGet(`/api/documents/inbound/${id}`), staleTime: 15_000, + // Bounded by the upload processing window, like the vault list: a + // document that is never indexed stops being polled. refetchInterval: (query: { state: { data?: InboundDocumentDetailDto }; - }) => - query.state.data && !query.state.data.hasContentIndex ? 2500 : false, + }) => stagedAttachmentPollInterval(query.state.data), })), }); const stagedDetailById = new Map< diff --git a/src/lib/queries/refetch-interval.ts b/src/lib/queries/refetch-interval.ts index 193828d82..b2fc5b0de 100644 --- a/src/lib/queries/refetch-interval.ts +++ b/src/lib/queries/refetch-interval.ts @@ -13,3 +13,61 @@ * refetch lands on a warm cache entry rather than a guaranteed miss. */ export const DASHBOARD_REFETCH_INTERVAL_MS = 120_000; + +/* + * ── Every client poll, in one place ───────────────────────────────────── + * + * The web client polls in three shapes, and each poll belongs to exactly + * one of them. A new poll picks its shape here, and is added to this list. + * + * 1. STANDING — a fixed interval while the surface is mounted. TanStack + * Query pauses it while the tab is hidden (`refetchIntervalInBackground` + * defaults to false), so it costs nothing on a background tab. + * + * | Surface | Interval | Why this figure | + * | ------------------------------------ | -------- | ------------------------------------------------- | + * | dashboard snapshot, analytics, digest | 120 s | this constant; matches the snapshot cache TTL | + * | version poller (`version-poller.tsx`) | 60 s | a deploy notice within a minute; plain timer | + * | notification channel status | 30 s | last-delivery results move while the panel is open | + * | admin app-log preview | 30 s | an operator watching a live log | + * | achievement unlock notifier | 5 min | out-of-band unlocks; an app-global poll | + * | admin host metrics chart | 5 min | the sampler's own cadence | + * + * 2. UNTIL-DONE — polls while a server job is visibly running and stops on + * the terminal state the response reports. The interval is short because + * a person is watching a progress indicator; the stop condition is what + * bounds it, so it must be a state the server always reaches. + * + * | Surface | Interval | Stops when | + * | ------------------------------------- | -------- | -------------------------------------------- | + * | Apple Health / Health Connect import | 2 s | the job reports a terminal status | + * | admin backup restore list | 2 s | no restore is active | + * | documents list | 4 s | no document is inside its 3-minute upload | + * | | | processing window without an index | + * | Coach staged attachment | 2.5 s | the same: index exists or window passed | + * | admin pulse day-mean / maintenance | 10 s | the recompute reports done | + * + * 3. BOUNDED — polls while the server says fresh model text is on its way + * (`preparing` / `revalidating`), with an attempt ceiling, because a + * generation that keeps failing would otherwise poll an open page + * forever. The ceilings live beside the deciders and are unit-tested. + * + * | Surface | Interval | Ceiling | + * | ----------------------------- | -------- | ------------------------------------------ | + * | status cards (`use-insight-status.ts`, `nextStatusPollInterval`) | 4 s | 12 attempts | + * | advisor / briefing (`nextAdvisorPollInterval`) | 25 s | 10 attempts | + * | period narrative | 5 s | stops once the narrative arrives | + * + * The period narrative has no attempt ceiling: it stops on a state the + * server reaches (the warm writes text or clears `revalidating`), and it + * is only mounted while a person is looking at it. + * + * A document that is never indexed is the reason the two document polls are + * bounded by the upload window (`RECENT_UPLOAD_WINDOW_MS` in + * `src/components/documents/vault-utils.ts`) rather than by the index alone: + * until v1.43.7 the Coach polled such an attachment every 2.5 s for as long + * as it stayed open. + * + * Server-side timers (job heartbeats, the heap monitor, the log flush) are + * not client polls and are not listed. + */