CodeGym /Courses /ChatGPT Apps /Flow UX: progress, partial results, canceling long-runnin...

Flow UX: progress, partial results, canceling long-running operations

ChatGPT Apps
Level 13 , Lesson 2
Available

1. Why flow UX matters specifically in a ChatGPT app

On the regular web, users are already used to a file upload progress bar, a spinning loader, and a skeleton screen. But in ChatGPT apps you have an additional “competitor”: the model itself, which can stream text in real time. If your widget shows a static spinner with no explanation at that moment, it loses in terms of perception — GPT feels “alive,” while the app feels “frozen.”

UX for long operations solves several problems at once. First, it reduces user anxiety: instead of “is it frozen or still thinking?” they see statuses, stages, percentages, and even first results. Second, it builds trust: when the app explicitly shows what it’s doing (analysing reviews, checking prices, filtering gifts), that creates operational transparency. The user understands: under the hood it’s not magic but a clear sequence of steps.

And finally, flow UX isn’t only about progress. It’s also about control. The ability to stop a heavy gift search, change parameters, and immediately restart is an important part of the feeling “I’m in control, not just waiting for the server’s mercy.”

In this lecture we will:

  • design a simple state model for a long-running job (pending / in_progress / partial_ready / …);
  • translate it into the widget’s React state;
  • figure out how to honestly show progress and partial results;
  • carefully implement cancelation of such jobs.

All of this — using our GiftGenius as an example.

2. A state model for a long-running operation in GiftGenius

To avoid turning the event stream into a mess of if (event.type === …), it’s handy to think of a long-running job as a client-side state machine. For GiftGenius we will use the following logical states you’ve already seen in theory: pending, in_progress, partial_ready, completed, failed, canceled, plus the waiting state idle.

Let’s summarise them in a table:

Status What it means on the backend What the user sees in the widget
idle
No job yet Regular form, a “Find a gift” button
pending
Job created, waiting for the worker to start Button disabled, light spinner
in_progress
Worker is running, sends job.progress Progress bar or steps “Step 1 of 3”
partial_ready
First results available, work continues First gifts already visible + still showing progress
completed
Received job.completed Final list of gifts, CTA (“Buy”)
failed
Received job.failed Error message + “Try again” button
canceled
Received job.canceled or a cancel flag Text “Selection stopped” + “Start over”

This same model maps nicely onto MCP events. For example, job.started moves you from pending to in_progress, job.progress can either just update the percent in in_progress, or say “we’ve got the first cards,” in which case you transition to partial_ready. job.completed, job.failed, and job.canceled close the story.

It looks like a small state machine:

stateDiagram-v2
    [*] --> idle
    idle --> pending: create job
    pending --> in_progress: job.started
    in_progress --> partial_ready: first partial results
    partial_ready --> completed: job.completed
    in_progress --> completed: job.completed (without partial)
    in_progress --> failed: job.failed
    partial_ready --> failed: job.failed
    in_progress --> canceled: job.canceled
    partial_ready --> canceled: job.canceled
    failed --> idle: restart
    canceled --> idle: restart

In the widget code this can be represented with a simple type:

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

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

For now, this is just a data shape. Next we’ll fill it as MCP events or stream updates come in.

3. Widget state: how a React component “listens” to the stream

Let’s transfer our state model into the React code of the GiftGenius widget. We need to store:

  • the current jobId to know which events belong to this job;
  • the job state (status, percent, stage);
  • an array of partial results (gift cards);
  • flags for buttons: whether it can be canceled and whether it can be restarted.

We’ll describe this with a single interface:

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

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

Initialization in the component can be very simple:

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

There are two key moments.

First, starting the job. This can be an MCP tool call via the Apps SDK (callTool) or an HTTP request to your backend that creates the job and returns a jobId. In this lecture we won’t go deep into how the async pipeline is implemented — we’ll do that in the next topic on queues and workers. Right now we only care about the UI reaction to an already created jobId.

Second, subscribing to events for that jobId. In practice this might be a hook like useJobEvents(jobId) or a wrapper subscribeToJobEvents that uses either an SSE connection or an MCP client under the hood but returns nice JS objects from the outside. Below, for simplicity, we’ll show a variant with subscribeToJobEvents inside useEffect:

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

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

Here, handleEvent simply updates the state depending on the event type. We’ll now walk through three groups of events it handles: progress, partial results, and cancelation.

4. Visualising progress: percentage, stages, and honesty

There are two kinds of progress in UX: determinate and indeterminate. In the first case you really know how much work is done: for example, you have 4 workflow steps, or 30 out of 100 files processed. In the second case you honestly admit you don’t know how much longer it will take and show a “thinking” animation instead of a fake “73%.”

In GiftGenius the logic can be as follows. If the backend really calculates progress — for example, it has steps collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — you can return in the job.progress event a payload with fields stepCurrent, stepTotal, statusText and (optionally) a reasonable percent.

Event type 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;
}

Progress handler in the component:

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 you can render both a progress bar and the stage text:

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

There is an important psychological nuance here. If you don’t have an honest percentage, it’s better to show just “Step 2 of 3: analysing preferences” plus an indeterminate progress bar animation than a “stuck” 99% for 30 seconds. This hybrid (stages + indeterminate bar) works great for AI operations where it’s hard to compute the exact remaining time.

5. Partial results: don’t wait until everything is perfect

The nicest part of streaming UX is partial results. Why keep the user waiting if after 5–7 seconds you already have the first relevant gifts? You can show them right away and load the rest later.

In GiftGenius this can look like this. The backend, as it works, sends either special events job.partial_result or, for example, resource.updated with a new batch of recommendations. Each such event brings an array of gifts that are appended to the existing ones.

Payload shape:

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

Handler:

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

In JSX you just render the cards, regardless of whether the job has finished:

<section>
  {state.partialGifts.map(gift => (
    <GiftCard key={gift.id} gift={gift} />
  ))}
  {(state.status === 'in_progress' || state.status === 'partial_ready') && (
    <p>We are still looking for more options...</p>
  )}
</section>

There are a few important UX nuances to keep in mind here.

First, try to avoid abrupt layout shifts. If you add new gifts to the top of the list, the user will lose their reading position. It’s safer to append them to the end (append-only) and gently animate their appearance.

Second, if you use a refinement strategy (a quick rough list first, then “polished” and re-ranked), you need to handle interactivity carefully. While the results are “draft,” don’t allow pressing “Buy,” or explicitly mark such a list as “preliminary.” Otherwise the user will pick a gift and a second later it disappears or the price changes — a UX disaster.

Third, the partial_ready state should be visually distinct from completed. The user should understand the list is still being filled in: either with the text “Selection is still in progress,” a small spinner in the corner, or a neutral highlight on new cards.

6. Canceling long-running operations: UX and technique

If you let the user start a heavy gift search, you almost always should let them stop it. Cancelation is not only about saving LLM and worker resources, but also about the feeling of control: “I decide what happens.”

From a UX standpoint, the cancel button should be noticeable enough, but not a loud red slab in the middle of the screen. A good combo is a primary button “Cancel gift search” and a small secondary text “you can restart at any time.” It’s important that the user understands exactly what is being canceled — the current analysis, not the entire application.

From a technical standpoint you have two levels of cancelation.

First, cancelation on the frontend: you can abort a local fetch or close the SSE connection. This saves traffic but by itself doesn’t stop the backend worker.

Second, the real job cancelation: via an MCP tool or an HTTP endpoint POST /jobs/{jobId}/cancel that marks the job as canceled and gives the worker a chance to finish gracefully. At the same time the server sends a job.canceled event that you handle in the widget.

From the widget’s point of view:

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

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

  try {
    await cancelJobOnServer(state.jobId); // MCP tool or HTTP
  } catch (e) {
    // If server-side cancel failed — revert the status
    setState(prev => ({ ...prev, status: 'in_progress' }));
  }
}

And the button:

<button
  onClick={handleCancelClick}
  disabled={
    state.status !== 'pending' &&
    state.status !== 'in_progress' &&
    state.status !== 'partial_ready'
  }
>
  Cancel gift search
</button>

Here we use an optimistic UI: we switch to canceled right away without waiting for server confirmation. This is helpful when cancelation might take seconds — the user immediately sees that their action was accepted. But be prepared for the server to still return job.completed or job.failed if the worker managed to finish. In your event handler it’s worth filtering such “late” finals and, for example, not overwriting an already canceled state.

A more conservative approach is a pessimistic UI: first show a “Canceling…” state, block the button, and only after job.canceled move the job to canceled. It’s simpler to implement but feels less responsive. Choose the approach based on your backend’s SLA.

7. Putting it all together: a mini progress panel for GiftGenius

Now let’s assemble the pieces. We have already written:

  • the progress handler handleJobProgress,
  • the partial results handler handlePartialResult,
  • and the cancel handler handleCancelClick.

This is essentially the general handleEvent from the previous section: it reacts to job.progress, job.partial_result, job.canceled and other events and updates the state of a single component. All that’s left is to wrap it into a small GiftJobPanel component that:

  • starts the gift search;
  • listens for events by jobId;
  • shows progress;
  • renders partial results;
  • allows canceling the job.

We’ll greatly simplify the integration details with the Apps SDK / MCP and focus on the state logic.

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

Starting a job can be implemented via the MCP tool start_gift_search:

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

  const jobId = await startGiftSearchOnServer(/* user parameters */);
  setState(prev => ({ ...prev, jobId }));
}

Then in JSX:

return (
  <div>
    {state.status === 'idle' && (
      <button onClick={handleStartClick}>Find a gift</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>Selection stopped. You can start again with different parameters.</p>
    )}
  </div>
);

Separate subcomponents like ProgressSection, GiftsList, and ErrorSection help keep the main component from turning into “spaghetti.” But the key idea is the same: the entire widget is driven by a single clear state model that directly corresponds to the MCP events and streaming channels you already know.

8. A bit about pairing with the ChatGPT dialog

Although this lecture focuses on the widget itself, it’s important to remember the user is still in a dialog with the model. A good scenario looks like this: GPT tells the user it is launching GiftGenius, then the widget shows progress, and GPT supports it with text: “I’ve just started an advanced gift search; you’ll see the list fill in gradually.”

After the search completes, ChatGPT can pick up the result from ToolOutput and produce a human summary: “I found 10 options; here’s a quick overview, and the full list is in the widget below.” This duet of text streaming and streaming UI creates a cohesive experience.

This pairing will be even more important in the modules on workflow and commerce, where each long step (basket analysis, availability check, payment wait) should be clear both in text and in the interface.

9. Common mistakes in flow UX

Mistake #1: “Eternal spinner with no text.”
The most common anti-pattern is just spinning an animation without explaining what’s happening. The user doesn’t understand whether the system is doing anything useful or has hung. This is cured with a simple stage text (“Collecting popular gifts...”, “Analysing reviews”), and even better — explicit statuses pending, in_progress, partial_ready that you already keep in the widget state.

Mistake #2: Fake progress percentages.
Trying to “increase trust” by drawing made-up progress (“73%” out of thin air) usually has the opposite effect. Users quickly notice that 99% can hang for 20 seconds and stop trusting the indicator. If you don’t have an honest metric, use stages and an indeterminate progress bar instead of deceiving the user.

Mistake #3: Partial results that break everything.
Sometimes partial results are implemented as a fully rebuilt list that disappears or reshuffles with each event. As a result, the user clicks a card and it suddenly runs away down the page. This jitter is especially dangerous in commerce scenarios. It’s better to add cards carefully (often — only to the end), preserve keys, and minimise layout shifts.

Mistake #4: Cancelation that doesn’t cancel anything.
It happens that the widget has a “Cancel” button that merely hides the UI but doesn’t stop the real job on the server. Resources keep being spent, late job.completed events arrive, while the user thinks everything has stopped. Real cancelation should affect both the frontend (disable buttons, stop the stream) and the backend (send a cancel signal to the worker and receive a job.canceled event).

Mistake #5: Ignoring the finale and a “dumb” error screen.
Sometimes after job.completed the widget just shows a list of gifts with no next steps, and on job.failed — only a technical message like “Error 500.” In both cases the UX breaks off. It’s better to give a short summary and an explicit CTA at the end (“Save selection,” “Proceed to purchase”), and on error — a human explanation and buttons like “Try again” or “Change parameters,” rather than leaving the user alone with a status code.

1
Task
ChatGPT Apps, level 13, lesson 2
Locked
JobStatusPanel — honest progress (determinate vs indeterminate)
JobStatusPanel — honest progress (determinate vs indeterminate)
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION