How would you design Stop and Regenerate in a chat UI so a stopped answer keeps its partial output?
Advertisement
🧩 Scenario
Architecture Walkthrough
Why a single string per message is not enough
Stop and Regenerate introduce states a plain { role, text } message cannot express: an answer that was cut short, an answer being replaced, and older answers the user might want back. A global isLoading flag also cannot tell you which bubble is streaming. So the first move is a richer message model.
The message model
Think of an assistant message like a Git file with history:
- status:
streaming,done,stoppedorerror, stored per version. - versions: every answer generated for this slot.
- activeVersion: which one is shown.
User messages stay simple. Only assistant messages carry versions.
Stop
Stop does two things. On the network side, it aborts the request (the AbortController mechanics are covered in the streaming week). On the state side, it marks the active version as stopped and keeps its text. The UI shows the partial answer with a small "Stopped" label and a Regenerate button. Nothing the user already read disappears.
Regenerate
Regenerate means "answer the same question again". So the history you send is everything before this assistant message, ending with the user message that triggered it. You do not include the version being replaced. You then push a new version with status streaming and make it active. Older versions stay in the array for the switcher.
If the user regenerates an answer in the middle of the conversation, decide what happens to later messages. The common choice is to drop them from the visible thread, because they were replies to a different answer.
Late chunks
When you abort one request and start another, a chunk from the old request can still arrive. Give each request an id, store it on the version, and ignore chunks whose id does not match. It is the same race you handle when an old search request returns after a new one.
With the AI SDK
useChat gives you stop(), regenerate() (last assistant message by default, or a specific messageId) and a status of submitted, streaming, ready or error. A versions switcher is still yours to build.
Key Code Explained
type Status = 'streaming' | 'done' | 'stopped' | 'error';
type Version = { requestId: string; text: string; status: Status };
type ChatMessage =
| { id: string; role: 'user'; text: string }
| { id: string; role: 'assistant'; versions: Version[]; active: number };
type Action =
| { type: 'chunk'; messageId: string; requestId: string; text: string }
| { type: 'stop'; messageId: string }
| { type: 'regenerate'; messageId: string; requestId: string };
function updateActive(m: ChatMessage, fn: (v: Version) => Version): ChatMessage {
if (m.role !== 'assistant') return m;
const versions = m.versions.map((v, i) => (i === m.active ? fn(v) : v));
return { ...m, versions };
}
export function chatReducer(state: ChatMessage[], action: Action): ChatMessage[] {
switch (action.type) {
case 'chunk':
return state.map((m) =>
m.id !== action.messageId
? m // untouched messages keep their reference
: updateActive(m, (v) =>
v.requestId === action.requestId && v.status === 'streaming'
? { ...v, text: v.text + action.text }
: v, // stale chunk from an aborted request: ignore
),
);
case 'stop':
return state.map((m) =>
m.id !== action.messageId
? m
: updateActive(m, (v) => (v.status === 'streaming' ? { ...v, status: 'stopped' } : v)),
);
case 'regenerate': {
const index = state.findIndex((m) => m.id === action.messageId);
const target = state[index];
if (!target || target.role !== 'assistant') return state;
const fresh: Version = { requestId: action.requestId, text: '', status: 'streaming' };
const updated = { ...target, versions: [...target.versions, fresh], active: target.versions.length };
// Later messages replied to the old answer, so drop them
return [...state.slice(0, index), updated];
}
}
}
// History sent to the model for regenerate: everything before the target message
export function historyForRegenerate(state: ChatMessage[], messageId: string) {
const index = state.findIndex((m) => m.id === messageId);
return state.slice(0, index).map((m) =>
m.role === 'user'
? { role: 'user' as const, content: m.text }
: { role: 'assistant' as const, content: m.versions[m.active].text },
);
}
The requestId check in chunk is what protects the new version from late chunks of the aborted one. historyForRegenerate stops before the target message, so the answer being replaced never goes back to the model. Earlier stopped answers are sent as the user saw them; change that line if your product decides otherwise.
Tradeoffs
| Decision | Option A | Option B |
|---|---|---|
| Stopped text on screen | Keep with a "Stopped" label (users keep what they read) | Remove (simpler, but loses content) |
| Regenerate storage | New version in the same slot (clean thread, switcher possible) | Append new message (simple, confusing history) |
| Stopped text in future context | Send it (model matches what user saw) | Omit it (no building on half answers) |
| Later messages after mid-thread regenerate | Drop them (consistent thread) | Keep as a branch (more complex tree state) |
What Interviewers Actually Check
- Whether you model status per message instead of one global flag
- Whether Stop keeps partial text
- Whether you build the correct history slice for Regenerate
- Whether you handle late chunks from an aborted request
- Whether you name the product decisions instead of hiding them
Follow-Up Questions
- How would you persist versions to a database so they survive a refresh?
- How is "Continue" different from Regenerate in the prompt you send?
- How would you show a branching conversation tree instead of dropping later messages?
- How would you test the stale-chunk race in a unit test?
- What would you log to understand how often users stop or regenerate?
Common Candidate Mistakes
- Treating Stop as an error and showing a failure message
- Including the answer being regenerated in the history, so the model sees its own previous attempt
- Mutating the version object in place, which breaks memoized rendering
- Forgetting to drop or branch later messages after a mid-thread regenerate
- Relying on "the old stream is aborted, so no more chunks will come" without a guard
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you sketch the message and version types from memory?
- Can you explain the network and state side of Stop?
- Can you write the history builder for Regenerate?
- Can you guard against late chunks with a request id?
- Can you defend your choice about stopped text in future context?
Summary
Stop and Regenerate need a message model with per-version status, not a string and a global loading flag. Stop aborts the request and marks the active version as stopped while keeping its text. Regenerate sends history up to the user message before the reply, adds a new streaming version in the same slot and keeps old versions for a switcher. Tag every request with an id so late chunks from an aborted stream cannot leak into the new version, and make an explicit decision about whether stopped text goes back to the model.
Should a stopped partial answer be sent back to the model in the next turn?
It is a product decision. Sending it keeps the model consistent with what the user saw, which matters if the user replies to it. Leaving it out avoids the model building on a half-finished thought. Pick one rule, apply it in the function that builds history, and keep it consistent.
Should Regenerate replace the old answer or add a new message?
Most chat products treat it as a new version of the same reply, so the conversation stays one linear thread and the user can switch between versions. Appending a second assistant message after the first makes history confusing for both the user and the model.
Does the Vercel AI SDK handle this?
useChat exposes stop() to abort the current stream and regenerate(), which by default regenerates the last assistant message and also accepts a messageId. Keeping multiple versions for a version switcher is something you design yourself.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement