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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { AiReviewConfig } from '../../../../../lib/models'
import {
createAiReviewConfig,
deleteAiReviewConfig,
fetchAiReviewConfigByChallenge,
fetchAiReviewTemplates,
fetchWorkflows,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -123,6 +125,7 @@ describe('AiReviewTab review mode options', () => {
mockedUseFetchChallengeTypes.mockReturnValue({
challengeTypes: [],
})
mockedDeleteAiReviewConfig.mockResolvedValue(undefined)
mockedFetchAiReviewConfigByChallenge.mockResolvedValue(baseConfiguration)
mockedFetchWorkflows.mockResolvedValue([])
})
Expand All @@ -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(
<AiReviewTab
challengeId='challenge-1'
reviewers={[]}
/>,
)
Expand All @@ -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(
<AiReviewTab
challengeId='challenge-1'
onConfigPersisted={onConfigPersisted}
reviewers={[]}
/>,
)

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(
<AiReviewTab
Expand Down Expand Up @@ -202,6 +227,41 @@ describe('AiReviewTab review mode options', () => {
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(
<AiReviewTab
challengeId='challenge-1'
onConfigRemoved={onConfigRemoved}
reviewers={persistedAiReviewers}
/>,
)

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,
Expand Down Expand Up @@ -398,6 +458,7 @@ describe('AiReviewTab review mode options', () => {
],
}

mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce(undefined)
mockedCreateAiReviewConfig.mockResolvedValueOnce(savedConfiguration)
mockedFetchWorkflows.mockResolvedValueOnce([
{
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,9 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
const trackId = props.trackId
const typeId = props.typeId
const lastSavedConfigurationRef = useRef<AiReviewConfig | AiReviewConfigurationDraft | undefined>()
// 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<string | undefined>()
const onConfigPersistedRef = useRef<typeof onConfigPersisted>(onConfigPersisted)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>()

Expand Down Expand Up @@ -519,7 +522,6 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
() => (reviewers || []).filter(isAiReviewer),
[reviewers],
)
const hasAssignedAiReviewers = aiReviewers.length > 0
const assignedWorkflowIds = useMemo(
() => new Set(
aiReviewers
Expand Down Expand Up @@ -829,6 +831,7 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
let mounted = true

if (!normalizedChallengeId) {
initialConfigLookupChallengeIdRef.current = undefined
setConfiguration(DEFAULT_CONFIGURATION)
setConfigurationMode(undefined)
setConfigId(undefined)
Expand All @@ -844,18 +847,15 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
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)

Expand Down Expand Up @@ -898,7 +898,7 @@ export const AiReviewTab: FC<AiReviewTabProps> = (
return () => {
mounted = false
}
}, [hasAssignedAiReviewers, hasPersistedConfigForCurrentChallenge, normalizedChallengeId])
}, [hasPersistedConfigForCurrentChallenge, normalizedChallengeId])

useEffect(() => {
let mounted = true
Expand Down
Loading