CodeGym /Corsi /ChatGPT Apps /UX dei flussi: progresso, risultati parziali, annullament...

UX dei flussi: progresso, risultati parziali, annullamento delle operazioni lunghe

ChatGPT Apps
Livello 13 , Lezione 2
Disponibile

1. Perché l’UX dei flussi è importante proprio in ChatGPT App

Nel web tradizionale gli utenti sono già abituati alla barra di avanzamento del caricamento dei file, allo spinner che gira e allo schermo skeleton. Ma nelle app ChatGPT avete un «concorrente» in più: il modello stesso, che sa fare streaming del testo in tempo reale. Se in quel momento il widget mostra uno spinner statico senza spiegazioni, perde in termini di percezione — GPT è «vivo», mentre l’App sembra «bloccata».

L’UX per le operazioni lunghe risolve subito diversi problemi. In primo luogo, riduce l’ansia dell’utente: invece di chiedersi «si è bloccato o sta ancora elaborando?», vede stati, fasi, percentuali e perfino primi risultati. In secondo luogo, aumenta la fiducia: quando l’App mostra chiaramente cosa sta facendo (analizza le recensioni, confronta i prezzi, filtra i regali), crea la cosiddetta operational transparency — trasparenza operativa. L’utente capisce: sotto il cofano non c’è magia, ma una sequenza di passi comprensibile.

Infine, l’UX dei flussi non riguarda solo il progresso. Riguarda anche il controllo. La possibilità di fermare una ricerca pesante di regali, cambiare i parametri e rilanciare subito — è una parte importante della sensazione «sto controllando io, non sto aspettando la benevolenza del server».

In questa lezione:

  • progetteremo un semplice modello di stati per un task lungo (pending / in_progress / partial_ready / …);
  • lo porteremo nello stato del widget in React;
  • capiremo come mostrare onestamente il progresso e i risultati parziali;
  • implementeremo con cura l’annullamento di tali task.

Tutto questo — sull’esempio del nostro GiftGenius.

2. Modello di stati di un’operazione lunga in GiftGenius

Per non trasformare il flusso di eventi in una pappa di if (event.type === …), è comodo pensare a un task lungo come a una macchina a stati (state machine) sul client. Per GiftGenius useremo i seguenti stati logici, che avete già incontrato in teoria: pending, in_progress, partial_ready, completed, failed, canceled più lo stato di attesa idle.

Ricapitoliamoli in una tabella:

Stato Cosa significa nel backend Cosa vede l’utente nel widget
idle
Non c’è ancora alcun job Modulo normale, pulsante «Scegli un regalo»
pending
Job creato, in attesa dell’avvio del worker Pulsante disabilitato, spinner leggero
in_progress
Il worker è in esecuzione, invia job.progress Barra di avanzamento o passi «Passo 1 di 3»
partial_ready
Ci sono i primi risultati, il lavoro continua Si vedono già i primi regali + il progresso prosegue
completed
È arrivato job.completed Elenco finale dei regali, CTA («Acquista»)
failed
È arrivato job.failed Messaggio di errore + pulsante «Riprova»
canceled
È arrivato job.canceled o il flag di annullamento Testo «Selezione interrotta» + «Ricomincia»

Lo stesso modello si adatta perfettamente agli eventi MCP. Per esempio, job.started porta da pending a in_progress, job.progress può o semplicemente aggiornare le percentuali in in_progress, oppure dire «abbiamo le prime card» e allora passate a partial_ready. job.completed, job.failed e job.canceled chiudono la storia.

Sembra una piccola macchina a stati:

stateDiagram-v2
    [*] --> idle
    idle --> pending: creare job
    pending --> in_progress: job.started
    in_progress --> partial_ready: primi risultati parziali
    partial_ready --> completed: job.completed
    in_progress --> completed: job.completed (senza partial)
    in_progress --> failed: job.failed
    partial_ready --> failed: job.failed
    in_progress --> canceled: job.canceled
    partial_ready --> canceled: job.canceled
    failed --> idle: nuovo avvio
    canceled --> idle: nuovo avvio

Nel codice del widget questo si può riflettere con un semplice tipo:

type JobStatus =
  | 'idle'
  | 'pending'
  | 'in_progress'
  | 'partial_ready'
  | 'completed'
  | 'failed'
  | 'canceled';

interface GiftJobState {
  status: JobStatus;
  percent?: number;
  stage?: string;
  error?: string;
}

Per ora è solo la forma dei dati. In seguito la riempiremo man mano che arrivano eventi da MCP o dallo stream.

3. Stato del widget: come il componente React «ascolta» il flusso

Portiamo il nostro modello di stati nel codice React del widget GiftGenius. Dobbiamo conservare:

  • il jobId corrente, per sapere quali eventi si riferiscono a quel task;
  • lo stato del task (status, percent, stage);
  • l’array dei risultati parziali (card dei regali);
  • i flag per i pulsanti: se si può annullare, se si può riavviare.

Descriviamolo con una sola interfaccia:

interface GiftSuggestion {
  id: string;
  title: string;
  price: string;
}

interface GiftWidgetState extends GiftJobState {
  jobId?: string;
  partialGifts: GiftSuggestion[];
}

L’inizializzazione nel componente può essere molto semplice:

const [state, setState] = useState<GiftWidgetState>({
  status: 'idle',
  partialGifts: [],
});

Poi abbiamo due punti chiave.

Primo, l’avvio del task. Può essere una chiamata a uno strumento MCP tramite Apps SDK (callTool) oppure una richiesta HTTP al vostro backend che crea il job e restituisce il jobId. In questa lezione non entriamo nei dettagli di come è fatto l’async‑pipeline — ce ne occuperemo nel prossimo argomento su code e worker. Ora ci interessa solo la reazione della UI a un jobId già creato.

Secondo, la sottoscrizione agli eventi di questo jobId. In pratica può essere un hook come useJobEvents(jobId) o un wrapper subscribeToJobEvents, che sotto il cofano usa o una connessione SSE o un client MCP, ma all’esterno ci restituisce oggetti JS puliti. Sotto, per semplicità, mostriamo una variante con subscribeToJobEvents dentro useEffect:

useEffect(() => {
  if (!state.jobId) return;

  const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
  return () => unsubscribe();
}, [state.jobId]);

Dove handleEvent si limita ad aggiornare lo state a seconda del tipo di evento. Di seguito analizzeremo tre gruppi di eventi che gestisce: progresso, risultati parziali e annullamento del task.

4. Visualizzazione del progresso: percentuali, fasi e onestà

Il progresso nell’UX può essere di due tipi: determinato (determinate) e indeterminato (indeterminate). Nel primo caso sapete davvero quanta parte del lavoro è stata fatta: per esempio, avete 4 passi nel workflow, oppure 30 file su 100 elaborati. Nel secondo caso ammettete onestamente di non sapere quanto manca e mostrate un’animazione «stiamo pensando» al posto di un finto «73%».

In GiftGenius la logica può essere questa. Se il backend calcola davvero il progresso — per esempio, ha fasi collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — potete restituire nell’evento job.progress un payload con i campi stepCurrent, stepTotal, statusText e (opzionalmente) un percent ragionevole.

Tipo dell’evento in 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;
}

Handler del progresso nel componente:

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}`,
  }));
}

In JSX si possono renderizzare sia la barra di avanzamento sia il testo della fase:

{(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>
)}

Qui c’è una sfumatura psicologica importante. Se non avete una percentuale onesta, è meglio mostrare semplicemente «Passo 2 di 3: analizziamo le preferenze» più una barra di avanzamento indeterminata (animazione della linea), piuttosto che un «99%» bloccato per 30 secondi. Questo ibrido (fasi + indicatore di avanzamento indeterminato) funziona benissimo per le operazioni di AI, dove è difficile stimare il residuo con precisione.

5. Risultati parziali: non aspettate che tutto sia perfetto

La parte più piacevole dell’UX a flusso sono i risultati parziali. Perché tenere l’utente in attesa se dopo 5–7 secondi avete già i primi regali rilevanti? Potete mostrarli subito e caricare gli altri in seguito.

In GiftGenius può funzionare così. Il backend invia durante l’elaborazione o eventi specifici job.partial_result, oppure, per esempio, resource.updated con una nuova porzione di raccomandazioni. Ogni evento porta un array di regali che si aggiungono a quelli esistenti.

Forma ipotetica del payload:

interface PartialResultPayload {
  gifts: GiftSuggestion[];
  isFinalChunk?: boolean;
}

Handler:

function handlePartialResult(payload: PartialResultPayload) {
  setState(prev => ({
    ...prev,
    status: 'partial_ready',
    partialGifts: [...prev.partialGifts, ...payload.gifts],
  }));
}

In JSX basta renderizzare le card, indipendentemente dal fatto che il task sia concluso o meno:

<section>
  {state.partialGifts.map(gift => (
    <GiftCard key={gift.id} gift={gift} />
  ))}
  {(state.status === 'in_progress' || state.status === 'partial_ready') && (
    <p>Stiamo continuando a cercare altre opzioni…</p>
  )}
</section>

Qui ci sono alcune importanti sfumature di UX da tenere a mente.

Primo, cercate di evitare salti bruschi del layout (layout shift). Se aggiungete nuovi regali in cima alla lista, l’utente perderà il punto di lettura. È più sicuro aggiungerli in coda (append‑only) e animarne dolcemente la comparsa.

Secondo, se utilizzate una strategia di refinement (prima una lista veloce e grezza, poi «lucidata» e riordinata), occorre trattare con cura l’interattività. Finché i risultati sono «provvisori», non permettete di cliccare «Acquista» o segnalate chiaramente che la lista è «preliminare». Altrimenti l’utente sceglie un regalo e dopo un secondo scompare o cambia prezzo — un disastro di UX.

Terzo, lo stato partial_ready deve essere visivamente distinguibile da completed. L’utente deve capire che la lista si sta ancora completando: o con un testo «La selezione è in corso», o con un piccolo spinner nell’angolo, o con un’evidenziazione neutra delle nuove card.

6. Annullamento delle operazioni lunghe: UX e tecnica

Se date all’utente il diritto di avviare una selezione pesante di regali, quasi sempre dovete dargli anche il diritto di fermarla. L’annullamento non è solo risparmio di risorse LLM e dei worker, ma anche sensazione di controllo: «decido io cosa succede».

Dal punto di vista dell’UX il pulsante di annullamento deve essere abbastanza visibile, ma non una grande banda rossa in mezzo allo schermo. Funziona bene la coppia: pulsante principale «Annulla selezione» e un piccolo testo secondario «puoi riavviare in qualsiasi momento». È importante che l’utente capisca cosa viene annullato — l’analisi corrente, non tutta l’applicazione.

Dal punto di vista tecnico avete due livelli di annullamento.

Primo, annullamento sul frontend: potete interrompere il fetch locale o chiudere la connessione SSE. Questo risparmia traffico, ma di per sé non ferma il worker sul backend.

Secondo, il vero annullamento del job: tramite uno strumento MCP o un endpoint HTTP POST /jobs/{jobId}/cancel, che contrassegna il task come canceled e dà al worker la possibilità di terminare correttamente. In questo caso il server invia l’evento job.canceled, che gestite nel widget.

Dal punto di vista del widget:

async function handleCancelClick() {
  if (!state.jobId) return;

  // Aggiornamento UI ottimistico
  setState(prev => ({ ...prev, status: 'canceled' }));

  try {
    await cancelJobOnServer(state.jobId); // Tool MCP o HTTP
  } catch (e) {
    // Se l’annullamento sul server non è riuscito — facciamo il rollback dello stato
    setState(prev => ({ ...prev, status: 'in_progress' }));
  }
}

E il pulsante:

<button
  onClick={handleCancelClick}
  disabled={
    state.status !== 'pending' &&
    state.status !== 'in_progress' &&
    state.status !== 'partial_ready'
  }
>
  Annulla selezione
</button>

Qui usiamo una UI ottimistica: passiamo subito a canceled senza aspettare la conferma del server. È utile quando l’annullamento può richiedere qualche secondo — l’utente vede immediatamente che l’azione è stata recepita. Ma bisogna essere pronti al fatto che il server possa comunque inviare job.completed o job.failed, se il worker è riuscito ad arrivare in fondo. Nell’handler degli eventi conviene filtrare questi «finali in ritardo» e, per esempio, non sovrascrivere uno stato già canceled.

Un approccio più conservativo è la UI pessimistica: prima mostriamo lo stato «Annullamento in corso…», blocchiamo il pulsante, e solo dopo job.canceled portiamo il task in canceled. È più semplice da implementare, ma visivamente meno reattivo. La scelta dipende dagli SLA del vostro backend.

7. Mettiamo tutto insieme: mini‑pannello di progresso di GiftGenius

Ora uniamo i vari pezzi. Abbiamo già scritto:

  • l’handler del progresso handleJobProgress,
  • l’handler dei risultati parziali handlePartialResult,
  • e l’handler dell’annullamento handleCancelClick.

Di fatto questo è il nostro handleEvent generale della sezione precedente: reagisce a job.progress, job.partial_result, job.canceled e altri eventi e aggiorna lo stato di un singolo componente. Resta da incapsulare tutto in un piccolo componente GiftJobPanel, che:

  • avvia la selezione dei regali;
  • ascolta gli eventi per il jobId;
  • mostra il progresso;
  • renderizza i risultati parziali;
  • permette di annullare il task.

Semplifichiamo molto i dettagli dell’integrazione con Apps SDK / MCP e concentriamoci sulla logica di stato.

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 ?? 'Qualcosa è andato storto',
          }));
          break;
        case 'job.canceled':
          setState(prev => ({ ...prev, status: 'canceled' }));
          break;
      }
    });
    return () => unsub();
  }, [state.jobId]);

L’avvio del task può essere implementato tramite lo strumento MCP start_gift_search:

async function handleStartClick() {
  setState({
    status: 'pending',
    partialGifts: [],
  });

  const jobId = await startGiftSearchOnServer(/* parametri dell’utente */);
  setState(prev => ({ ...prev, jobId }));
}

Poi in JSX:

return (
  <div>
    {state.status === 'idle' && (
      <button onClick={handleStartClick}>Scegli un regalo</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>Selezione interrotta. Puoi riavviare con altri parametri.</p>
    )}
  </div>
);

Sottocomponenti separati come ProgressSection, GiftsList, ErrorSection aiutano a non trasformare il componente principale in «spaghetti». Ma l’idea chiave è una: tutto il widget è governato da un unico modello di stato chiaro, che corrisponde direttamente agli eventi MCP e ai canali di streaming che già conoscete.

8. Un po’ sul collegamento con il dialogo di ChatGPT

Anche se questa lezione è focalizzata sul widget stesso, è importante ricordare che l’utente si trova comunque in un dialogo con il modello. Un buon scenario è questo: GPT informa l’utente che sta avviando GiftGenius, poi il widget mostra il progresso e GPT supporta il tutto con un testo: «Ho avviato una selezione avanzata di regali, vedrai come l’elenco si riempirà gradualmente».

Dopo il completamento della selezione, ChatGPT può riprendere il risultato da ToolOutput e formulare un riassunto comprensibile: «Ho trovato 10 opzioni, ecco una breve panoramica; l’elenco completo è nel widget qui sotto». Questo duetto tra streaming testuale e UI a flusso crea un’esperienza coerente.

Questo collegamento sarà ancora più importante nei moduli su workflow e commerce, dove ogni passo lungo (analisi del carrello, verifica della disponibilità, attesa del pagamento) deve essere chiaro sia nel testo sia nell’interfaccia.

9. Errori tipici nell’UX dei flussi

Errore n. 1: «Spinner infinito senza testo».
Il pattern più comune è far girare un’animazione senza spiegare cosa sta succedendo. L’utente non capisce se il sistema sta facendo qualcosa di utile o si è bloccato. Si risolve con un semplice testo di fase («Stiamo raccogliendo i regali popolari…», «Stiamo analizzando le recensioni»), e ancora meglio — con stati espliciti pending, in_progress, partial_ready, che già mantenete nello stato del widget.

Errore n. 2: Percentuali di progresso fasulle.
Il tentativo di «aumentare la fiducia» mostrando un progresso inventato («73%» dal nulla) normalmente ha l’effetto opposto. L’utente nota in fretta che il 99% può restare fermo per 20 secondi e smette di credere all’indicatore. Se non avete una metrica onesta, usate le fasi e una barra di avanzamento indeterminata, invece di ingannare.

Errore n. 3: Risultati parziali che rompono tutto.
A volte i risultati parziali sono implementati come una lista completamente ricostruita, che scompare o si rimescola a ogni evento. Di conseguenza l’utente clicca su una card e questa improvvisamente scappa in basso. Questo tremolio è particolarmente pericoloso negli scenari di commerce. È meglio aggiungere le card con cautela (spesso — solo in coda), mantenere le chiavi e minimizzare i salti di layout.

Errore n. 4: Annullamento che non annulla nulla.
Capita anche questo: nel widget c’è un pulsante «Annulla» che si limita a nascondere la UI, ma non ferma il job reale sul server. Il risultato è che le risorse continuano a essere consumate, arrivano tardivi job.completed e l’utente pensa già che tutto sia fermo. Un vero annullamento deve riguardare sia il frontend (disattivare i pulsanti, fermare lo stream) sia il backend (inviare il segnale di cancel al worker e ricevere l’evento job.canceled).

Errore n. 5: Ignorare il finale e uno schermo di errore «ottuso».
A volte dopo job.completed il widget mostra semplicemente la lista dei regali senza alcun passo successivo, e con job.failed — solo il messaggio tecnico «Errore 500». In entrambi i casi l’UX si interrompe. È meglio, alla fine, fornire un breve riepilogo e un CTA chiaro («Salva selezione», «Vai all’acquisto»), e in caso di errore — una spiegazione umana e i pulsanti «Riprova» o «Modifica parametri» invece di lasciare l’utente da solo con il codice di stato.

Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION