-
Notifications
You must be signed in to change notification settings - Fork 0
Design Decisions
Behaviours that look like omissions or bugs, and are neither. Each has a reason, and most have a failure behind them. If you are tempted to "fix" one of these, read the reason first.
The user stays the author of their own message. A chip that sends on click commits someone to a phrasing they did not choose and cannot edit — and the one time it misfires, they have said something to your product they did not mean.
A surface that keeps asking whether you are still there is a surface people close. Once is a courtesy; twice is nagging.
A disabled control leaves the user watching output they cannot interrupt, which is the single thing a streaming interface must never do. Mid-generation, the most likely intent is "stop", so that is what the button is.
Within 48px. Yanking someone back to the bottom while they are reading earlier context is the most common way a chat surface becomes unusable, and it happens precisely when the model is being most useful.
It never re-renders the list. Re-rendering steals focus, collapses a text selection mid-copy, and makes a screen reader re-announce turns the user already heard. A test asserts node identity survives a streaming append.
The convention every chat surface now shares. Violating it costs a user one mis-sent message before they learn otherwise, and that message is already gone. IME composition is respected, so Enter mid-composition does not send.
/clear is not a prompt. Sending it as one wastes a turn, costs a token budget, and
produces a confident wrong answer about what was cleared.
The CLI prints /name failed: … in its output line. A chat surface that dies
because one command had a bug loses the conversation too.
Speaking stream chunks as they arrive produces stuttered nonsense — the synthesiser has no sentence to work with and no prosody to apply.
A microphone that transmits the moment it recognises a phrase will eventually send half a sentence, or a conversation happening in the room.
Neither Speech API is universal, so "it works on my machine" is the default failure here. The component disables what is missing and shows the reason, rather than rendering a button that silently does nothing.
Two independent chat surfaces on one page must not share a conversation, and a module-level singleton makes that impossible to express. Document position already says "these belong together", so we use it and add nothing.
Not its shadow root. Slotted children walk up the light tree, so a services element inside the shadow root would be invisible to exactly the components that need it.
EventTarget binds the bus to whichever realm supplied the global, and dispatching
an event constructed in another realm throws. In the sibling package this made a
successful tool call log as an error, because a jsdom CustomEvent met Node's
EventTarget. The hand-rolled listener set is realm-free and works in a worker or
in plain Node.
emit catches per handler. A chat surface has many independent listeners and one
bad render must not take the rest down with it.
Handing out the live array lets any component corrupt the transcript, and the transcript is the record of what was actually said — there is nothing to recover it from.
Recent turns are the ones a reader needs, and an unbounded list is an unbounded render.
Not a silent drop. Silently dropping it leaves the user staring at a prompt that appears to have done nothing, which reads as "the site is broken" rather than "that request failed".
The user already read it. Deleting what they just read is disorienting in a way that leaving it is not.
It returns null rather than queueing. Interleaving two streams into one transcript
produces output no reader can attribute to anything.
meta is deliberately open, because integrators put CRM ids, ticket numbers and
session keys there. An unnamespaced reserved key would collide with a real field
and silently overwrite it. Keys outside the namespace are carried verbatim and never
stripped; userMeta(record) returns just yours.
A transcript grows without bound and localStorage is a synchronous ~5 MB cliff that fails exactly when the conversation got interesting.
Storing someone's conversation is a decision a page makes deliberately. See Data Agency.
A delete that only drops the stored row leaves the data readable, which makes the control a lie.
Model output is untrusted input. It may contain text the model copied from a
document, a web page or a tool result, and innerHTML would make every reply a
script-injection vector. The inspector is not exempt — a dev tool that executes
what it inspects is a worse version of the same hole. Two tests cover this.
If you add Markdown or HTML rendering, that decision is yours and it reopens the hole: sanitize before inserting.
Stack agnosticism is the point: the moment this package has an opinion about a
provider it has an opinion about your billing and your data residency. A test fails
if any module gains fetch, XMLHttpRequest, WebSocket or an external URL —
because a claim of absence with no test goes false one feature later, with nothing
failing to say so.
class X extends HTMLElement is evaluated at module load and each module
self-registers at the bottom, so importing one where no DOM exists throws
ReferenceError: HTMLElement is not defined.
This is inherent to custom elements, not an oversight — and declaring the classes lazily to work around it would break self-registration on import, which is the feature. In an SSR framework, import from a client-only path. See Getting Started.
Next: Testing a Chat Surface.