-
-
-
- {t('instructions')}
-
-
-
-
- {exercise.instructions}
-
+ {/* Kept beside the recorder on a retry: this is the one place the student
+ needs the verdict and the control at the same time. */}
+ {showFeedbackSummary &&
}
+
+ {showRecorder && !dailyLimitReached && (
+
+
+
+ {t('recordYourResponse')}
+
+
+ {errorMsg && (
+
+ {errorMsg}
-
+ )}
- {config.topic_prompt && (
-
-
- {t('topicPrompt')}
-
-
{config.topic_prompt}
+ {submitState === 'analyzing' && (
+
+
+
+
{t('analyzing')}
+
{t('analyzingDescription')}
+
)}
- {isExerciseCompletedSection && (
-
{isExerciseCompletedSection}
+ {submitState !== 'analyzing' && (
+
)}
-
- {/* Right: Recorder / Results */}
-
-
- {/* 0. Completion summary */}
- {(isExerciseCompleted || passed === true) && (
-
- )}
+
+ {t('durationRange', { min: `${minDuration}s`, max: formatDuration(maxDuration) })}
+ {t('uploadLimit')}
+
+
+ )}
+
+ )
- {/* 1. Attempt counter */}
- {!isUnlimited && passed !== true && (
-
- )}
+ const hasResult =
+ isExerciseCompleted || passed === true || (evaluation && !showRecorder) || submissionHistory.length > 0
+
+ const resultPanel = hasResult ? (
+
+ {(isExerciseCompleted || passed === true) && (
+
+ )}
- {/* 2. Daily limit banner */}
- {dailyLimitReached && passed !== true && (
-
-
-
-
-
-
-
{t('dailyLimitReached')}
-
- {t('dailyLimitMessage', { limit: maxDaily })}
-
-
-
-
- )}
+ {evaluation && !showRecorder && (
+
+
+
+ {t('aiFeedback')}
+
+
+
+ )}
- {/* 3. Retry panel */}
- {showRetryPanel && (
-
0 && (
+
+
+
+ {t('submissionHistory')}
+
+
+ {submissionHistory.map((sub, idx) => (
+
- )}
-
- {/* 4. Collapsible feedback summary (on retry) */}
- {showFeedbackSummary && (
-
- )}
-
- {/* 5. Recording area */}
- {showRecorder && !dailyLimitReached && (
-
-
-
- {t('recordYourResponse')}
-
-
- {errorMsg && (
-
- {errorMsg}
-
- )}
-
- {submitState === 'analyzing' && (
-
-
-
-
{t('analyzing')}
-
{t('analyzingDescription')}
-
-
- )}
-
- {submitState !== 'analyzing' && (
-
- )}
-
-
- {t('durationRange', { min: `${minDuration}s`, max: formatDuration(maxDuration) })}
- {t('uploadLimit')}
-
-
- )}
-
- {/* 6. Full AI Feedback */}
- {evaluation && !showRecorder && (
-
-
-
- {t('aiFeedback')}
-
-
-
- )}
-
- {/* 7. Submission history */}
- {submissionHistory.length > 0 && (
-
-
-
- {t('submissionHistory')}
-
-
- {submissionHistory.map((sub, idx) => (
-
- ))}
-
-
- )}
+ ))}
-
+ )}
+
+ ) : undefined
+
+ return (
+
+
+
+ 0,
+ })}
+ revealResultNonce={gradedNonce}
+ />
)
}
@@ -616,8 +580,8 @@ function VideoSubmissionHistoryRow({ submission, attemptNumber, passingScore }:
className={cn(
'text-[10px] font-bold px-2 py-0',
didPass
- ? 'border-emerald-500/30 text-emerald-600 bg-emerald-500/10'
- : 'border-amber-500/30 text-amber-600 bg-amber-500/10'
+ ? 'border-emerald-500/30 text-emerald-700 dark:text-emerald-400 bg-emerald-500/10'
+ : 'border-amber-500/30 text-amber-700 dark:text-amber-400 bg-amber-500/10'
)}
>
{Math.round(score)}/100
diff --git a/messages/en.json b/messages/en.json
index f3f52851..e6357f3f 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -5350,6 +5350,14 @@
"title": "No exercises found",
"description": "There are no active exercises for this course at the moment."
}
+ },
+ "workspace": {
+ "brief": "Instructions",
+ "result": "Result",
+ "sections": "Exercise sections",
+ "task": "Exercise",
+ "coach": "AI Coach",
+ "record": "Record"
}
},
"exams": {
diff --git a/messages/es.json b/messages/es.json
index b6d9f1b5..25660a73 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -5343,6 +5343,14 @@
"title": "No se encontraron ejercicios",
"description": "No hay ejercicios activos para este curso en este momento."
}
+ },
+ "workspace": {
+ "brief": "Instrucciones",
+ "result": "Resultado",
+ "sections": "Secciones del ejercicio",
+ "task": "Ejercicio",
+ "coach": "Tutor IA",
+ "record": "Grabar"
}
},
"exams": {
diff --git a/tests/unit/exercise-workspace-panel.test.ts b/tests/unit/exercise-workspace-panel.test.ts
new file mode 100644
index 00000000..42caee71
--- /dev/null
+++ b/tests/unit/exercise-workspace-panel.test.ts
@@ -0,0 +1,49 @@
+import { describe, it, expect } from 'vitest'
+
+import { initialWorkspacePanel } from '../../components/exercises/exercise-workspace'
+
+/**
+ * Pins which panel a phone opens on when a student lands on an exercise.
+ *
+ * The workspace shows one panel at a time below `lg`, so this choice is the
+ * whole first impression: pick wrong and the student either lands on a task
+ * whose instructions they have not read, or has to hunt for feedback that is
+ * the only reason they came back.
+ *
+ * Contract, in priority order:
+ * 1. A graded attempt they did NOT pass wins. That is the same rule
+ * `shouldAutoExpandCheckpoint` uses inside a lesson, deliberately.
+ * 2. Otherwise, anyone who has attempted before goes straight to the work.
+ * 3. A first-time student reads the brief.
+ */
+
+describe('initialWorkspacePanel', () => {
+ it('opens the result for a graded attempt the student did not pass', () => {
+ expect(initialWorkspacePanel({ hasResult: true, passed: false, attempted: true })).toBe('result')
+ })
+
+ it('opens the task for a passed attempt — the result is a receipt, not a task', () => {
+ expect(initialWorkspacePanel({ hasResult: true, passed: true, attempted: true })).toBe('task')
+ })
+
+ it('opens the brief for a first-time student', () => {
+ expect(initialWorkspacePanel({ hasResult: false, attempted: false })).toBe('brief')
+ })
+
+ it('opens the task for a returning student with no grade yet', () => {
+ // Attempted but ungraded: they have read the brief, so send them to the work.
+ expect(initialWorkspacePanel({ hasResult: false, attempted: true })).toBe('task')
+ })
+
+ it('ignores a failure flag with no result to show', () => {
+ // `passed: false` without a result panel would otherwise select an empty
+ // tab that is not even rendered.
+ expect(initialWorkspacePanel({ hasResult: false, passed: false, attempted: false })).toBe('brief')
+ })
+
+ it('treats an ungraded result as nothing to act on', () => {
+ // `passed` is undefined for engines that record a completion without a
+ // verdict; that is not a failure and must not hijack the first screen.
+ expect(initialWorkspacePanel({ hasResult: true, passed: undefined, attempted: true })).toBe('task')
+ })
+})