1. Por que o UX de fluxos é especialmente importante no ChatGPT App
No web “tradicional”, os usuários já estão acostumados a barra de progresso de upload, spinner girando e tela skeleton. Mas em aplicativos do ChatGPT você tem um “concorrente” extra: o próprio modelo, que consegue fazer streaming de texto em tempo real. Se o widget, nesse momento, renderiza um spinner estático sem explicações, ele perde na percepção — o GPT está “vivo”, e o App parece “travado”.
O UX para operações longas resolve várias tarefas ao mesmo tempo. Primeiro, reduz a ansiedade do usuário: em vez de “travou ou ainda está pensando?”, ele vê status, etapas, porcentagens e até os primeiros resultados. Segundo, aumenta a confiança: quando o App mostra explicitamente o que está fazendo (analisa avaliações, confere preços, filtra presentes), isso cria a chamada operational transparency — transparência operacional. O usuário entende: debaixo do capô não há magia, mas uma sequência de passos compreensível.
E por fim, o UX de fluxos não é só sobre progresso. É também sobre controle. A possibilidade de parar uma busca pesada de presentes, mudar parâmetros e iniciar novamente imediatamente — parte importante da sensação de “eu comando, não fico à mercê do servidor”.
Nesta aula, nós vamos:
- projetar um modelo simples de estados de uma tarefa longa (pending / in_progress / partial_ready / …);
- traduzir esse modelo para o estado de um widget em React;
- entender como mostrar progresso e resultados parciais de forma honesta;
- implementar com cuidado o cancelamento dessas tarefas.
Tudo isso — usando nosso GiftGenius como exemplo.
2. Modelo de estados de uma operação longa no GiftGenius
Para não transformar o fluxo de eventos em uma sopa de if (event.type === …), é útil pensar na tarefa longa como uma máquina de estados (state machine) no cliente. Para o GiftGenius usaremos os seguintes estados lógicos, que você já viu na teoria: pending, in_progress, partial_ready, completed, failed, canceled mais o estado de espera idle.
Vamos resumir em uma tabela:
| Status | O que significa no backend | O que o usuário vê no widget |
|---|---|---|
|
Ainda não há tarefa | Formulário normal, botão “Encontrar presente” |
|
Job criado, aguardando o início do worker | Botão desabilitado, spinner leve |
|
O worker está rodando e envia job.progress | Barra de progresso ou etapas “Etapa 1 de 3” |
|
Já há primeiros resultados, o trabalho continua | Primeiros presentes já visíveis + ainda mostrando progresso |
|
Chegou job.completed | Lista final de presentes, CTA (“Comprar”) |
|
Chegou job.failed | Mensagem de erro + botão “Tentar novamente” |
|
Chegou job.canceled ou flag de cancelamento | Texto “Seleção interrompida” + “Iniciar novamente” |
Esse mesmo modelo se mapeia muito bem para os eventos MCP. Por exemplo, job.started muda de pending para in_progress, job.progress pode apenas atualizar a porcentagem em in_progress ou dizer “temos os primeiros cards”, e então você passa para partial_ready. job.completed, job.failed e job.canceled encerram o fluxo.
Parece um pequeno autômato de estados:
stateDiagram-v2
[*] --> idle
idle --> pending: criar job
pending --> in_progress: job.started
in_progress --> partial_ready: primeiros resultados parciais
partial_ready --> completed: job.completed
in_progress --> completed: job.completed (sem parciais)
in_progress --> failed: job.failed
partial_ready --> failed: job.failed
in_progress --> canceled: job.canceled
partial_ready --> canceled: job.canceled
failed --> idle: reiniciar
canceled --> idle: reiniciar
No código do widget, isso pode ser refletido com um tipo simples:
type JobStatus =
| 'idle'
| 'pending'
| 'in_progress'
| 'partial_ready'
| 'completed'
| 'failed'
| 'canceled';
interface GiftJobState {
status: JobStatus;
percent?: number;
stage?: string;
error?: string;
}
Por enquanto, isso é apenas a forma dos dados. Depois vamos preenchê-la conforme os eventos forem chegando do MCP ou via streaming.
3. Estado do widget: como o componente React “escuta” o fluxo
Vamos levar nosso modelo de estados para o código React do widget GiftGenius. Precisamos armazenar:
- o jobId atual, para saber quais eventos pertencem a essa tarefa;
- o estado da tarefa (status, percent, stage);
- um array de resultados parciais (cards de presentes);
- flags para botões: se é possível cancelar, se é possível reiniciar.
Vamos descrever isso com uma única interface:
interface GiftSuggestion {
id: string;
title: string;
price: string;
}
interface GiftWidgetState extends GiftJobState {
jobId?: string;
partialGifts: GiftSuggestion[];
}
A inicialização no componente pode ser bem simples:
const [state, setState] = useState<GiftWidgetState>({
status: 'idle',
partialGifts: [],
});
Depois, temos dois pontos‑chave.
Primeiro, iniciar a tarefa. Isso pode ser uma chamada de ferramenta MCP via Apps SDK (callTool) ou uma requisição HTTP para seu backend, que cria o job e retorna o jobId. Nesta aula não vamos nos aprofundar em como o pipeline assíncrono é estruturado — veremos isso no próximo tópico sobre filas e workers. Agora nos importa apenas a reação do UI ao jobId já criado.
Segundo, a assinatura dos eventos desse jobId. Na prática, pode ser um hook como useJobEvents(jobId) ou um wrapper subscribeToJobEvents, que por baixo dos panos usa uma conexão SSE ou um cliente MCP, mas por fora nos devolve objetos JS “normais”. Abaixo, para simplificar, mostramos a opção com subscribeToJobEvents dentro do useEffect:
useEffect(() => {
if (!state.jobId) return;
const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
return () => unsubscribe();
}, [state.jobId]);
Onde o handleEvent apenas atualiza o state dependendo do tipo de evento. Em seguida, analisaremos três grupos de eventos que ele trata: progresso, resultados parciais e cancelamento da tarefa.
4. Visualização do progresso: porcentagens, etapas e honestidade
O progresso no UX pode ser de dois tipos: determinado (determinate) e indeterminado (indeterminate). No primeiro caso, você realmente sabe quanto do trabalho foi feito: por exemplo, há 4 passos no workflow, ou 30 de 100 arquivos foram processados. No segundo, você admite honestamente que não sabe quanto falta e mostra uma animação de “pensando” em vez de um “73%” falso.
No GiftGenius, a lógica pode ser assim. Se o backend realmente calcula o progresso — por exemplo, ele tem etapas collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — você pode enviar no evento job.progress um payload com os campos stepCurrent, stepTotal, statusText e (opcionalmente) um percent razoável.
Tipo do evento em 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 de progresso no 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}`,
}));
}
No JSX, você pode renderizar a barra de progresso e o texto da 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>
)}
Há um ponto psicológico importante aqui. Se você não tem uma porcentagem honesta, é melhor mostrar apenas “Etapa 2 de 3: analisando preferências” junto com uma barra de progresso indeterminada (animação), do que um “99%” parado por 30 segundos. Esse híbrido (etapas + indicador de progresso indeterminado) funciona muito bem para operações de IA, nas quais é difícil calcular o restante com precisão.
5. Resultados parciais: não espere tudo ficar perfeito
A parte mais agradável do UX de streaming são os resultados parciais. Por que manter o usuário esperando se, em 5–7 segundos, você já tem os primeiros presentes relevantes? Mostre-os imediatamente e carregue o restante depois.
No GiftGenius, pode funcionar assim. O backend, à medida que trabalha, envia eventos específicos job.partial_result ou, por exemplo, resource.updated com um novo lote de recomendações. Cada evento traz um array de presentes, que é adicionado ao que já existe.
Forma hipotética do payload:
interface PartialResultPayload {
gifts: GiftSuggestion[];
isFinalChunk?: boolean;
}
Handler:
function handlePartialResult(payload: PartialResultPayload) {
setState(prev => ({
...prev,
status: 'partial_ready',
partialGifts: [...prev.partialGifts, ...payload.gifts],
}));
}
No JSX, você apenas renderiza os cards, independentemente de a tarefa ter sido concluída ou não:
<section>
{state.partialGifts.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
{(state.status === 'in_progress' || state.status === 'partial_ready') && (
<p>Continuamos procurando mais opções…</p>
)}
</section>
Há alguns nuances importantes de UX a serem lembrados.
Primeiro, evite saltos bruscos de layout (layout shift). Se você adiciona novos presentes no topo da lista, o usuário perde o ponto de leitura. É mais seguro adicioná-los ao final (append‑only) e animar suavemente o aparecimento.
Segundo, se você usa a estratégia de refinement (primeiro uma lista rápida de rascunho, depois “polida” e reordenada), precisa ter cuidado com a interatividade. Enquanto os resultados estiverem “em rascunho”, não permita clicar em “Comprar” ou marque explicitamente essa lista como “preliminar”. Caso contrário, o usuário escolhe um presente e, em seguida, ele some ou muda de preço — uma catástrofe de UX.
Terceiro, o estado partial_ready deve ser visualmente distinto de completed. O usuário precisa entender que a lista ainda está sendo preenchida: seja com o texto “A seleção continua”, seja com um pequeno spinner no canto, seja com um realce neutro nos novos cards.
6. Cancelamento de operações longas: UX e técnica
Se você dá ao usuário o direito de iniciar uma busca pesada de presentes, quase sempre deve dar a ele o direito de pará-la. O cancelamento não é apenas economia de recursos da LLM e dos workers, mas também uma sensação de controle: “eu decido o que está acontecendo”.
Do ponto de vista de UX, o botão de cancelamento deve ser visível o suficiente, mas não uma faixa vermelha gritando no meio da tela. Funciona bem a dupla: botão principal “Cancelar seleção” e um pequeno texto secundário “você pode iniciar de novo a qualquer momento”. É importante que o usuário entenda o que exatamente está sendo cancelado — a análise atual, e não todo o aplicativo.
Do ponto de vista técnico, há dois níveis de cancelamento.
Primeiro, o cancelamento no frontend: você pode interromper um fetch local ou fechar a conexão SSE. Isso economiza tráfego, mas, por si só, não para o worker no backend.
Segundo, o cancelamento real do job: via ferramenta MCP ou endpoint HTTP POST /jobs/{jobId}/cancel, que marca a tarefa como canceled e dá ao worker a chance de encerrar corretamente. Nesse caso, o servidor envia o evento job.canceled, que você já trata no widget.
Do ponto de vista do widget:
async function handleCancelClick() {
if (!state.jobId) return;
// Atualização otimista do UI
setState(prev => ({ ...prev, status: 'canceled' }));
try {
await cancelJobOnServer(state.jobId); // MCP tool ou HTTP
} catch (e) {
// Se o cancelamento no servidor falhar — desfazemos o status
setState(prev => ({ ...prev, status: 'in_progress' }));
}
}
E o botão:
<button
onClick={handleCancelClick}
disabled={
state.status !== 'pending' &&
state.status !== 'in_progress' &&
state.status !== 'partial_ready'
}
>
Cancelar seleção
</button>
Aqui usamos UI otimista: trocamos imediatamente para canceled, sem esperar a confirmação do servidor. Isso é útil quando o cancelamento pode levar segundos — o usuário vê instantaneamente que sua ação foi aceita. Mas esteja preparado para o servidor ainda retornar job.completed ou job.failed se o worker chegar ao fim. No handler de eventos, vale filtrar esses “finais atrasados” e, por exemplo, não sobrescrever um estado já canceled.
Uma abordagem mais conservadora é o UI pessimista: primeiro mostramos o estado “Cancelando…”, bloqueamos o botão e apenas após job.canceled mudamos a tarefa para canceled. É mais simples de implementar, mas visualmente menos responsivo. A escolha depende do SLA do seu backend.
7. Juntando tudo: mini‑painel de progresso do GiftGenius
Agora vamos juntar as partes. Já escrevemos:
- o handler de progresso handleJobProgress,
- o handler de resultados parciais handlePartialResult,
- e o handler de cancelamento handleCancelClick.
Na essência, isso é o handleEvent geral do trecho anterior: ele reage a job.progress, job.partial_result, job.canceled e outros eventos e atualiza o estado de um único componente. Falta embrulhar tudo em um pequeno componente GiftJobPanel, que:
- inicia a seleção de presentes;
- ouve eventos pelo jobId;
- mostra o progresso;
- renderiza resultados parciais;
- permite cancelar a tarefa.
Vamos simplificar bastante os detalhes de integração com Apps SDK / MCP e focar na 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 deu errado',
}));
break;
case 'job.canceled':
setState(prev => ({ ...prev, status: 'canceled' }));
break;
}
});
return () => unsub();
}, [state.jobId]);
O início da tarefa pode ser implementado via MCP‑tool start_gift_search:
async function handleStartClick() {
setState({
status: 'pending',
partialGifts: [],
});
const jobId = await startGiftSearchOnServer(/* parâmetros do usuário */);
setState(prev => ({ ...prev, jobId }));
}
Depois, no JSX:
return (
<div>
{state.status === 'idle' && (
<button onClick={handleStartClick}>Encontrar presente</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>Seleção interrompida. Você pode iniciar novamente com outros parâmetros.</p>
)}
</div>
);
Subcomponentes como ProgressSection, GiftsList, ErrorSection ajudam a evitar que o componente principal vire “espaguete”. Mas a ideia central é uma: todo o widget é controlado por um único modelo de estado, que corresponde diretamente aos eventos MCP e aos canais de streaming que você já conhece.
8. Um pouco sobre a ligação com o diálogo do ChatGPT
Embora esta aula se concentre no widget em si, é importante lembrar que o usuário ainda está em um diálogo com o modelo. Um bom cenário é: o GPT informa ao usuário que está iniciando o GiftGenius, depois o widget mostra o progresso, e o GPT apoia com texto: “Acabei de iniciar uma seleção avançada de presentes; você verá a lista se preencher gradualmente”.
Após a conclusão, o ChatGPT pode captar o resultado do ToolOutput e formular um resumo humano: “Encontrei 10 opções, aqui vai um panorama rápido; a lista completa está no widget abaixo”. Esse dueto de streaming de texto e UI em fluxo cria uma experiência coesa.
Essa integração será ainda mais importante nos módulos sobre workflow e commerce, onde cada passo longo (análise do carrinho, verificação de disponibilidade, espera pelo pagamento) deve ser compreensível tanto no texto quanto na interface.
9. Erros comuns no UX de fluxos
Erro nº 1: “Spinner eterno sem texto”.
O anti‑padrão mais comum é apenas girar uma animação sem explicar o que está acontecendo. O usuário não entende se o sistema está fazendo algo útil ou se travou. Resolve-se com um texto simples de etapa (“Coletando presentes populares…”, “Analisando avaliações”) e, melhor ainda, com status explícitos pending, in_progress, partial_ready, que você já mantém no estado do widget.
Erro nº 2: Porcentagens de progresso falsas.
A tentativa de “aumentar a confiança” desenhando um progresso inventado (“73%” do nada) geralmente tem o efeito oposto. O usuário logo percebe que 99% pode ficar parado por 20 segundos e deixa de acreditar no indicador. Se você não tem uma métrica honesta, prefira etapas e uma barra de progresso indeterminada, em vez de enganar.
Erro nº 3: Resultados parciais que bagunçam tudo.
Às vezes, os resultados parciais são implementados como uma lista totalmente reconstruída, que some e se reordena a cada evento. No fim, o usuário clica em um card e ele de repente vai para baixo. Essa tremedeira é especialmente perigosa em cenários de comércio. O correto é adicionar cards com cuidado (muitas vezes — só no final), manter as keys e minimizar os saltos de layout.
Erro nº 4: Cancelamento que não cancela nada.
Acontece também: o widget tem um botão “Cancelar” que apenas esconde o UI, mas não para o job real no servidor. Como resultado, os recursos continuam sendo gastos, chegam job.completed tardios, e o usuário acha que tudo foi interrompido. O cancelamento real deve afetar o frontend (desabilitar botões, parar o streaming) e o backend (enviar o sinal de cancelamento ao worker e receber o evento job.canceled).
Erro nº 5: Ignorar o final e uma tela de erro “burra”.
Às vezes, após job.completed o widget apenas mostra a lista de presentes sem próximos passos, e em job.failed — apenas a mensagem técnica “Erro 500”. Em ambos os casos, o UX fica truncado. O ideal é, no final, dar um breve resumo e um CTA claro (“Salvar seleção”, “Ir para a compra”), e, em caso de erro — uma explicação humana e botões “Tentar novamente” ou “Alterar parâmetros”, em vez de deixar o usuário sozinho com o código de status.
GO TO FULL VERSION