From bdc033e68993f3c2e2ac89e450d3d85abfc88a42 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Mon, 29 Jun 2026 01:49:55 +0300 Subject: [PATCH] fix(ai): extract reindex-button loading predicate + correct poll comment (PR #242) F4: extract the reindex button `loading` predicate into a pure, unit-tested `isReindexButtonLoading({ mutationPending, deadline, status })` next to the other reindex helpers, replacing the inline JSX expression. Covers the load-bearing post-cap case (deadline nulled, reindexing stale-true -> not loading) plus mutationPending, active-run, and finished cases. F5: rewrite the `useAiSettingsQuery` poll comment to match the actual `nextReindexPollInterval` stop condition (continues while reindexing===true OR within deadline and not fully indexed; stops only when reindexing===false && indexed>=total, or the deadline cap) instead of the stale "until indexed===total". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/ai-provider-settings.spec.tsx | 47 +++++++++++++++++++ .../components/ai-provider-settings.tsx | 41 +++++++++++----- .../workspace/queries/ai-settings-query.ts | 8 +++- 3 files changed, 81 insertions(+), 15 deletions(-) diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx index 147c426d..1d58eba7 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.spec.tsx @@ -5,6 +5,7 @@ import { resolveKeyField, nextReindexPollInterval, isReindexComplete, + isReindexButtonLoading, } from './ai-provider-settings'; describe('resolveCardStatus', () => { @@ -176,3 +177,49 @@ describe('isReindexComplete', () => { ).toBe(true); }); }); + +describe('isReindexButtonLoading', () => { + it('loads while the POST mutation is pending', () => { + expect( + isReindexButtonLoading({ + mutationPending: true, + deadline: null, + status: false, + }), + ).toBe(true); + }); + + it('does NOT load post-cap: deadline nulled but reindexing left stale-true', () => { + // The key case: after the poll cap fires `reindexDeadline` is null while + // `settings.reindexing` can be a stale `true` from the last poll. Gating on + // the deadline keeps the spinner from sticking forever so the admin can + // restart. + expect( + isReindexButtonLoading({ + mutationPending: false, + deadline: null, + status: true, + }), + ).toBe(false); + }); + + it('loads during an active run within the poll window', () => { + expect( + isReindexButtonLoading({ + mutationPending: false, + deadline: 10_000, + status: true, + }), + ).toBe(true); + }); + + it('does not load once the run finished while still polling', () => { + expect( + isReindexButtonLoading({ + mutationPending: false, + deadline: 10_000, + status: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 832f8436..dac956c2 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -215,6 +215,26 @@ export function isReindexComplete(status?: ReindexStatus): boolean { ); } +/** + * Whether the reindex button should show its spinner (and stay disabled). + * + * Spins while the POST is in flight, and for the WHOLE background run while the + * server reports `reindexing === true`. The `deadline !== null` gate is the + * load-bearing part: once the 120s poll cap fires it nulls `reindexDeadline` + * and stops refetching, so `status` (settings?.reindexing) can be a stale + * `true` from the last poll. Without the gate the spinner would stick forever + * for a run that outlives the cap and block a restart; gating on the active + * poll window clears it so the admin can re-trigger. + */ +export function isReindexButtonLoading(args: { + mutationPending: boolean; + deadline: number | null; + status?: boolean; +}): boolean { + const { mutationPending, deadline, status } = args; + return mutationPending || (deadline !== null && status === true); +} + // Translate the dot's tooltip label. Kept in one place so all three endpoint // cards share identical wording. function cardStatusLabel(status: CardStatus, t: (k: string) => string): string { @@ -1083,19 +1103,14 @@ export default function AiProviderSettings() { // Spin for the WHOLE run: the POST resolves immediately, but the // background job keeps running, so also stay loading while the // server reports `reindexing` (this also blocks a redundant - // re-trigger mid-run; the server de-dupes regardless). - // - // Gate the `reindexing` part on the active poll window - // (reindexDeadline !== null): once the 120s poll cap fires it nulls - // reindexDeadline and stops refetching, so `settings.reindexing` - // can be a stale `true` from the last poll. Without this gate the - // spinner would stay stuck (and the button disabled) forever for a - // run that outlives the cap — clearing it here lets the admin - // restart. - loading={ - reindexMutation.isPending || - (reindexDeadline !== null && settings?.reindexing === true) - } + // re-trigger mid-run; the server de-dupes regardless). The + // deadline gate (and why it matters post-cap) lives in + // `isReindexButtonLoading`, which is unit-tested. + loading={isReindexButtonLoading({ + mutationPending: reindexMutation.isPending, + deadline: reindexDeadline, + status: settings?.reindexing, + })} onClick={() => reindexMutation.mutate(undefined, { // Begin bounded polling so the counter climbs as the async diff --git a/apps/client/src/features/workspace/queries/ai-settings-query.ts b/apps/client/src/features/workspace/queries/ai-settings-query.ts index fe8ce775..1b1a70e0 100644 --- a/apps/client/src/features/workspace/queries/ai-settings-query.ts +++ b/apps/client/src/features/workspace/queries/ai-settings-query.ts @@ -23,8 +23,12 @@ export function useAiSettingsQuery( enabled: boolean = true, // While reindexing runs as an async background job, the counter only climbs // if the client keeps refetching. The component passes a refetchInterval - // function that polls until indexed === total or a bounded deadline, then - // returns false to stop. See AiProviderSettings. + // function (`nextReindexPollInterval`) that keeps polling while the server + // reports an active run (reindexing === true) OR we are still within the + // bounded deadline and not yet fully indexed; it returns false to stop only + // once the run has finished AND indexed >= total, or the deadline cap is hit + // (the cap always wins). Note: a transient indexed === total during an active + // run does NOT stop polling. See AiProviderSettings. refetchInterval?: | number | false