1. Pourquoi l’UX des flux est importante précisément dans l’application ChatGPT
Sur le Web classique, les utilisateurs sont déjà habitués à la barre de progression de téléversement de fichiers, au spinner qui tourne et à l’écran « skeleton ». Mais dans les applications ChatGPT, vous avez un « concurrent » supplémentaire : le modèle lui‑même, capable de diffuser du texte en temps réel. Si, au même moment, le widget affiche un spinner statique sans explication, il perd en perception — GPT est « vivant », tandis que l’app « semble figée ».
L’UX pour les opérations longues résout plusieurs problèmes à la fois. D’une part, elle réduit l’anxiété de l’utilisateur : au lieu de « c’est bloqué ou ça pense encore ? », il voit des états, des étapes, des pourcentages et même des premiers résultats. D’autre part, elle renforce la confiance : lorsque l’app montre explicitement ce qu’elle fait (analyser les avis, comparer les prix, filtrer des idées cadeaux), cela crée la fameuse transparence opérationnelle — l’utilisateur comprend qu’il ne s’agit pas de magie, mais d’une séquence d’étapes compréhensibles.
Enfin, l’UX des flux n’est pas seulement la progression. C’est aussi le contrôle. La possibilité d’arrêter une sélection lourde de cadeaux, de changer des paramètres et de relancer immédiatement — c’est une part essentielle du sentiment « je maîtrise, je ne subis pas le serveur ».
Dans ce cours, nous allons :
- concevoir un modèle simple d’états pour une tâche longue (pending / in_progress / partial_ready / …) ;
- le traduire en état React du widget ;
- comprendre comment afficher honnêtement la progression et les résultats partiels ;
- implémenter proprement l’annulation de ces tâches.
Le tout sur l’exemple de notre GiftGenius.
2. Modèle d’états d’une opération longue dans GiftGenius
Pour ne pas transformer le flux d’événements en bouillie de if (event.type === …), il est pratique de penser à une tâche longue comme à un automate fini (state machine) côté client. Pour GiftGenius, nous utiliserons les états logiques suivants, que vous avez déjà rencontrés en théorie : pending, in_progress, partial_ready, completed, failed, canceled plus l’état d’attente idle.
Récapitulons dans un tableau :
| Statut | Ce que cela signifie côté back‑end | Ce que l’utilisateur voit dans le widget |
|---|---|---|
|
Aucun job encore | Formulaire normal, bouton « Choisir un cadeau » |
|
Job créé, en attente du démarrage du worker | Bouton désactivé, petit spinner |
|
Le worker tourne, envoie job.progress | Barre de progression ou étapes « Étape 1 sur 3 » |
|
Premiers résultats disponibles, le travail continue | Premiers cadeaux visibles + progression toujours affichée |
|
job.completed reçu | Liste finale des cadeaux, CTA (« Acheter ») |
|
job.failed reçu | Message d’erreur + bouton « Réessayer » |
|
job.canceled reçu ou drapeau d’annulation | Texte « Sélection arrêtée » + « Recommencer » |
Ce même modèle s’adapte parfaitement aux événements MCP. Par exemple, job.started fait passer de pending à in_progress, job.progress peut soit simplement mettre à jour les pourcentages en in_progress, soit indiquer « nous avons nos premières cartes » et vous passez alors à partial_ready. job.completed, job.failed et job.canceled clôturent l’histoire.
Visuellement, cela ressemble à un petit automate d’états :
stateDiagram-v2
[*] --> idle
idle --> pending: créer un job
pending --> in_progress: job.started
in_progress --> partial_ready: premiers résultats partiels
partial_ready --> completed: job.completed
in_progress --> completed: job.completed (sans partial)
in_progress --> failed: job.failed
partial_ready --> failed: job.failed
in_progress --> canceled: job.canceled
partial_ready --> canceled: job.canceled
failed --> idle: relancer
canceled --> idle: relancer
Dans le code du widget, on peut refléter cela par un simple type :
type JobStatus =
| 'idle'
| 'pending'
| 'in_progress'
| 'partial_ready'
| 'completed'
| 'failed'
| 'canceled';
interface GiftJobState {
status: JobStatus;
percent?: number;
stage?: string;
error?: string;
}
Pour l’instant, ce n’est qu’un conteneur de données. Ensuite, nous le remplirons au fur et à mesure de la réception des événements MCP ou du flux.
3. État du widget : comment le composant React « écoute » le flux
Transposons notre modèle d’états dans le code React du widget GiftGenius. Nous devons conserver :
- le jobId courant, pour savoir quels événements se rapportent à cette tâche ;
- l’état de la tâche (status, percent, stage) ;
- un tableau de résultats partiels (cartes cadeaux) ;
- des indicateurs pour les boutons : peut‑on annuler, peut‑on relancer.
Décrivons cela avec une seule interface :
interface GiftSuggestion {
id: string;
title: string;
price: string;
}
interface GiftWidgetState extends GiftJobState {
jobId?: string;
partialGifts: GiftSuggestion[];
}
L’initialisation dans le composant peut être très simple :
const [state, setState] = useState<GiftWidgetState>({
status: 'idle',
partialGifts: [],
});
Ensuite, il y a deux points clés.
Premièrement, le démarrage du job. Cela peut être un appel d’outil MCP via l’Apps SDK (callTool) ou une requête HTTP vers votre back‑end, qui crée un job et renvoie le jobId. Dans ce cours, nous ne détaillons pas la manière dont le pipeline asynchrone est construit — nous le ferons dans le prochain module sur les files d’attente et les workers. Pour l’instant, seul nous importe le comportement de l’UI une fois le jobId créé.
Deuxièmement, l’abonnement aux événements de ce jobId. En pratique, cela peut être un hook comme useJobEvents(jobId) ou un wrapper subscribeToJobEvents, qui utilisent sous le capot soit une connexion SSE, soit un client MCP, mais exposent à l’extérieur de vrais objets JS. Ci‑dessous, pour la simplicité, nous montrons la variante avec subscribeToJobEvents à l’intérieur de useEffect :
useEffect(() => {
if (!state.jobId) return;
const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
return () => unsubscribe();
}, [state.jobId]);
Ici, handleEvent se contente de mettre à jour le state selon le type d’événement. Nous verrons ensuite trois groupes d’événements qu’il traite : la progression, les résultats partiels et l’annulation de la tâche.
4. Visualisation de la progression : pourcentages, étapes et honnêteté
La progression en UX est de deux types : déterminée (determinate) et indéterminée (indeterminate). Dans le premier cas, vous savez vraiment quelle part du travail est faite : par exemple, vous avez 4 étapes dans le workflow, ou 30 fichiers traités sur 100. Dans le second, vous admettez honnêtement ne pas savoir combien de temps il reste et vous montrez une animation « on réfléchit » plutôt qu’un faux « 73% ».
Dans GiftGenius, la logique peut être la suivante. Si le back‑end calcule réellement la progression — par exemple, il a des étapes collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — vous pouvez renvoyer dans l’événement job.progress un payload avec les champs stepCurrent, stepTotal, statusText et (facultatif) un percent raisonnable.
Type d’événement en TS :
interface JobProgressPayload {
stepCurrent: number;
stepTotal: number;
percent?: number;
statusText: string;
}
interface JobEvent {
type:
| 'job.started'
| 'job.progress'
| 'job.partial_result'
| 'job.completed'
| 'job.failed'
| 'job.canceled';
jobId: string;
payload?: any;
}
Gestionnaire de progression dans le composant :
function handleJobProgress(payload: JobProgressPayload) {
setState(prev => ({
...prev,
status: prev.status === 'idle' ? 'in_progress' : prev.status,
percent: payload.percent,
stage: `${payload.stepCurrent} / ${payload.stepTotal}: ${payload.statusText}`,
}));
}
En JSX, vous pouvez afficher à la fois une barre de progression et le texte de l’étape :
{(state.status === 'pending' || state.status === 'in_progress' || state.status === 'partial_ready') && (
<div>
{typeof state.percent === 'number'
? <progress value={state.percent} max={100} />
: <div className="spinner" />}
{state.stage && <p>{state.stage}</p>}
</div>
)}
Un point psychologique important : si vous n’avez pas de pourcentage honnête, mieux vaut afficher simplement « Étape 2 sur 3 : nous analysons les préférences » plus une barre de progression indéterminée (animation), plutôt qu’un « 99% » figé pendant 30 secondes. Ce hybride (étapes + indicateur de progression indéterminée) fonctionne très bien pour les opérations IA où il est difficile de calculer un reste précis.
5. Résultats partiels : inutile d’attendre que tout soit parfait
La partie la plus agréable de l’UX en flux — les résultats partiels. Pourquoi faire attendre l’utilisateur si, après 5–7 secondes, vous avez déjà des cadeaux pertinents à montrer ? Affichez‑les tout de suite et chargez le reste plus tard.
Dans GiftGenius, cela peut ressembler à ceci. Le back‑end envoie au fil de l’eau soit des événements dédiés job.partial_result, soit, par exemple, resource.updated avec une nouvelle salve de recommandations. Chaque événement apporte un tableau de cadeaux qui s’ajoute à ceux déjà présents.
Forme indicative du payload :
interface PartialResultPayload {
gifts: GiftSuggestion[];
isFinalChunk?: boolean;
}
Gestionnaire :
function handlePartialResult(payload: PartialResultPayload) {
setState(prev => ({
...prev,
status: 'partial_ready',
partialGifts: [...prev.partialGifts, ...payload.gifts],
}));
}
En JSX, vous affichez simplement les cartes, que la tâche soit terminée ou non :
<section>
{state.partialGifts.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
{(state.status === 'in_progress' || state.status === 'partial_ready') && (
<p>Nous continuons à chercher d’autres options…</p>
)}
</section>
Voici plusieurs nuances UX importantes à garder à l’esprit.
Premièrement, évitez les sauts brusques de mise en page (layout shift). Si vous ajoutez de nouveaux cadeaux en haut de la liste, l’utilisateur perdra son point de lecture. Il est plus sûr de les ajouter à la fin (append‑only) et d’animer doucement leur apparition.
Deuxièmement, si vous utilisez une stratégie de refinement (d’abord une liste rapide et grossière, puis « polissage » et reranking), gérez l’interactivité avec soin. Tant que les résultats sont « brouillon », n’autorisez pas « Acheter » ou marquez clairement la liste comme « préliminaire ». Sinon, l’utilisateur choisit un cadeau, puis une seconde plus tard il disparaît ou change de prix — une catastrophe UX.
Troisièmement, l’état partial_ready doit être visuellement distinct de completed. L’utilisateur doit comprendre que la liste est encore en cours de remplissage : soit par un texte « Sélection en cours », soit par un petit spinner dans un coin, soit par une mise en évidence neutre des nouvelles cartes.
6. Annulation des opérations longues : UX et technique
Si vous donnez à l’utilisateur le droit de lancer une sélection lourde de cadeaux, vous devez presque toujours lui donner aussi le droit de l’arrêter. L’annulation n’est pas seulement une économie de ressources LLM et de workers, mais aussi un sentiment de contrôle : « c’est moi qui décide de ce qui se passe ».
Du point de vue UX, le bouton d’annulation doit être suffisamment visible, sans être une grosse bannière rouge au milieu de l’écran. Une bonne paire fonctionne bien : un bouton principal « Annuler la sélection » et un petit texte secondaire « vous pouvez relancer à tout moment ». Il est important que l’utilisateur comprenne ce qui est annulé — l’analyse en cours, et non toute l’application.
Techniquement, vous avez deux niveaux d’annulation.
Premièrement, l’annulation côté front‑end : vous pouvez interrompre le fetch local ou fermer la connexion SSE. Cela économise de la bande passante, mais n’arrête pas en soi le worker côté back‑end.
Deuxièmement, la vraie annulation du job : via un outil MCP ou un endpoint HTTP POST /jobs/{jobId}/cancel, qui marque la tâche comme canceled et donne au worker la possibilité de se terminer proprement. Le serveur envoie alors l’événement job.canceled, que vous traitez déjà dans le widget.
Du point de vue du widget :
async function handleCancelClick() {
if (!state.jobId) return;
// Mise à jour optimiste de l'UI
setState(prev => ({ ...prev, status: 'canceled' }));
try {
await cancelJobOnServer(state.jobId); // MCP tool ou HTTP
} catch (e) {
// Si l'annulation côté serveur a échoué — on rétablit le statut
setState(prev => ({ ...prev, status: 'in_progress' }));
}
}
Et le bouton :
<button
onClick={handleCancelClick}
disabled={
state.status !== 'pending' &&
state.status !== 'in_progress' &&
state.status !== 'partial_ready'
}
>
Annuler la sélection
</button>
Ici, nous utilisons une UI optimiste : on passe tout de suite à canceled sans attendre la confirmation du serveur. C’est utile quand l’annulation peut prendre quelques secondes — l’utilisateur voit immédiatement que son action a été prise en compte. Mais il faut être prêt à ce que le serveur renvoie malgré tout job.completed ou job.failed si le worker a eu le temps de finir. Dans le gestionnaire d’événements, filtrez ces « finales tardives » et, par exemple, évitez d’écraser un état déjà canceled.
Une approche plus conservatrice — l’UI pessimiste : on affiche d’abord l’état « Annulation en cours… », on bloque le bouton, et seulement après job.canceled on passe la tâche à canceled. Elle est plus simple à implémenter, mais visuellement moins réactive. Choisissez l’approche selon le SLA de votre back‑end.
7. Rassembler le tout : mini‑panneau de progression GiftGenius
Assemblons maintenant les pièces. Nous avons déjà écrit :
- le gestionnaire de progression handleJobProgress,
- le gestionnaire de résultats partiels handlePartialResult,
- et le gestionnaire d’annulation handleCancelClick.
C’est, en substance, le handleEvent commun de la section précédente : il réagit à job.progress, job.partial_result, job.canceled et autres événements, et met à jour l’état d’un seul composant. Il ne reste qu’à emballer tout cela dans un petit composant GiftJobPanel, qui :
- lance la sélection de cadeaux ;
- écoute les événements du jobId ;
- affiche la progression ;
- rend les résultats partiels ;
- permet d’annuler la tâche.
Nous simplifierons fortement les détails d’intégration avec l’Apps SDK / MCP et nous nous concentrerons sur la logique d’état.
export function GiftJobPanel() {
const [state, setState] = useState<GiftWidgetState>({
status: 'idle',
partialGifts: [],
});
useEffect(() => {
if (!state.jobId) return;
const unsub = subscribeToJobEvents(state.jobId, event => {
switch (event.type) {
case 'job.started':
setState(prev => ({ ...prev, status: 'in_progress' }));
break;
case 'job.progress':
handleJobProgress(event.payload);
break;
case 'job.partial_result':
handlePartialResult(event.payload);
break;
case 'job.completed':
setState(prev => ({ ...prev, status: 'completed' }));
break;
case 'job.failed':
setState(prev => ({
...prev,
status: 'failed',
error: event.payload?.message ?? 'Quelque chose s\'est mal passé',
}));
break;
case 'job.canceled':
setState(prev => ({ ...prev, status: 'canceled' }));
break;
}
});
return () => unsub();
}, [state.jobId]);
Le démarrage de la tâche peut être implémenté via l’outil MCP start_gift_search :
async function handleStartClick() {
setState({
status: 'pending',
partialGifts: [],
});
const jobId = await startGiftSearchOnServer(/* paramètres utilisateur */);
setState(prev => ({ ...prev, jobId }));
}
Ensuite, en JSX :
return (
<div>
{state.status === 'idle' && (
<button onClick={handleStartClick}>Choisir un cadeau</button>
)}
{['pending', 'in_progress', 'partial_ready'].includes(state.status) && (
<ProgressSection state={state} onCancel={handleCancelClick} />
)}
<GiftsList gifts={state.partialGifts} status={state.status} />
{state.status === 'failed' && (
<ErrorSection error={state.error} onRetry={handleStartClick} />
)}
{state.status === 'canceled' && (
<p>Sélection arrêtée. Vous pouvez relancer avec d'autres paramètres.</p>
)}
</div>
);
Des sous‑composants tels que ProgressSection, GiftsList, ErrorSection évitent de transformer le composant principal en « spaghetti ». Mais l’idée clé est unique : tout le widget est piloté par un seul modèle d’état compréhensible, qui correspond directement aux événements MCP et aux canaux de streaming que vous connaissez déjà.
8. Un mot sur l’articulation avec le dialogue ChatGPT
Bien que ce cours se concentre sur le widget lui‑même, il faut se souvenir que l’utilisateur reste dans un dialogue avec le modèle. Un bon scénario ressemble à ceci : GPT informe l’utilisateur qu’il lance GiftGenius, puis le widget affiche la progression, et GPT soutient cela par un texte : « Je viens de lancer une recherche de cadeaux avancée ; vous verrez la liste se remplir progressivement ».
Après la fin de la sélection, ChatGPT peut récupérer le résultat depuis le ToolOutput et formuler un résumé humain : « J’ai trouvé 10 options, voici un bref aperçu ; la liste complète est dans le widget ci‑dessous ». Ce duo de streaming textuel et d’UI en flux crée une expérience cohérente.
Ce lien sera encore plus important dans les modules sur le workflow et le commerce, où chaque étape longue (analyse du panier, vérification des stocks, attente du paiement) doit être claire à la fois dans le texte et dans l’interface.
9. Erreurs typiques dans l’UX des flux
Erreur n°1 : « Spinner éternel sans texte ».
L’antipattern le plus fréquent — faire tourner une animation sans expliquer ce qui se passe. L’utilisateur ne comprend pas si le système fait quelque chose d’utile ou s’il est bloqué. On corrige avec un simple texte d’étape (« Nous collectons les cadeaux populaires… », « Nous analysons les avis »), et mieux encore — des états explicites pending, in_progress, partial_ready, que vous conservez déjà dans l’état du widget.
Erreur n°2 : Pourcentages de progression bidons.
Essayer de « renforcer la confiance » en affichant une progression inventée (« 73% » sortis du chapeau) produit généralement l’effet inverse. L’utilisateur remarque vite que 99% peut rester affiché 20 secondes, et cesse de croire à l’indicateur. Si vous n’avez pas de métrique honnête, privilégiez les étapes et une barre de progression indéterminée plutôt que de tromper.
Erreur n°3 : Résultats partiels qui cassent tout.
Parfois, les résultats partiels sont implémentés comme une liste entièrement reconstruite, qui disparaît ou se re‑mélange à chaque événement. Au final, l’utilisateur clique sur une carte, qui soudain s’enfuit vers le bas. Ce « tremblement » est particulièrement critique dans les scénarios commerce. Il vaut mieux ajouter les cartes prudemment (souvent — seulement en fin de liste), conserver les clés et minimiser les décalages de mise en page.
Erreur n°4 : Annulation qui n’annule rien.
Cela arrive : le widget a un bouton « Annuler », qui ne fait que masquer l’UI mais n’arrête pas le job réel côté serveur. Les ressources continuent d’être consommées, des job.completed tardifs arrivent, alors que l’utilisateur pense que tout est arrêté. Une véritable annulation doit concerner à la fois le front‑end (désactiver les boutons, stopper le flux) et le back‑end (envoyer le signal d’annulation au worker et recevoir l’événement job.canceled).
Erreur n°5 : Ignorer la fin et écran d’erreur « bête ».
Parfois, après job.completed, le widget montre simplement la liste des cadeaux sans prochaines étapes, et pour job.failed — seulement un message technique « Erreur 500 ». Dans les deux cas, l’UX s’interrompt. Il vaut mieux conclure par un court résumé et un CTA explicite (« Enregistrer la sélection », « Passer à l’achat »), et, en cas d’erreur, une explication humaine et des boutons « Réessayer » ou « Modifier les paramètres » plutôt que d’abandonner l’utilisateur avec un code de statut.
GO TO FULL VERSION