diff --git a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx index 6fa698720..d460314b7 100644 --- a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.spec.tsx @@ -1,7 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports */ import '@testing-library/jest-dom' import type { PropsWithChildren, ReactNode } from 'react' -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { useMemberBadges, type UserBadge, type UserBadgesResponse, type UserProfile } from '~/libs/core' @@ -32,17 +32,24 @@ jest.mock('../../components', () => ({ const mockUseMemberBadges = useMemberBadges as jest.MockedFunction -function createBadge(badgeName: string): UserBadge { +/** + * Builds a profile award fixture for CommunityAwards tests. The badge name and + * index are used to create stable display text and IDs, it returns a UserBadge + * accepted by the component, and it does not raise exceptions. + */ +function createBadge(badgeName: string, index: number = 1): UserBadge { + const badgeId: string = `badge-${index}` + return { awarded_at: new Date('2026-06-04T00:00:00.000Z'), awarded_by: 'admin', org_badge: { active: true, badge_description: 'Awarded for AI profile work.', - badge_image_url: 'https://example.com/ai-rookie.svg', + badge_image_url: `https://example.com/${badgeId}.svg`, badge_name: badgeName, badge_status: 'active', - id: 'badge-1', + id: badgeId, organization_id: 'topcoder', orgranization: { id: 'topcoder', @@ -50,7 +57,7 @@ function createBadge(badgeName: string): UserBadge { }, tags_id_tags: [], }, - org_badge_id: 'badge-1', + org_badge_id: badgeId, user_handle: 'tester', user_id: '123', } @@ -83,4 +90,38 @@ describe('CommunityAwards', () => { expect(mockUseMemberBadges) .toHaveBeenCalledWith(123, { limit: 500 }) }) + + it('lets members expand awards and collapse them back to the default view', () => { + const memberBadges: UserBadgesResponse = { + count: 6, + rows: Array.from({ length: 6 }, (_, index) => createBadge(`AI Award ${index + 1}`, index + 1)), + } + + mockUseMemberBadges.mockReturnValue(memberBadges) + + render() + + expect(screen.getByRole('button', { name: 'View AI Award 1 award details' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'View AI Award 4 award details' })) + .toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'View AI Award 5 award details' })) + .not + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '+ 2 more badges' })) + + expect(screen.getByRole('button', { name: 'View AI Award 5 award details' })) + .toBeInTheDocument() + expect(screen.getByRole('button', { name: 'See less' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'See less' })) + + expect(screen.queryByRole('button', { name: 'View AI Award 5 award details' })) + .not + .toBeInTheDocument() + expect(screen.getByRole('button', { name: '+ 2 more badges' })) + .toBeInTheDocument() + }) }) diff --git a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx index c67f08155..f383413f3 100644 --- a/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx +++ b/src/apps/profiles/src/member-profile/community-awards/CommunityAwards.tsx @@ -37,10 +37,20 @@ const CommunityAwards: FC = (props: CommunityAwardsProps) setIsAwardsExpanded(false) }, [props.profile?.userId]) - function handleAwardsExpandClick(): void { - setIsAwardsExpanded(true) + /** + * Toggles the awards section between the collapsed four-badge preview and + * the expanded list. It is used by the more/less control, takes no + * parameters, returns nothing, and does not raise exceptions. + */ + function handleAwardsToggleClick(): void { + setIsAwardsExpanded(isExpanded => !isExpanded) } + /** + * Closes the selected badge details modal. It is passed to + * MemberBadgeModal, takes no parameters, returns nothing, and does not + * raise exceptions. + */ function handleMemberBadgeModalClose(): void { setIsBadgeDetailsOpen(false) } @@ -82,13 +92,15 @@ const CommunityAwards: FC = (props: CommunityAwardsProps) } - {!isAwardsExpanded && additionalBadgeCount > 0 && ( + {additionalBadgeCount > 0 && ( )} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss index c375f2ea7..c3294b1b3 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.module.scss @@ -216,4 +216,51 @@ margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,255,255,0.3); -} \ No newline at end of file +} + +// Score with inline override +.scoreWithOverride { + display: flex; + align-items: center; + gap: 8px; +} + +.originalScore { + color: $black-40; + font-size: 12px; +} + +.overrideInput { + width: 70px; + padding: 4px 6px; + border: 1px solid $link-blue-dark; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + text-align: center; + + &:focus { + outline: none; + border-color: $link-blue-dark; + box-shadow: 0 0 0 2px rgba(13, 105, 212, 0.15); + } + + &::placeholder { + color: $black-40; + font-size: 10px; + } +} + +.overriddenScore { + display: flex; + align-items: center; + gap: 4px; + color: $orange-120; + font-weight: 600; +} + +.overrideLabel { + font-size: 11px; + font-weight: 400; + color: $black-40; +} diff --git a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx index a8ed8da62..8b24fcc31 100644 --- a/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx +++ b/src/apps/review/src/lib/components/AiReviewsTable/AiReviewsTable.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ /* eslint-disable max-len */ import { FC, MouseEvent as ReactMouseEvent, useCallback, useContext, useMemo, useState } from 'react' import { Link } from 'react-router-dom' @@ -42,11 +43,18 @@ import styles from './AiReviewsTable.module.scss' interface AiReviewsTableProps { submission: Pick aiReviewers?: { aiWorkflowId: string }[] + /** Enable editing mode for manager score overrides */ + editMode?: boolean + /** Current edited scores by workflowId */ + editedScores?: Record + /** Callback when a score is changed */ + onScoreChange?: (workflowId: string, value: string) => void } interface AiReviewerRow { id: string isGating?: boolean + managerScore?: number | null minScore?: number reviewDate?: string run?: Pick @@ -249,16 +257,17 @@ const AiReviewsTable: FC = props => { const status = fromDecision ? normalizeStatus(run && aiRunInProgress(run) ? undefined - : fromDecision.runStatus, fromDecision.runScore, minScore) + : fromDecision.runStatus, fromDecision.managerScore ?? fromDecision.runScore, minScore) : undefined return { id: workflowId, isGating: fromDecision?.isGating ?? configured?.isGating, + managerScore: fromDecision?.managerScore ?? (run?.initialScore !== null && run?.initialScore !== undefined ? run.score : undefined), minScore, reviewDate: run?.completedAt, run, - score: fromDecision?.runScore ?? run?.score, + score: fromDecision?.managerScore ?? fromDecision?.runScore ?? run?.score, status, title: getConfiguredWorkflowName(configured?.workflow) ?? run?.workflow?.name ?? 'AI Review', weight: fromDecision?.weightPercent ?? configured?.weightPercent, @@ -502,13 +511,40 @@ const AiReviewsTable: FC = props => {
Score
- {typeof row.score === 'number' ? ( + {row.workflowId && props.editMode && props.onScoreChange ? ( +
+ + {typeof row.score === 'number' ? formatScore(row.score) : '-'} + + , + ) { + if (props.onScoreChange && row.workflowId) { + props.onScoreChange(row.workflowId, e.target.value) + } + }} + placeholder='Override' + /> +
+ ) : typeof row.score === 'number' ? ( row.workflowId ? ( - - {formatScore(row.score)} - + <> + + {formatScore(row.score)} + + {row.managerScore !== null && row.managerScore !== undefined && ( + + (override) + + )} + ) : formatScore(row.score) ) : '-'}
@@ -612,13 +648,40 @@ const AiReviewsTable: FC = props => { )} - {typeof row.score === 'number' ? ( + {row.workflowId && props.editMode && props.onScoreChange ? ( +
+ + {typeof row.score === 'number' ? formatScore(row.score) : '-'} + + , + ) { + if (props.onScoreChange && row.workflowId) { + props.onScoreChange(row.workflowId, e.target.value) + } + }} + placeholder='Override' + /> +
+ ) : typeof row.score === 'number' ? ( row.workflowId ? ( - - {formatScore(row.score)} - + <> + + {formatScore(row.score)} + + {row.managerScore !== null && row.managerScore !== undefined && ( + + (override) + + )} + ) : formatScore(row.score) ) : '-'} diff --git a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx index ff02f379e..2adafc562 100644 --- a/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx +++ b/src/apps/review/src/lib/components/AppealComment/AppealComment.tsx @@ -24,10 +24,7 @@ import { ScorecardQuestion, SelectOption, } from '../../models' -import { formAppealResponseSchema, isAppealsResponsePhase } from '../../utils' -import { - QUESTION_YES_NO_OPTIONS, -} from '../../../config/index.config' +import { formAppealResponseSchema, getScoreResponseOptions, isAppealsResponsePhase } from '../../utils' import { ChallengeDetailContext } from '../../contexts' import styles from './AppealComment.module.scss' @@ -103,28 +100,9 @@ export const AppealComment: FC = (props: Props) => { } }, [addAppealResponse, appealInfo, reviewItem, updatedResponse]) - const responseOptions = useMemo(() => { - if (scorecardQuestion.type === 'SCALE') { - const length - = scorecardQuestion.scaleMax - - scorecardQuestion.scaleMin - + 1 - return Array.from( - new Array(length), - (x, i) => `${i + scorecardQuestion.scaleMin}`, - ) - .map(item => ({ - label: item, - value: item, - })) - } - - if (scorecardQuestion.type === 'YES_NO') { - return QUESTION_YES_NO_OPTIONS - } - - return [] - }, [scorecardQuestion]) + const responseOptions = useMemo(() => ( + getScoreResponseOptions(scorecardQuestion) + ), [scorecardQuestion]) useEffect(() => { setAppealResponse(data.appealResponse?.content ?? '') diff --git a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx index 3d1f95c08..02da68b9c 100644 --- a/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx +++ b/src/apps/review/src/lib/components/ChallengeDetailsContent/ChallengeDetailsContent.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ /** * Challenge Details Content. */ @@ -34,6 +35,7 @@ import { shouldIncludeInReviewPhase, } from '../../utils/reviewPhaseGuards' +import TabContentAiApproval from './TabContentAiApproval' import TabContentApproval from './TabContentApproval' import TabContentCheckpoint from './TabContentCheckpoint' import TabContentIterativeReview from './TabContentIterativeReview' @@ -278,6 +280,21 @@ export const ChallengeDetailsContent: FC = (props: Props) => { : undefined), [challengeInfo?.phases, props.selectedPhaseId], ) + const isFuturePhase = useMemo(() => { + if (!props.isActiveChallenge) return false + if (!selectedPhase) return false + const isOpen = Boolean((selectedPhase as { isOpen?: boolean }).isOpen) + const hasStarted = Boolean(selectedPhase.actualStartDate) + // If phase is not open and hasn't actually started, consider it future + if (!isOpen && !hasStarted) return true + // Fallback to scheduled start in the future if available + const startMs = Date.parse(selectedPhase.actualStartDate || selectedPhase.scheduledStartDate || '') + if (Number.isFinite(startMs)) { + return startMs > Date.now() + } + + return false + }, [actionChallengeRole, selectedPhase, props.isActiveChallenge]) const isFuturePhaseForSubmitter = useMemo(() => { if (!props.isActiveChallenge) return false if (actionChallengeRole !== SUBMITTER) return false @@ -497,6 +514,19 @@ export const ChallengeDetailsContent: FC = (props: Props) => { } if (selectedTabNormalized === 'approval') { + if (aiReviewConfig?.mode === 'AI_ONLY') { + return isFuturePhase ? ( + + ) : ( + + ) + } + return ( void +} + +interface SubmissionRowData { + submission: BackendSubmission + decision: AiReviewDecision | undefined +} + +function formatScore(score: number | null | undefined): string { + if (score === null || score === undefined) { + return '-' + } + + return Number.isInteger(score) ? `${score}` : score.toFixed(2) +} + +export const TabContentAiApproval: FC = (props: Props) => { + const { + aiReviewDecisionsBySubmissionId, + challengeInfo, + }: ChallengeDetailContextModel = useContext(ChallengeDetailContext) + const { isPrivilegedRole }: useRoleProps = useRole() + + const aiReviewers = useMemo<{ aiWorkflowId: string }[]>( + () => (challengeInfo?.reviewers?.filter(r => !!r.aiWorkflowId) as { aiWorkflowId: string }[]) ?? [], + [challengeInfo?.reviewers], + ) + + const isApprovalPhaseOpen = useMemo( + () => (challengeInfo?.phases ?? []).some( + p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen), + ), + [challengeInfo?.phases], + ) + + const canEdit = isPrivilegedRole && isApprovalPhaseOpen + + const { + getRestrictionMessageForMember, + isSubmissionDownloadRestricted, + isSubmissionDownloadRestrictedForMember, + restrictionMessage, + shouldRestrictSubmitterToOwnSubmission, + }: UseSubmissionDownloadAccessResult = useSubmissionDownloadAccess() + + const { + ownedMemberIds, + }: UseRolePermissionsResult = useRolePermissions() + + const downloadButtonConfig = useMemo( + () => ({ + downloadSubmission: props.downloadSubmission, + getRestrictionMessageForMember, + isDownloading: props.isDownloading, + isSubmissionDownloadRestricted, + isSubmissionDownloadRestrictedForMember, + ownedMemberIds, + restrictionMessage, + shouldRestrictSubmitterToOwnSubmission, + }), + [ + props.downloadSubmission, + props.isDownloading, + getRestrictionMessageForMember, + isSubmissionDownloadRestricted, + isSubmissionDownloadRestrictedForMember, + ownedMemberIds, + restrictionMessage, + shouldRestrictSubmitterToOwnSubmission, + ], + ) + + const navigate = useNavigate() + + const contestSubmissions = useMemo( + () => props.submissions.filter(s => (s.type || '').toUpperCase() === 'CONTEST_SUBMISSION' && s.isLatest), + [props.submissions], + ) + + const tableData = useMemo( + () => contestSubmissions.map(submission => ({ + decision: aiReviewDecisionsBySubmissionId[submission.id], + submission, + })), + [contestSubmissions, aiReviewDecisionsBySubmissionId], + ) + + const handleViewScorecard = useCallback( + (submissionId: string, workflowId?: string) => (): void => { + const path = workflowId + ? `../reviews/${submissionId}?workflowId=${workflowId}` + : `../reviews/${submissionId}` + navigate(path) + }, + [navigate], + ) + + const columns = useMemo[]>(() => { + const cols: TableColumn[] = [ + { + columnId: 'submission-id', + label: 'Submission ID', + renderer: (row: SubmissionRowData) => ( + renderSubmissionIdCell( + row.submission as unknown as SubmissionRow, + downloadButtonConfig, + ) + ), + type: 'element', + }, + { + columnId: 'submitted-date', + label: 'Submitted', + renderer: (row: SubmissionRowData) => { + const date = row.submission.createdAt + ? moment(row.submission.createdAt) + .format(TABLE_DATE_FORMAT) + : '-' + + return {date} + }, + type: 'element', + }, + { + columnId: 'status', + label: 'Status', + renderer: (row: SubmissionRowData) => { + const status = row.decision?.status ?? 'PENDING' + const statusMap: Record = { + ERROR: { className: styles.statusError, label: 'Error' }, + FAILED: { className: styles.statusFailed, label: 'Failed' }, + HUMAN_OVERRIDE: { className: styles.statusOverride, label: 'Override' }, + PASSED: { className: styles.statusPassed, label: 'Passed' }, + PENDING: { className: styles.statusPending, label: 'Pending' }, + } + const config = statusMap[status] ?? statusMap.PENDING + + return ( + + {config.label} + + ) + }, + type: 'element', + }, + { + columnId: 'ai-score', + label: 'AI Score', + renderer: (row: SubmissionRowData) => ( + + {formatScore(row.decision?.totalScore)} + + ), + type: 'element', + }, + ] + + if (canEdit) { + cols.push({ + columnId: 'actions', + label: 'Actions', + renderer: (row: SubmissionRowData) => { + if (!row.decision) { + return - + } + + const workflowId = row.decision.breakdown?.workflows?.[0]?.workflowId + + return ( +
+ +
+ ) + }, + type: 'element', + }) + } + + cols.push({ + columnId: 'ai-reviews-expand', + isExpand: true, + label: '', + renderer: (row: SubmissionRowData) => ( +
+ {row.decision?.managerComment && ( +
+ Manager Comment: + {row.decision.managerComment} +
+ )} + + +
+ ), + type: 'element', + }) + + return cols + }, [ + aiReviewers, + canEdit, + handleViewScorecard, + ]) + + if (props.isLoading) { + return + } + + if (contestSubmissions.length === 0) { + return + } + + return ( + +

+ Review the AI scorecards below. + {canEdit && ( + <> + {' '} + Click View scorecard to inspect workflow scores. + + )} +

+ + + + + ) +} + +export default TabContentAiApproval diff --git a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx index 10c45101b..8378c4f9f 100644 --- a/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx +++ b/src/apps/review/src/lib/components/CollapsibleAiReviewsRow/CollapsibleAiReviewsRow.tsx @@ -25,10 +25,18 @@ interface CollapsibleAiReviewsRowProps { defaultOpen?: boolean aiReviewers: { aiWorkflowId: string }[] submission: Pick + /** Enable editing mode for manager score overrides */ + editMode?: boolean + /** Current edited scores by workflowId */ + editedScores?: Record + /** Callback when a score is changed */ + onScoreChange?: (workflowId: string, value: string) => void } export function normalizeDecisionStatus( status?: AiReviewDecisionStatus, + totalScore?: number | null, + minPassingThreshold?: number | null, ): 'passed' | 'failed-score' | 'pending' | 'failed' | 'human-override' { if (!status || status === 'PENDING') { return 'pending' @@ -47,6 +55,13 @@ export function normalizeDecisionStatus( } if (status === 'HUMAN_OVERRIDE') { + if ( + typeof totalScore === 'number' + && typeof minPassingThreshold === 'number' + ) { + return totalScore >= minPassingThreshold ? 'passed' : 'failed-score' + } + return 'human-override' } @@ -174,10 +189,17 @@ const CollapsibleAiReviewsRow: FC = props => { [aiReviewDecisionsBySubmissionId, props.submission.id], ) + const minPassingThreshold = currentDecision?.breakdown?.minPassingThreshold + ?? aiReviewConfig?.minPassingThreshold + // Extracted into its own memo to reduce the complexity count of the component arrow function const normalizedStatus = useMemo( - () => normalizeDecisionStatus(currentDecision?.status), - [currentDecision?.status], + () => normalizeDecisionStatus( + currentDecision?.status, + currentDecision?.totalScore, + minPassingThreshold, + ), + [currentDecision?.status, currentDecision?.totalScore, minPassingThreshold], ) /** @@ -291,7 +313,13 @@ const CollapsibleAiReviewsRow: FC = props => { {isOpen && portalContainer && createPortal(
- +
, portalContainer, )} diff --git a/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx b/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx index ce099349f..a7ce6ddbe 100644 --- a/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx +++ b/src/apps/review/src/lib/components/ManagerComment/ManagerComment.tsx @@ -17,8 +17,7 @@ import { yupResolver } from '@hookform/resolvers/yup' import { MarkdownReview } from '../MarkdownReview' import { FieldMarkdownEditor } from '../FieldMarkdownEditor' import { FormManagerComment, ReviewItemInfo, ScorecardQuestion, SelectOption } from '../../models' -import { formManagerCommentSchema } from '../../utils' -import { QUESTION_YES_NO_OPTIONS } from '../../../config/index.config' +import { formManagerCommentSchema, getScoreResponseOptions } from '../../utils' import styles from './ManagerComment.module.scss' @@ -69,28 +68,9 @@ export const ManagerComment: FC = (props: Props) => { ) }, [addManagerComment, reviewItem]) - const responseOptions = useMemo(() => { - if (scorecardQuestion.type === 'SCALE') { - const length - = scorecardQuestion.scaleMax - - scorecardQuestion.scaleMin - + 1 - return Array.from( - new Array(length), - (x, i) => `${i + scorecardQuestion.scaleMin}`, - ) - .map(item => ({ - label: item, - value: item, - })) - } - - if (scorecardQuestion.type === 'YES_NO') { - return QUESTION_YES_NO_OPTIONS - } - - return [] - }, [scorecardQuestion]) + const responseOptions = useMemo(() => ( + getScoreResponseOptions(scorecardQuestion) + ), [scorecardQuestion]) useEffect(() => { if (reviewItem.managerComment) { diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss index 67f35f286..010e8644c 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.module.scss @@ -55,6 +55,81 @@ } } +.feedbackEditRow { + display: flex; + align-items: center; + gap: $sp-3; + margin-bottom: $sp-3; + flex-wrap: wrap; +} + +.editActions { + display: flex; + align-items: center; + gap: $sp-2; +} + +.scoreEditSelect { + min-width: 140px; + padding: $sp-2 $sp-3; + border: 1px solid var(--BorderColor); + border-radius: 8px; + background: var(--Background); + color: var(--FontColor); + font-size: 14px; + line-height: 20px; + min-height: 40px; +} + +.scoreEditTextarea { + width: 100%; + min-height: 112px; + margin-top: $sp-2; + padding: $sp-3; + border: 1px solid var(--BorderColor); + border-radius: 8px; + background: var(--Background); + color: var(--FontColor); + resize: vertical; + font-size: 14px; + line-height: 22px; +} + +.scoreEditTrigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-left: $sp-2; + padding: 0; + border: none; + background: transparent; + color: var(--TextSecondary); + cursor: pointer; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.scoreEditItem { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: $sp-3 $sp-4; + color: var(--FontColor); + cursor: pointer; + + &:hover, + &:focus { + background: var(--Tertiary); + } +} + .mdReview { h1, h2, h3, h4, h5, h6 { font-size: 14px; diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx index 0aa841c61..0b03cf92e 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedback/AiFeedback.tsx @@ -1,12 +1,17 @@ -import { FC, useCallback, useMemo, useState } from 'react' +import { ChangeEvent, FC, useCallback, useMemo, useState } from 'react' import { mutate } from 'swr' import { IconAiReview } from '~/apps/review/src/lib/assets/icons' import { ReviewsContextModel, ScorecardQuestion } from '~/apps/review/src/lib/models' -import { createFeedbackComment } from '~/apps/review/src/lib/services' +import { createFeedbackComment, updateRunItemScore } from '~/apps/review/src/lib/services' +import { getAiReviewDecisionsCacheKey } from '~/apps/review/src/lib/services/aiReview.service' +import { getAiWorkflowRunsCacheKey } from '~/apps/review/src/lib/hooks/useFetchAiWorkflowRuns' import { useReviewsContext } from '~/apps/review/src/pages/reviews/ReviewsContext' +import { getScoreResponseOptions } from '~/apps/review/src/lib/utils' import { EnvironmentConfig } from '~/config' -import { Tooltip } from '~/libs/ui' +import { Button, IconOutline, Tooltip } from '~/libs/ui' +import { useRole } from '~/apps/review/src/lib/hooks' +import { handleError } from '~/libs/shared/lib/utils/handle-error' import { ScorecardViewerContextValue, useScorecardViewerContext } from '../../ScorecardViewer.context' import { ScorecardQuestionRow } from '../ScorecardQuestionRow' @@ -18,17 +23,161 @@ import { AiFeedbackReply } from '../AiFeedbackReply/AiFeedbackReply' import styles from './AiFeedback.module.scss' +const getInitialEditedScore = (questionScore: number | undefined, isYesNo: boolean): string => { + if (isYesNo) { + return questionScore ? 'Yes' : 'No' + } + + return String(questionScore ?? '') +} + +const getQuestionScoreFromEditedScore = (editedScore: string, isYesNo: boolean): number => { + if (isYesNo) { + if (editedScore === 'Yes') { + return 1 + } + + if (editedScore === 'No') { + return 0 + } + } + + return Number(editedScore) +} + interface AiFeedbackProps { question: ScorecardQuestion } +const renderAiFeedbackContent = ( + props: AiFeedbackProps, + feedback: any, + scoreMap: Map, + isEditingScore: boolean, + editedScore: string, + isUpdatingScore: boolean, + isYesNo: boolean, + hasQuestionScoreEditAccess: boolean, + scoreOptions: Array<{ value: string, label: string }>, + commentsArr: any[], + showReply: boolean, + handleScoreChange: (event: ChangeEvent) => void, + handleStartEditing: () => void, + handleCancelEditing: () => void, + handleSaveScore: (content: string) => Promise, + onShowReply: () => void, + onSubmitReply: (content: string) => Promise, + handleCloseReply: () => void, +): JSX.Element => ( + } + index='AI Feedback' + className={styles.wrap} + score={( + + )} + > +
+ + {isEditingScore ? ( + + ) : ( + isYesNo ? (feedback.questionScore ? 'Yes' : 'No') : ( + + + {feedback.questionScore} + + + ) + )} + + + {hasQuestionScoreEditAccess && !isEditingScore && ( +
+
+ )} +
+ + {isEditingScore && ( + + )} + + + + + + {commentsArr.length > 0 && ( + + )} + + {showReply && ( + + )} +
+) + const AiFeedback: FC = props => { const { aiFeedbackItems, scoreMap }: ScorecardViewerContextValue = useScorecardViewerContext() const feedback: any = useMemo(() => ( aiFeedbackItems?.find((r: any) => r.scorecardQuestionId === props.question.id) ), [props.question.id, aiFeedbackItems]) - const { workflowId, workflowRun }: ReviewsContextModel = useReviewsContext() + const { + workflowId, + workflowRun, + challengeInfo, + submissionId, + aiReviewConfig, + }: ReviewsContextModel = useReviewsContext() + const { isPrivilegedRole }: { isPrivilegedRole: boolean } = useRole() const [showReply, setShowReply] = useState(false) + const [isUpdatingScore, setIsUpdatingScore] = useState(false) + const [isEditingScore, setIsEditingScore] = useState(false) + const [editedScore, setEditedScore] = useState('') + + const isApprovalPhaseOpen = useMemo( + () => (challengeInfo?.phases ?? []).some( + p => (p.name || '').toLowerCase() === 'approval' && Boolean(p.isOpen), + ), + [challengeInfo?.phases], + ) const commentsArr: any[] = (feedback?.comments) || [] @@ -45,66 +194,108 @@ const AiFeedback: FC = props => { setShowReply(false) }, [workflowId, workflowRun?.id, workflowRun?.status, feedback?.id]) - if (!aiFeedbackItems?.length || !feedback) { - return <> - } - const isYesNo = props.question.type === 'YES_NO' + const hasQuestionScoreEditAccess = isPrivilegedRole + && !!workflowId + && !!workflowRun?.id + && !!feedback?.id + && isApprovalPhaseOpen - return ( - } - index='AI Feedback' - className={styles.wrap} - score={( - - )} - > -

- - {isYesNo && (feedback.questionScore ? 'Yes' : 'No')} - {!isYesNo && ( - - - {feedback.questionScore} - - - )} - -

+ const scoreOptions = useMemo(() => getScoreResponseOptions(props.question), [props.question]) - + const handleStartEditing = useCallback(() => { + setIsEditingScore(true) + setEditedScore(getInitialEditedScore(feedback?.questionScore, isYesNo)) + }, [feedback?.questionScore, isYesNo]) - + const handleScoreChange = useCallback((event: ChangeEvent) => { + setEditedScore(event.target.value) + }, []) - {commentsArr.length > 0 && ( - - )} + const handleCancelEditing = useCallback(() => { + setIsEditingScore(false) + }, []) - { - showReply && ( - - ) + const handleCloseReply = useCallback(() => { + setShowReply(false) + }, []) + + const handleSaveScore = useCallback(async (content: string) => { + if (!hasQuestionScoreEditAccess || isUpdatingScore) { + return + } + + if (!workflowId || !workflowRun?.id || !feedback?.id) { + return + } + + const questionScore = getQuestionScoreFromEditedScore(editedScore, isYesNo) + if (!Number.isFinite(questionScore)) { + return + } + + setIsUpdatingScore(true) + const itemsKey = `${EnvironmentConfig.API.V6}/workflows/${workflowId}/runs/${ + workflowRun.id + }/items?[${workflowRun?.status}]` + + try { + await updateRunItemScore(workflowId, workflowRun.id, feedback.id, { + comment: content.trim(), + questionScore, + }) + + await mutate(itemsKey) + if (submissionId) { + await mutate(getAiWorkflowRunsCacheKey(submissionId)) } -
+ + if (aiReviewConfig?.id) { + await mutate(getAiReviewDecisionsCacheKey(aiReviewConfig.id)) + } + + setIsEditingScore(false) + } catch (err) { + handleError(err) + } finally { + setIsUpdatingScore(false) + } + }, [ + editedScore, + feedback?.id, + hasQuestionScoreEditAccess, + isYesNo, + isUpdatingScore, + workflowId, + workflowRun?.id, + workflowRun?.status, + submissionId, + aiReviewConfig?.id, + ]) + + if (!aiFeedbackItems?.length || !feedback) { + return <> + } + + return renderAiFeedbackContent( + props, + feedback, + scoreMap, + isEditingScore, + editedScore, + isUpdatingScore, + isYesNo, + hasQuestionScoreEditAccess, + scoreOptions, + commentsArr, + showReply, + handleScoreChange, + handleStartEditing, + handleCancelEditing, + handleSaveScore, + onShowReply, + onSubmitReply, + handleCloseReply, ) } diff --git a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx index 0ee6fe0ee..b37073d64 100644 --- a/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx +++ b/src/apps/review/src/lib/components/Scorecard/ScorecardViewer/ScorecardQuestion/AiFeedbackReply/AiFeedbackReply.tsx @@ -21,6 +21,7 @@ interface AiFeedbackReplyProps { initialValue?: string onCloseReply: () => void onSubmitReply: (content: string, id?: string) => Promise + submitLabel?: string } export const AiFeedbackReply: FC = props => { @@ -94,9 +95,7 @@ export const AiFeedbackReply: FC = props => { className='filledButton' type='submit' > - { - props.id ? 'Edit Reply' : 'Submit Reply' - } + {props.submitLabel ?? (props.id ? 'Edit Reply' : 'Submit Reply')} + ) : undefined} @@ -1667,6 +1746,15 @@ export const MarathonMatchScorerSection: FC = ( /> ) : undefined} + + {showCompilationErrorsModal && selectedTester?.compilationStatus === 'FAILED' + ? ( + + ) + : undefined} ) } diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx index 23a64dbc9..352cc6a87 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.spec.tsx @@ -824,6 +824,90 @@ describe('HumanReviewTab', () => { }) }) + it('adds the next selectable default reviewer phase for one-round design challenges', async () => { + mockedFetchDefaultReviewers.mockResolvedValue([ + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'checkpoint-review', + scorecardId: 'checkpoint-review-scorecard', + shouldOpenOpportunity: true, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'screening', + scorecardId: 'screening-scorecard', + shouldOpenOpportunity: true, + }, + { + isMemberReview: true, + memberReviewerCount: 1, + phaseId: 'review', + scorecardId: 'review-scorecard', + shouldOpenOpportunity: false, + }, + ]) + mockedFetchScorecards.mockResolvedValue([ + { + id: 'screening-scorecard', + name: 'Screening scorecard', + phaseId: 'screening', + }, + { + id: 'review-scorecard', + name: 'Review scorecard', + phaseId: 'review', + }, + ]) + + render( + , + ) + + await waitFor(() => { + expect((screen.getByRole('button', { name: 'Add reviewer' }) as HTMLButtonElement).disabled) + .toBe(false) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Add reviewer' })) + + await waitFor(() => { + expect(screen.getByTestId('reviewers.1.phaseId') + .getAttribute('data-value')) + .toBe('review') + }) + expect(screen.getByTestId('reviewers.1.scorecardId') + .getAttribute('data-value')) + .toBe('review-scorecard') + }) + it('adds single-round design reviewers on the approval phase when default metadata is stale', async () => { mockedUseFetchChallengeTracks.mockReturnValue({ tracks: [ @@ -915,7 +999,10 @@ describe('HumanReviewTab', () => { expect(screen.getByTestId('role-id-value').textContent) .toBe('role-approver') expect(screen.getByTestId('scorecard-id-value').textContent) - .toBe('scorecard-approval') + .toBe('') + expect(screen.getByTestId('reviewers.0.scorecardId') + .getAttribute('data-options')) + .toContain('Approval scorecard') }) it('adds the missing approval reviewer when design default reviewers fail to load', async () => { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx index f55a8177f..c4bb96680 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/HumanReviewTab.tsx @@ -555,31 +555,6 @@ function getReviewerPhaseId( return getFallbackReviewerPhaseId(phases) } -/** - * Returns the next default reviewer row that maps to an unassigned selectable phase. - * - * @param params default reviewer metadata and current manual reviewer phase usage. - * @returns matching default reviewer metadata, or `undefined` when no unused default phase exists. - */ -function getNextDefaultReviewerForManualRow(params: { - assignedPhaseIds: Set - defaultReviewers: DefaultReviewer[] - phaseNameById: Map - phases: ChallengeEditorFormData['phases'] -}): DefaultReviewer | undefined { - return params.defaultReviewers.find(defaultReviewer => { - if (!isMemberReviewer(defaultReviewer)) { - return false - } - - const phaseId = getReviewerPhaseId(defaultReviewer, params.phases) - - return !!phaseId - && !params.assignedPhaseIds.has(phaseId) - && isSelectableReviewerPhaseName(params.phaseNameById.get(phaseId), false) - }) -} - /** * Resolves the stored review opportunity type for a manual reviewer row. * @@ -690,6 +665,62 @@ function mapDefaultReviewerToReviewer( } } +/** + * Selects the default reviewer metadata used when adding the next manual + * reviewer card. + * + * @param params.defaultReviewers default reviewer rows for the selected challenge type and track. + * @param params.phases current challenge phases used to resolve legacy defaults without a phase id. + * @param params.phaseNameById current challenge phase names keyed by phase id. + * @param params.reviewers existing manual reviewer rows already shown in the human review tab. + * @param params.allowAppealPhases whether appeal phases are valid manual reviewer phases. + * @returns the first member-review default for a selectable phase, or `undefined` to use fallback defaults. + * @remarks Used by `HumanReviewTab` so environment-specific default-reviewer rows for unrelated phases are skipped. + * @throws Does not throw. + */ +function getNextDefaultReviewer(params: { + allowAppealPhases: boolean + defaultReviewers: DefaultReviewer[] + phaseNameById: Map + phases: ChallengeEditorFormData['phases'] + reviewers: Reviewer[] +}): DefaultReviewer | undefined { + const memberDefaultReviewers = params.defaultReviewers.filter(reviewer => isMemberReviewer(reviewer)) + const assignedPhaseIds = new Set( + params.reviewers + .map(reviewer => normalizeText(reviewer.phaseId)) + .filter(Boolean), + ) + const currentPhaseIds = new Set( + (Array.isArray(params.phases) + ? params.phases + : []) + .map(phase => getChallengePhaseId(phase)) + .filter(Boolean), + ) + const getSelectableDefaultPhaseId = (defaultReviewer: DefaultReviewer): string => { + const configuredPhaseId = normalizeText(defaultReviewer.phaseId) + const defaultPhaseId = configuredPhaseId + ? (currentPhaseIds.has(configuredPhaseId) ? configuredPhaseId : '') + : normalizeText(getReviewerPhaseId(defaultReviewer, params.phases)) + const defaultPhaseName = params.phaseNameById.get(defaultPhaseId) + + return defaultPhaseId + && defaultPhaseName + && isSelectableReviewerPhaseName(defaultPhaseName, params.allowAppealPhases) + ? defaultPhaseId + : '' + } + + const unassignedDefaultReviewer = memberDefaultReviewers.find(defaultReviewer => { + const defaultPhaseId = getSelectableDefaultPhaseId(defaultReviewer) + + return defaultPhaseId && !assignedPhaseIds.has(defaultPhaseId) + }) + + return unassignedDefaultReviewer +} + function getSelectValue(selected: unknown): string { if (!selected || typeof selected !== 'object') { return '' @@ -1741,18 +1772,23 @@ export const HumanReviewTab: FC = () => { .map(reviewer => normalizeText(reviewer.phaseId)) .filter(Boolean), ) - const defaultReviewer = getNextDefaultReviewerForManualRow({ - assignedPhaseIds, + const defaultReviewer = getNextDefaultReviewer({ + allowAppealPhases, defaultReviewers, phaseNameById, phases, + reviewers: reviewerRows, }) const reviewerFromDefaults = mapDefaultReviewerToReviewer( defaultReviewer, phases, ) - const phaseId = (defaultReviewer ? reviewerFromDefaults.phaseId : undefined) - || getFallbackReviewerPhaseId(phases, assignedPhaseIds) + const defaultPhaseId = defaultReviewer + ? reviewerFromDefaults.phaseId + : undefined + const phaseId = defaultPhaseId && !assignedPhaseIds.has(defaultPhaseId) + ? defaultPhaseId + : getFallbackReviewerPhaseId(phases, assignedPhaseIds) const roleIdForResolvedPhase = resolveRoleIdForPhase(phaseId) formContext.setValue('reviewers', [ @@ -1767,6 +1803,7 @@ export const HumanReviewTab: FC = () => { shouldValidate: true, }) }, [ + allowAppealPhases, defaultReviewers, allReviewerRows, formContext, diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx index d2c848f07..c14a8ead8 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewConfigurationSummary.tsx @@ -838,7 +838,13 @@ export const ReviewConfigurationSummary: FC = ( ) - :
No human reviewers configured.
} + : ( +
+ {isAiOnlyMode + ? 'No manual reviewers are needed in AI Only mode.' + : 'No human reviewers configured.'} +
+ )} diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx index 799b108eb..d5c40fdeb 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.spec.tsx @@ -27,12 +27,25 @@ jest.mock('./AiReviewTab', () => ({ props: { hasSubmissions?: boolean onConfigRemoved?: () => Promise | void + onConfigPersisted?: (config: unknown) => void }, ) { function handleRemoveClick(): void { props.onConfigRemoved?.() } + function handlePersistClick(): void { + props.onConfigPersisted?.({ + autoFinalize: false, + challengeId: 'challenge-1', + id: 'config-1', + minPassingThreshold: 75, + mode: 'AI_GATING', + templateId: undefined, + workflows: [], + }) + } + return (
{props.hasSubmissions @@ -44,6 +57,12 @@ jest.mock('./AiReviewTab', () => ({ > Remove AI config + AI review content
) @@ -238,6 +257,24 @@ describe('ReviewersField', () => { expect(screen.getByTestId('ai-review-tab-read-only')).not.toBeNull() }) + it('requires manual reviewer configuration when AI Review mode is AI GATING', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('tab', { name: 'AI Review (0)' })) + await user.click(screen.getByRole('button', { name: 'Persist AI config' })) + + expect(screen.getByText( + 'Manual review configuration is required when AI Review mode is AI GATING.', + )) + .toBeInTheDocument() + }) + it('supports keyboard navigation between review tabs', async () => { const user = userEvent.setup() diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx index 8d66375f2..b340fc2a1 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ReviewersField/ReviewersField.tsx @@ -2,12 +2,16 @@ import { FC, KeyboardEvent, useCallback, + useEffect, useMemo, useRef, useState, } from 'react' import { + FieldErrors, + UseFormClearErrors, useFormContext, + UseFormSetError, useWatch, } from 'react-hook-form' import classNames from 'classnames' @@ -19,6 +23,7 @@ import { Reviewer, } from '../../../../../lib/models' import { + fetchAiReviewConfigByChallenge, fetchChallenge, patchChallenge, } from '../../../../../lib/services' @@ -52,7 +57,8 @@ function hasReviewerChanges( export const ReviewersField: FC = (props: ReviewersFieldProps) => { const formContext = useFormContext() const [activeTab, setActiveTab] = useState('human') - const [aiReviewMode, setAiReviewMode] = useState(undefined) + const [aiReviewMode, setAiReviewMode] = useState() + const [hasLoadedAiConfig, setHasLoadedAiConfig] = useState(false) const humanTabRef = useRef(null) const aiTabRef = useRef(null) @@ -64,6 +70,15 @@ export const ReviewersField: FC = (props: ReviewersFieldPro control: formContext.control, name: 'id', }) as string | undefined + const { + setError, + clearErrors, + formState: { errors }, + }: { + setError: UseFormSetError + clearErrors: UseFormClearErrors + formState: { errors: FieldErrors } + } = useFormContext() const phases = useWatch({ control: formContext.control, name: 'phases', @@ -85,6 +100,35 @@ export const ReviewersField: FC = (props: ReviewersFieldPro name: 'prizeSets', }) as ChallengeEditorFormData['prizeSets'] + useEffect(() => { + let mounted = true + + if (!challengeId || aiReviewMode !== undefined || hasLoadedAiConfig) { + return undefined + } + + fetchAiReviewConfigByChallenge(challengeId) + .then((config: AiReviewConfig | undefined) => { + if (!mounted) { + return + } + + if (config?.mode) { + setAiReviewMode(config.mode) + } + }) + .catch(() => undefined) + .finally(() => { + if (mounted) { + setHasLoadedAiConfig(true) + } + }) + + return () => { + mounted = false + } + }, [aiReviewMode, challengeId, hasLoadedAiConfig]) + const reviewerRows = useMemo( () => (Array.isArray(reviewers) ? reviewers @@ -101,6 +145,28 @@ export const ReviewersField: FC = (props: ReviewersFieldPro ) const humanReviewLabel = `Human Review (${humanReviewersCount})` const aiReviewLabel = `AI Review (${aiReviewersCount})` + const aiGatingManualReviewError = useMemo( + () => (aiReviewMode !== 'AI_ONLY' && humanReviewersCount === 0 + ? 'Manual review configuration is required.' + : undefined), + [aiReviewMode, humanReviewersCount], + ) + + useEffect(() => { + if (!aiGatingManualReviewError) { + if (errors.reviewers?.type === 'aiGatingManualReview') { + clearErrors('reviewers') + } + + return + } + + setError('reviewers', { + message: aiGatingManualReviewError, + type: 'aiGatingManualReview', + }) + }, [aiGatingManualReviewError, clearErrors, errors.reviewers?.type, setError]) + const hasSubmissions = useMemo( () => Number(numOfSubmissions || 0) > 0, [numOfSubmissions], @@ -282,6 +348,9 @@ export const ReviewersField: FC = (props: ReviewersFieldPro
+ {aiGatingManualReviewError && !errors.reviewers && ( +

{aiGatingManualReviewError}

+ )}
= (props: ReviewersFieldPro No manual reviewers are needed in AI Only mode.

)} - {aiReviewMode !== 'AI_ONLY' && ( - - )} +
-