emptyState(input, strings) is a pure decision — given whether there are rows, whether the engine needs a key, and whether one is present, it returns the view to paint, or null to paint nothing.
Quick Start
1
Fresh install, no key
2
Key configured, no messages yet
3
Any transcript row
How It Works
The whole page is four rules, checked in this order.emptyState returns an EmptyStateView, or null meaning “paint nothing”.
Rule 1 — A transcript wins over everything
hasRows is checked first and alone. Any row at all — restored history or a live turn — returns null, and the panel is not painted.
Rule 2 — No key is only a problem when something wants one
keyRequired comes from the engine actually in force. The remote engine authenticates at its own server, so a remote-engine user is never shown the “add a key” panel.
Rule 3 — An unresolved key check reads as “fine”, not as “missing”
KeyPresence is a three-value type — "present" | "absent" | "unknown" — and unknown resolves to the welcome copy, not the guidance.
SecretsPort.has() is asynchronous, and the first paint happens before the answer lands. Guessing "absent" during that window would accuse every configured user of not having set a key, on every launch, and then take it back a frame later.
Rule 4 — The action is part of the state
action is non-null exactly when kind === "needs-key". The pairing is the invariant — a button with no reason and a reason with no button are both bugs.
strings.recoveryLabel("settings") → “Open settings”, so the button reads the same everywhere in the app that offers this route.
Exported types
User interaction flow
1
A new user opens the app
The chat screen shows the “Add an API key to start” heading and an “Open settings” button — not a raw SDK error. Tapping the button lands on Settings.
2
They paste a key and return
Back on Chat, the panel now reads “Ask something to begin.” with the one-sentence description of what the app does. The transition from “you cannot yet” to “start typing” is complete.
Strings
The four user-visible strings are constants — not functions — read straight from theen table.
The action button label is not a new string — it is
strings.recoveryLabel("settings") → “Open settings”, reused so the app names the destination the same way everywhere.
No suggestion chips, on purpose. Example prompts were rejected: a chip is read once by a user who has already understood what a message box is, then in the way of every new chat afterwards, at the cost of a translatable string per example. What the new user lacks is not inspiration — it is the key, and that is what the space is spent on.
Related
API Keys
Where the “no key” guidance now appears, and how the key is stored.
i18n & A11y
The empty-chat strings and the polite live-region announcement.

