baseUrl field is the recovery path when a phone cannot reach the default engine.
Back on Settings returns to the chat, not out of the app — see Native Shell → Back-gesture arbitration for how the app declares its back state out of band so it never races the Android watchdog.
Layout
The Settings and Chats screens are bare<section class="screen screen-settings"> / screen-chats with no .topbar. Before this was fixed they consumed no insets at all: on an Android 15 emulator (Pixel-class, 49 px cutout) the “Settings” heading’s box started at y = 20 with a 49 px top inset, so ~29 px of the title painted underneath the status-bar clock, and every heading sat at x = 0 — inside the left cutout in landscape. The screen could not scroll either, so a longer list, a large font scale, or landscape left the bottom rows unreachable.
The .screen-settings, .screen-chats rule in app.css fixes both:
The insets flow from
#root (written by main.ts from shell.insets), not from a chat-screen-scoped variable — sibling screens need to read the same numbers. See Native Shell → Insets and keyboard..row-chat, renamed in this change from a stylesheet-only .chat-row that buildChatsScreen never emitted — so every row had been falling back to the bare button rule (a centre-aligned, auto-width pill) instead of a full-width list row. The truncation for a long title is now on the row itself, where a chat named from a long first message would otherwise wrap to as many lines as it liked.
Quick Start
1
Open Settings from the top bar
The Settings screen renders one editable row per
value setting. On a fresh phone install, the Engine address field shows the default http://127.0.0.1:8765 — the phone itself, which nothing answers.2
Change the Engine address
Edit Engine address to a reachable host — your dev machine on the LAN, for example:
3
Blur the field or press Enter
The write commits on
change, not per keystroke. On blur or Enter the value is validated, then persisted through facade.set.4
Send the next message
No relaunch. The next
/chat and /health request resolves baseUrl fresh from the store, so the very next message reaches the address you just typed. Any turn already streaming stays pinned to the address it started at — its /cancel and /approve still land on the engine that issued the run.How It Works
Everyvalue row is an editable control wired to the same pure validateInput the store would run.
The four input paths and their outcomes:
How a refusal is spoken
A refused value is said, not silently undone. Each row carries arole="alert" node from first paint — empty and hidden — so an assertive announcement is picked up reliably by screen readers. An alert region inserted at the moment it has something to say is announced unreliably by every screen reader, so the node exists from the start and only its text changes.
The message is the labeled i18n string settingRejected(label) — "Engine address was not changed: that value was refused." — not a generic "Invalid value". It names the setting because the field may already have scrolled off, and it is set on both the inline role="alert" node and the assertive live region, so the user hears it interrupt rather than wait behind queued status ticks.
Only the refused key’s message is touched. A successful write on one key clears its own message and leaves another key’s refusal standing. Clearing every note would wipe a refusal the user has not read off a different setting; leaving them all would keep accusing a write that has since succeeded. The guard is
if (errorFor !== key) continue; in syncSettings.How the change reaches the store
Committing a field is an intent, decoded on the app root — never a per-field listener. The control emits{ kind: "set-setting"; key; raw } (app/src/intents.ts). One change listener sits on the app root and decodes the target’s Actionable chain through intentFrom(chain): a data-action="set-setting" element carrying data-setting-key="..." and an Actionable.value.
Actionable.value is absent on every non-field element on purpose: absence means “not a field”, never “cleared”. A <div> row, a <span> label, or a section heading has no value, so a stray tap on a row background cannot decode into a set-setting with an empty raw and wipe the engine address. Both the key and the value are required — a missing key is refused (the store would refuse "" silently), and a missing value means the element is not a field at all.
A secret field emits set-secret, not set-setting, and its Remove button emits clear-secret. The two intents are kept separate on purpose (from the intents.ts block comment): so no single code path decides at runtime whether a value lands in the plaintext settings file or in the keychain — a shared set-setting with a boolean flag would be exactly that path, and getting the boolean wrong once from a stale def puts an API key on disk. Two intents cannot make that mistake. For set-secret, intentFrom applies the trim-and-refuse-empty rule: a cleared field on blur (raw.trim() === "") is refused rather than treated as “delete it”, because removing a credential must be asked for by name through clear-secret.
The settings screen is rebuilt on every visit, so a listener attached to a field would belong to a node thrown away on the next navigation. Delegating on the root — like every tap — is what keeps the commit path alive across navigations.
Secret rows
A secret row is a masked field, a Remove button, and a separate presence node — never a value read back from the store.secretControls in app/src/main.ts builds the row under a three-property contract, and each property has a broken version that looks completely normal on screen:
refreshSecretPresence resolves hasSecret for every secret def and writes the answer into that presence node. It is sequence-guarded: a save followed by a Remove fires two overlapping lookups, and with a native keychain adapter hasSecret can resolve out of order, so an older answer landing last would paint a stale label over the current one. Each call takes a latestPresenceSeq ticket, and only the latest is allowed to write.
syncSecret(key, refusal) empties the field after every commit and sets the refusal text on the row’s role="alert" node (or clears and hides it when there is none) — the same assertive channel a rejected value setting uses.
See API Keys for the user-facing flow and Storage & Secrets for where the value lives.
The software-secrets warning
The “secrets are not hardware-backed” warning is now keyed offfacade.secretsAreHardwareBacked — a platform-shape check — not off a hard-coded platform name. buildSettings in ui/src/settings/view-model.ts renders the warning row only when that flag is false, which for the shipping app is only the browser: a phone gets src-tauri/plugins/secrets and never shows it.
Keying off the adapter rather than a name means a future adapter that cannot reach a keychain gets the warning without anyone remembering to add it. The wording was also rewritten — see Storage & Secrets → the four Tauri commands — to speak only to browsers now that a device no longer keeps secrets in a Map.
The row is emitted by the same loop as every other row, so a renderer that only walks rows cannot forget it. Anchor:
SOFTWARE_SECRETS_WARNING and buildSettings in ui/src/settings/view-model.ts.Configuration Options
SETTING_DEFS ships three editable settings today — two value rows and one secret row (source: app/src/registry.ts).
The store’s coercion and validation machinery stays intact for any future setting;
engineId, baseUrl, and openaiApiKey are the keys the shipping app reads (CONSUMED_SETTING_KEYS), and registry.test.ts forbids a declared-but-unread setting. See Storage & Secrets → Shipped defaults are valid.Common Patterns
Recover from an unreachable engine on a phone — the golden path this change unblocks.Best Practices
A refusal is refused, and said
A refusal is refused, and said
validateInput runs before set, so an invalid input is rejected before it reaches the store. And the refusal is not silent: settingRejected(label) is written to the field’s role="alert" node and the assertive live region, so a typo on the recovery screen no longer looks like the tap did not register.A change reaches the store as an intent, not a per-field listener
A change reaches the store as an intent, not a per-field listener
Committing a field emits
{ kind: "set-setting"; key; raw }, decoded by one root-delegated change handler through intentFrom. Actionable.value is absent on non-field elements on purpose — absence means “not a field”, never “cleared” — so a stray tap on a row background cannot wipe the engine address.Persist commits on blur/Enter
Persist commits on blur/Enter
The control listens on
change, not input, so a half-typed address is never stored and set is not hit per keystroke.Reset from the store, not from the last-seen value
Reset from the store, not from the last-seen value
The field resets to
settings.get(key) ?? def.default, so a value that never persisted cannot linger in memory. A rolled-back write leaves the field showing what the next launch will actually read.A secret row takes writes but never reads back
A secret row takes writes but never reads back
Editing a secret is a first-class row now: a masked field commits through
set-secret, and a Remove button through clear-secret. But the value is unreadable from the UI facade — the row shows presence and takes writes; only the engine, holding the full SecretsPort, reads the value back. See API Keys and Storage & Secrets.Related
Storage & Secrets
The persist-before-mutate contract behind
set.Errors & Recovery
Where an unreachable engine routes the user.
Engines
How
engineId and baseUrl pick and reach an engine.Boot Failures
The warning notice a phone sees before editing the address.
i18n & A11y
Why
settingRejected is announced assertively.
