Appearance
Chat that feels attentive
The message that looked answered
A user sent a second question while the assistant was still composing its first reply. The new message entered the history query before that reply was stored. On the next run, the model saw the question above an assistant response and assumed it had already answered it.
The assistant had not ignored the user intentionally. The context builder had destroyed causality.
The decision is to build each turn around the exact outbox rows it claimed. Remove those messages from ordinary history, then append them together as the final user turn. The model sees what remains unanswered, regardless of database insertion timing.
Attentiveness starts with ordering, not tone.
A small map of the chat loop
text
user sends one or more messages
-> outbox debounce
-> worker claims all pending rows for the thread
-> context loads older history without claimed messages
-> claimed messages become final user turn
-> assistant answers every item
-> reply updates unread metadata
-> visible thread marks reply read
first message
-> immediate excerpt title
-> asynchronous generated title
-> manual rename always winsThis design covers both runtime causality and the interface signals that tell the user where attention is needed.
Batch the messages the worker owns
A short debounce, such as 1.5 seconds, catches the common case where the user sends a correction or adds context in a second bubble. More important, the worker should claim every pending outbox row for that conversation in one transaction.
If the batch contains multiple messages, render them as one final user turn with local timestamps. Add a clear section that says how many messages remain unanswered and asks the model to cover each item. Timestamps help when the user changes direction between messages.
ts
function pendingTurn(messages: ClaimedMessage[], timeZone: string): Turn {
const content = messages.length === 1
? messages[0].content
: messages
.map(message => `[${formatTime(message.createdAt, timeZone)}] ${message.content}`)
.join("\n");
return {
role: "user",
content: messages.length === 1
? content
: `## Unanswered messages\nThere are ${messages.length} items. Cover each one.\n\n${content}`,
};
}The recall query should use the full batch, not whichever message sorts last. Open todos from the thread can enter the same context as a separate bounded section.
If the previous assistant response already addressed one item by coincidence, the model can say so briefly. It should not silently skip the item.
Do not confuse activity with read state
Conversation updated_at tells when anything changed. It cannot say whether the user has seen the latest assistant reply. Store:
last_assistant_at- a bounded first-line excerpt
last_read_at
A thread is unread when the assistant timestamp is newer than the read timestamp. Mark it read only while the thread is active and the document is visible. This avoids clearing the dot for a background tab.
The same state can drive a sidebar dot, bold title, home inbox card, document title count, and app badge. One predicate should own the comparison so the surfaces do not disagree.
Failed replies need an excerpt too. A conversation that contains an error still deserves attention and should not vanish from the inbox.
Titles should be immediate, then improve
Waiting for a model-generated title delays navigation and leaves blank rows. Use the first user message as an immediate fallback, trimmed to a fixed length. Generate a shorter title in a background poller only after an error-free assistant reply exists.
Persist title state such as pending, generating, generated, failed, and manual. Retry a bounded number of times. Recover interrupted generating rows on startup.
The critical race is manual rename during generation. The final model update must include a condition that the row is still generating. A manual state then wins without coordination between the UI and poller.
Generated output needs normalization. Remove surrounding quotes and ending punctuation, trim whitespace, and enforce a length limit. If generation fails, the fallback remains useful.
The header is part of attentiveness
A common field observation is a title on one line with a connection dot stranded on the next. In the same class of implementation, desktop may hide the only heading while two live status regions appear at once. None of these bugs changes model output, but they make the conversation feel unreliable.
Use one heading component in mobile and desktop layouts. It should own the truncated title, inline connection dot, optional status text, and any private or provenance label. Keep one live status region visible to assistive technology. Set the browser document title from the open conversation.
Status is not decoration. Connected, retrying, offline, and sign-in states must occupy predictable places without shifting the title.
Terminology
- Claimed message is an outbox item owned by the current worker run.
- Pending batch is all claimed user input that the next reply must address.
- Causality is the ordering relationship between user input and the reply generated for it.
- Debounce is a short delay used to collect nearby sends.
- Unread state compares the latest assistant activity with the user's read marker.
- Fallback title is the immediate excerpt shown before generation.
- Manual title is user-owned and cannot be replaced by background work.
- Provenance label explains why a message appeared, such as an intent or proactive check-in.
These terms separate durable state from UI effects. An unread dot is a rendering of timestamps, not an independent flag to synchronize.
Decisions and rejected alternatives
The first decision is to append claimed messages after history rather than trust global message order. Database timestamps tell insertion order, not which reply was produced for which outbox set.
The second is to batch at the worker. Client-only debounce cannot cover messages from another tab or channel, and it cannot repair a message that arrives during model execution.
The third is to derive unread state from timestamps. A boolean flag is vulnerable to lost updates when new replies and read actions race.
The fourth is to generate titles asynchronously. Title quality matters for navigation, but never enough to delay the main answer.
Rejected alternatives include one run per message, using only the latest pending message, and marking a thread read as soon as it mounts. One run per message creates overlapping answers. Latest-only drops context. Mount-based reads clear indicators in hidden tabs.
Failure modes
- A message arriving mid-run sorts before the reply and appears answered.
- Only the newest outbox row enters recall.
- A long debounce makes chat feel delayed.
- The model answers one item from a batch and ignores the rest.
- Read state clears while the document is hidden.
- Intent and proactive replies fail to update unread metadata.
- A title poller overwrites a manual rename.
- Failed title generation leaves a blank row.
- Mobile and desktop headings use separate logic and drift.
- Two connection live regions announce the same state.
Field checklist
An attentive chat can still present complex information without forcing every client to understand a private format. The next chapter defines rich replies that remain useful as plain Markdown: Rich replies with plain fallbacks. The interruption controls for unsolicited messages are in Proactive with a budget.