Topic 4.5
Many Conversations
In one line
Users keep several chats. Each chat has its own messages and its own stage, a title made from its first question, and a place in a list. The hard part is a stream that is still running in one chat while the user opens, deletes or starts another: every token must land in the chat it was asked in.
Think of it like this
A desk with several case folders. You can have folder B open and be reading it while a clerk finishes typing a note into folder A. The clerk must put the note in the folder named on the note, not in whichever folder happens to be open in front of you. Every token carries the name of its chat, and goes there.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Active chat
- The chat shown on screen right now (
activeId). - Chat id
- The unique name of a chat. Every update to a chat's answer carries it.
- Routing
- Sending an action to the right chat's reducer by its id.
- Background stream
- An answer still arriving in a chat the user is not looking at.
- Neighbour
- The chat next to a deleted one in the list, opened so the screen isn't left blank.
- Code point
- One Unicode character. Some emoji are one code point, some are several.
- Surrogate pair
- The two UTF-16 units that make up characters such as 🍵.
slicecan cut between them. - Empty state
- What the screen shows when there is nothing to list yet.
Step by step
01The state
One list and one pointer. A chat remembers its own stage, so two chats can be in two different stages at once.
{
chats: [
{ id: "c2", title: "Return policy?", messages: [...], status: "idle", currentId: null },
{ id: "c1", title: "Where is ORD…", messages: [...], status: "streaming", currentId: "a7" },
],
activeId: "c2", // null when there are no chats
}02The list: new, select, ask, rename, delete
The first live example is the list on its own, with a scripted visitor. Read the snapshots: an empty list, a first chat that gets its title from its question, a second chat, a blank rename that is ignored, and three deletes: the open chat (its neighbour opens), the last chat (the empty state returns) and one that doesn't exist (nothing happens).
The first line compares the two ways to shorten a title with an emoji at the cut: the naive cut leaves a broken character (\ud83c), the safe one keeps the whole emoji.
03Where does the token go?
The second live example asks in chat A, opens chat B right after the 3rd token (triggered by the stream, not a timer) and lets the stream finish. It does this three times with the same stream. In uses-open-chat the code looks up the open chat when each token arrives, so every token after the switch is glued onto B's last message. In uses-the-chat-it-started-in the chat is remembered at send time, and both chats are right.
The third run deletes chat A while it streams and aborts the request: only 3 tokens were ever received, and nothing crashes because the missing chat is skipped.
04Delete: three consequences
Deleting a chat touches three things at once: the list (remove it), the screen (choose the neighbour), and the network (abort its request). Forgetting the third costs money on every deleted chat that was still streaming; the break-it below shows how many tokens leak.
Try it yourself
- 1
Rename with only spaces
In the first example, change the rename step's title to
"My chat", then run it.The title in the list changes. With
" "it does not, becausetitle.trim() || c.titlefalls back to the old title. A blank title would give an invisible button that the user could never find again. - 2
Delete a chat that is not open
In the list of steps, delete the line
["select c1", …]and run it again.The open chat is now
c2, sodelete the open chat(which deletesc1) removes a chat nobody is looking at. The*stays onc2and nothing is re-opened: the neighbour rule only applies when the chat being deleted is the open one. - 3
Slow the switch
In the second example change
tokens === 3totokens === 100and run it.The user never switches during the answer, so all three runs finish correctly and the first one looks fine. A race that passes when the user is slow and fails when they are quick is the most dangerous kind, which is why the chat id has to be fixed at send time.
Code & diagrams
A * marks the open chat. The first line shows why titles are cut with Array.from: the naive cut ends in half an emoji (the lone surrogate shows as \ud83c). Deleting c1, the open chat, opens its neighbour c2.
Preview
Console
Nothing logged yet.Expected console output
naive cut: "Hello chai \ud83c" | safe cut: "Hello chai 🍵…"
empty list: No chats yet. Start one! || Pick or start a chat.
new chat: New chat* || Ask Mitra anything.
ask: Where is ORD…* || Where is ORD-1042? It was due Monday
new chat: New chat* | Where is ORD… || Ask Mitra anything.
ask: Return polic…* | Where is ORD… || Return policy?
rename (blank is ignored): Return polic…* | Where is ORD… || Return policy?
select c1: Return polic… | Where is ORD…* || Where is ORD-1042? It was due Monday
delete the open chat: Return polic…* || Return policy?
delete the last chat: No chats yet. Start one! || Pick or start a chat.
delete a missing chat: No chats yet. Start one! || Pick or start a chat.The same stream, three behaviours. In the first run chat B, which the user was reading, ends up with half of chat A's answer glued onto its own last message.
Preview
Console
Nothing logged yet.Expected console output
uses-open-chat:
chat A last message: "It left Pune"
chat B last message: "Seven days. this morning and arrives tomorrow."
uses-the-chat-it-started-in:
chat A last message: "It left Pune this morning and arrives tomorrow."
chat B last message: "Seven days."
delete-and-abort:
chat B last message: "Seven days."
chat A is gone, tokens received before the stop: 3Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Delete a chat but keep its request running
In the second example, delete the line controller.abort(); // ...so stop paying for its answer.
Myth vs fact
Myth
While one answer streams, the whole app must be locked.
Fact
Busy belongs to one chat. The user can read or start another chat while the first one is still writing.
Myth
Writing into the active chat is the same as writing into the right chat.
Fact
They are equal only until the user switches. The chat id must be captured when the request is sent.
Myth
text.slice(0, 20) is a safe way to shorten a title.
Fact
It counts UTF-16 units, and can cut an emoji or a character outside the basic plane in half. Cut on characters with Array.from (or graphemes with Intl.Segmenter).
Myth
A new empty chat on every page load is harmless.
Fact
They pile up. Create a chat when the first message is sent, or reuse the empty one.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep a
MapofchatId → AbortControllerin a ref, outside React state. A controller is not renderable data, and you need to reach it from "delete" and "stop". - ▸
Debounce the reorder of the chat list. Moving a chat to the top on
send, not on each token, keeps the list steady while the user is moving the mouse towards it. - ▸
Offer undo for delete: remove the chat from the screen at once, keep it in memory for a few seconds, and only erase it from storage (and the server) when the undo window ends.
- ▸
Put the chat id in the URL (
/chat/c2). Then reloading, the back button and a copied link all do the obvious thing, and tests can open a chat directly.
Remember this
- 1
The problem this solves: One chat is simple: there is only one place for everything to go. With a list of chats, the user can start a question in one chat and click another a second later. If your code writes tokens into "the chat on screen", the answer to the first question appears in the second chat. This topic builds the list and makes switching safe.
- 2
The state. Put every chat in one list, each with its own
id,title,messages,statusandcurrentId, and keepactiveIdbeside it: which chat is on screen. The screen showschats.find((c) => c.id === activeId). The important point is that busy is a property of a chat, not of the whole app. Chat A can be streaming while chat B is idle and ready for a question. - 3
Routing actions. Topic 4.2's reducer handled one chat. For many chats, wrap it: every action that touches an answer (
send,token,done,error,stop) carries achatId, and the outer reducer hands the action to that chat's reducer and leaves the others alone. The chat id is read once, when the question is sent, and stays in the closure of that request. Reading "which chat is active" later, when a token arrives, is the bug. - 4
Switching while streaming. The stream doesn't need the user to be looking at it. It keeps writing into its own chat in the background. When the user returns, the answer is there (or still arriving). Show a small sign in the list for a chat that is busy (the live example adds " …"), so the user knows an answer is on its way. The second live example below runs the same stream three times: writing to the open chat (wrong), writing to its own chat (right), and with the chat deleted halfway.
- 5
Titles. Start with "New chat". When the first question is sent, make the title from its text, shortened. Shorten by characters a person sees, not by UTF-16 units:
"Hello chai 🍵🍵🍵".slice(0, 12)cuts an emoji in half and leaves a broken character, whileArray.from(text).slice(0, 12)keeps whole characters. (Some emoji and accented letters are built from several code points;Intl.Segmentersplits by grapheme if you need to be exact.) Let the user rename; ignore a blank rename. Having a model write a title is possible, costs one more request, and should never block the chat. - 6
Deleting. Delete is permanent, so ask first or offer "Undo". If the deleted chat was open, open its neighbour (the next one, or the previous when it was last); if it was the only one, show the empty state. If the deleted chat has a request in flight, abort that request so you stop paying for an answer nobody will read, and make the reducer ignore any tokens that still arrive for a chat that no longer exists. The second live example shows the tokens and the saved cost.
- 7
The empty state. With no chats the screen must still make sense: say "No chats yet" and offer the way to start one. Don't create an empty "New chat" automatically every time the page loads: after a week the list is full of blank chats. Create the chat when the first message is sent, or reuse an existing empty chat when the user presses "New chat".
- 8
Ordering and addressing. Sort by last activity, newest first, and move a chat up when a question is sent, not on every token (a list that reorders while the user is aiming at it makes them click the wrong chat). In a real product each chat also deserves its own address (
/chat/abc123), so the back button, a reload and a shared link all go to the right chat. Mark the open chat witharia-currentand give the delete buttons a real name ("Delete Where is ORD…").
Explain it without notes
Why should chat status be stored per chat instead of one flag for the whole app?
A user starts a question in chat A and opens chat B. How do you make sure the answer lands in A?
What should happen when a chat is deleted while its answer is still streaming?
Why shorten a title with Array.from instead of slice?
Practice
Add an unread flag: a chat that finishes an answer while it is not the active chat gets unread: true, and selecting it clears the flag. Which action needs to know whether the chat is active?
Write neighbourId(chats, id) that returns the id to open after deleting id: the next chat, else the previous, else null.
Trade-offs
- ↔
Letting every chat stream in the background is the friendliest behaviour and the most to manage: one controller per chat, one status per chat, more cost if the user starts many. Allowing only one running answer at a time is simpler and cheaper, but forces the user to wait or cancel.
- ↔
Titles from the first question are free and good enough. Model-written titles read better and cost an extra request per chat; generate them once, in the background, and keep the simple title until they arrive.
Done when you can
I can keep many chats in one state with a status for each.
I can explain why the chat id must be captured when the question is sent.
I can shorten a title without breaking an emoji.
I can delete a chat safely: open the neighbour, abort its request, ignore late tokens.
I can show a sensible screen when there are no chats.