Topic 4.8
Build: Mitra Remembers
In one line
Mitra becomes a real multi-turn assistant: a reducer holds each chat, every request sends a trimmed history, several chats are listed and can be switched while an answer streams, and the conversations are saved and survive a reload.
Think of it like this
A shop assistant who starts keeping a customer notebook. Before, every customer was a stranger. Now there is a page per customer, the assistant reads the recent notes before answering, a customer can come back tomorrow and find their page, and the assistant never writes a note on the wrong page. Mitra v4 gets that notebook.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Mitra v4
- The shop assistant after this phase: multi-turn, many chats, saved.
- chatsReducer
- The outer reducer. It routes each action to the chat named by
chatId. - sendable()
- The function that decides which messages go in the next request.
- Controller map
- A ref holding one
AbortControllerper chat that has a request running. - Interrupted
- An answer that was streaming when the page closed. It is restored as cut off.
- Scripted visitor
- Lines at the end of an example that click and type for you.
Step by step
01The route and what it receives
The route is the same /api/chat, now with the whole trimmed history in messages. The stand-in model answers from what it receives, so it can recall a name only if the client sent it. In a real app this is the one place that calls the provider with your key (Topic 1.5).
serve("/api/chat", fakeOpenAI(({ text, messages }) => {
// ...answers from the messages it received, like a real model would
}, { delayMs: 15, firstDelayMs: 40 }));02State: chats, each with its own stage
chatsReducer handles the list itself (new, select, delete) and passes every other action to the right chat's chatReducer. The title is set the first time a question is sent to an empty chat.
03Sending: capture, guard, trim, dispatch
send() does these in order. It returns if the text is empty or this chat already has a request (the ref check). It captures chatId and makes the ids. It stores a new AbortController. It builds the body from sendable(). Only then does it dispatch send and start the request.
Tokens, done and error all carry the chatId captured at the start, so switching chats cannot misdirect them. AbortError is ignored. finally removes the controller so the chat can send again.
04Saving and restoring
save() writes a versioned object with only id, title and messages. init() validates it, resets every chat to idle, and turns streaming and pending messages into error. The scripted visitor ends by throwing the screen away mid-answer (root.unmount()) and mounting a new one, which calls init() again. In this preview the storage is an in-memory copy that resets on each Run, so the "reload" happens inside one run; in a real browser it also survives a real reload.
05The tour
Press Run and read the console. The visitor says a name and asks for it back, starts a second chat, switches away while an answer streams, comes back, stops one answer, breaks one stream, crashes the server, and finally "reloads" mid-answer. Each console line is one of the scenarios listed above.
Then use it yourself: type your own messages, switch chats, delete one, press stop.
Try it yourself
- 1
Raise the budget
In either example change
BUDGET = 40to400.Nothing is trimmed any more and the notice never appears, so the model keeps seeing the name. The price is a bigger request on every turn.
- 2
Ask your own things
After the tour, type "My name is Ravi", then "What is my name?". Then open a new chat and ask "What is my name?".
The new chat answers "I don't know your name yet.": chats do not share history, because each request carries only its own chat.
- 3
Delete a chat that is streaming
Ask "Where is ORD-1043?", and immediately click the
xnext to that chat.The chat disappears, its request is aborted, and nothing is written anywhere. This is the cleanup from Topic 4.5.
Code & diagrams
A failed answer and a stopped answer are not sent, and the oldest messages are trimmed. Look at the last question: the name was said in the very first message, so after trimming Mitra can no longer answer it. That is exactly why the screen shows a notice.
Preview
Console
Nothing logged yet.Expected console output
in the chat: 8 messages
good enough to send: 6
sent after trimming to the budget: 4
user: Try again
assistant: ORD-1043 (Ginger biscuits) is still being processed.
user: ORD-1042 please
user: What is my name?
does the model still see the name Asha? false
one huge message alone: 1 sentThe visitor runs the whole tour by itself, then you can type. The last three lines are the "reload": the page was thrown away while an answer streamed, and the chats came back from storage. In this preview the storage resets on every Run, so only the reload inside one run is shown.
Preview
Console
Nothing logged yet.Expected console output
empty: Start a new chat to ask Mitra. | No chats yet.
chat 1: My name is Asha / Nice to meet you! / What is my name? / Your name is Asha.
viewing chat 1 while chat 2 streams: Where is ORD-1… … | My name is Ash…*
back in chat 2: Where is ORD-1043? / ORD-1043 (Ginger biscuits) is still being processed.
stopped: ORD-1042 (Masala [stopped] | Status: done
broken stream: ORD-1043 is being pa [cut off] | The answer was cut off. Please try again.
server error: (no answer) | Mitra could not answer. Please try again.
long chat: Mitra no longer sees the first 1 message.
after reload: Where is ORD-1…* | My name is Ash…
question kept: true | interrupted answer shown as cut off: true
status after reload: Status: idle | send enabled: trueBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Restore interrupted answers as they were
In the second example, change (m.status === "streaming" || m.status === "pending" ? { ...m, status: "error" } : m) to just m.
Myth vs fact
Myth
Saving the chats means I saved Mitra's state.
Fact
Only the messages. Stage flags, errors and controllers describe the moment and are rebuilt at load.
Myth
A bigger app needs a bigger architecture than a reducer and a ref.
Fact
This whole app is one reducer pair, one request function and one storage pair. The SDK in Phase 5 removes some of it, but the ideas stay.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Resend does not need the whole history on the wire forever: Phase 5 shows servers that load history by chat id, so the client sends one message.
- ▸
Add a test for each console line in the tour. They are the scenarios your users will find first.
- ▸
Before adding features, check that
send()still captureschatIdat the top. Every later feature (retry, regenerate, tools) should reuse the same capture.
Remember this
- 1
The problem this solves: After Phase 3, Mitra could stream, stop and recover, but each question stood alone and a reload erased everything. This topic joins Topics 4.1 to 4.7 into one app, so a customer can say their name, ask about an order, switch to another chat, close the tab and come back.
- 2
Where we start. Mitra v3 (Phase 3) is one component with one answer: a route
/api/chat, a reading loop, stop, retry and a rate-limit countdown. We keep the same route, element ids and error texts, and change what is behind the screen: the singleanswervariable becomes a list of messages in chats. - 3
What this version is made of.
chatReducer(Topic 4.2) handles one chat.chatsReducerwraps it and routes each action bychatId(Topic 4.5).sendable()builds the request from the chat'sdonemessages and trims to a budget (Topic 4.3).init()andsave()handle storage with a version and mark interrupted answers (Topic 4.4). A ref holds oneAbortControllerper chat for stop, delete and the double-send guard (Topic 4.7).readSSE()from Phase 2 reads the stream. - 4
Every scenario this version handles. *Memory:* the name given in turn 1 is answered in turn 2. *Trim:* with a tiny budget of 40 estimated tokens, old messages stop being sent and a notice says so. *Switch:* leaving a chat that is streaming, and coming back to a finished answer. *Stop:* the text so far stays, marked
[stopped]. *Broken stream:* the partial text stays, marked[cut off], with a message. *Server error:* "(no answer)" and a message a person can act on. *Reload mid-answer:* the chat list returns, and the interrupted answer comes back as cut off, not as running. *Empty:* no chats gives a clear start screen. - 5
What Mitra can do now that it could not before: hold a conversation, keep several, and survive a reload. What is still missing, and where it is added: the hand-written plumbing is replaced by an SDK that manages messages for us (Phase 5); answers are still plain text, so Markdown, code and links need safe rendering (Phase 6); answers can't be cards (Phase 7) or take actions (Phase 8); there is no protection against hostile input (Phase 9), no polish for accessibility and mobile (Phase 10), no usage limits or metrics (Phase 13), and chats are saved only in this browser.
- 6
Two shortcuts to know about. The history budget here is 40 estimated tokens so the trimming is visible in a short demo; a real one is thousands, set from your model's window minus the longest reply (Topic 4.3). And
save()runs after every state change; a real app throttles it while streaming (Topic 4.4).
Explain it without notes
Walk through what happens from pressing Send to the answer being saved.
Why does Mitra v4 not forget when you switch chats?
What does Mitra v4 do after a reload in the middle of an answer?
Practice
Add a "Clear chat" button that is disabled while the chat is busy. Which reducer needs a new case?
Make the budget a prop and show "Mitra sees the last N messages" instead of the number hidden. What function already has the number?
Trade-offs
- ↔
Everything in the browser makes this version free to run and impossible to share across devices. Server-side chats (Topic 4.4) add sync and cost a backend.
- ↔
Hand-written plumbing teaches what an SDK does and costs about 200 lines. Phase 5 replaces much of it, and you will recognise each piece.
Done when you can
I can describe how the reducer, the history trimming, the controller map and storage fit together.
I can switch chats during a stream without misplacing tokens.
I can show every scenario in the tour: stop, cut off, server error, trimmed history, reload.
I can say what Mitra v4 still lacks and which phase adds each missing piece.