{"course":{"slug":"building-with-ai","title":"Building with AI","url":"https://addaly.com/learn/building-with-ai","level":"intermediate","track":"build"},"lesson":{"slug":"the-chat-interface-honestly","title":"The chat interface: the controls that are load-bearing","url":"https://addaly.com/learn/building-with-ai/the-chat-interface-honestly","position":47,"of":80,"minutes":9,"answer":"The text box is the trivial part of a chat interface; what matters is that every control tells the truth about the model: a stop button that cancels the upstream request.","keyIdea":"The text box is the trivial part of a chat interface; what matters is that every control tells the truth about the model: a stop button that cancels the upstream request, edits that branch the history, visible tool calls and sources, sanitised rendering of untrusted output, and an AI disclosure on the surface itself."},"body":{"format":"markdown","text":"## The box is the easy part\n\nA text area, a send button, a list of bubbles. Any framework gives you that in an afternoon, and the free path is no framework at all: a `textarea`, a `fetch` that reads a stream, and a markdown renderer. What separates a chat interface people trust from one they abandon is a set of controls, each of which corresponds to something true about the model underneath. This lesson is that list.\n\n## Stop, and what it must actually do\n\nStreaming, from the first module, means the user sees tokens as they arrive. A stop button must do two things. In the browser it aborts the fetch, so the display freezes where it is and the partial text is kept: the user stopped because they had read enough, not because they wanted it discarded. On the server it must cancel the upstream request to the provider. If it does not, generation continues to completion and you pay for every token nobody will read; with long outputs that is most of the cost of the request. Propagate the abort signal all the way through.\n\n## Regenerate and edit are branches\n\nRegenerate sends the same prompt again for a new sample. Editing an earlier user message means everything after it is discarded and the conversation continues from the edit. Both imply that the history is a tree, not a list, and that the previous branch should remain reachable. The `parent` column in the messages table from the first lesson of this block is what makes that possible; add it at the start, because converting a list to a tree with live data is an unpleasant migration.\n\n## Show the sources\n\nFor anything built on retrieval, render citations as links, each with a preview of the chunk it points at, and verify every id against the retrieved set before rendering, as the permissions lesson required. Few users click sources (a small fraction on most products) but the presence of a link that could be clicked changes how the answer is read. An answer with no source is a claim; an answer with a source is a claim you can check.\n\n## Show what the model sees\n\nWhen a file is attached, show that it is in context, and when it has dropped out, show that. When history has been compacted, mark the point. When a tool runs, say so in the transcript (\"Searched orders for #48213\") with the arguments visible. Hidden tool calls are the reason a wrong answer becomes an inexplicable one: the user cannot see that the search was for the wrong order number, so the product seems wrong. Visibility is also the first line of defence against the tool being used in a way the user did not intend.\n\n## Render model output as untrusted\n\nModel output is text you did not write, and it can contain anything a prompt injection or a bad sample puts there. Render markdown through a sanitiser: `marked` or `react-markdown` with raw HTML disabled, and DOMPurify or an equivalent on the result. Do not auto-load remote images from model output: an image tag pointing at an attacker's server with the conversation in the query string is a data-exfiltration channel the safety module will return to. Show links with their real domain visible, and open them in a new tab. This is standard web hygiene, applied to a source that is less trustworthy than a user.\n\n## Latency states\n\nTime to first token should be under a second; the streaming lesson explained why that is the number that matters. While waiting, show a state that says what is happening, and when a tool call takes long, say which tool. On a timeout, offer a retry, and never lose the text the user typed: a cleared input after an error is a small thing that makes people leave.\n\n## Errors as messages\n\nA rate limit, an overloaded provider or a failed tool should appear in the transcript as a plain message with a retry, not as a toast that vanishes. The conversation is the place the user is looking.\n\n## Say it is AI, on the surface\n\nEvery chat surface should say that the assistant is an AI, near the input, in words rather than an icon. This site's own rule for its tutors is the standard: the disclosure is on the surface the person is using, not in a policy page. Add the one honest limitation (that it can be wrong, and that important details should be checked) where it will be read. Several jurisdictions are moving towards requiring this disclosure; the safety module covers what is known and what is unsettled.\n\n## Keyboard, screen readers and phones\n\nEnter sends and Shift+Enter inserts a newline, and the reverse on a phone, where Enter is the only way to make a line break. Streamed text should be announced to a screen reader without reading every token as it arrives: an `aria-live=\"polite\"` region updated at sentence boundaries, or an announcement when the message completes. On a phone the keyboard covers half the screen; the input and the newest message must both stay visible, and every control must be a comfortable tap. Test on a real phone, because most of this readership is on one."},"check":{"question":"A team's stop button aborts the browser's fetch but does nothing server-side. What is the consequence?","options":[{"text":"The provider keeps generating and the team pays for tokens nobody reads, because the abort never reached the upstream request.","correct":true},{"text":"The partial response is lost, because aborting the fetch discards the buffered tokens.","correct":false,"tempting":"Aborting a fetch stops delivery; what has already been rendered stays unless the code clears it."},{"text":"The next message in the conversation will be rejected because the history has an unfinished assistant turn.","correct":false,"tempting":"A stopped turn is stored as a complete message with partial content; the API does not know it was interrupted."},{"text":"Nothing; providers detect that the client has disconnected and stop generating as soon as the connection from the browser is dropped.","correct":false,"tempting":"A disconnect between browser and your server does not reach the provider unless your server cancels its own request."}]},"prev":{"slug":"asking-instead-of-guessing","title":"Asking instead of guessing: clarifying questions, slot filling and when a form beats a chat","url":"https://addaly.com/learn/building-with-ai/asking-instead-of-guessing"},"next":{"slug":"voice-in-and-out","title":"Voice in and out: transcription, speech and the latency budget","url":"https://addaly.com/learn/building-with-ai/voice-in-and-out"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'The chat interface: the controls that are load-bearing', in Building with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-with-ai/the-chat-interface-honestly (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"The chat interface: the controls that are load-bearing","courseTitle":"Building with AI","module":{"index":5,"slug":"conversation-memory-and-the-interface","title":"Conversation, memory and the interface"},"answer":null,"keyIdea":"The text box is the trivial part of a chat interface; what matters is that every control tells the truth about the model: a stop button that cancels the upstream request, edits that branch the history, visible tool calls and sources, sanitised rendering of untrusted output, and an AI disclosure on the surface itself.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-box-is-the-easy-part","runs":[{"text":"The box is the easy part"}]},{"t":"p","runs":[{"text":"A text area, a send button, a list of bubbles. Any framework gives you that in an afternoon, and the free path is no framework at all: a "},{"text":"textarea","code":true},{"text":", a "},{"text":"fetch","code":true},{"text":" that reads a stream, and a markdown renderer. What separates a chat interface people trust from one they abandon is a set of controls, each of which corresponds to something true about the model underneath. This lesson is that list."}]},{"t":"h2","id":"stop-and-what-it-must-actually-do","runs":[{"text":"Stop, and what it must actually do"}]},{"t":"p","runs":[{"text":"Streaming, from the first module, means the user sees tokens as they arrive. A stop button must do two things. In the browser it aborts the fetch, so the display freezes where it is and the partial text is kept: the user stopped because they had read enough, not because they wanted it discarded. On the server it must cancel the upstream request to the provider. If it does not, generation continues to completion and you pay for every token nobody will read; with long outputs that is most of the cost of the request. Propagate the abort signal all the way through."}]},{"t":"h2","id":"regenerate-and-edit-are-branches","runs":[{"text":"Regenerate and edit are branches"}]},{"t":"p","runs":[{"text":"Regenerate sends the same prompt again for a new sample. Editing an earlier user message means everything after it is discarded and the conversation continues from the edit. Both imply that the history is a tree, not a list, and that the previous branch should remain reachable. The "},{"text":"parent","code":true},{"text":" column in the messages table from the first lesson of this block is what makes that possible; add it at the start, because converting a list to a tree with live data is an unpleasant migration."}]},{"t":"h2","id":"show-the-sources","runs":[{"text":"Show the sources"}]},{"t":"p","runs":[{"text":"For anything built on retrieval, render citations as links, each with a preview of the chunk it points at, and verify every id against the retrieved set before rendering, as the permissions lesson required. Few users click sources (a small fraction on most products) but the presence of a link that could be clicked changes how the answer is read. An answer with no source is a claim; an answer with a source is a claim you can check."}]},{"t":"h2","id":"show-what-the-model-sees","runs":[{"text":"Show what the model sees"}]},{"t":"p","runs":[{"text":"When a file is attached, show that it is in context, and when it has dropped out, show that. When history has been compacted, mark the point. When a tool runs, say so in the transcript (“Searched orders for #48213”) with the arguments visible. Hidden tool calls are the reason a wrong answer becomes an inexplicable one: the user cannot see that the search was for the wrong order number, so the product seems wrong. Visibility is also the first line of defence against the tool being used in a way the user did not intend."}]},{"t":"h2","id":"render-model-output-as-untrusted","runs":[{"text":"Render model output as untrusted"}]},{"t":"p","runs":[{"text":"Model output is text you did not write, and it can contain anything a prompt injection or a bad sample puts there. Render markdown through a sanitiser: "},{"text":"marked","code":true},{"text":" or "},{"text":"react-markdown","code":true},{"text":" with raw HTML disabled, and DOMPurify or an equivalent on the result. Do not auto-load remote images from model output: an image tag pointing at an attacker’s server with the conversation in the query string is a data-exfiltration channel the safety module will return to. Show links with their real domain visible, and open them in a new tab. This is standard web hygiene, applied to a source that is less trustworthy than a user."}]},{"t":"h2","id":"latency-states","runs":[{"text":"Latency states"}]},{"t":"p","runs":[{"text":"Time to first token should be under a second; the streaming lesson explained why that is the number that matters. While waiting, show a state that says what is happening, and when a tool call takes long, say which tool. On a timeout, offer a retry, and never lose the text the user typed: a cleared input after an error is a small thing that makes people leave."}]},{"t":"h2","id":"errors-as-messages","runs":[{"text":"Errors as messages"}]},{"t":"p","runs":[{"text":"A rate limit, an overloaded provider or a failed tool should appear in the transcript as a plain message with a retry, not as a toast that vanishes. The conversation is the place the user is looking."}]},{"t":"h2","id":"say-it-is-ai-on-the-surface","runs":[{"text":"Say it is AI, on the surface"}]},{"t":"p","runs":[{"text":"Every chat surface should say that the assistant is an AI, near the input, in words rather than an icon. This site’s own rule for its tutors is the standard: the disclosure is on the surface the person is using, not in a policy page. Add the one honest limitation (that it can be wrong, and that important details should be checked) where it will be read. Several jurisdictions are moving towards requiring this disclosure; the safety module covers what is known and what is unsettled."}]},{"t":"h2","id":"keyboard-screen-readers-and-phones","runs":[{"text":"Keyboard, screen readers and phones"}]},{"t":"p","runs":[{"text":"Enter sends and Shift+Enter inserts a newline, and the reverse on a phone, where Enter is the only way to make a line break. Streamed text should be announced to a screen reader without reading every token as it arrives: an "},{"text":"aria-live=\"polite\"","code":true},{"text":" region updated at sentence boundaries, or an announcement when the message completes. On a phone the keyboard covers half the screen; the input and the newest message must both stay visible, and every control must be a comfortable tap. Test on a real phone, because most of this readership is on one."}]}],"check":{"question":[{"text":"A team’s stop button aborts the browser’s fetch but does nothing server-side. What is the consequence?"}],"options":[{"text":[{"text":"The provider keeps generating and the team pays for tokens nobody reads, because the abort never reached the upstream request."}],"correct":true,"tempting":null},{"text":[{"text":"The partial response is lost, because aborting the fetch discards the buffered tokens."}],"correct":false,"tempting":[{"text":"Aborting a fetch stops delivery; what has already been rendered stays unless the code clears it."}]},{"text":[{"text":"The next message in the conversation will be rejected because the history has an unfinished assistant turn."}],"correct":false,"tempting":[{"text":"A stopped turn is stored as a complete message with partial content; the API does not know it was interrupted."}]},{"text":[{"text":"Nothing; providers detect that the client has disconnected and stop generating as soon as the connection from the browser is dropped."}],"correct":false,"tempting":[{"text":"A disconnect between browser and your server does not reach the provider unless your server cancels its own request."}]}],"order":[2,3,1,0],"why":null},"prev":{"slug":"asking-instead-of-guessing","title":"Asking instead of guessing: clarifying questions, slot filling and when a form beats a chat","minutes":8},"next":{"slug":"voice-in-and-out","title":"Voice in and out: transcription, speech and the latency budget","minutes":9}}}