CodeGym /Cursos /ChatGPT Apps /UX de flujos: progreso, resultados parciales, cancelación...

UX de flujos: progreso, resultados parciales, cancelación de operaciones prolongadas

ChatGPT Apps
Nivel 13 , Lección 2
Disponible

1. Por qué el UX de flujos es especialmente importante en ChatGPT App

En la web tradicional, los usuarios ya están acostumbrados a la barra de progreso de subida de archivos, al spinner girando y a la pantalla skeleton. Pero en las apps de ChatGPT tenéis un “competidor” adicional: el propio modelo, que sabe hacer streaming de texto en tiempo real. Si el widget en ese momento dibuja un spinner estático sin explicaciones, pierde en percepción: GPT es «vivo» y la App «parece colgada».

El UX para operaciones largas resuelve varias tareas a la vez. En primer lugar, reduce la ansiedad del usuario: en lugar de «¿se ha colgado o sigue pensando?» ve estados, etapas, porcentajes e incluso los primeros resultados. En segundo lugar, aumenta la confianza: cuando la App muestra claramente lo que hace (analiza reseñas, compara precios, filtra regalos), se crea esa operational transparency — transparencia operativa. El usuario entiende que debajo del capó no hay magia, sino una secuencia de pasos comprensible.

Y, por último, el UX de flujos no va solo de progreso. También va de control. La posibilidad de detener una selección pesada de regalos, cambiar parámetros y relanzar de inmediato es una parte importante de la sensación de «yo controlo, no espero a que el servidor me haga un favor».

En esta lección vamos a:

  • diseñar un modelo sencillo de estados para una tarea larga (pending / in_progress / partial_ready / …);
  • trasladarlo al estado del widget en React;
  • entender cómo mostrar de forma honesta el progreso y los resultados parciales;
  • implementar con cuidado la cancelación de este tipo de tareas.

Todo ello con el ejemplo de nuestro GiftGenius.

2. Modelo de estados de una operación larga en GiftGenius

Para no convertir el flujo de eventos en una papilla de if (event.type === …), conviene pensar en una tarea larga como en un autómata finito (state machine) en el cliente. Para GiftGenius usaremos los siguientes estados lógicos, que ya habéis visto en la teoría: pending, in_progress, partial_ready, completed, failed, canceled más el estado de espera idle.

Resumámoslos en una tabla:

Estado Qué significa en el backend Qué ve el usuario en el widget
idle
Aún no hay tarea Formulario normal, botón «Elegir un regalo»
pending
El job se ha creado; esperando a que arranque el worker Botón deshabilitado y un spinner ligero
in_progress
El worker está trabajando y envía job.progress Barra de progreso o pasos «Paso 1 de 3»
partial_ready
Hay primeros resultados; el trabajo continúa Ya se ven los primeros regalos + sigue el progreso
completed
Llega job.completed Lista final de regalos, CTA («Comprar»)
failed
Llega job.failed Mensaje de error + botón «Volver a intentarlo»
canceled
Llega job.canceled o una marca de cancelación Texto «Selección detenida» + «Empezar de nuevo»

Este mismo modelo encaja muy bien con los eventos de MCP. Por ejemplo, job.started lleva de pending a in_progress, job.progress puede o bien actualizar solo el porcentaje en in_progress, o bien decir «tenemos las primeras tarjetas» y entonces pasáis a partial_ready. job.completed, job.failed y job.canceled cierran la historia.

Parece un pequeño autómata de estados:

stateDiagram-v2
    [*] --> idle
    idle --> pending: crear job
    pending --> in_progress: job.started
    in_progress --> partial_ready: primeros resultados parciales
    partial_ready --> completed: job.completed
    in_progress --> completed: job.completed (sin resultados parciales)
    in_progress --> failed: job.failed
    partial_ready --> failed: job.failed
    in_progress --> canceled: job.canceled
    partial_ready --> canceled: job.canceled
    failed --> idle: reinicio
    canceled --> idle: reinicio

En el código del widget se puede reflejar con un tipo sencillo:

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

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

Por ahora esto es solo la forma de los datos. Después la iremos rellenando a medida que vayan llegando eventos de MCP o por streaming.

3. Estado del widget: cómo el componente de React «escucha» el flujo

Llevemos nuestro modelo de estados al código React del widget de GiftGenius. Necesitamos guardar:

  • el jobId actual para saber qué eventos pertenecen a esa tarea;
  • el estado de la tarea (status, percent, stage);
  • un array de resultados parciales (tarjetas de regalos);
  • flags para los botones: si se puede cancelar o reiniciar.

Describámoslo con una sola interfaz:

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

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

La inicialización en el componente puede ser muy simple:

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

Después tenemos dos puntos clave.

Primero, el arranque de la tarea. Puede ser una llamada a una herramienta MCP a través del Apps SDK (callTool) o una petición HTTP a vuestro backend, que crea el job y devuelve el jobId. En esta lección no profundizamos en cómo está hecho el async‑pipeline — lo veremos en el siguiente tema sobre colas y workers. Ahora solo nos importa la reacción de la UI ante un jobId ya creado.

Segundo, la suscripción a los eventos de ese jobId. En la práctica puede ser un hook como useJobEvents(jobId) o un wrapper subscribeToJobEvents, que por debajo usan o bien una conexión SSE o el cliente MCP, pero por fuera nos entregan objetos JS ya normales. Abajo, para simplificar, mostramos la variante con subscribeToJobEvents dentro de useEffect:

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

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

Donde handleEvent simplemente actualiza el state según el tipo de evento. A continuación, veremos por turnos tres grupos de eventos que maneja: progreso, resultados parciales y cancelación de la tarea.

4. Visualización del progreso: porcentajes, etapas y honestidad

El progreso en UX suele ser de dos tipos: determinado (determinate) e indeterminado (indeterminate). En el primer caso realmente sabéis cuánta parte del trabajo está hecha: por ejemplo, tenéis 4 pasos en el workflow, o se han procesado 30 de 100 archivos. En el segundo caso reconocéis honestamente que no sabéis cuánto queda por esperar y mostráis una animación de «pensando» en lugar de un falso «73%».

En GiftGenius la lógica puede ser así. Si el backend calcula de verdad el progreso — por ejemplo, tiene etapas collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — podéis devolver en el evento job.progress un payload con los campos stepCurrent, stepTotal, statusText y (opcionalmente) un percent razonable.

Tipo de evento 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;
}

Manejador de progreso en el 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}`,
  }));
}

En JSX se puede renderizar tanto la barra de progreso como el texto de la etapa:

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

Aquí hay un matiz psicológico importante. Si no tenéis un porcentaje honesto, mejor mostrar simplemente «Paso 2 de 3: analizando preferencias» más una barra de progreso indeterminada (animación), que un «99%» congelado durante 30 segundos. Este híbrido (etapas + barra indeterminada) funciona muy bien para operaciones de IA, donde calcular el restante exacto es difícil.

5. Resultados parciales: no hay que esperar a que todo sea perfecto

La parte más agradable de un UX por streaming son los resultados parciales. ¿Para qué mantener al usuario esperando si a los 5–7 segundos ya tenéis los primeros regalos relevantes? Podéis mostrarlos de inmediato y cargar el resto después.

En GiftGenius puede verse así. El backend, a medida que trabaja, envía o bien eventos específicos job.partial_result, o por ejemplo resource.updated con una nueva tanda de recomendaciones. Cada evento de este tipo trae un array de regalos que se añaden a los existentes.

Forma de payload aproximada:

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

Manejador:

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

En JSX simplemente renderizáis las tarjetas, independientemente de si la tarea ha terminado o no:

<section>
  {state.partialGifts.map(gift => (
    <GiftCard key={gift.id} gift={gift} />
  ))}
  {(state.status === 'in_progress' || state.status === 'partial_ready') && (
    <p>Seguimos buscando más opciones…</p>
  )}
</section>

Hay varios matices de UX importantes a tener en cuenta.

Primero, evitad los saltos bruscos de maquetación (layout shift). Si añadís nuevos regalos en la parte superior de la lista, el usuario perderá su posición de lectura. Es más seguro agregarlos al final (append‑only) y animar suavemente su aparición.

Segundo, si usáis la estrategia de refinement (primero una lista rápida de borrador y luego «pulida» y reranqueada), hay que tratar la interactividad con cuidado. Mientras los resultados sean «de borrador», no permitáis pulsar «Comprar» o marcad claramente esa lista como «preliminar». De lo contrario, el usuario elegirá un regalo y en un segundo desaparecerá o cambiará de precio — una catástrofe de UX.

Y tercero, el estado partial_ready debe distinguirse visualmente de completed. El usuario debe entender que la lista aún se está completando: ya sea con el texto «La selección continúa», o con un pequeño spinner en una esquina, o con un resaltado neutro de las nuevas tarjetas.

6. Cancelación de operaciones largas: UX y técnica

Si dais al usuario el derecho a lanzar una selección pesada de regalos, casi siempre debéis darle también el derecho a detenerla. La cancelación no es solo ahorro de recursos de la LLM y de los workers, sino también sensación de control: «yo decido lo que ocurre».

Desde el punto de vista del UX, el botón de cancelación debe ser lo bastante visible, pero no una gran banda roja en medio de la pantalla. Funciona bien la pareja: botón principal «Cancelar la selección» y un texto secundario pequeño «se puede volver a iniciar en cualquier momento». Es importante que el usuario entienda qué se cancela exactamente — el análisis en curso, no toda la aplicación.

Desde el punto de vista técnico tenéis dos niveles de cancelación.

Primero, la cancelación en el frontend: podéis interrumpir el fetch local o cerrar la conexión SSE. Esto ahorra tráfico, pero por sí mismo no detiene el worker en el backend.

Segundo, la cancelación real del job: a través de una herramienta MCP o del endpoint HTTP POST /jobs/{jobId}/cancel, que marca la tarea como canceled y da al worker la oportunidad de finalizar correctamente. Al mismo tiempo, el servidor envía el evento job.canceled, que ya manejáis en el widget.

Vista desde el widget:

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

  // Actualización optimista de la UI
  setState(prev => ({ ...prev, status: 'canceled' }));

  try {
    await cancelJobOnServer(state.jobId); // Herramienta MCP o HTTP
  } catch (e) {
    // Si la cancelación en el servidor falla, revertimos el estado
    setState(prev => ({ ...prev, status: 'in_progress' }));
  }
}

Y el botón:

<button
  onClick={handleCancelClick}
  disabled={
    state.status !== 'pending' &&
    state.status !== 'in_progress' &&
    state.status !== 'partial_ready'
  }
>
  Cancelar la selección
</button>

Aquí usamos una UI optimista: cambiamos inmediatamente a canceled sin esperar la confirmación del servidor. Es útil cuando la cancelación puede llevar segundos — el usuario ve al instante que su acción se ha aceptado. Pero hay que estar preparados para que el servidor aún devuelva job.completed o job.failed si el worker alcanzó a terminar. En el manejador de eventos conviene filtrar estos finales «tardíos» y, por ejemplo, no sobrescribir un estado ya canceled.

Un enfoque más conservador es la UI pesimista: primero mostramos el estado «Cancelando…», bloqueamos el botón y solo tras job.canceled pasamos la tarea a canceled. Es más sencillo de implementar, pero visualmente menos reactivo. Se puede elegir el enfoque según el SLA de vuestro backend.

7. Juntándolo todo: mini panel de progreso de GiftGenius

Ahora unimos las piezas. Ya hemos escrito:

  • el manejador de progreso handleJobProgress,
  • el manejador de resultados parciales handlePartialResult,
  • y el manejador de cancelación handleCancelClick.

En esencia, este es el handleEvent general del apartado anterior: reacciona a job.progress, job.partial_result, job.canceled y otros eventos, y actualiza el estado de un componente. Falta envolver todo en un pequeño componente GiftJobPanel, que:

  • lanza la selección de regalos;
  • escucha los eventos por jobId;
  • muestra el progreso;
  • renderiza los resultados parciales;
  • permite cancelar la tarea.

Simplificaremos mucho los detalles de integración con Apps SDK / MCP y nos centraremos en la lógica de estado.

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

El inicio de la tarea puede implementarse mediante la herramienta MCP start_gift_search:

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

  const jobId = await startGiftSearchOnServer(/* parámetros del usuario */);
  setState(prev => ({ ...prev, jobId }));
}

Después, en JSX:

return (
  <div>
    {state.status === 'idle' && (
      <button onClick={handleStartClick}>Elegir 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>Selección detenida. Puedes iniciarla de nuevo con otros parámetros.</p>
    )}
  </div>
);

Subcomponentes como ProgressSection, GiftsList y ErrorSection ayudan a que el componente principal no se convierta en «spaghetti». Pero la idea clave es una: todo el widget se gestiona con un único modelo de estado comprensible, que corresponde directamente a los eventos de MCP y a los canales de streaming que ya conocéis.

8. Un poco sobre la integración con el diálogo de ChatGPT

Aunque esta lección se centra en el propio widget, es importante recordar que el usuario sigue estando en diálogo con el modelo. Un buen escenario es así: GPT informa al usuario de que va a lanzar GiftGenius, luego el widget muestra el progreso y GPT lo refuerza con texto: «Acabo de iniciar una búsqueda avanzada de regalos; verás cómo la lista se va completando poco a poco».

Tras finalizar la selección, ChatGPT puede recoger el resultado de ToolOutput y formular un resumen en lenguaje natural: «He encontrado 10 opciones; aquí tienes un breve resumen y la lista completa está en el widget de abajo». Este dueto de streaming textual y UI por streaming crea una experiencia coherente.

Esta combinación será aún más importante en los módulos de workflow y commerce, donde cada paso largo (analizar el carrito, comprobar disponibilidad, esperar el pago) debe ser comprensible tanto en el texto como en la interfaz.

9. Errores típicos en el UX de flujos

Error n.º 1: «Spinner eterno sin texto».
El anti‑patrón más común es hacer girar una animación sin explicar qué ocurre. El usuario no entiende si el sistema está haciendo algo útil o si se ha colgado. Se soluciona con un simple texto de etapa («Recopilando regalos populares…», «Analizando reseñas»), y aún mejor — con estados explícitos pending, in_progress, partial_ready, que ya mantenéis en el estado del widget.

Error n.º 2: Porcentajes de progreso falsos.
Intentar «aumentar la confianza» dibujando un progreso inventado («73%» de la nada) suele tener el efecto contrario. El usuario nota rápido que un 99% puede quedarse 20 segundos y deja de creer en el indicador. Si no tenéis una métrica honesta, mejor usad etapas y una barra de progreso indeterminada en lugar de engañar.

Error n.º 3: Resultados parciales que lo rompen todo.
A veces los resultados parciales se implementan como una lista completamente reconstruida que desaparece o se reordena en cada evento. Al final, el usuario hace clic en una tarjeta y esta de repente se va hacia abajo. Ese “temblor” es especialmente grave en escenarios de commerce. Es mejor añadir las tarjetas con cuidado (a menudo solo al final), conservar las keys y minimizar los saltos de maquetación.

Error n.º 4: Cancelación que no cancela nada.
También pasa: el widget tiene un botón «Cancelar» que solo oculta la UI pero no detiene el job real en el servidor. En consecuencia, los recursos se siguen gastando, llegan job.completed tardíos y el usuario ya cree que todo se ha detenido. La cancelación real debe afectar tanto al frontend (deshabilitar botones, detener el stream) como al backend (enviar la señal de cancelación al worker y recibir el evento job.canceled).

Error n.º 5: Ignorar el final y una pantalla de error “tonta”.
A veces, tras job.completed, el widget solo muestra la lista de regalos sin pasos siguientes, y con job.failed — solo el mensaje técnico «Error 500». En ambos casos el UX se corta. Es mejor dar al final un breve resumen y un CTA claro («Guardar la selección», «Ir a la compra»), y en caso de error — una explicación humana y botones de «Volver a intentarlo» o «Cambiar parámetros» en lugar de dejar al usuario a solas con un código de estado.

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