From 2495bb046c2e9e7bab67f4c2c58ae72871f37e8e Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 9 Oct 2026 18:06:48 +1100 Subject: [PATCH] PM-5573: Adjust active challenge timeline when switching AI only and AI gating What was broken On a launched challenge, switching the AI review mode between AI_ONLY and AI_GATING did not change Timeline & Schedule. AI_GATING kept the AI Review and Approval phases, and AI_ONLY kept AI Screening and the review phases. After the first submission the whole AI review configuration was also read-only, so the mode could not be switched at all. Root cause - The editor never asked challenge-api to move an ACTIVE challenge to the timeline matching its new mode. challenge-api did not do it for ACTIVE challenges either (fixed in challenge-api-v6 for PM-5573). - The form also kept the old phases. Leaving AI_ONLY requires a manual reviewer, but the only phases offered were AI Review and Approval. - AiReviewTab locked every field, and skipped autosave, whenever the challenge had submissions. What was changed - ReviewersField: - When a persisted AI config's mode no longer matches an ACTIVE challenge's timeline, it PATCHes the challenge reviewers right away so challenge-api moves the challenge to the matching template. It then loads the returned phases and timelineTemplateId into the form. - Both steps only happen before a review phase starts. - It passes canSwitchReviewMode to AiReviewTab until a review phase starts. - AiReviewTab: with submissions, canSwitchReviewMode keeps the Review Mode select (AI_GATING / AI_ONLY) enabled and autosaving. This works for both manual and template configs. Every other setting stays locked, and the lock banner says the mode can still be switched. - reviewers-field.utils: add hasAiOnlyTimelinePhases() and hasStartedReviewPhase(). - ChallengeEditorPage README documents the behaviour. Added/updated tests - ReviewersField.spec: - an active AI_ONLY challenge moves to the AI gating timeline, and the reverse - nothing is patched when the timeline already matches the mode - the mode is locked and the timeline kept once review starts - AiReviewTab.spec: - only the review mode is editable and autosaves with submissions (manual and template configs) - the mode is locked once review starts - reviewers-field.utils.spec: hasAiOnlyTimelinePhases and hasStartedReviewPhase. Co-Authored-By: Claude Opus 5.5 --- .../challenges/ChallengeEditorPage/README.md | 2 +- .../ReviewersField/AiReviewTab.spec.tsx | 145 ++++++++++++++ .../components/ReviewersField/AiReviewTab.tsx | 36 +++- .../ReviewersField/ReviewersField.spec.tsx | 185 +++++++++++++++++- .../ReviewersField/ReviewersField.tsx | 82 +++++++- .../reviewers-field.utils.spec.ts | 45 +++++ .../ReviewersField/reviewers-field.utils.ts | 38 ++++ 7 files changed, 519 insertions(+), 14 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index e0a58d3ad..2f7c76c65 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -50,7 +50,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha - `skills`: required unless billing account is listed in `SKILLS_OPTIONAL_BILLING_ACCOUNT_IDS`. - `reviewer`: optional for task challenges. - `reviewers`: when using `Save as Draft` from `NEW` status, non-task/non-marathon challenges must include reviewer coverage for configured review phases. If required phases are configured, each phase must have at least one member reviewer with a scorecard. The Screening and Checkpoint Screening configurations and scorecards remain required, but their Screener member assignments may be left empty until after launch. Design `Challenge` reviewers additionally leave the Checkpoint Review, Review, and Approval member assignments optional because the selected copilot is assigned to those private phases during save; other closed manual reviewer assignments remain required. -- `AI review configuration`: templates and manual configs autosave separately once valid, switching a template-backed config to manual mode keeps its copied settings but clears the template link on save, and the AI tab becomes read-only after the challenge has submissions. +- `AI review configuration`: templates and manual configs autosave separately once valid, switching a template-backed config to manual mode keeps its copied settings but clears the template link on save, and the AI tab becomes read-only after the challenge has submissions. The only exception is the review mode: it can still switch between AI_GATING and AI_ONLY (for manual and template configs) until a review phase (AI Screening, AI Review, Review, ...) starts. When an active challenge's mode no longer matches its timeline, the editor saves the reviewers right away so challenge-api moves the challenge to the matching timeline (AI Review and Approval for AI_ONLY, AI Screening and the review phases for AI_GATING), then loads the new phases into Timeline & Schedule. ## Autosave Behavior 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 0de48113d..897480e1a 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 @@ -585,4 +585,149 @@ describe('AiReviewTab review mode options', () => { ) .toBe(true) }) + + describe('with submissions', () => { + const lockedWorkflows = [ + { + id: 'config-workflow-1', + isGating: false, + weightPercent: 100, + workflowId: 'workflow-1', + }, + ] + + /** + * Finds the Review Mode select, whose label also wraps the mode description. + */ + async function findReviewModeSelect(): Promise { + return (await screen.findByRole('option', { name: 'AI_ONLY' })) + .closest('select') as HTMLSelectElement + } + + beforeEach(() => { + mockedFetchWorkflows.mockResolvedValue([ + { + id: 'workflow-1', + name: 'Workflow 1', + }, + ]) + mockedUpdateAiReviewConfig.mockImplementation(async (_configId, input) => ({ + ...baseConfiguration, + ...input, + id: 'config-1', + })) + }) + + it('keeps only the review mode editable until the review phase starts', async () => { + jest.useFakeTimers() + mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({ + ...baseConfiguration, + mode: 'AI_ONLY', + workflows: lockedWorkflows, + }) + + render( + , + ) + + const reviewModeSelect = await findReviewModeSelect() + + expect(reviewModeSelect.disabled) + .toBe(false) + expect((screen.getByRole('slider') as HTMLInputElement).disabled) + .toBe(true) + expect((screen.getByLabelText('AI Workflow') as HTMLSelectElement).disabled) + .toBe(true) + expect(screen.queryByRole('button', { name: 'Remove AI config' })) + .toBeNull() + expect(screen.getByText(/You can still switch the review mode/)).not.toBeNull() + + fireEvent.change(reviewModeSelect, { + target: { + value: 'AI_GATING', + }, + }) + + await act(async () => { + jest.advanceTimersByTime(1600) + }) + + await waitFor(() => { + expect(mockedUpdateAiReviewConfig) + .toHaveBeenCalledWith( + 'config-1', + expect.objectContaining({ + minPassingThreshold: 75, + mode: 'AI_GATING', + }), + ) + }) + }) + + it('lets template configs switch the review mode until the review phase starts', async () => { + jest.useFakeTimers() + mockedFetchAiReviewTemplates.mockResolvedValue([]) + mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({ + ...baseConfiguration, + templateId: 'template-1', + workflows: lockedWorkflows, + }) + + render( + , + ) + + fireEvent.change(await findReviewModeSelect(), { + target: { + value: 'AI_ONLY', + }, + }) + + await act(async () => { + jest.advanceTimersByTime(1600) + }) + + await waitFor(() => { + expect(mockedUpdateAiReviewConfig) + .toHaveBeenCalledWith( + 'config-1', + expect.objectContaining({ + mode: 'AI_ONLY', + templateId: 'template-1', + }), + ) + }) + }) + + it('locks the review mode once the review phase has started', async () => { + mockedFetchAiReviewConfigByChallenge.mockResolvedValueOnce({ + ...baseConfiguration, + workflows: lockedWorkflows, + }) + + render( + , + ) + + expect((await findReviewModeSelect()).disabled) + .toBe(true) + expect(screen.queryByText(/You can still switch the review mode/)) + .toBeNull() + }) + }) }) 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 d6a064b21..71eec5936 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 @@ -53,6 +53,11 @@ export interface AiReviewConfigSaveController { } interface AiReviewTabProps { + /** + * Keeps the review mode (AI_GATING / AI_ONLY) editable while `hasSubmissions` locks the + * rest of the configuration. The parent sets it until the challenge's review phase starts. + */ + canSwitchReviewMode?: boolean challengeId?: string hasSubmissions?: boolean onConfigRemoved?: () => Promise | void @@ -89,6 +94,8 @@ interface ManualWorkflowEditorProps { } interface ReviewSettingsProps { + /** Keeps the review mode select enabled when `readOnly` locks the other settings. */ + canChangeMode?: boolean configuration: AiReviewConfigurationDraft onUpdate: ( field: K, @@ -418,7 +425,7 @@ const ReviewSettings: FC = (