JavaRush /Курси /ChatGPT Apps /UX потоків: прогрес, partial results і скасування тривали...

UX потоків: прогрес, partial results і скасування тривалих операцій

ChatGPT Apps
Рівень 13 , Лекція 2
Відкрита

1. Чому UX потоків важливий саме у ChatGPT App

У звичайному вебі користувачі вже звикли до прогрес-бару під час завантаження файлів, обертового спінера та skeleton‑екрана. Але в застосунках ChatGPT у вас є додатковий «конкурент»: сама модель, яка вміє стрімити текст у реальному часі. Якщо віджет у цей момент показує статичний спінер без пояснень, враження буде гіршим: GPT «живий», а застосунок ніби підвисає.

UX для тривалих операцій розвʼязує одразу кілька завдань. По‑перше, він знижує тривожність користувача: замість «зависло чи ще думає?» людина бачить статуси, етапи, відсотки й навіть перші результати. По‑друге, UX підвищує довіру: коли застосунок прямо показує, що саме він робить (аналізує відгуки, звіряє ціни, фільтрує подарунки), це створює ту саму операційну прозорість (operational transparency). Користувач розуміє: під капотом не магія, а цілком зрозуміла послідовність кроків.

І нарешті, UX потоків — це не лише про прогрес. Це ще й про контроль. Можливість зупинити складний підбір подарунків, змінити параметри й одразу запустити процес знову — важлива частина відчуття «я керую, а не чекаю милості від сервера».

У цій лекції ми:

  • спроєктуємо просту модель станів тривалого завдання (pending / in_progress / partial_ready / …);
  • перенесемо її в стан React‑віджета;
  • розберемося, як чесно показувати прогрес і часткові результати;
  • акуратно реалізуємо скасування таких завдань.

Усе це — на прикладі нашого GiftGenius.

2. Модель станів тривалої операції в GiftGenius

Щоб не перетворити потік подій на кашу з if (event.type === …), зручно мислити тривале завдання як скінченний автомат станів (state machine) на клієнті. Для GiftGenius ми використовуватимемо логічні стани, які ви вже бачили в теорії: pending, in_progress, partial_ready, completed, failed, canceled плюс стан очікування idle.

Зведімо їх у таблицю:

Статус Що це означає на бекенді Що бачить користувач у віджеті
idle
Завдання ще немає Звичайна форма, кнопка «Підібрати подарунок»
pending
Завдання створено, чекаємо, доки стартує воркер Кнопка вимкнена, легкий спінер
in_progress
Воркер працює, надсилає job.progress Прогрес-бар або кроки «Крок 1 з 3»
partial_ready
Є перші результати, робота триває Уже видно перші подарунки + досі триває прогрес
completed
Прийшов job.completed Фінальний список подарунків, CTA («Купити»)
failed
Прийшов job.failed Повідомлення про помилку + кнопка «Спробувати ще раз»
canceled
Прийшов job.canceled або cancel‑прапорець Текст «Підбір зупинено» + «Почати заново»

Ця ж модель чудово лягає на MCP‑події. Наприклад, job.started переводить зі pending у in_progress. Подія job.progress може або просто оновити відсотки в in_progress, або «сказати»: у нас зʼявилися перші картки — і тоді ви переходите в partial_ready. Події job.completed, job.failed і job.canceled закривають цю історію.

Це виглядає як невеликий автомат станів:

stateDiagram-v2
    [*] --> idle
    idle --> pending: створити job
    pending --> in_progress: job.started
    in_progress --> partial_ready: перші partial results
    partial_ready --> completed: job.completed
    in_progress --> completed: job.completed (без partial)
    in_progress --> failed: job.failed
    partial_ready --> failed: job.failed
    in_progress --> canceled: job.canceled
    partial_ready --> canceled: job.canceled
    failed --> idle: повторний запуск
    canceled --> idle: повторний запуск

У коді віджета це можна відобразити простим типом:

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

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

Поки що це лише форма даних. Далі ми наповнюватимемо її змістом у міру надходження подій із MCP або потоком.

3. Стан віджета: як React‑компонент «слухає» потік

Перенесімо нашу модель станів у React‑код віджета GiftGenius. Нам потрібно зберігати:

  • поточний jobId, щоб розуміти, які події належать саме до цього завдання;
  • стан завдання (status, percent, stage);
  • масив часткових результатів (картки подарунків);
  • прапорці для кнопок: чи можна скасувати та чи можна перезапустити.

Опишімо це одним інтерфейсом:

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

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

Ініціалізація в компоненті може виглядати зовсім просто:

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

Далі маємо два ключові моменти.

По‑перше, запуск завдання. Це може бути виклик MCP‑інструмента через Apps SDK (callTool) або HTTP‑запит на ваш бекенд, який створює job і повертає jobId. У цій лекції ми не заглиблюємося в те, як саме влаштований асинхронний конвеєр, — цим займемося в наступній темі про черги та воркери. Зараз нам важлива лише реакція UI на вже створений jobId.

По‑друге, підписка на події цього jobId. На практиці це може бути хук на кшталт useJobEvents(jobId) або обгортка subscribeToJobEvents. Під капотом вони використовують або SSE‑зʼєднання, або MCP‑клієнт, але назовні повертають нам уже нормальні JS‑обʼєкти. Нижче для простоти покажемо варіант із subscribeToJobEvents усередині useEffect:

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

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

Де handleEvent просто оновлює state залежно від типу події. Далі по черзі розберемо три групи подій, які він обробляє: прогрес, часткові результати та скасування завдання.

4. Візуалізація прогресу: відсотки, етапи й чесність

У UX прогрес буває двох видів: визначений (determinate) і невизначений (indeterminate). У першому випадку ви справді знаєте, скільки роботи вже зроблено: наприклад, у вас є 4 кроки робочого процесу або опрацьовано 30 зі 100 файлів. У другому випадку ви чесно визнаєте, що не знаєте, скільки ще чекати, і показуєте анімацію «думаємо» замість вигаданих «73 %».

У GiftGenius логіка може бути такою: якщо бекенд реально рахує прогрес — наприклад, у нього є кроки collect_sources, analyze_preferences, rank_candidates, enrich_descriptions — ви можете повертати в події job.progress payload із полями stepCurrent, stepTotal, statusText і (за потреби) розумним percent.

Тип події в 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;
}

Обробник прогресу в компоненті:

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

У JSX можна відобразити і прогрес-бар, і текст етапу:

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

Тут важливий один психологічний нюанс. Якщо у вас немає чесного відсотка, краще показати «Крок 2 з 3: аналізуємо вподобання» плюс невизначений прогрес-бар (анімацію смужки), ніж «застиглі» 99 % протягом 30 с. Такий гібрид (етапи + індикатор невизначеного прогресу, indeterminate‑бар) чудово працює для AI‑операцій, де точний залишок підрахувати складно.

5. Partial results: не варто чекати, доки все стане ідеальним

Найприємніша частина потокового UX — часткові результати (partial results). Навіщо тримати користувача в очікуванні, якщо вже за 5–7 с у вас є перші доречні подарунки? Можна показати їх одразу, а решту довантажити пізніше.

У GiftGenius це може виглядати так. Бекенд у процесі роботи надсилає або спеціальні події job.partial_result, або, наприклад, resource.updated із новою порцією рекомендацій. Кожна така подія приносить масив подарунків, які додаються до вже наявних.

Приблизна форма payload:

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

Обробник:

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

У JSX ви просто відображаєте картки, незалежно від того, завершене завдання чи ні:

<section>
  {state.partialGifts.map(gift => (
    <GiftCard key={gift.id} gift={gift} />
  ))}
  {(state.status === 'in_progress' || state.status === 'partial_ready') && (
    <p>Ми продовжуємо шукати ще варіанти…</p>
  )}
</section>

Тут є кілька важливих UX‑нюансів, про які варто памʼятати.

По‑перше, намагайтеся уникати різких стрибків макета (layout shift). Якщо ви додаєте нові подарунки на початок списку, користувач втрачатиме місце читання. Безпечніше додавати їх у кінець (append‑only) і мʼяко анімувати появу.

По‑друге, якщо ви використовуєте стратегію уточнення (refinement) — спочатку швидкий чорновий список, а потім його допрацювали й пересортували, — потрібно акуратно поводитися з інтерактивністю. Поки результати «чорнові», не давайте натиснути «Купити» або прямо позначте такий список як «попередній». Інакше користувач вибере подарунок, а за секунду він зникне або змінить ціну — UX‑катастрофа.

І по‑третє, стан partial_ready має візуально відрізнятися від completed. Користувач має розуміти, що список ще доповнюється: або з тексту «Підбір триває», або з маленького спінера в кутку, або з нейтрального підсвічування нових карток.

6. Скасування тривалих операцій: UX і техніка

Якщо ви даєте користувачеві змогу запустити складний підбір подарунків, майже завжди варто дати змогу й зупинити його. Скасування — це не лише економія ресурсів LLM і воркерів, а й відчуття контролю: «я сам вирішую, що відбувається».

З погляду UX кнопка скасування має бути достатньо помітною, але не кричущою червоною плашкою посеред екрана. Добре працює пара: основна кнопка «Скасувати підбір» і невеликий вторинний текст «можна знову запустити будь-коли». Важливо, щоб користувачеві було зрозуміло, що саме скасовується — поточний аналіз, а не весь застосунок.

З технічного погляду у вас є два рівні скасування.

По‑перше, скасування на фронтенді: ви можете перервати локальний fetch або закрити SSE‑зʼєднання. Це економить трафік, але саме по собі не зупиняє воркер на бекенді.

По‑друге, справжнє скасування jobʼу: через MCP‑tool або HTTP‑ендпойнт POST /jobs/{jobId}/cancel, який позначає завдання як canceled і дає воркеру шанс коректно завершитися. При цьому сервер надсилає подію job.canceled, яку ви вже обробляєте у віджеті.

Погляд з точки зору віджета:

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

  // Оптимістичне оновлення UI
  setState(prev => ({ ...prev, status: 'canceled' }));

  try {
    await cancelJobOnServer(state.jobId); // MCP tool або HTTP
  } catch (e) {
    // Якщо скасування на сервері не вдалося — відкотимо статус
    setState(prev => ({ ...prev, status: 'in_progress' }));
  }
}

І кнопка:

<button
  onClick={handleCancelClick}
  disabled={
    state.status !== 'pending' &&
    state.status !== 'in_progress' &&
    state.status !== 'partial_ready'
  }
>
  Скасувати підбір
</button>

Тут ми використовуємо оптимістичний UI: одразу перемикаємося в canceled, не чекаючи підтвердження від сервера. Це корисно, коли скасування може тривати кілька секунд — користувач миттєво бачить, що його дію прийнято. Але потрібно бути готовими до того, що сервер усе ж поверне job.completed або job.failed, якщо воркер устиг дійти до кінця. В обробнику подій варто відсікати такі запізнілі фінали й, наприклад, не перезаписувати вже стан canceled.

Більш консервативний підхід — песимістичний UI: спочатку показуємо стан «Скасовуємо…», блокуємо кнопку і лише після job.canceled переводимо завдання в canceled. Він простіший у реалізації, але візуально менш відзивчивий. Підхід можна обирати залежно від SLA вашого бекенду.

7. Збираємо все докупи: міні‑панель прогресу GiftGenius

Тепер зберемо окремі частини разом. Ми вже написали:

  • обробник прогресу handleJobProgress,
  • обробник часткових результатів handlePartialResult,
  • і обробник скасування handleCancelClick.

Фактично це і є той самий загальний handleEvent із попереднього розділу: він реагує на job.progress, job.partial_result, job.canceled та інші події й оновлює стан одного компонента. Залишилося обгорнути все це в невеликий компонент GiftJobPanel, який:

  • запускає підбір подарунків;
  • слухає події за jobId;
  • показує прогрес;
  • рендерить partial results;
  • дає змогу скасувати завдання.

Помітно спростимо деталі інтеграції з Apps SDK / MCP і зосередимося на логіці стану.

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 ?? 'Щось пішло не так',
          }));
          break;
        case 'job.canceled':
          setState(prev => ({ ...prev, status: 'canceled' }));
          break;
      }
    });
    return () => unsub();
  }, [state.jobId]);

Запуск завдання може бути реалізований через MCP‑tool start_gift_search:

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

  const jobId = await startGiftSearchOnServer(/* параметри користувача */);
  setState(prev => ({ ...prev, jobId }));
}

Далі в JSX:

return (
  <div>
    {state.status === 'idle' && (
      <button onClick={handleStartClick}>Підібрати подарунок</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>Підбір зупинено. Можна запустити знову з іншими параметрами.</p>
    )}
  </div>
);

Окремі підкомпоненти на кшталт ProgressSection, GiftsList, ErrorSection допомагають не перетворювати основний компонент на важкочитану «локшину». Але ключова ідея одна: увесь віджет керується однією зрозумілою моделлю стану, яка безпосередньо відповідає MCP‑подіям і потоковим каналам, про які ви вже знаєте.

8. Трохи про звʼязок із ChatGPT‑діалогом

Хоча ця лекція зосереджена на самому віджеті, важливо памʼятати, що користувач усе ще перебуває в діалозі з моделлю. Гарний сценарій виглядає так: GPT повідомляє користувачеві, що запускає GiftGenius, потім віджет показує прогрес, а GPT підкріплює це текстом: «Я щойно запустив розширений підбір подарунків, ви бачитимете, як список поступово заповнюється».

Після завершення підбору ChatGPT може підхопити результат із ToolOutput і сформулювати людське резюме: «Я знайшов 10 варіантів, ось короткий огляд, а повний список — у віджеті нижче». Такий дует текстового стримінгу й потокового UI створює цілісний досвід.

Цей звʼязок буде ще важливішим у модулях про робочі процеси та комерційні сценарії, де кожен тривалий крок (аналіз кошика, перевірка наявності, очікування оплати) має бути зрозумілим і в тексті, і в інтерфейсі.

9. Типові помилки в UX потоків

Помилка № 1: «Вічний спінер без тексту».
Найпоширеніший антипатерн — просто крутити анімацію й ніяк не пояснювати, що відбувається. Користувач не розуміє, чи робить система щось корисне, чи зависла. Виправляється це простим текстом етапу («Збираємо популярні подарунки…», «Аналізуємо відгуки»), а ще краще — явними статусами pending, in_progress, partial_ready, які ви вже тримаєте в стані віджета.

Помилка № 2: Фейкові відсотки прогресу.
Спроба «підняти довіру» намальованим вигаданим прогресом («73 %» із повітря) зазвичай дає зворотний ефект. Користувач швидко помічає, що 99 % може «висіти» 20 с, і перестає вірити індикатору. Якщо у вас немає чесної метрики, краще використовуйте етапи й невизначений прогрес-бар замість того, щоб обманювати.

Помилка № 3: Часткові результати, які все ламають.
Інколи partial‑результати реалізують як список, який щоразу повністю перебудовується та пересортовується при кожній події. У результаті користувач натискає на картку, а вона раптово зʼїжджає вниз. Таке «смикання» особливо небезпечне в комерційних сценаріях. Краще додавати картки акуратно (часто — лише в кінець), зберігати ключі й мінімізувати стрибки макета.

Помилка № 4: Скасування, яке нічого не скасовує.
Буває й так: у віджеті є кнопка «Скасувати», яка лише ховає UI, але не зупиняє реальний job на сервері. У результаті ресурси продовжують витрачатися, приходять пізні job.completed, а користувач уже думає, що все зупинено. Справжнє скасування має зачіпати і фронтенд (вимкнути кнопки, зупинити стрим), і бекенд (передати cancel‑сигнал воркеру та отримати подію job.canceled).

Помилка № 5: Ігнорування фіналу і «глухий» екран помилки.
Інколи після job.completed віджет просто показує список подарунків без жодних наступних кроків, а при job.failed — лише технічне повідомлення «Помилка 500». І в тому, і в іншому випадку UX обривається. Правильніше наприкінці дати коротке резюме та явний CTA («Зберегти підбір», «Перейти до покупки»), а при помилці — людське пояснення і кнопки «Спробувати ще раз» або «Змінити параметри» замість того, щоб залишати користувача віч‑на‑віч із кодом статусу.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ