Try first
Frame your answer before reading the reference
Candidate prompt
Design the composer and current reply for an AI chat app. A user may type Japanese with an IME, attach a file that is still uploading, press Enter twice, or stop a streaming answer and retry. An event from the old Send, Stop, or stream must never change the new active turn. Assign the state owned by the textarea, draft store, upload flow, and current turn. Explain recovery when a Send or Stop response is lost.
Constraints
- IME Enter cannot send.
- Only ready attachments may send.
- An active reply turns Send into Stop.
- Superseded events are ignored.
Helpful before you start
- Controlled text input
- AbortController
- Streaming event basics
What good looks like in a 15-minute first pass
Must cover
- IME composition and unready attachments cannot send.
- Stable identities protect one logical send and active stream.
Strong signals
- Drafting continues, but Send becomes Stop during an active reply.
- Reconnect by sequence or snapshot; reject superseded stream events.
Stretch if time
Account drafts, cross-tab ownership, and safe Markdown.
Avoid
Local abort masquerades as server Stop, or stale streams mutate the active turn.
Need a hint? Three decisions to make
- Separate persisted draft, transient composition, and attachment readiness state.
- Choose stable identities for send retransmission, stream events, and Stop retransmission.
- Define terminal retry, sequence resume, and snapshot recovery without crossing turn identity.
Guided mock requires a tablet or desktop viewport of at least 768px. It will not start automatically.
Requirements
- Frame the interaction boundaryLimit the case to the composer, upload readiness, and one current assistant turn. History and model execution stay behind abstract contracts. The user may edit a new draft while a reply streams, but Send becomes Stop and cannot start a second response.
- Separate input and upload stateKeep composition status in the mounted textarea, recoverable text in an account-and-conversation draft, and each file in the upload flow. Enter submits only after compositionend; Shift+Enter preserves a newline. A snapshot contains only ready asset IDs. IME Enter, an uploading file, or a second Enter while Send is pending cannot submit.
- Create one logical sendFreeze text, ready attachment IDs, commandId, clientMessageId, and draft revision before clearing that revision. Show the user row immediately. If the Send response is lost, retransmit the same commandId; transport retry is not new intent. Later text belongs to a new editable draft.
- Stream and stop by identityBind one assistant placeholder to streamId and accept only its next sequence. Stop creates one stopCommandId and reuses it after response loss. AbortSignal may close the browser reader, but local abort is not authoritative server Stop. Preserve partial text; keep Send unavailable until an event or snapshot confirms complete, stopped, or failed.
- Reconcile before the next turnReconnect after lastSequence or fetch getTurnSnapshot. Merge results only when command, message, stream, and sequence identities match. After terminal state, Retry or Regenerate creates a fresh commandId and new streamId. Delayed Send, Stop, or stream results fail the active-turn check. Announce meaningful pending, stopped, failed, and completed changes, not token deltas.
Frontend boundary
Architecture
| Piece | Responsibility | Design rationale |
|---|---|---|
| ComposerView | Text, IME events, shortcuts, autosize, and focus | Keeps native input semantics at the interaction boundary. |
| DraftStore | Scopes and persists unsent text, excluding transient IME state | Allows navigation recovery without serializing mounted-control state or writing conversation history. |
| TurnCoordinator | Owns the stored Send and Stop commands, optimistic row, only active stream, and terminal reconciliation | Retransmission keeps an intent identity; Retry after terminal creates a new one. |
| AttachmentCoordinator | Tracks local, uploading, ready, and failed attachments | Send includes only finalized asset IDs. |
| StreamAdapter | Parses lowercase SSE fields and exposes typed sequenced events | Transport parsing stays outside components. |
ComposerView (transient IME interaction)
-> DraftStore (account + conversation + text revision)
-> AttachmentCoordinator (local file -> finalized asset ID)
-> TurnCoordinator (stored Send + stored Stop + only active turn)
-> ChatTurnClient (send + resume + snapshot + authoritative Stop)
-> StreamAdapter (stream ID + message ID + sequence)
-> Current-turn reducer (identity gate + terminal state)
-> MessageList and restrained status announcementsWorked example: Enter during IME composition followed by cancel
| Event | Store change | Visible UI | Invariant |
|---|---|---|---|
| Enter during composition | Observe composition state and do not submit. | The IME commits or selects text normally. | Shortcut does not corrupt input. |
| Composition ends | Update the draft and validated attachment references. | Autosize follows measured content within bounds. | Draft is one coherent value. |
| User sends | Store commandId plus clientMessageId and clear only the submitted draft revision. | The user row appears; a fresh draft remains editable; Send becomes Stop. | One response is active and a lost Send response reuses the stored command. |
| User stops stream | Create one stopCommandId, optionally abort local reading, and retain partial text while authoritative status reconciles. | Stop remains pending, then becomes Retry or Regenerate after terminal confirmation. | Local cancellation is not proof of server cancellation or message deletion. |
| User retries after stopped | Allocate a new commandId; acknowledgement establishes a new streamId. | A new assistant reply starts from the preserved prompt or product-defined retry input. | Late events from the prior Send, Stop, or stream cannot mutate the new turn. |
Read diagram as text
Text fallback: The composer owns text, IME state, and attachment readiness. Send freezes a snapshot with a command ID. The turn store accepts snapshot or event data only when conversation, message, stream, and sequence identities match.
Data
type AttachmentPhase = 'local' | 'uploading' | 'ready' | 'error';
type StreamPhase = 'pending' | 'streaming' | 'complete' | 'stopped' | 'failed';
interface ComposerDraft {
accountId: string;
conversationId: string;
text: string;
revision: number;
savedAt: number;
attachmentLocalIds: string[];
}
interface ComposerInteractionState {
isComposing: boolean;
}
interface AttachmentDraft {
localId: string;
phase: AttachmentPhase;
assetId: string | null;
error: string | null;
}
interface PendingSend {
conversationId: string;
commandId: string;
clientMessageId: string;
draftRevision: number;
phase: 'sending' | 'acknowledged' | 'uncertain';
}
interface StreamMessage {
conversationId: string;
commandId: string;
clientMessageId: string;
messageId: string;
streamId: string;
lastSequence: number;
text: string;
phase: StreamPhase;
}
interface TurnSnapshot extends StreamMessage {
updatedAt: number;
}
Draft, send-command, and stream ownership
| Situation | Identity policy | Result |
|---|---|---|
| Send response is lost | Retransmit commandId plus clientMessageId | The same logical user message converges |
| Stop response is lost | Retransmit the existing stopCommandId | One Stop intent is applied idempotently |
| User retries after terminal | Create a new commandId; accept a new streamId | Retry or Regenerate is a new user intent |
| Old event arrives | Compare command, message, stream, and sequence | A superseded turn cannot change active state |
Interface
interface SendCommand {
commandId: string;
clientMessageId: string;
text: string;
attachmentIds: string[];
}
interface StopCommand {
stopCommandId: string;
conversationId: string;
streamId: string;
}
type TurnEvent =
| { type: 'message.delta'; conversationId: string; streamId: string; messageId: string; sequence: number; delta: string }
| { type: 'message.completed'; conversationId: string; streamId: string; messageId: string; sequence: number }
| { type: 'message.stopped'; conversationId: string; streamId: string; messageId: string; sequence: number }
| { type: 'message.failed'; conversationId: string; streamId: string; messageId: string; sequence: number; errorCode: string };
interface OpenTurnEventsInput {
conversationId: string;
streamId: string;
afterSequence: number;
signal: AbortSignal;
}
interface UseChatComposerResult {
draft: ComposerDraft;
interaction: ComposerInteractionState;
attachments: AttachmentDraft[];
activeTurn: StreamMessage | null;
primaryAction: 'send' | 'stop';
updateText(text: string): void;
send(command: SendCommand): Promise<void>;
retransmitSend(): Promise<void>;
stop(command: StopCommand): Promise<void>;
retryAfterTerminal(command: SendCommand): Promise<void>;
}
interface ChatTurnClient {
send(input: SendCommand & { conversationId: string; signal: AbortSignal }): Promise<{
acceptedCommandId: string;
acceptedClientMessageId: string;
messageId: string;
assistantMessageId: string;
streamId: string;
}>;
openEvents(input: OpenTurnEventsInput): AsyncIterable<TurnEvent>;
stop(input: StopCommand & { signal: AbortSignal }): Promise<{
acceptedStopCommandId: string;
}>;
getTurnSnapshot(input: {
conversationId: string;
commandId: string;
clientMessageId: string;
streamId: string;
signal: AbortSignal;
}): Promise<TurnSnapshot>;
}event: message.delta
data: {"conversationId":"c_123","streamId":"s_123","messageId":"m_123","sequence":1,"delta":"hello"}
event: message.completed
data: {"conversationId":"c_123","streamId":"s_123","messageId":"m_123","sequence":2}
- RestoreRestore the account-and-conversation draft independently from recent messages.
- Send or retransmitAfter composition ends and attachments are ready, call send with stable command and client message IDs. If only its response is lost, retransmit that same commandId.
- Open or resume eventsOpen with afterSequence equal to lastSequence. Accept a next-sequence event only for the active conversationId, streamId, and messageId.
- Stop and reconcileCreate one stopCommandId per user intent and reuse it after a lost response. Local reading may abort, but resumed events or getTurnSnapshot must still confirm authoritative terminal status.
- Retry after terminalOnly complete, stopped, or failed exposes Retry or Regenerate. That user action creates a new Send command and accepts a new streamId; the old command is never repurposed.
UI-facing contract
Optimizations
- Keep the composer responsiveThe assistant reply is streaming from streamId s1. Send is rendered as Stop, yet the user may edit a separate draft. Recent history is paged independently, so neither a long transcript nor current-turn recovery blocks typing.
- Store the Stop intentThe user activates Stop. The coordinator creates stopCommandId x1, targets s1, and may abort the local reader for immediate feedback. The partial assistant text remains visible with a stopping status.
- Recover uncertain authorityThe Stop response is lost. Retrying reuses x1; it does not invent another cancellation intent. Local abort still is not authoritative Stop, so the client reconnects with afterSequence or asks getTurnSnapshot for s1.
- Reject the late resultA delayed delta or Send acknowledgement from s1 is accepted only while its command, message, stream, and sequence match the active turn. Once a terminal snapshot is stored, any later s1 event is ignored without erasing preserved text.
- Create a new retry turnAfter stopped or failed is authoritative, Retry or Regenerate creates a new commandId and receives streamId s2. A late Stop result for x1 may confirm old history, but it cannot change s2 or the new draft.
Accessibility behavior
Draft confidentiality and safe rendering
Performance and measurement
Read diagram as text
Text fallback: Stop targets stream one with a stable stop command. Retry creates stream two after reconciliation. A delayed delta from stream one fails the active-stream check and is ignored.
Use the Question Library for baseline coverage, then move into a Study Plan before targeted Company Prep.