feat(t1-live): T1 Live frontend — Sprint 7b
Some checks are pending
CI / quality (push) Waiting to run

- Add T1 state machine (8 states, presenting ⇄ interrupted)
- Add useT1LiveSession (WS /t1/live, uplink gate by ref, no context msg)
- Add T1PreparationPage, T1DialoguePage, T1SpeakingIndicator
- Add EO_T1_LIVE card in TaskSelector gated via oral_t2_live
- Extract shared t1Questionnaire.ts for batch/live DRY
- Remove T1LiveQuestionnairePage + T1LiveContext (post patch 7a)
- Simplified flow: card → preparation → dialogue
- FTD-44 frozen (cross-feature audio hooks, Sprint 7.5)
- FTD-45/46 frozen (Gemini relance quality + transcription)
- Tests: 301/301 green
This commit is contained in:
Hermann_Kitio 2026-06-30 22:53:57 +03:00
parent eb8987ddb3
commit 3016d909a6
14 changed files with 1385 additions and 68 deletions

View file

@ -0,0 +1,237 @@
/**
* Page /simulation/eo/t1/live/dialogue phase de dialogue live T1 (Sprint 7b).
*
* Démarre la session WS au mount, pilote l'UI selon l'état machine T1, affiche le
* timer 3:00 et l'indicateur d'état. Spécificité T1 : l'examinateur peut
* INTERROMPRE le monologue de façon NON DÉTERMINISTE (état `interrupted`) l'UI
* ne suppose JAMAIS qu'une relance suit. À la fin (REPORT_READY), écran terminal
* avec « Télécharger l'audio » + « Voir le rapport ».
*/
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Mic, Volume2, Download, FileText, Loader2 } from 'lucide-react'
import { Button } from '@/shared/ui/Button'
import { Card } from '@/shared/ui/Card'
import { T1SpeakingIndicator } from '../components/T1SpeakingIndicator'
import { useT1LiveSession } from '../hooks/useT1LiveSession'
const DIALOGUE_SECONDS = 180 // 3:00
function formatMmSs(totalSeconds: number): string {
const remaining = Math.max(0, totalSeconds)
const m = Math.floor(remaining / 60)
const s = remaining % 60
return `${m}:${s.toString().padStart(2, '0')}`
}
export function T1DialoguePage() {
const navigate = useNavigate()
const [autoStarted, setAutoStarted] = useState(false)
const session = useT1LiveSession()
// Démarrer le dialogue automatiquement au mount (la prépa est déjà finie).
useEffect(() => {
if (autoStarted) return
setAutoStarted(true)
void session.startDialogue()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoStarted])
const remaining = DIALOGUE_SECONDS - session.elapsedSeconds
const stateLabel = (() => {
switch (session.state) {
case 'idle':
case 'connecting':
return 'Connexion à lexaminateur…'
case 'presenting':
return 'À vous — présentez-vous.'
case 'interrupted':
return 'Lexaminateur vous interrompt — répondez-lui.'
case 'processing':
return 'Évaluation en cours…'
case 'ended':
return 'Session terminée.'
case 'error':
return 'Erreur.'
case 'preparing':
return 'Préparation…'
}
})()
function handleDownload() {
const blob = session.recording.exportWAV()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `expria-t1-${new Date().toISOString().slice(0, 10)}.wav`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function handleViewReport() {
if (!session.simulationId) return
navigate(`/rapport/${session.simulationId}`)
}
function handleRestart() {
navigate('/simulation/eo/t1/live/preparation')
}
// Abandon : ferme la session sans évaluation (cancelDialogue ne déclenche ni
// correction ni persistance), puis sortie du flux.
function handleCancel() {
session.cancelDialogue()
navigate('/simulation/eo')
}
// « Annuler » / « Terminer » n'ont de sens que pendant la session active
// (connexion, présentation ou interruption), pas en évaluation.
const canCancel =
session.state === 'connecting' ||
session.state === 'presenting' ||
session.state === 'interrupted'
// ── État terminal : rapport prêt ─────────────────────────────────────────
if (session.state === 'ended') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Session terminée</h2>
<Card variant="default" className="space-y-3 p-5">
<p className="text-sm text-ink-primary">
Votre présentation a é évaluée. Vous pouvez télécharger l'enregistrement audio avant
de consulter le rapport.
</p>
<p className="text-xs text-ink-secondary">
Durée enregistrée :{' '}
<span className="font-semibold tabular-nums text-ink-primary">
{session.recording.durationSeconds.toFixed(1)} s
</span>
</p>
</Card>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="secondary"
icon={<Download className="size-4" aria-hidden="true" />}
onClick={handleDownload}
disabled={session.recording.durationSeconds === 0}
>
Télécharger l'audio
</Button>
<Button
variant="primary"
icon={<FileText className="size-4" aria-hidden="true" />}
onClick={handleViewReport}
disabled={!session.simulationId}
>
Voir le rapport
</Button>
</div>
</main>
</div>
)
}
// ── État erreur ──────────────────────────────────────────────────────────
if (session.state === 'error') {
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<h2 className="text-lg font-semibold text-ink-primary">Erreur</h2>
<div
role="alert"
className="rounded-md border border-danger/40 bg-danger-soft px-4 py-3 text-sm text-danger"
>
{session.errorMessage ?? 'Une erreur est survenue.'}
</div>
<div className="flex gap-3">
<Button variant="secondary" onClick={handleRestart}>
Recommencer
</Button>
</div>
</main>
</div>
)
}
// ── État dialogue actif ──────────────────────────────────────────────────
return (
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
<main className="mx-auto max-w-2xl space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-ink-primary">Présentation en cours</h2>
<span
className="rounded-full border border-border bg-surface px-3 py-1 text-sm font-semibold tabular-nums text-ink-primary"
aria-label="Temps restant de présentation"
>
{formatMmSs(remaining)}
</span>
</div>
{session.warning && (
<div
role="status"
className="rounded-md border border-warning/40 bg-warning-soft px-3 py-2 text-sm text-ink-primary"
>
30 secondes restantes.
</div>
)}
<Card variant="default" className="space-y-4 p-5">
<div className="flex items-center gap-3">
{session.state === 'processing' ? (
<Loader2 className="size-5 animate-spin text-brand-text" aria-hidden="true" />
) : session.state === 'interrupted' ? (
<Volume2 className="size-5 text-brand-text" aria-hidden="true" />
) : (
<Mic
className={
session.state === 'presenting'
? 'size-5 text-success'
: 'size-5 text-ink-secondary'
}
aria-hidden="true"
/>
)}
<p className="text-sm font-semibold text-ink-primary">{stateLabel}</p>
</div>
{/* Indicateur : 'presenting' = amplitude micro réelle (analyser lu par
ref en rAF) ; 'interrupted' = animation décorative (uplink coupé). */}
{canCancel && (
<T1SpeakingIndicator analyserRef={session.analyserRef} state={session.state} />
)}
<p className="text-xs text-ink-secondary">
Présentez-vous naturellement. L'examinateur peut vous interrompre à tout moment.
</p>
</Card>
<div className="flex items-center justify-center gap-3">
{canCancel && (
<Button
variant="ghost"
className="text-danger hover:bg-danger-soft hover:text-danger"
onClick={handleCancel}
title="Abandonner sans générer de rapport"
>
Annuler
</Button>
)}
<Button
variant="secondary"
onClick={() => session.endDialogue()}
disabled={session.state === 'processing'}
>
Terminer
</Button>
</div>
<p className="text-center text-xs text-ink-tertiary">
« Annuler » abandonne la session sans rapport. « Terminer » lance l'évaluation.
</p>
</main>
</div>
)
}