1. Warum Stream‑UX gerade in der ChatGPT‑App wichtig ist
Im klassischen Web sind Nutzer an Upload‑Fortschrittsbalken, drehende Spinner und Skeleton‑Screens gewöhnt. In ChatGPT‑Apps gibt es jedoch einen zusätzlichen „Konkurrenten“: das Modell selbst, das Text in Echtzeit streamen kann. Wenn euer Widget in diesem Moment nur einen statischen Spinner ohne Erläuterung zeigt, verliert es subjektiv – GPT wirkt „lebendig“, die App hingegen „hängt“.
UX für lang laufende Operationen löst mehrere Aufgaben gleichzeitig. Erstens senkt es die Nervosität des Nutzers: Anstatt „ist es eingefroren oder denkt es noch?“ sieht er Status, Phasen, Prozente und sogar erste Ergebnisse. Zweitens stärkt es das Vertrauen: Wenn die App klar zeigt, was sie tut (Bewertungen analysieren, Preise vergleichen, Geschenke filtern), entsteht die viel zitierte operational transparency – operative Transparenz. Der Nutzer versteht: Unter der Haube steckt keine Magie, sondern eine nachvollziehbare Abfolge von Schritten.
Und schließlich geht es bei Stream‑UX nicht nur um Fortschritt, sondern auch um Kontrolle. Die Möglichkeit, eine aufwendige Geschenk‑Suche zu stoppen, Parameter zu ändern und sofort neu zu starten, ist ein wichtiger Teil des Gefühls „Ich steuere, statt auf Gnade des Servers zu warten“.
In dieser Vorlesung werden wir:
- ein einfaches Zustandsmodell für einen lang laufenden Job entwerfen (pending / in_progress / partial_ready / …);
- es in den React‑State des Widgets überführen;
- klären, wie man ehrlichen Fortschritt und Teilergebnisse anzeigt;
- und den Abbruch solcher Jobs sauber umsetzen.
All das – am Beispiel unseres GiftGenius.
2. Zustandsmodell für lang laufende Operationen in GiftGenius
Damit der Ereignisstrom nicht in ein Chaos aus if (event.type === …) ausartet, ist es hilfreich, einen lang laufenden Job als endlichen Automaten (State Machine) auf dem Client zu betrachten. Für GiftGenius verwenden wir folgende logische Zustände, die ihr bereits aus der Theorie kennt: pending, in_progress, partial_ready, completed, failed, canceled sowie den Wartezustand idle.
Fassen wir sie in einer Tabelle zusammen:
| Status | Was es im Backend bedeutet | Was der Nutzer im Widget sieht |
|---|---|---|
|
Es gibt noch keinen Job | Normales Formular, Schaltfläche „Geschenk finden“ |
|
Job erstellt, warten auf Start des Workers | Button deaktiviert, leichter Spinner |
|
Worker läuft, sendet job.progress | Fortschrittsbalken oder Schritte „Schritt 1 von 3“ |
|
Erste Ergebnisse vorhanden, Arbeit läuft weiter | Erste Geschenke bereits sichtbar + weiterhin Fortschritt |
|
job.completed eingetroffen | Endgültige Geschenkliste, CTA („Kaufen“) |
|
job.failed eingetroffen | Fehlermeldung + Schaltfläche „Erneut versuchen“ |
|
job.canceled oder Cancel‑Flag eingetroffen | Text „Auswahl gestoppt“ + „Neu starten“ |
Dasselbe Modell passt gut zu MCP‑Ereignissen. Beispielsweise übersetzt job.started den Status von pending zu in_progress; job.progress kann entweder nur die Prozente in in_progress aktualisieren oder mitteilen „wir haben erste Karten“, woraufhin ihr zu partial_ready wechselt. job.completed, job.failed und job.canceled schließen die Geschichte ab.
Das sieht wie ein kleiner Zustandsautomat aus:
stateDiagram-v2
[*] --> idle
idle --> pending: Job erstellen
pending --> in_progress: job.started
in_progress --> partial_ready: erste Teilergebnisse
partial_ready --> completed: job.completed
in_progress --> completed: job.completed (ohne Partial)
in_progress --> failed: job.failed
partial_ready --> failed: job.failed
in_progress --> canceled: job.canceled
partial_ready --> canceled: job.canceled
failed --> idle: erneuter Start
canceled --> idle: erneuter Start
Im Widget‑Code lässt sich das mit einem einfachen Typ abbilden:
type JobStatus =
| 'idle'
| 'pending'
| 'in_progress'
| 'partial_ready'
| 'completed'
| 'failed'
| 'canceled';
interface GiftJobState {
status: JobStatus;
percent?: number;
stage?: string;
error?: string;
}
Bislang ist das nur die Datenform. Als Nächstes füllen wir sie mit Inhalt, sobald Ereignisse aus MCP oder per Stream eintreffen.
3. Widget‑State: Wie die React‑Komponente dem Stream „zuhört“
Übertragen wir unser Zustandsmodell in den React‑Code des GiftGenius‑Widgets. Wir müssen speichern:
- die aktuelle jobId, um zu wissen, welche Events zu diesem Job gehören;
- den Job‑State (status, percent, stage);
- ein Array mit Teilergebnissen (Geschenk‑Karten);
- Flags für Buttons: ob Abbrechen möglich ist, ob ein Neustart möglich ist.
Beschreiben wir das in einem Interface:
interface GiftSuggestion {
id: string;
title: string;
price: string;
}
interface GiftWidgetState extends GiftJobState {
jobId?: string;
partialGifts: GiftSuggestion[];
}
Die Initialisierung in der Komponente kann ganz einfach aussehen:
const [state, setState] = useState<GiftWidgetState>({
status: 'idle',
partialGifts: [],
});
Danach gibt es zwei Schlüsselpunkte.
Erstens: das Starten des Jobs. Das kann ein Aufruf eines MCP‑Tools über das Apps SDK sein (callTool) oder ein HTTP‑Request an euer Backend, das den Job erstellt und eine jobId zurückgibt. In dieser Vorlesung vertiefen wir uns nicht in die Details des Async‑Pipelines – das kommt im nächsten Thema über Queues und Worker. Uns interessiert hier nur die UI‑Reaktion auf eine bereits erzeugte jobId.
Zweitens: das Abonnieren der Events dieser jobId. In der Praxis kann das ein Hook wie useJobEvents(jobId) sein oder ein Wrapper subscribeToJobEvents, die unter der Haube entweder SSE oder einen MCP‑Client nutzen, aber nach außen bereits normale JS‑Objekte liefern. Unten zeigen wir der Einfachheit halber subscribeToJobEvents innerhalb von useEffect:
useEffect(() => {
if (!state.jobId) return;
const unsubscribe = subscribeToJobEvents(state.jobId, handleEvent);
return () => unsubscribe();
}, [state.jobId]);
Dabei aktualisiert handleEvent einfach den state abhängig vom Event‑Typ. Als Nächstes betrachten wir nacheinander drei Ereignisgruppen, die es verarbeitet: Fortschritt, Teilergebnisse und Job‑Abbruch.
4. Fortschritt visualisieren: Prozente, Phasen und Ehrlichkeit
Fortschritt im UX gibt es in zwei Varianten: bestimmt (determinate) und unbestimmt (indeterminate). Im ersten Fall wisst ihr tatsächlich, wie viel Arbeit erledigt ist – zum Beispiel vier Workflow‑Schritte oder 30 von 100 Dateien. Im zweiten Fall gebt ihr ehrlich zu, dass ihr die Restzeit nicht kennt, und zeigt eine „wir denken nach“-Animation statt eines ausgedachten „73%“.
In GiftGenius kann die Logik so aussehen: Wenn das Backend den Fortschritt wirklich berechnet – zum Beispiel mit Schritten wie collect_sources, analyze_preferences, rank_candidates, enrich_descriptions – könnt ihr im job.progress-Event eine Payload mit Feldern stepCurrent, stepTotal, statusText und optional einem sinnvollen percent zurückgeben.
Event‑Typ 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;
}
Fortschritts‑Handler in der Komponente:
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 kann man sowohl den Fortschrittsbalken als auch den Phasentext rendern:
{(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>
)}
Hier zählt ein psychologischer Aspekt: Wenn ihr keinen ehrlichen Prozentsatz habt, zeigt lieber „Schritt 2 von 3: Präferenzen analysieren“ plus einen unbestimmten Fortschrittsbalken (animierte Leiste), statt für 30 Sekunden bei „99%“ stehen zu bleiben. So ein Hybrid (Phasen + indeterminater Indikator) funktioniert sehr gut bei KI‑Operationen, deren Restdauer schwer abzuschätzen ist.
5. Teilergebnisse: Nicht warten, bis alles perfekt ist
Der angenehmste Teil des Stream‑UX sind Teilergebnisse. Warum den Nutzer warten lassen, wenn ihr nach 5–7 Sekunden bereits erste relevante Geschenke habt? Zeigt sie sofort an und ladet den Rest nach.
In GiftGenius kann das so aussehen: Das Backend sendet im Laufe der Arbeit entweder spezielle Events job.partial_result oder z. B. resource.updated mit einer neuen Portion Empfehlungen. Jedes Event bringt ein Array von Geschenken mit, das zu den bereits vorhandenen hinzugefügt wird.
Beispielhafte Form der 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 rendert ihr die Karten einfach unabhängig davon, ob der Job abgeschlossen ist oder nicht:
<section>
{state.partialGifts.map(gift => (
<GiftCard key={gift.id} gift={gift} />
))}
{(state.status === 'in_progress' || state.status === 'partial_ready') && (
<p>Wir suchen weiter nach weiteren Optionen…</p>
)}
</section>
Hier gibt es einige wichtige UX‑Details, die man beachten sollte.
Erstens: Vermeidet abrupte Layout‑Sprünge (layout shift). Wenn ihr neue Geschenke oben einfügt, verliert der Nutzer seine Leseposition. Sicherer ist es, am Ende anzuhängen (append‑only) und das Erscheinen sanft zu animieren.
Zweitens: Wenn ihr eine Refinement‑Strategie nutzt (zuerst eine schnelle Rohliste, dann Politur und Neu‑Ranking), geht vorsichtig mit Interaktivität um. Solange die Ergebnisse „vorläufig“ sind, erlaubt keinen Klick auf „Kaufen“ oder kennzeichnet die Liste klar als „vorläufig“. Sonst wählt der Nutzer ein Geschenk aus, und kurz darauf verschwindet es oder der Preis ändert sich – eine UX‑Katastrophe.
Drittens: Der Zustand partial_ready sollte sich visuell von completed unterscheiden. Der Nutzer muss verstehen, dass die Liste noch wächst: etwa mit dem Text „Auswahl läuft weiter“, einem kleinen Spinner in der Ecke oder einer neutralen Hervorhebung neuer Karten.
6. Abbruch lang laufender Operationen: UX und Technik
Wenn ihr dem Nutzer erlaubt, eine aufwendige Geschenk‑Suche zu starten, solltet ihr ihm fast immer auch erlauben, sie zu stoppen. Abbruch spart nicht nur LLM‑ und Worker‑Ressourcen, sondern vermittelt auch Kontrolle: „Ich entscheide, was passiert.“
Aus UX‑Sicht sollte der Abbrechen‑Button gut sichtbar sein, aber nicht als schreiendes rotes Banner in der Mitte. Gut funktioniert ein Paar: eine primäre Schaltfläche „Auswahl abbrechen“ und ein kleiner sekundärer Text „kann jederzeit neu gestartet werden“. Wichtig ist, dass klar ist, was genau abgebrochen wird – die aktuelle Analyse, nicht die gesamte Anwendung.
Aus technischer Sicht gibt es zwei Ebenen des Abbruchs.
Erstens: Abbruch im Frontend. Ihr könnt das lokale fetch abbrechen oder die SSE‑Verbindung schließen. Das spart Traffic, stoppt den Worker im Backend aber nicht.
Zweitens: der echte Job‑Abbruch – über ein MCP‑Tool oder einen HTTP‑Endpoint POST /jobs/{jobId}/cancel, der den Job als canceled markiert und dem Worker erlaubt, sauber zu beenden. Der Server sendet dabei ein job.canceled-Event, das ihr im Widget verarbeitet.
Aus Sicht des Widgets:
async function handleCancelClick() {
if (!state.jobId) return;
// Optimistisches UI-Update
setState(prev => ({ ...prev, status: 'canceled' }));
try {
await cancelJobOnServer(state.jobId); // MCP-Tool oder HTTP
} catch (e) {
// Wenn der Abbruch auf dem Server scheitert – Status zurücksetzen
setState(prev => ({ ...prev, status: 'in_progress' }));
}
}
Und der Button:
<button
onClick={handleCancelClick}
disabled={
state.status !== 'pending' &&
state.status !== 'in_progress' &&
state.status !== 'partial_ready'
}
>
Auswahl abbrechen
</button>
Hier nutzen wir ein optimistisches UI: Wir wechseln sofort zu canceled, ohne auf die Bestätigung vom Server zu warten. Das ist nützlich, wenn der Abbruch Sekunden dauern kann – der Nutzer sieht unmittelbar, dass seine Aktion angenommen wurde. Man muss aber damit rechnen, dass der Server dennoch job.completed oder job.failed zurückgibt, falls der Worker noch rechtzeitig fertig wurde. Im Event‑Handler sollte man solche verspäteten Abschlüsse filtern und z. B. einen bereits canceled Status nicht überschreiben.
Konservativer ist ein pessimistisches UI: Zuerst zeigen wir „Wird abgebrochen …“, blockieren den Button und wechseln erst nach job.canceled zu canceled. Das ist einfacher umzusetzen, visuell aber weniger reaktiv. Welcher Ansatz passt, hängt vom SLA eures Backends ab.
7. Alles zusammenführen: die kleine Fortschritts‑Panel von GiftGenius
Nun setzen wir die Bausteine zusammen. Wir haben bereits geschrieben:
- den Fortschritts‑Handler handleJobProgress,
- den Handler für Teilergebnisse handlePartialResult,
- und den Abbruch‑Handler handleCancelClick.
Im Grunde ist das der allgemeine handleEvent aus dem vorherigen Abschnitt: Er reagiert auf job.progress, job.partial_result, job.canceled und andere Events und aktualisiert den State einer Komponente. Es bleibt, das Ganze in eine kleine Komponente GiftJobPanel zu kapseln, die:
- die Geschenk‑Suche startet,
- Events zur jobId abonniert,
- den Fortschritt zeigt,
- Teilergebnisse rendert,
- und den Job abbrechen lässt.
Wir vereinfachen die Integrationsdetails mit Apps SDK / MCP stark und konzentrieren uns auf die Zustandslogik.
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 ?? 'Etwas ist schiefgelaufen',
}));
break;
case 'job.canceled':
setState(prev => ({ ...prev, status: 'canceled' }));
break;
}
});
return () => unsub();
}, [state.jobId]);
Das Starten des Jobs kann über ein MCP‑Tool start_gift_search erfolgen:
async function handleStartClick() {
setState({
status: 'pending',
partialGifts: [],
});
const jobId = await startGiftSearchOnServer(/* Benutzerparameter */);
setState(prev => ({ ...prev, jobId }));
}
Weiter in JSX:
return (
<div>
{state.status === 'idle' && (
<button onClick={handleStartClick}>Geschenk finden</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>Auswahl gestoppt. Sie können mit anderen Parametern neu starten.</p>
)}
</div>
);
Einzelne Unterkomponenten wie ProgressSection, GiftsList, ErrorSection verhindern, dass die Hauptkomponente zur „Spaghetti“ wird. Die Kernidee bleibt: Das gesamte Widget wird von einem einzigen, verständlichen Zustandsmodell gesteuert, das direkt den MCP‑Events und Stream‑Kanälen entspricht, die ihr bereits kennt.
8. Ein paar Worte zur Kopplung mit dem ChatGPT‑Dialog
Auch wenn diese Vorlesung sich auf das Widget konzentriert, bleibt der Nutzer weiterhin im Dialog mit dem Modell. Ein guter Ablauf sieht so aus: GPT teilt dem Nutzer mit, dass es GiftGenius startet; dann zeigt das Widget den Fortschritt, und GPT unterstützt das textlich: „Ich habe soeben eine erweiterte Geschenk‑Suche gestartet. Sie sehen gleich, wie sich die Liste schrittweise füllt.“
Nach Abschluss der Suche kann ChatGPT das Ergebnis aus dem ToolOutput aufgreifen und ein verständliches Resümee formulieren: „Ich habe 10 Optionen gefunden; hier ist ein kurzer Überblick, die vollständige Liste steht im Widget unten.“ Dieses Duo aus Text‑Streaming und Stream‑UI schafft ein stimmiges Erlebnis.
Diese Verbindung wird in den Modulen zu Workflow und Commerce noch wichtiger, wo jeder lang laufende Schritt (Warenkorb analysieren, Verfügbarkeit prüfen, auf Zahlung warten) sowohl im Text als auch in der Oberfläche verständlich sein sollte.
9. Typische Fehler im Stream‑UX
Fehler Nr. 1: „Ewiger Spinner ohne Text“.
Das häufigste Anti‑Pattern ist, einfach eine Animation zu drehen, ohne zu erklären, was passiert. Der Nutzer weiß nicht, ob das System etwas Sinnvolles tut oder hängt. Abhilfe schafft ein einfacher Phasentext („Wir sammeln beliebte Geschenke …“, „Wir analysieren Bewertungen“), noch besser – explizite Status pending, in_progress, partial_ready, die ihr ohnehin im Widget‑State haltet.
Fehler Nr. 2: Fake‑Prozentangaben beim Fortschritt.
Der Versuch, „Vertrauen zu gewinnen“, indem man einen erfundenen Fortschritt zeichnet („73% aus der Luft gegriffen“), bewirkt meist das Gegenteil. Nutzer merken schnell, dass 99% 20 Sekunden stehen bleiben können, und glauben dem Indikator nicht mehr. Wenn ihr keine ehrliche Metrik habt, nutzt lieber Phasen und einen indeterminaten Balken, statt zu täuschen.
Fehler Nr. 3: Teilergebnisse, die alles durcheinanderbringen.
Manchmal werden Teilergebnisse als komplett neu aufgebaute Liste implementiert, die bei jedem Event verschwindet oder neu sortiert wird. Der Nutzer klickt auf eine Karte, und sie springt plötzlich nach unten. Dieses Zittern ist im Commerce‑Kontext besonders schlimm. Korrekt ist, Karten behutsam hinzuzufügen (oft nur ans Ende), Keys stabil zu halten und Layout‑Sprünge zu minimieren.
Fehler Nr. 4: Abbruch, der nichts abbricht.
Es kommt vor, dass ein Widget zwar einen „Abbrechen“-Button hat, der nur die UI versteckt, den echten Job auf dem Server aber nicht stoppt. Ressourcen werden weiter verbraucht, späte job.completed treffen ein, und der Nutzer glaubt bereits, alles sei gestoppt. Ein echter Abbruch muss sowohl Frontend (Buttons deaktivieren, Stream stoppen) als auch Backend betreffen (Cancel‑Signal an den Worker senden und ein job.canceled-Event erhalten).
Fehler Nr. 5: Das Finale ignorieren und ein „dummer“ Fehlerschirm.
Manchmal zeigt das Widget nach job.completed einfach die Geschenkliste ohne nächste Schritte, und bei job.failed nur eine technische Meldung „Fehler 500“. In beiden Fällen reißt der UX‑Fluss ab. Besser ist es, am Ende eine kurze Zusammenfassung und einen klaren CTA zu bieten („Auswahl speichern“, „Zum Kauf fortfahren“), und bei Fehlern eine verständliche Erklärung sowie Buttons „Erneut versuchen“ oder „Parameter ändern“ statt den Nutzer mit dem Statuscode allein zu lassen.
GO TO FULL VERSION