diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
index dfb2a1851..847f91d58 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md
@@ -15,7 +15,7 @@
footer actions. Manual saves from an existing `/edit` route, including trailing-slash variants,
navigate back to the matching `/view` route after the update succeeds.
- `components/*Field.tsx`: field-level components for each challenge section.
-- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded only when the challenge already has synced AI reviewer entries or the challenge changes, which avoids empty-config lookups on new challenges and prevents ordinary parent rerenders from refetching the same config in edit mode. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs.
+- `components/ReviewersField/*`: tabbed human/AI review configuration. Human reviewers stay on the challenge form, while AI reviewer configs load/save through the review API and sync saved AI workflows back into the challenge `reviewers` array. Existing AI configs are reloaded once per saved challenge even if the challenge payload is temporarily missing synced AI reviewer rows, while still avoiding empty-config lookups for unsaved challenges, ordinary parent rerenders in edit mode, and same-session re-fetches right after a config is intentionally removed. Removing an AI config also detaches the synced AI workflow reviewers from the challenge. In read-only view mode the tab switcher remains clickable so users can inspect AI config details inside the disabled challenge form, and the review summary surfaces the human-review table, AI workflow details, resolved scorecard names, review flow, and estimated reviewer cost without requiring edits. The AI-gating failure path keeps the locked state grouped under the gate so the diagram matches the legacy work-manager layout, including `AI_GATING` configs whose workflows do not explicitly mark `isGating`. When AI reviewers exist without a persisted AI screening phase, the schedule editor injects a virtual `AI Screening` row after submission phases. This `Review` section is hidden for `Task` and `Marathon Match` challenges because those flows use dedicated reviewer assignment UIs.
- `ChallengeEditorPage.module.scss` and `components/ChallengeEditorForm.module.scss`: page and form layout styling, including the grouped `Prizes & Billing` layout that keeps the challenge-prizes and copilot-fee inputs at fixed widths on larger screens, preserves whitespace to the right, and moves the billing summary underneath them.
## Validation Rules
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx
index e28bc58cc..2c73fdd0b 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.spec.tsx
@@ -19,6 +19,7 @@ import {
import { AiReviewConfig } from '../../../../../lib/models'
import {
createAiReviewConfig,
+ deleteAiReviewConfig,
fetchAiReviewConfigByChallenge,
fetchAiReviewTemplates,
fetchWorkflows,
@@ -93,6 +94,7 @@ jest.mock('~/libs/ui', () => ({
const mockedUseFetchChallengeTracks = useFetchChallengeTracks as jest.Mock
const mockedUseFetchChallengeTypes = useFetchChallengeTypes as jest.Mock
const mockedCreateAiReviewConfig = createAiReviewConfig as jest.Mock
+const mockedDeleteAiReviewConfig = deleteAiReviewConfig as jest.Mock
const mockedFetchAiReviewTemplates = fetchAiReviewTemplates as jest.Mock
const mockedFetchAiReviewConfigByChallenge = fetchAiReviewConfigByChallenge as jest.Mock
const mockedFetchWorkflows = fetchWorkflows as jest.Mock
@@ -123,6 +125,7 @@ describe('AiReviewTab review mode options', () => {
mockedUseFetchChallengeTypes.mockReturnValue({
challengeTypes: [],
})
+ mockedDeleteAiReviewConfig.mockResolvedValue(undefined)
mockedFetchAiReviewConfigByChallenge.mockResolvedValue(baseConfiguration)
mockedFetchWorkflows.mockResolvedValue([])
})
@@ -131,10 +134,9 @@ describe('AiReviewTab review mode options', () => {
jest.useRealTimers()
})
- it('does not fetch a persisted AI review config before any AI reviewers are synced', async () => {
+ it('does not fetch a persisted AI review config before the challenge has been saved', async () => {
render(
,
)
@@ -144,6 +146,29 @@ describe('AiReviewTab review mode options', () => {
.not.toHaveBeenCalled()
})
+ it(
+ 'loads a persisted AI review config for existing challenges when synced AI reviewers are missing',
+ async () => {
+ const onConfigPersisted = jest.fn()
+
+ render(
+ ,
+ )
+
+ expect(await screen.findByRole('combobox')).not.toBeNull()
+ await waitFor(() => {
+ expect(mockedFetchAiReviewConfigByChallenge)
+ .toHaveBeenCalledWith('challenge-1')
+ })
+ expect(onConfigPersisted)
+ .toHaveBeenCalledWith(baseConfiguration)
+ },
+ )
+
it('shows only AI_GATING as a visible review mode option for standard configs', async () => {
render(
{
expect(secondOnConfigPersisted).not.toHaveBeenCalled()
})
+ it('does not refetch a removed persisted AI review config for the same challenge', async () => {
+ const user = userEvent.setup()
+ const onConfigRemoved = jest.fn()
+
+ render(
+ ,
+ )
+
+ expect(await screen.findByRole('combobox')).not.toBeNull()
+ await waitFor(() => {
+ expect(mockedFetchAiReviewConfigByChallenge)
+ .toHaveBeenCalledTimes(1)
+ })
+
+ await user.click(screen.getByRole('button', { name: 'Remove AI config' }))
+
+ await waitFor(() => {
+ expect(mockedDeleteAiReviewConfig)
+ .toHaveBeenCalledWith('config-1')
+ })
+ await waitFor(() => {
+ expect(onConfigRemoved)
+ .toHaveBeenCalledTimes(1)
+ })
+ expect(await screen.findByRole('button', { name: 'Choose template' })).not.toBeNull()
+ expect(mockedFetchAiReviewConfigByChallenge)
+ .toHaveBeenCalledTimes(1)
+ expect(screen.queryByText('Loading AI review configuration...'))
+ .toBeNull()
+ })
+
it('keeps legacy AI_ONLY configs visible without exposing AI_ONLY in the dropdown list', async () => {
mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({
...baseConfiguration,
@@ -398,6 +458,7 @@ describe('AiReviewTab review mode options', () => {
],
}
+ mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce(undefined)
mockedCreateAiReviewConfig.mockResolvedValueOnce(savedConfiguration)
mockedFetchWorkflows.mockResolvedValueOnce([
{
@@ -457,8 +518,7 @@ describe('AiReviewTab review mode options', () => {
})
await waitFor(() => {
expect(mockedFetchAiReviewConfigByChallenge)
- .not
- .toHaveBeenCalled()
+ .toHaveBeenCalledTimes(1)
})
expect(
(screen.getByRole('checkbox', { name: 'Use as gating workflow' }) as HTMLInputElement)
diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx
index 7016635fd..3f4de24ca 100644
--- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx
+++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/AiReviewTab.tsx
@@ -471,6 +471,9 @@ export const AiReviewTab: FC = (
const trackId = props.trackId
const typeId = props.typeId
const lastSavedConfigurationRef = useRef()
+ // Track the initial persisted-config lookup per challenge so delete/mode changes
+ // do not re-trigger the edit-mode fallback fetch for the same saved challenge.
+ const initialConfigLookupChallengeIdRef = useRef()
const onConfigPersistedRef = useRef(onConfigPersisted)
const saveTimerRef = useRef | undefined>()
@@ -519,7 +522,6 @@ export const AiReviewTab: FC = (
() => (reviewers || []).filter(isAiReviewer),
[reviewers],
)
- const hasAssignedAiReviewers = aiReviewers.length > 0
const assignedWorkflowIds = useMemo(
() => new Set(
aiReviewers
@@ -829,6 +831,7 @@ export const AiReviewTab: FC = (
let mounted = true
if (!normalizedChallengeId) {
+ initialConfigLookupChallengeIdRef.current = undefined
setConfiguration(DEFAULT_CONFIGURATION)
setConfigurationMode(undefined)
setConfigId(undefined)
@@ -844,18 +847,15 @@ export const AiReviewTab: FC = (
return undefined
}
- // Saved AI configs sync their workflows back into the challenge reviewers array.
- // If there are no AI reviewers yet, there is no persisted config to load.
- if (!hasAssignedAiReviewers) {
- setConfiguration(DEFAULT_CONFIGURATION)
- setConfigurationMode(undefined)
- setConfigId(undefined)
+ if (initialConfigLookupChallengeIdRef.current === normalizedChallengeId) {
setIsConfigLoading(false)
setLoadError(undefined)
- lastSavedConfigurationRef.current = DEFAULT_CONFIGURATION
return undefined
}
+ // Existing challenges can have a persisted AI config even when the
+ // challenge payload is temporarily missing the synced AI reviewer rows.
+ initialConfigLookupChallengeIdRef.current = normalizedChallengeId
setIsConfigLoading(true)
setLoadError(undefined)
@@ -898,7 +898,7 @@ export const AiReviewTab: FC = (
return () => {
mounted = false
}
- }, [hasAssignedAiReviewers, hasPersistedConfigForCurrentChallenge, normalizedChallengeId])
+ }, [hasPersistedConfigForCurrentChallenge, normalizedChallengeId])
useEffect(() => {
let mounted = true