Vancetope Mobile-UI — Specification
Status: v1 Initial Draft. Binding for the development of the mobile app under
repos/vance/client/packages/vance-fingers/.Prerequisite:
@vance/sharedis platform-neutral. The Mobile-Spec assumes this — it referencesconfigurePlatform,KeyValueStore,RestConfig, and the separated Speech modules.
1. Goals and Scope
Vancetope will receive a native mobile app for iOS and Android as a complement to the existing clients (vance-cli, vance-foot, vance-face). The mobile app is a standalone client with a significantly narrower range of functions than the Web-UI.
What the Mobile App is:
- Three main views: Inbox, Chat, Documents (read-only / simple).
- Voice control in Chat (Push-to-Talk STT + optional TTS Voice Output).
- “What do I need to decide now?” — Inbox as the primary entry point, as it is the main use case on the go.
- Bearer JWT Auth (no cookie concept).
What the Mobile App explicitly is not (v1):
- No Sessions/Process/Settings/Recipes/Projects editor. These belong on a large display.
- No Document editing. Documents are read-only — including inline text. Structured Kinds (Mindmap/Graph/Sheet) are rendered as a Plain-Text fallback.
- No Push Notifications in v1. Slot is open, implementation follows in v2 (see §11).
- No Offline capability. WS-only Chat is not compatible with offline; Inbox/Documents could REST-cache, but that is v2.
- No Cross-Device State Synchronization beyond the server state.
- No Tablet-specific layouts. iPhone and Android phone form factor (Portrait). Tablet support comes with re-layout in v2.
The deliberate asymmetry to the Web-UI: Mobile only shows what is useful on the go. Those at a laptop use vance-face.
2. Architecture
2.1 Repository Layout
The mobile app is located in the same pnpm workspace as the Web-UI, as a fourth package alongside generated, shared, and vance-face:
repos/vance/client/ (Workbench-Symlink: vance-wb/client_web)
├── pnpm-workspace.yaml
├── package.json
├── tsconfig.base.json
└── packages/
├── generated/ @vance/generated
├── shared/ @vance/shared (platform-neutral after reorg)
├── vance-face/ @vance/vance-face (Web-UI)
└── vance-fingers/ @vance/vance-fingers (NEW)
Workspace Tool: pnpm. Mobile runs in the same workspace so that @vance/shared can be referenced via workspace:* and the build reorder works via pnpm filters (pnpm --filter @vance/shared build ▶ pnpm --filter @vance/vance-fingers start).
Reason against a separate repo: The TypeScript contracts (@vance/generated and @vance/shared) must remain synchronized. A separate repo boundary would force version pinning and cross-repo sync effort without added value.
2.2 Package Responsibilities
| Package | Scope | Content |
|---|---|---|
@vance/generated |
DTOs from Java | TypeScript interfaces, generated by the Maven plugin. No logic. |
@vance/shared |
Client Logic (UI- and platform-agnostic) | JWT/Token Storage via adapter, REST client, WS Connection Manager, Speech Preferences. No Vue, no React, no Tailwind. |
@vance/vance-face |
Web-UI | Vue 3 + Vite + Tailwind + DaisyUI. Multi-page app, one HTML entry per editor. |
@vance/vance-fingers |
Mobile-UI | React Native + Expo + NativeWind. Single app with tab navigation. |
2.3 Dependency Rules (strict)
| Package | May depend on |
|---|---|
@vance/generated |
nothing (no runtime deps) |
@vance/shared |
@vance/generated |
@vance/vance-face |
@vance/shared (transitive @vance/generated) — no direct REST/WS imports |
@vance/vance-fingers |
@vance/shared (transitive @vance/generated) — no direct REST/WS imports |
@vance/vance-fingers must not contain direct fetch or WebSocket code. All network calls go through @vance/shared. This ensures @vance/shared remains the sole location for Auth, Reconnect logic, and URL construction.
2.4 Stack
| Component | Technology | Rationale |
|---|---|---|
| Language | TypeScript | Consistent with Web, full re-use of DTOs |
| Framework | React Native 0.76+ | Established, large ecosystem selection |
| Toolchain | Expo (managed workflow) | No Xcode/Gradle setup on dev machines, EAS Build for store releases, OTA updates for bug fixes |
| Navigation | React Navigation 7 | Standard, well-documented, Bottom-Tabs + Stack combined |
| Styling | NativeWind 4 | Tailwind classes for RN — consistency with Web Tailwind scale |
| State | Zustand + TanStack Query | Zustand for local UI state, TanStack Query for REST caching/invalidation. Pinia is Vue-specific, no re-use. |
| Storage (Tokens) | expo-secure-store |
Keychain (iOS) / Keystore (Android) |
| Storage (Prefs) | @react-native-async-storage/async-storage |
Fast, non-sensitive KV store |
| TTS | expo-speech |
Native AVSpeechSynthesizer / Android TTS |
| STT | @react-native-voice/voice |
Native Speech Recognition (iOS Speech / Android SpeechRecognizer) |
| HTTP/WS | @vance/shared (internal: fetch + WebSocket, both RN-native) |
No polyfills needed |
| Icons | @expo/vector-icons (Material/Feather) |
Distributed with Expo |
Deliberately not in the stack:
- React Native Paper / Tamagui / NativeBase: too large and opinionated style, would undermine NativeWind+Tailwind consistency.
- Reanimated for Chat animations: not v1. Token streaming is sufficient with React state.
- Expo Notifications: prepare, but do not use (see §11).
2.5 App Identity
| iOS | Android | |
|---|---|---|
| Bundle ID / Package | de.mhus.vance.mobile |
de.mhus.vance.mobile |
| App Name | Vancetope |
Vancetope |
| Min Version | iOS 15 | Android 8 (API 26) |
3. View Inventory v1
The mobile app is a single app with Bottom-Tab Navigation (three tabs) and a Login stack above it.
| View | Tab / Stack | Channel | Live | Purpose |
|---|---|---|---|---|
| Login | Stack over tabs (modal-blocking) | REST | – | Tenant + Username + Password. “Remember user” saves Tenant+Username in prefsStore, no password. |
| Inbox List | Tab 1 (default) | REST | – | List of current user’s PENDING items. Filter via header picker (Tag, Criticality). Pull-to-refresh. |
| Inbox Detail | Stacked under Inbox tab | REST | – | Item payload + type-specific action bar (APPROVAL, DECISION, FEEDBACK …). Reply / Archive / Dismiss / Delegate. |
| Chat Picker | Tab 2 | REST | – | List of active Sessions (grouped by Project). Tap opens Live Chat. |
| Chat Live | Stacked under Chat tab | WS + REST (History) | ✅ | Session Chat with streaming, voice in/out, composer, Process progress display. |
| Documents List | Tab 3 | REST | – | List with filter by Project, Path-Prefix, Kind. Pull-to-refresh. |
| Document Detail | Stacked under Documents tab | REST | – | Read-only content: Inline text is rendered; files (image, PDF) with expo-image / react-native-pdf. Structured Kinds (Mindmap/Graph/Sheet) as JSON plain-text fallback. |
Consequence: The WS module from @vance/shared is only imported by the Chat-Live screen. Other screens exclusively use REST (TanStack-Query caches).
3.1 Tab Bar
| Index | Label | Icon | Badge |
|---|---|---|---|
| 0 | Inbox | mail |
Number of PENDING items from last REST request (see §3.2) |
| 1 | Chat | chat-bubble |
– |
| 2 | Docs | folder |
– |
The Inbox badge number is read from the inbox-pending-summary WS frame as soon as the WS connection is open when the Chat tab is opened. If the user is on another tab and no WS is active, the badge falls back to a REST poll value (every 60s, disabled in app background — see §9).
4. REST/WS Division
The Mobile App follows the same doctrine as the Web-UI: WS is the primary backend channel, REST is convenience. For v1, live updates are exclusively in Chat. Inbox updates in v1 are fetched manually via pull-to-refresh; live updates will move to v2 along with push notifications.
4.1 REST Endpoints (all tenant-scoped: /brain/{tenant}/...)
| View | Endpoint | Purpose |
|---|---|---|
| Login | POST /access/{username} with requestCookies: false |
Token-in-body path. Tokens land in Keychain via secureStore. |
| Login (Refresh) | POST /refresh |
Body refresh: Refresh Token from secureStore → new Access Token back |
| Logout | POST /logout |
Server-side invalidate, then delete tokens locally |
| Inbox List | GET /inbox?status=PENDING&assignedTo=me&tag=... |
TanStack-Query, Stale-Time 30s, manual refresh via Pull-to-Refresh |
| Inbox Detail | GET /inbox/{id} |
If not sufficiently populated from list cache |
| Inbox Tags | GET /inbox/tags |
Filter picker options |
| Inbox Answer | POST /inbox/{id}/answer with AnswerPayload |
Send answer |
| Inbox Archive | POST /inbox/{id}/archive |
|
| Inbox Dismiss | POST /inbox/{id}/dismiss |
|
| Inbox Delegate | POST /inbox/{id}/delegate |
|
| Chat History | GET /sessions/{id}/messages?limit=200 |
When opening a Session, before WS-Connect |
| Sessions List | GET /sessions |
Chat picker data source |
| Documents List | GET /documents?projectId=...&pathPrefix=...&kind=... |
Pageable |
| Document Detail | GET /documents/{id} |
Inline content in body, if present |
| Document Content | GET /documents/{id}/content |
Storage-backed Files (images, PDF). Authorization header is injected by RestClient from secureStore — no ?token= hack on Mobile. |
4.2 WebSocket Frames
Mobile opens a WS connection only when entering the Chat-Live screen, closes it when leaving. One connection per Session (analogous to the Web-UI).
Connection Profile: mobile (see specification/recipes.md §6a for profile block selection on server side).
| Direction | Frame | Purpose |
|---|---|---|
| → Brain | process-create / process-steer / process-pause / process-resume / process-stop |
Standard lifecycle |
| → Brain | session-create / session-resume |
Session bootstrap on entry |
| ← Brain | welcome |
Connection handshake |
| ← Brain | chat-message-stream-chunk |
Token streaming, buffered in streamingDrafts map |
| ← Brain | chat-message-appended |
Commit, replaces stream buffer |
| ← Brain | process-progress |
Status side-channel, right-panel slot or Mobile: compact inline banner above the composer |
| ← Brain | inbox-pending-summary |
On welcome — populates the Inbox tab badge |
| ← Brain | inbox-item-added / inbox-item-updated |
Live pinging of the Inbox tab badge |
Auth via WS: Bearer JWT in the ?token= query parameter. The server accepts this according to specification/websocket-protokoll.md §2 — there is no cookie concept on Mobile.
4.3 Stream Buffer Pattern
Identical to the Web-UI (see specification/web-ui.md §6.5):
// vance-fingers/src/screens/chat/useChatStream.ts
const streamingDrafts = new Map<string, string>(); // thinkProcessId → buffered chunks
ws.on('chat-message-stream-chunk', (data: ChatMessageChunkData) => {
streamingDrafts.set(data.thinkProcessId, (streamingDrafts.get(data.thinkProcessId) ?? '') + data.chunk);
// re-render bubble for this process with the buffered text
});
ws.on('chat-message-appended', (data: ChatMessageAppendedData) => {
streamingDrafts.delete(data.thinkProcessId);
// append finalized message; if id is already in history (REST snapshot), dedupe
});
5. Auth Flow
5.1 Login
User opens app
↓
Boot reads `prefsStore.get('vance.identity.tenantId')` and refresh-token from `secureStore`
↓
If both present and refresh succeeds → land on Inbox tab
If anything missing/expired → push Login screen modally
Login Request:
const body: AccessTokenRequest = {
password,
requestRefreshToken: true,
requestCookies: false, // ← Mobile-Flag: no cookies, tokens in body
includeWebUiSettings: false, // ← Mobile does not need webui.*
};
const r = await fetch(`${baseUrl}/brain/${tenant}/access/${user}`, { method: 'POST', body: JSON.stringify(body), headers: {...} });
const resp = await r.json() as AccessTokenResponse;
await secureStore.set('vance.auth.accessToken', resp.accessToken);
await secureStore.set('vance.auth.refreshToken', resp.refreshToken);
await prefsStore.set('vance.identity.tenantId', resp.tenantId);
await prefsStore.set('vance.identity.username', resp.username);
5.2 Refresh
RestConfig is set to authMode: 'bearer'. restClient.ts injects Authorization: Bearer ${accessToken} from secureStore. On 401, the client attempts a refresh:
const r = await fetch(`${baseUrl}/brain/${tenant}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: await secureStore.get('vance.auth.refreshToken') }),
});
if (!r.ok) onUnauthorized(); // navigation.replace('Login')
const resp = await r.json() as RefreshTokenResponse;
await secureStore.set('vance.auth.accessToken', resp.accessToken);
// retry the original request once
onUnauthorized on Mobile is a navigation callback to the Login screen, not window.location.href.
5.3 Logout
await fetch(`${baseUrl}/brain/${tenant}/logout`, { method: 'POST', headers: bearer() });
await secureStore.remove('vance.auth.accessToken');
await secureStore.remove('vance.auth.refreshToken');
await prefsStore.remove('vance.identity.tenantId');
await prefsStore.remove('vance.identity.username');
navigation.replace('Login');
5.4 Biometrics (optional, v1.1)
expo-local-authentication wraps the app start: If a refresh token is present, the app prompts for Face ID / Touch ID / Fingerprint once before reading the token from secureStore. Pure UX polish, the token itself is already hardware-protected. By default off in v1, can be enabled in App Settings.
6. Voice Strategy
Voice control in Chat is the primary differentiating feature of the Mobile App.
6.1 STT (Speech-to-Text) — Push-to-Talk
Library: @react-native-voice/voice (native Speech Recognition).
Pattern: Push-to-Talk via hold button next to the composer. No always-listening — saves battery, avoids permission friction, clear user mental model (“when I hold the button, it listens to me”).
Flow:
- User holds the mic button (
onPressIn→Voice.start(lang)). - While speaking,
partialResultsare displayed as gray preview text in the composer. - User releases (
onPressOut→Voice.stop()). finalResultsare written into the composer as plain text.- User can edit, then taps Send (or uses
auto-sendfrom settings).
Language: Read from @vance/shared/speech/preferences.ts getSpeechLanguage(). Default is BCP-47 from Localization.getLocales()[0].languageTag.
Permissions:
- iOS:
NSSpeechRecognitionUsageDescriptionandNSMicrophoneUsageDescriptioninapp.json→expo.ios.infoPlist. On the first tap of the mic button,Voice.start()automatically triggers the OS prompt. - Android:
RECORD_AUDIO.Voice.requestPermissions()on first tap.
Fallback: If STT is unavailable (permission denied, offline on iOS — on-device recognition is possible since iOS 13, but the server variant requires network), the mic button is grayed out. Tap shows a toast “Speech recognition unavailable — check Settings”.
6.2 TTS (Text-to-Speech) — Speaker for Assistant Messages
Library: expo-speech (native AVSpeechSynthesizer / Android TTS).
Toggle: Speaker icon in the Chat topbar. Persisted in prefsStore (vance.speakerEnabled) — same convention as Web.
Pattern:
- On incoming
chat-message-appendedwithrole=ASSISTANTandspeakerEnabled === true:- Text is filtered by
stripMarkdown()from@vance/shared/speech/markdown.ts. expo-speech.speak(text, { language, rate, voice })starts playback.
- Text is filtered by
- When leaving the Chat-Live screen:
expo-speech.stop(). - Stream chunks are not read aloud — TTS only starts on
appended. (The stream would be too fragmented for TTS, and the audio would stop/restart per chunk.)
Settings:
- Rate (0.5–2.0) —
prefsStorevance.speechRate, shared with Web. - Voice —
expo-speech.getAvailableVoicesAsync()provides a list per language; user selects once. URI is stored inprefsStorevance.speechVoiceUri. On resolve, we fall back to the platform default if the stored voice no longer exists (OS updates can remove voices).
Background Audio: If the speaker is on and the screen locks, reading should continue. iOS requires the audio background mode for this in app.json → expo.ios.infoPlist.UIBackgroundModes: ['audio']. On Android, this is on by default as long as the process is alive.
6.3 Voice Settings Screen
A dedicated settings sub-screen in the User Menu (Topbar Avatar) bundles all voice options — analogous to the Speech popover in the Web-UI:
- Speaker on/off
- Voice picker (list of native voices, filtered by current language)
- Rate slider
- Volume — on Mobile not in app settings, but system volume. (
expo-speechdoes have avolumeparameter, but this is confusing on Mobile.) - Language picker (BCP-47, same list as Web)
7. UI Consistency
To ensure the Mobile App remains visually coherent with the Web-UI, the following applies, analogous to the Web-UI doctrine:
7.1 Mandatory Shell — <MobileShell>
Every top-level screen renders via <MobileShell>. Header (title + optional back action + optional trailing actions), body, optional footer (e.g., composer in chat). Geometry centralized, no one builds their own <View> header.
<MobileShell title="Inbox" trailing={<MobileShell.IconButton icon="filter" onPress={openFilter} />}>
<InboxList />
</MobileShell>
7.2 Mandatory Components
Component library in vance-fingers/src/components/ with V-Prefix (same convention as Web — facilitates cross-reading, even if implementations differ):
| Component | Web Counterpart | Purpose |
|---|---|---|
<MobileShell> |
<EditorShell> |
Top-level layout with header/body/footer |
<VButton variant size loading> |
<VButton> |
Variants: primary | secondary | ghost | danger | link |
<VInput> |
<VInput> |
Single-line input with label/help/error |
<VTextarea> |
<VTextarea> |
Multiline input |
<VSelect> |
<VSelect> |
Dropdown — on Mobile as ActionSheet (@expo/react-native-action-sheet) |
<MarkdownView> |
<MarkdownView> |
Read-only Markdown — react-native-markdown-display |
<VAlert> |
<VAlert> |
Banner (info/warning/error/success) |
<VEmptyState> |
<VEmptyState> |
Icon + Headline + Body + Action |
<VToast> |
<VToast> |
Auto-dismiss Notification — Position: above the tab bar |
<VLoading> |
<VLoading> |
Skeleton in lists, spinner for inline actions |
<VBadge> |
<VBadge> |
Tab-bar badge, item counts |
<VDataList> |
<VDataList> |
FlatList wrapper with Pull-to-Refresh + Empty-State + Pagination |
NativeWind classes outside this directory are allowed — unlike on Web (btn/input/…), NativeWind has no semantic DaisyUI classes, but pure Tailwind atomics. Layout classes (flex-1, gap-2, p-4) are used directly by each screen. Style atomics (bg-primary, text-base, rounded-lg) also — as long as the style works with the theme from @vance/shared/theme.ts (see §7.4).
7.3 Enforcement
- Code Review Block: Screens that build their own header / own buttons / own modal layout instead of the primitives will not be merged. If a new UI pattern is needed, it is first added to
src/components/and then used. /preview-screen: A dedicated dev-only screen (hidden behind a long-press on the app logo in Login) that shows every primitive in every state. Maintained when adding a new primitive.
7.4 Theme
@vance/shared/theme.ts (new module, platform-neutral) exports a canonical color palette in light/dark:
export const theme = {
light: { primary: '#2563eb', surface: '#ffffff', text: '#0f172a', ... },
dark: { primary: '#3b82f6', surface: '#0f172a', text: '#f1f5f9', ... },
};
Web reads this in tailwind.config.js, Mobile in tailwind.config.js of NativeWind. This keeps both platforms color-synchronized — changing primary changes it in one place.
Theme Selection: System default (useColorScheme() from react-native). Toggle in App Settings (no topbar toggle like on Web — on Mobile, theming is a settings concern, not a workflow concern).
Typography: System fonts (San Francisco / Roboto). No Inter/JetBrains-Mono on Mobile — custom fonts are more expensive on Mobile (bundle size, font loading flicker), and the Web Inter hardly differs from the system font.
8. Build & Deployment
8.1 Dev Workflow
# Workspace Boot
cd repos/vance/client && pnpm install
# Mobile Dev with Expo Go (iOS Simulator / Android Emulator / physical device via QR)
pnpm --filter @vance/vance-fingers start
# Brain Dev in parallel
cd vance/vance-brain && mvn spring-boot:run
expo-cli loads app.json with extra.brainUrl from an app.config.ts function that distinguishes between dev and prod builds:
// app.config.ts
export default ({ config }) => ({
...config,
extra: {
brainUrl: process.env.VANCE_BRAIN_URL ?? 'http://localhost:8080',
},
});
bootMobile.ts calls configurePlatform with this URL.
8.2 wb-Integration
New targets in wb:
wb build mobile # pnpm install + pnpm --filter @vance/vance-fingers build (Type-Check)
wb mobile dev # pnpm --filter @vance/vance-fingers start
wb mobile build:ios # eas build --profile production --platform ios
wb mobile build:android # eas build --profile production --platform android
wb build mobile does not run automatically in wb build all — Mobile requires Node and is optional, like Web today.
8.3 EAS Build / Stores
- EAS Build: Cloud build via
eas.json. Free tier is sufficient for low build frequency; paid tier for continuous release. - App Store / Play Store: Manual submission after EAS build. Privacy Manifest (iOS) and Data Safety (Android) are declared in
eas.json/app.json: STT/TTS permissions, no tracking, no advertising. - OTA Updates via
expo-updates: JS bundle updates without store resubmit. Useful for bug fixes, not for native code changes. v1: on, channelproduction.
8.4 Version Pinning
@vance/shared and @vance/generated are referenced via workspace:*. The Mobile build therefore always takes the local state. For store release, the pnpm lock hash is versioned (tag in Git).
9. App Lifecycle
9.1 Foreground / Background
Mobile apps are often pushed to the background or terminated entirely by the OS. Consequences:
| Transition | Behavior |
|---|---|
| App → Background | WS connection of the Chat screen remains active as long as iOS/Android keeps the process alive. iOS typically gives 30s, Android varies. |
| App → Suspended (OS-killed) | On resume, fresh WS connection. Chat state (streamingDrafts) is lost — REST history loads from server, dedupe as in §4.3. |
| Network Switch (WiFi → LTE) | WS connection may break. BrainWebSocket must be able to reconnect with exponential backoff. On reconnect: deduplicate old message IDs from the last REST snapshot. |
| Complete Network Loss | UI shows “Offline” (NetInfo listener). REST calls fail → TanStack-Query shows “stale”. Pull-to-Refresh retries. No offline cache in v1. |
9.2 Reconnect Logic
Managed by @vance/shared/ws/brainWebSocket.ts — when BrainWebSocket.connect() is called, it returns a Promise; on close outside the regular logout path, the caller (useChatLive hook) is informed via onClose listener and decides whether to reconnect.
Exponential Backoff: 1s, 2s, 4s, 8s, max 30s. Reset on successful welcome.
Visibility: Connection state indicator (dot) in the Chat topbar — green (open), grey (reconnecting), red (gave up). Tap on the dot triggers manual reconnect.
10. What Mobile Does NOT Do (v1)
If a suggestion goes in one of these directions: stop and begin with a spec extension, do not implement ad-hoc.
- Document Editing, neither text nor structured. Read-only, period.
- Process Tree Display (Marvin / Vogon). Belongs on a large display.
- Sessions/Settings/Recipes/Projects Editor. Web-only.
- Tablet Layouts. iPhone and Android phone portrait. iPad gets a re-layout in v2.
- Cross-Device Live Sync beyond the server state.
- In-App Updates beyond what
expo-updatesdoes on its own. - Telemetry / Analytics. No third-party SDKs. No crash reporter in v1 (Sentry or similar later, with privacy review).
11. Push Notifications (v2 Preparation)
Push is the most obvious mobile added value: a critical Inbox item arrives, the user gets a notification, taps it, lands directly in the Inbox detail. v1 does not implement this, but prepares for it:
| Preparation | v1 |
|---|---|
expo-notifications as dependency, OS permission prompt on first Inbox tab entry |
✅ |
Send Expo Push Token to Brain after login (new REST endpoint POST /brain/{tenant}/devices) |
❌ Endpoint does not exist yet — v2 |
| Server-Side: NotificationDispatcher implements APNs/FCM sending | ❌ Spec user-interaction.md §”Notification-Dispatcher” describes this as a stub — v2 |
Deep-Link vance://inbox/{id} opens Inbox detail |
✅ (Linking config created, no user trigger v1) |
This will be activated in v2. The slot remains open so that the refactor remains small.
12. Risks and Open Points
12.1 WS Stability on Mobile
Main risk. Real tests in tunnels / on trains / with Wi-Fi changes must happen before the mobile chat experience is marketed. Possibly fallback to REST polling for non-streaming-critical chat updates — this would be a spec change in v1.x.
12.2 Bearer Refresh Race
If two parallel REST calls both receive 401, two refresh requests would run in parallel. @vance/shared/auth/refreshClient.ts needs a single-flight pattern: a running refresh promise is cached, parallel callers wait for it. Web did not need this as urgently because cookies are serialized by the browser — Mobile explicitly needs it.
12.3 STT Language vs. Account Language
prefsStore.vance.speechLanguage is the STT/TTS BCP-47 code. If the account has a different default language (webui.language), this can drift apart. v1: speechLanguage takes precedence, no auto-sync. This is accepted because many users want to speak in language A and chat in language B.
12.4 React-Native-Voice Maintainership
@react-native-voice/voice is community-maintained and has had phases with outdated RN versions. If this breaks in practice, an alternative is expo-speech-recognition (in Expo SDK from v52) — same API structure, different maintainer. Dependency choice will be finally decided during implementation.
13. Reference
specification/web-ui.md— Sister spec, common concepts (Token Lifecycle §5, Style Guide §7)specification/user-interaction.md— Inbox subsystem (Item Types, Lifecycle, Notification Dispatcher Stub)specification/arthur-engine.md— Chat Engine (Streaming, Inbox/ProcessEvent)specification/websocket-protokoll.md— WS Frame contract, Token Auth via Query Paramspecification/recipes.md§6a — Profile block selection on server side (mobileprofile)