Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ~35 min· topic 8 of 8

Topic 10.8

Build: Mitra Feels Good to Use

In one line

Mitra gets starters on an empty chat, a proper prompt box (Enter, Shift+Enter, IME, growth, limit, give-back on failure), thumbs feedback on finished answers, and an announcement pattern that a screen reader can follow. All of it is checked with scripted events.

Think of it like this

A shop that already sells well and now gets a sign over the door, a bell on the counter, a comment box and a ramp at the entrance. The goods are the same, but more people can come in, and they come back. Phases 5 to 9 gave Mitra its goods. This topic adds the sign, the bell, the comment box and the ramp.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Starter
A suggested question shown as a button on an empty chat.
Give-back
Returning the user's text to the input box after a failed send, so they do not retype it.
Announcement
A change inside a live region that assistive technology reports to the user.
Single button
One DOM element that changes its label and action (Send to Stop), so keyboard focus is not lost.
Grapheme count
The number of characters as a person sees them, used for the length limit.
Scripted visitor
Code at the end of an example that types, presses keys and clicks, so the example proves its own behaviour.
Audit
A short script that checks the page for things that must always hold, such as every button having a name.

Step by step

01Start with the part that decides when a question is spent

The first example is only the part of the composer that handles a send: it ignores spaces, locks against a second send while one runs, and gives the question back when the send fails. It uses a promise instead of the SDK, so you can see the logic with nothing else around it.

Read its three lines of output: spaces-only sends nothing, a double click sends once, a failure sends and returns the question to the box.

Start with the part that decides when a question is spentdiagram
Rendering diagram…

02Add the server routes

The second example has two routes. /api/chat uses streamText with instructions (the AI SDK 7 name for the system prompt) and a scripted stand-in model. A question containing "limit" makes the model call fail with a 429, so you can see the failure path. /api/feedback validates the rating and answers 204.

03Build the prompt box

State: the draft text, a notice, and refs for the composition flag, the in-flight guard and the last question sent. The keydown handler follows Topic 10.5: ignore anything that is not Enter, ignore Shift, ignore composition, then prevent the default, send, and clear the box now. The auto-grow effect and the grapheme counter follow Topic 10.5 as well. A refused send (busy, empty, over the limit) leaves the text in the box.

One <button> has id={busy ? "stop" : "send"} and a label that changes, so focus stays. The form's submit handler stops the stream when busy and sends when not.

04Give the question back

useChat takes onError. In it Mitra resets the guard, removes the last message (the question that got no answer), puts the text back in the box, sets the notice and focuses the box. Because the guard is a ref and the text is restored from a ref, this works even when the error arrives in the middle of other updates.

05Make the live regions quiet

The transcript is role="log" with aria-label="Conversation" and aria-busy={busy}. A separate role="status" element shows "Mitra is answering." while busy and "Answer ready." when the last message is an assistant message and the status is ready. The alert for errors is its own role="alert" element, and appears only when there is something to say.

06Show feedback only when the answer is done

The Good and Bad buttons are rendered only for the last assistant message and only when status is ready. They carry aria-pressed, and the rating is marked after /api/feedback answers ok.

07Let the scripted visitor prove it

The last lines drive the page: they double-click a starter, wait for "Answer ready.", count announcements, press Good, type a question, send an Enter that belongs to an IME word, Shift+Enter, then two real Enters for a question that fails. The final audit counts unnamed buttons, checks the region has a name and that there is one <h1>.

Try it yourself

  1. 1

    Lower the limit and paste

    In Mitra v10 set LIMIT to 20. After the scripted visitor finishes, paste a long sentence into the box.

    The counter appears from 18 characters in red, and Send is disabled over 20. Nothing is cut: the text stays and the counter explains why you cannot send.

  2. 2

    Add a third starter

    Add { label: "Cancel an order", prompt: "Can you cancel my order?" } to STARTERS.

    In this version Mitra says it can help with orders, shipping and returns. In the full course Mitra can cancel an order only after approval (Phase 8). Would you keep a starter for something that needs approval? Write down what a user would expect to see after clicking it.

  3. 3

    Type in the middle of an answer

    Press Run, then type a new question in the box while the first answer is still streaming (the first 200 ms), and press Enter.

    Nothing is sent, because Mitra is busy, and your text stays in the box. When the answer is done you can send it. A stop button is one click away.

Code & diagrams

When is a question spent? Spaces, double clicks and a failed send

The send is a promise that succeeds or fails after 20 ms, standing in for the request. The scripted visitor waits for what the page shows, not for a clock. Phase 5's `useChat` does the sending in the next example; the idea is the same.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

spaces only, sent: 0
double click, sent: 1 | box after success: ""
after a failure, sent: 2 | box: "And ORD-1043?" | message: Mitra is busy right now. Your question is back in the box.
Mitra v10: starters, a real prompt box, feedback and quiet announcements

Everything the visitor does is a real event on the real page: clicks, composition events and key presses. The scripted visitor waits for what the page shows. The (server) line comes from the feedback route; the AI_APICallError line is the SDK reporting the 429 that the stand-in model returned. Afterwards, type in the box yourself, hold Shift while pressing Enter, and try a very long paste.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

empty chat shows starters: true | input label: Your question
after the starter: [ 'user: How do I return an item?', 'assistant: You can return unopened items within 7 days of delivery.' ]
announcements for that answer: transcript at most 2: true | status line: 2
the Send button became Stop without being replaced: true
(server) feedback: up
thumbs up saved, pressed: true
Enter inside an IME word sent it: false
Shift+Enter sent it: false
AI_APICallError: slow down
the failed question was removed from the chat: true
alert: Mitra is busy right now. Your question is back in the box.
question back in the box: "Is there a limit?" | focus is on: prompt
audit: unnamed buttons: 0 | log region named: true | h1 count: 1

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Throw away the question when the request fails

In the first example delete the line setText(question); // the question is not lost.

terminal
$ The console, last line
── what you'll see ──
after a failure, sent: 2 | box: "" | message: Mitra is busy right now. Your question is back in the box.

Break #2

Forget to unlock after a send

In the first example delete the line sending.current = false; in the finally block.

terminal
$ The console (the last line never prints)
── what you'll see ──
spaces only, sent: 0
double click, sent: 1 | box after success: ""

Break #3

Leave aria-busy off the transcript

In the second example delete aria-busy={busy} from the #messages element.

terminal
$ The console, third line
── what you'll see ──
announcements for that answer: transcript at most 2: false | status line: 2

Myth vs fact

Myth

UX polish can wait until the product works.

Fact

The empty state, the input rules and the failure path decide whether people can start, keep going and recover. They are part of "works".

Myth

Once the success path runs, the failure path will take care of itself.

Fact

It won't. Both examples in this topic fail on purpose, because that is where questions get lost and forms get stuck.

Myth

If a scanner shows no errors, the chat is accessible.

Fact

A scanner cannot hear a stream or see where focus goes. The scripted visitor and a real screen reader test catch what a scanner cannot.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Keep the composer logic in one hook (useComposer) that returns the props for the textarea and the button, so the keyboard rules, the limit, the draft and the give-back are tested once and reused by every chat surface in the product.

  • ▸

    Write the scripted visitor as a test file early. The same lines (double-click a starter, send an IME Enter, force a 429, count announcements) run in your test suite in Phase 13 with a real test runner.

  • ▸

    Decide where the draft lives when you add many conversations (Topic 4.5): one draft per conversation id, cleared on send, restored on switch. The pattern is in Topic 10.5; the wiring is the work.

Remember this

  1. 1

    The problem this solves: Topics 10.1 to 10.7 each fixed one detail on its own page. A product needs them to work together: the starters must hand over to the prompt box, the prompt box must give a question back when the request fails, the feedback must appear only when an answer is finished, and the live regions must stay quiet while all of that happens. This topic assembles the pieces and checks the whole.

  2. 2

    Where Mitra starts. After Phase 9, Mitra is a useChat client on a /api/chat route that uses streamText, renders model output safely, can look up an order and ask before it cancels one, and treats hostile content as untrusted. You do not need to have that code open. This topic's example keeps just the chat and the route, and adds the new pieces.

  3. 3

    What the new Mitra does, step by step. An empty chat shows a welcome line and two starters. A starter sends once. The prompt box grows, sends on Enter, adds a line on Shift+Enter, ignores Enter that confirms an IME word, counts graphemes against a limit of 500, and has a single button that changes between Send and Stop. During an answer the transcript is aria-busy and a role="status" line says "Mitra is answering.", then "Answer ready.". When the answer is finished, Good/Bad buttons appear with aria-pressed. If a request fails, the failed question is removed from the chat, put back in the box, and a role="alert" message says what happened; focus goes back to the box.

  4. 4

    Every scenario this version handles. *Empty chat*: welcome line and starters. *Double click on a starter*: one question. *Empty or spaces*: Send is disabled and the handler checks again. *Rapid Enter*: one send. *IME Enter*: ignored. *Shift+Enter*: ignored by the send logic. *Over the limit*: Send disabled with a counter from 90 percent. *A 429*: the question comes back, the alert is shown. *Streaming*: two announcements for the transcript (the question you sent and the finished answer, not one per word) and two for the status line. *Typing while it answers*: Enter does nothing and the text stays in the box. *Feedback*: marked only after the server says yes.

  5. 5

    What Mitra can do now that it could not before: start a conversation without a blank-box problem, take multi-line and multi-language input safely, recover from a failed send without losing the question, collect feedback, and be followed by a screen reader without noise.

  6. 6

    What is still missing, and the phase that adds it: attachments, screenshots, voice input and spoken answers (Phase 11); running small tasks on the device (Phase 12); usage meters, daily limits, fallbacks, metrics and a test suite (Phase 13); multi-step agent work with plans and progress (Phase 14); the assembled capstone and interview practice (Phase 15). Draft saving per conversation and translated messages are patterns from this phase that you wire in when you add a conversation list (Topic 4.5).

Explain it without notes

01

Why does Mitra v10 remove the failed question from the chat and put it back in the box?

02

Which parts of Mitra v10 protect against sending twice, and why is more than one needed?

03

How does Mitra v10 keep a screen reader from being flooded?

04

What would you test first before shipping this version?

Practice

01

Extend the scripted visitor to press the Bad answer button after the first answer and check that aria-pressed becomes true on #down and false on #up.

02

Add a Regenerate button that appears next to the feedback buttons and calls regenerate(). What must you do about the feedback buttons when the answer is replaced?

Trade-offs

  • ↔

    Every polish item adds code and tests to maintain. The set in this phase is the smallest one that prevents lost work, double sends, a blank first screen and a noisy screen reader. Add more only for a measured reason.

  • ↔

    A scripted visitor is quick and exact but only tests what you thought of. A session with a real screen reader and a real phone finds the rest.

Done when you can

  • I can name what Mitra v10 can do that v9 could not.

  • I can build starters that send once and a prompt box that follows the keyboard rules.

  • I can give a question back after a failed send and focus the box.

  • I can show feedback only on finished answers and mark it only after the server accepts it.

  • I can announce a stream once or twice and show it with a counted check.

  • I can say which phase adds attachments, limits and agent work.